diff --git a/README.md b/README.md index 338c464..6045082 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ **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! @@ -34,45 +34,45 @@ 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) + - [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) + - [Dependency Scripts](#dependency-scripts) + - [Feature Scripts](#feature-scripts) + - [Auto-Inject Dependencies](#auto-inject-dependencies) + - [Info For Developers](#info-for-developers) - [Client Support](#client-support) - [Changes to default Jellyfin functionality](#changes-to-default-jellyfin-functionality) - [Design Philosophy](#design-philosophy) @@ -82,12 +82,13 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the - [Support](#support) - [FAQ](#faq) - [Roadmap](#roadmap) - - [Planned Features](#planned-features) - - [Version History](#version-history) + - [Planned Features](#planned-features) + - [Version History](#version-history) ## Installation ### Prerequisites + - Jellyfin 10.10.7 or earlier (10.11.X is untested and "unsupported" but may be mostly functional) - [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 @@ -95,7 +96,7 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the ### 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** @@ -136,7 +137,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 @@ -161,10 +162,12 @@ All configuration options for KefinTweaks can be managed directly from the UI. ### 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. +
## Uninstallation @@ -178,8 +181,6 @@ These tasks only run for administrator users and happen automatically in the bac

- - ## 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. @@ -190,21 +191,22 @@ As mentioned above, most of the functionality included in KefinTweaks are featur 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 @@ -230,43 +232,49 @@ 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) -
+
### Core Features ## **Watchlist** -### **Watchlist Page**: -Tired of forgetting everything you wanted to watch? Add movies, series, seasons, and episodes to your Watchlist! + +### **Watchlist Page**: + +Tired of forgetting everything you wanted to watch? Add movies, series, seasons, and episodes to your Watchlist!
watchlist
-
+
-### **Series Progress**: -An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched. +### **Series Progress**: + +An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched.
progress
-
+
-### **Movie History**: -An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites. +### **Movie History**: + +An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites.
history
-
+
-### **Statistics**: -A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched. +### **Statistics**: + +A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched.
statistics
-### **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. @@ -279,54 +287,55 @@ 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. + Automatically removes watched items from your watchlist when playback completes. - **Real-time Updates**: -Cached watchlist updates when toggling watchlist status from card overlays -
- +Cached watchlist updates when toggling watchlist status from card overlays +
#### **Enhanced Home Screen** + - **Custom Sections**: -Add playlist or collection-based sections to your home screen + Add playlist or collection-based sections to your home screen - **New & Trending**: -Creates sections for movies and episodes released in the last 7 days (trending sections incomplete) + Creates sections for movies and episodes released in the last 7 days (trending sections incomplete) - **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 and favorited, 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/) - **Seasonal Content**: -Seasonally-themed sections (Halloween, Christmas, etc.) + Seasonally-themed sections (Halloween, Christmas, etc.)
homescreen
-
+
- **Watchlist Integration**: -Dedicated watchlist section on home screen + Dedicated watchlist section on home screen
homescreen
-
+
#### **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 @@ -337,8 +346,9 @@ Zero-config support for searching with the [Meilisearch](https://github.com/arne ### UI Enhancements #### **Watchlist Support** + - **Watchlist Toggle**: -Watchlist toggle button added to all item card overlays + Watchlist toggle button added to all item card overlays
watchlistoverlay @@ -346,7 +356,6 @@ Watchlist toggle button added to all item card overlays

- #### **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. @@ -361,74 +370,84 @@ 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. -
+
### UX Improvements -#### **Subtitle Search**: -Search and download subtitles directly from the video OSD +#### **Subtitle Search**: + +Search and download subtitles directly from the video OSD
subtitlesearch
-
+
-#### **Remove from Continue Watching**: -Adds a card overlay button to remove items from Continue Watching for all resumable items +#### **Remove from Continue Watching**: + +Adds a card overlay button to remove items from Continue Watching for all resumable items
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. +
### Navigation Improvements -#### **Header Tab Enhancements**: -Improved tab navigation and functionality, supports linking to specific tabs +#### **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
-
+
-#### **Custom Menu Integration**: -Add custom menu links to the side navigation drawer menu +#### **Custom Menu Integration**: + +Add custom menu links to the side navigation drawer menu
custommenu
-
+
-#### **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 +#### **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
-
+
-#### **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. +#### **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 @@ -436,69 +455,70 @@ This shows an "Included In" section on the Item Details page which displays any

- ### System Improvements #### **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** + - **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 | +| 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` | +| 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 @@ -507,7 +527,7 @@ KefinTweaks automatically enables required dependencies when you enable a script - Enabling `watchlist` automatically enables `cardBuilder`, `localStorageCache`, `modal`, and `utils` -This ensures all scripts have their required dependencies without manual configuration. +This ensures all scripts have their required dependencies without manual configuration. ### Info for Developers @@ -521,7 +541,8 @@ The KefinTweaks Watchlist leverages the existing Jellyfin Item UserData field: " - Add a Watchlist toggle button to all card overlays which toggles the UserData Likes value - Add a listener for PlaybackStopped to check if a Watchlisted item has been watched and automatically remove it from the Watchlist -Even if you only implement the first bullet point and add a section for Watchlist items, KefinTweaks will automatically handle Watchlist syncing whenever the user connects with a KefinTweaks supported client. +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 @@ -538,10 +559,11 @@ Here is a list of known supported clients: - [Jellyfin for Tizen](https://github.com/jellyfin/jellyfin-tizen) 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 @@ -565,29 +587,28 @@ 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 + - This removes extra backdrops from the DOM + - It fixes an issue which causes Jellyfin to endless add backdrop items to the DOM if the Jellyfin browser tab is not focused - ### What functionality is overridden? - Search - - The default Jellyfin search is hidden in favor of the KefinTweaks search - - KefinTweaks 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 @@ -596,6 +617,7 @@ 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. +
## AI Disclaimer @@ -608,10 +630,9 @@ As it stands right now, and how I expect to proceed going forward, is to act in 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. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Acknowledgments @@ -619,48 +640,73 @@ 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. + https://freefrontend.com/css-frames/ ## 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 -

+

+> ### 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: +
+ jsinjector publicjs +
+ +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 +
+ jsinjector publicjs +
## 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 -
+This README and the inline comments in kefinTweaks.js and the other scripts is the only real documentation available at this time +
## Roadmap ### Planned Features -- **Jellyfin 10.11 Support** + +- **Jellyfin 10.11 Support** - **Additional options for User configuration in the Jellyfin UI**
### Version History + - **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 index 17875f7..0aed482 100644 --- a/configuration/customMenuLinks-configuration.js +++ b/configuration/customMenuLinks-configuration.js @@ -14,7 +14,9 @@ */ async function loadConfig() { const config = window.KefinTweaksConfig || {}; - return config.customMenuLinks || []; + return { + customMenuLinks: Array.isArray(config.customMenuLinks) ? config.customMenuLinks : [] + }; } /** @@ -22,31 +24,30 @@ */ async function saveConfig(customMenuLinks) { try { - // Ensure window.KefinTweaksConfig exists if (!window.KefinTweaksConfig) { window.KefinTweaksConfig = {}; } - // Update customMenuLinks config window.KefinTweaksConfig.customMenuLinks = customMenuLinks; + // Placement is per-link; do not keep the legacy global top-nav flag + delete window.KefinTweaksConfig.showCustomMenuLinksInTopNav; - // 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; - } + } 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); @@ -63,9 +64,9 @@
Custom Menu Links JSON
-
Add custom menu links that will appear in the custom menu. Use Material icon names for icons.
+
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
[
@@ -73,13 +74,43 @@
     "name": "My Custom Link",
     "icon": "link",
     "url": "#/userpluginsettings.html?pageUrl=https://domain.com/custom-page",
-    "openInNewTab": false
+    "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
+    "openInNewTab": true,
+    "sideMenu": true,
+    "topNavigation": "none"
   }
 ]
@@ -95,11 +126,12 @@ function collectConfig() { try { const jsonText = document.getElementById('customMenuLinksJson')?.value || '[]'; - return JSON.parse(jsonText); + 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 []; + return null; } } @@ -108,14 +140,11 @@ */ 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'; @@ -125,7 +154,6 @@ `; - // Create modal const modalInstance = window.ModalSystem.create({ id: MODAL_ID, title: 'Custom Menu Links Configuration', @@ -135,12 +163,12 @@ closeOnEscape: true, showCloseButton: true, onOpen: (modal) => { - // Save button const saveBtn = modal.dialogFooter.querySelector('#save-custommenulinks-config-btn'); if (saveBtn) { saveBtn.addEventListener('click', async () => { - const customMenuLinks = collectConfig(); - const success = await saveConfig(customMenuLinks); + 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!'); @@ -154,9 +182,11 @@ } }); - modalInstance.dialog.style.maxWidth = '90vw'; - modalInstance.dialog.style.width = '1400px'; - modalInstance.dialog.style.height = '90vh'; + if (window.innerWidth >= 900) { + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.height = '90vh'; + } LOG('Custom Menu Links configuration modal opened'); } catch (error) { @@ -165,11 +195,9 @@ } } - // Export 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 index b22d9c4..82f5dff 100644 --- a/configuration/homeScreen-configuration.css +++ b/configuration/homeScreen-configuration.css @@ -1,83 +1,207 @@ [data-modal-id="kefin-homescreen-config-main"] .config-tab-content details { - max-height: calc(90vh - 19em); - overflow: auto; + max-height: calc(90vh - 19em); + overflow: auto; } -[data-type="Collections"], [data-type="Playlists"], [data-type="Generic Parent"] { - display: none; + +[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; + display: block; } [data-parent-item-type="Playlist"] [data-type="Playlists"] { - display: block; + display: block; } [data-parent-item-type="Generic Parent"] [data-type="Generic Parent"] { - display: block; + display: block; } [data-modal-id="kefin-homescreen-config-main"] details[open] summary.listItemBodyText::after { - content: ''; + content: ""; } [data-modal-id="kefin-homescreen-config-main"] #section-content { - padding-right: 1em; + padding-right: 1em; } [data-modal-id="kefin-homescreen-config-main"] div#section-content { - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - padding: 0.75em; - max-height: calc(90vh - 19em); - overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 0.75em; + max-height: calc(90vh - 19em); + overflow: auto; } div#order-sections-container .editable-group-name { - pointer-events: none; + pointer-events: none; } .section-group[data-section-type-seasonal="false"] .section-row[data-section-type="Seasonal"] { - display: none !important; + display: none !important; } .section-group[data-section-type-default="false"] .section-row[data-section-type="Default"] { - display: none !important; + display: none !important; } .section-group[data-section-type-custom="false"] .section-row[data-section-type="Custom"] { - display: none !important; + display: none !important; } div#discovery-sections-list .section-preview-btn { - display: none; -} - -.listItem.section-row:has(.discovery-tag) { - box-shadow: black inset 1px 1px 2px 1px; + display: none; } div#order-sections-container .section-group .group-sections-list { - max-height: calc(90vh - 25em); - overflow: auto; - margin-bottom: -1em; + max-height: calc(90vh - 25em); + overflow: auto; + margin-bottom: -1em; } span.listItemBodyText.secondary.discovery-tag { - background: #3F51B5; - padding: 2px 5px; + background: #3f51b5; + padding: 2px 5px; } span.listItemBodyText.secondary.spotlight-tag { - background: #501515; - padding: 2px 5px; + background: #501515; + padding: 2px 5px; } span.listItemBodyText.secondary.seasonal-tag { - background: #3b5562; - padding: 2px 5px; + background: #3b5562; + padding: 2px 5px; } -.discovery-tag, .spotlight-tag, .seasonal-tag { - color: rgba(255, 255, 255, 0.9) !important; - border-radius: 5px; +.discovery-tag, +.spotlight-tag, +.seasonal-tag { + color: rgba(255, 255, 255, 0.9) !important; + border-radius: 5px; } -.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child > *:not(.verticalSection-extrabottompadding) { +.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: 900px) { + [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; } @@ -85,19 +209,135 @@ span.listItemBodyText.secondary.seasonal-tag { margin-bottom: 0; } -.homeScreenSettingsContainer form { - display: flex; - flex-direction: column; +.listItem.section-row:has(.discovery-tag) { + box-shadow: black inset 1px 1px 2px 1px; +} */ + +.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) .selectContainer { + display: none; } -.homeScreenSettingsContainer form > * { - order: 2; +/* Global Settings panel layouts */ +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-panel { + display: flex; + flex-direction: column; + gap: 1em; } -.homeScreenSettingsContainer #kefin-user-home-sections-editor { - order: 1; +[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; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid { + display: grid; + gap: 0.75em; + align-items: stretch; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-2 { + 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(3, minmax(0, 1fr)); +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-span-2 { + grid-column: 1 / -1; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-span-2 > .kefin-toggle-card { + width: 100%; +} + +[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: 900px) { + [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)); + } +} + +@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; + } } -.kefin-restore-defaults-btn .restoreBtn::after { - content: 'Restore Defaults'; -} \ No newline at end of file diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index 30bf7d8..6b86f80 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -11,116 +11,121 @@ // Modal IDs const MAIN_MODAL_ID = 'kefin-homescreen-config-main'; - const SECTION_EDITOR_MODAL_ID = 'kefin-homescreen-section-editor'; - const DISCOVERY_EDITOR_MODAL_ID = 'kefin-homescreen-discovery-editor'; + const PREVIEW_DEFAULT_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16; - // 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' }, - 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: { label: 'Is Unplayed', type: 'boolean' }, - 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' }, - EnableUserData: { label: 'Enable User Data', type: 'boolean' } + 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.' + }, + '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.' + } }; - const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc']; - 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 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.', + off: 'Discovery Sections will only be rendered when the user presses the "Load More" button.' + }, + '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.' + } + }; - 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' } - ]; + function toggleDesc(map, id, checked) { + const entry = map[id]; + if (!entry) return ''; + return checked ? entry.on : entry.off; + } - // Current config state + 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 = 'settings'; // 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 COMMUNITY_IMPORT_MODAL_ID = 'kefin-homescreen-community-import'; + 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 || 'settings'; + } /** * Show toast notification @@ -150,6 +155,28 @@ return flattened; } + /** + * 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 @@ -306,6 +333,149 @@ 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 @@ -396,18 +566,18 @@ try { if (!window.ApiClient || !window.ApiClient._loggedIn) { WARN('User not logged in, skipping library sections verification'); - return config; + return { config, hasChanges: false }; } if (!window.dataHelper || !window.dataHelper.getLibraries) { WARN('dataHelper.getLibraries not available, skipping library sections verification'); - return config; + return { config, hasChanges: false }; } const libraries = await window.dataHelper.getLibraries(); if (!libraries || libraries.length === 0) { LOG('No libraries found, skipping library sections verification'); - return config; + return { config, hasChanges: false }; } // Filter out boxsets and playlists; all templates apply to this list @@ -434,13 +604,16 @@ id: `recently-added-${library.Id}`, name: `Recently Added ${library.Name}`, viewMoreUrl: viewMoreUrl, + jellyfinId: 'latestmedia', + userConfigurable: true, queries: [{ path: '/Items/Latest', queryOptions: { ParentId: library.Id, - SortBy: 'DateCreated', - SortOrder: 'Descending', - Limit: 16 + Fields: 'PrimaryImageAspectRatio,Path', + Limit: 16, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', } }] }; @@ -467,6 +640,7 @@ return { id: `popular-genres-${library.Id}`, name: `Popular ${library.Name} Genres`, + userConfigurable: true, queries: [{ path: '/Genres', ParentId: library.Id, @@ -489,7 +663,7 @@ let hasChanges = false; for (const template of libraryTemplates) { - let group = updatedGroups.find(g => g.id === template.groupId); + let group = updatedGroups.find(g => g.id === template.groupId || g.name === template.groupName); if (!group) { group = { id: template.groupId, @@ -498,6 +672,9 @@ }; updatedGroups.push(group); hasChanges = true; + } else if (!group.id) { + group.id = template.groupId; + hasChanges = true; } const groupSections = group.sections || []; @@ -530,17 +707,182 @@ }); } + // 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; + } + } + if (!hasChanges) { LOG('Library-based sections are in sync with libraries'); - return config; + return { config, hasChanges: false }; } config.HOME_SECTION_GROUPS = updatedGroups; LOG('Library-based sections synced successfully'); - return config; + return { config, hasChanges: true }; } catch (error) { ERR('Error verifying library sections config:', error); - return config; + return { config, hasChanges: false }; + } + } + + const NATIVE_QUERY_SYNC_KEYS = { + queryLevel: ['path'], + queryOptions: ['Fields', 'Filters', 'IncludeItemTypes', 'MediaTypes', 'IsActive', 'IsScheduled', 'IsAiring', 'HasAired', 'IsInProgress'], + }; + + function buildCanonicalHomeSectionMap() { + const defaults = window.KefinHomeConfig2; + const map = new Map(); + if (!defaults?.HOME_SECTION_GROUPS) return map; + flattenSectionGroups(defaults.HOME_SECTION_GROUPS).forEach(section => { + if (section?.id) map.set(section.id, section); + }); + return map; + } + + 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; + } + + function syncKefinTweaksDefaultSections(config) { + const canonicalMap = buildCanonicalHomeSectionMap(); + const recentlyAddedTemplate = getRecentlyAddedCanonicalQuery(); + let hasChanges = false; + + const groups = config.HOME_SECTION_GROUPS || []; + 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?.queries?.length) continue; + + 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; + } + } + } + } + + return hasChanges; + } + + 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 queryChanges = syncKefinTweaksDefaultSections(config); + + if (libraryChanges || queryChanges) { + await saveConfig(config); + LOG('KefinTweaks default home sections synced on startup'); + } + } catch (error) { + ERR('Error ensuring KefinTweaks default sections:', error); + } + } + + let startupSyncDone = false; + + async function runStartupDefaultSectionSync() { + if (startupSyncDone) return; + + if (window.userHelper?.waitForLogin) { + const loggedIn = await window.userHelper.waitForLogin(); + if (!loggedIn) return; + } else if (!window.ApiClient?._loggedIn) { + return; + } + + startupSyncDone = true; + + try { + if (window.migrateHomeScreenConfig) { + await window.migrateHomeScreenConfig(); + } + await ensureKefinTweaksDefaultSections(); + } catch (error) { + ERR('Startup default section sync failed:', error); } } @@ -585,6 +927,7 @@ 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 || {}) }, MERGE_NEXT_UP: existingConfig.MERGE_NEXT_UP ?? defaults.MERGE_NEXT_UP ?? false }; @@ -610,7 +953,10 @@ 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 = flattenSectionGroups(mergedConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled === 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]; @@ -658,11 +1004,14 @@ return currentDate >= start && currentDate <= end; }; - const enabledHomeSections = flattenSectionGroups(config.HOME_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); - const enabledSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate)); - const enabledCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); - const enabledDiscoverySections = flattenSectionGroups(config.DISCOVERY_SECTION_GROUPS).filter(s => s.enabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); - const enabledCustomDiscoverySections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + 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 + }); return { @@ -675,7 +1024,7 @@ * 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 - Type of section to merge (home, seasonal, discovery, custom) + * @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') { @@ -773,10 +1122,24 @@ } }); - // Add the type to each section in the merged sections + // Stamp surface type on each section (never type=custom; authorship is isCustom) merged.forEach(group => { group.sections.forEach(section => { - section.type = sectionType; + 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; + } }); }); @@ -834,51 +1197,27 @@ return window.KefinTweaksConfig || {}; } - const server = window.ApiClient._serverAddress; - const token = window.ApiClient.accessToken(); - - // Get plugins list - const pluginsResponse = await fetch(`${server}/Plugins`, { - headers: { - 'X-Emby-Token': token + 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 || {}; } - }); - - if (!pluginsResponse.ok) { - throw new Error(`Failed to get plugins: ${pluginsResponse.status} ${pluginsResponse.statusText}`); - } - - const pluginsData = await pluginsResponse.json(); - const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); - - // Find JavaScript Injector plugin - const injectorPlugin = pluginsList.find(plugin => - plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector' - ); - - if (!injectorPlugin) { - WARN('JavaScript Injector plugin not found, using window.KefinTweaksConfig as fallback'); + } else { + WARN('KefinTweaksUtils.getPluginConfiguration not available, using window.KefinTweaksConfig as fallback'); return window.KefinTweaksConfig || {}; } - // Get injector config - const configResponse = await fetch(`${server}/Plugins/${injectorPlugin.Id}/Configuration`, { - headers: { - 'X-Emby-Token': token - } - }); - - if (!configResponse.ok) { - throw new Error(`Failed to get plugin config: ${configResponse.status} ${configResponse.statusText}`); - } - - const injectorConfig = await configResponse.json(); - // 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]*});/); @@ -891,7 +1230,7 @@ } } } - + // Fallback: use window.KefinTweaksConfig if available return window.KefinTweaksConfig || {}; } catch (error) { @@ -1002,6 +1341,8 @@ 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 @@ -1052,13 +1393,16 @@ mergedSections.push(JSON.parse(JSON.stringify(currentSection))); } }); - - // Add any remaining saved sections that weren't in current (preserve them) - savedSectionMap.forEach(savedSection => { - if (!savedSection.deleted) { - mergedSections.push(JSON.parse(JSON.stringify(savedSection))); - } - }); + + // Library template groups: current list is authoritative (verify may have pruned sections) + const groupId = currentGroup.id || savedGroup.id; + if (!LIBRARY_SYNC_GROUP_IDS.has(groupId)) { + savedSectionMap.forEach(savedSection => { + if (!savedSection.deleted) { + mergedSections.push(JSON.parse(JSON.stringify(savedSection))); + } + }); + } merged.sections = mergedSections; return merged; @@ -1124,12 +1468,17 @@ /** * Save configuration to JS Injector - * Merges partial configs with saved config to preserve existing data + * 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 { - // Load saved config from JS Injector - const savedConfig = await getSavedConfig(); + // 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 @@ -1170,24 +1519,22 @@ ...(savedHomeScreenConfig.HOME_SETTINGS || {}), ...(config.HOME_SETTINGS || {}) }, + USER_HOME_SCREEN_SETTINGS: { + ...(savedHomeScreenConfig.USER_HOME_SCREEN_SETTINGS || {}), + ...(config.USER_HOME_SCREEN_SETTINGS || {}) + }, MERGE_NEXT_UP: config.MERGE_NEXT_UP !== undefined ? config.MERGE_NEXT_UP : (savedHomeScreenConfig.MERGE_NEXT_UP !== undefined ? savedHomeScreenConfig.MERGE_NEXT_UP : false) }; - - // Ensure window.KefinTweaksConfig exists - if (!window.KefinTweaksConfig) { - window.KefinTweaksConfig = {}; - } - // Update homeScreenConfig with merged result + // 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; - - // Merge other top-level config properties (preserve existing) - Object.keys(savedConfig).forEach(key => { - if (key !== 'homeScreenConfig' && !window.KefinTweaksConfig.hasOwnProperty(key)) { - window.KefinTweaksConfig[key] = savedConfig[key]; - } - }); // Save to JS Injector using utils if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { @@ -1213,289 +1560,6 @@ /** * Build Jellyfin checkbox HTML */ - function buildCheckbox(id, checked, label) { - return ` - - `; - } - - /** - * Build Jellyfin select/dropdown HTML - */ - function buildSelect(id, options, selectedValue, label) { - const optionsHTML = options.map(opt => { - const value = typeof opt === 'string' ? opt : opt.value; - const label = typeof opt === 'string' ? opt : opt.label; - const selected = value === selectedValue ? 'selected' : ''; - return ``; - }).join(''); - - return ` -
- - -
- `; - } - - /** - * Build Jellyfin text input HTML - */ - function buildTextInput(id, value, label, type = 'text', placeholder = '') { - return ` -
- - -
- `; - } - - /** - * Build enabled toggle buttons (ENABLED/DISABLED) - */ - function buildEnabledToggleButtons(id, enabled) { - const isEnabled = enabled !== false; - return ` -
- - -
- `; - } - - /** - * Build toggle slider (ON/OFF switch) - generic function for both settings and sections - * @param {string} id - ID for the toggle (used for hidden checkbox if includeHiddenCheckbox is true) - * @param {boolean} checked - Initial checked/enabled state - * @param {string} label - Label text (only used if wrapInLabel is true) - * @param {object} options - Configuration options - * @param {boolean} options.includeHiddenCheckbox - Whether to include hidden checkbox (for settings) - * @param {object} options.dataAttributes - Data attributes object (e.g., {sectionId: '...', sectionType: '...'}) - * @param {string} options.cssClass - CSS class for toggle button (default: 'toggle-slider') - * @param {boolean} options.wrapInLabel - Whether to wrap in label container (default: true) - */ - function buildToggleSlider(id, checked, label, options = {}) { - const { - includeHiddenCheckbox = false, - dataAttributes = {}, - cssClass = 'toggle-slider', - wrapInLabel = true - } = options; - - const isEnabled = checked !== false; - const dataAttrsString = Object.entries(dataAttributes) - .map(([key, value]) => `data-${key}="${value}"`) - .join(' '); - - const toggleButton = ` - - `; - - const hiddenCheckbox = includeHiddenCheckbox ? ` - - ` : ''; - - if (wrapInLabel) { - return ` - - `; - } else { - return hiddenCheckbox + toggleButton; - } - } - - /** - * Update toggle slider UI programmatically - * @param {HTMLElement} toggleButton - The toggle button element - * @param {boolean} isEnabled - New enabled state - */ - function updateToggleSliderUI(toggleButton, isEnabled) { - if (!toggleButton) return; - - toggleButton.dataset.enabled = isEnabled; - toggleButton.style.background = isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'; - - // Update ON text - const onText = toggleButton.querySelector('span:first-of-type'); - if (onText) { - onText.style.opacity = isEnabled ? '1' : '0'; - onText.style.left = isEnabled ? '8px' : '6px'; - } - - // Update OFF text - const offText = toggleButton.querySelector('span:nth-of-type(2)'); - if (offText) { - offText.style.opacity = isEnabled ? '0' : '1'; - offText.style.right = isEnabled ? '6px' : '8px'; - } - - // Update knob position - const knob = toggleButton.querySelector('span:last-of-type'); - if (knob) { - knob.style.left = isEnabled ? 'calc(100% - 26px)' : '3px'; - } - - // Update hidden checkbox if present - const checkboxId = toggleButton.dataset.checkboxId; - if (checkboxId) { - const checkbox = document.getElementById(checkboxId); - if (checkbox) { - checkbox.checked = isEnabled; - } - } - } - - /** - * Build filter by played status toggle button - */ - function buildFilterByPlayedStatusToggle(id, checked) { - const isChecked = checked === true; - return ` -
- - -
- `; - } /** * Build compact section row HTML @@ -1603,8 +1667,7 @@ { id: 'home', label: 'Home Sections' }, { id: 'seasonal', label: 'Seasonal Sections' }, { id: 'discovery', label: 'Discovery Sections' }, - { id: 'custom', label: 'Custom Sections' }, - { id: 'create', label: 'Create New Section' } + { id: 'custom', label: 'Custom Sections' } ]; return ` @@ -1651,7 +1714,7 @@ function buildGlobalSettingsPanelContent(subTab) { switch (subTab) { case 'general': return buildGeneralSettingsHTML(); - case 'spotlight': return buildSpotlightSettingsHTML(); + case 'spotlight': return se().buildGlobalSpotlightSettingsHTML?.(currentConfig) || ''; case 'discovery': return buildDiscoverySettingsHTML(); case 'cache': return buildCacheSettingsHTML(); default: return buildGeneralSettingsHTML(); @@ -1664,8 +1727,6 @@ function buildGroupHTML(group, sectionType) { let sections = group.sections || []; if (sections.length === 0) return ''; - - sections = sections.filter(s => s.hidden !== true); const allEnabled = sections.every(s => s.enabled !== false); const groupId = `group-${group.name || 'unnamed'}-${group.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-'); @@ -1770,7 +1831,7 @@ return `
Custom Sections
-
Customize your Custom Home Sections here. You can add, edit, and delete your own sections. You can also import sections from the community from the "Browse Community Sections" tab or import from a file you exported from the "Import / Export Sections" tab.
+
Customize your Custom Home Sections here. You can add, edit, and delete your own sections. You can also import community collections or a file you exported from the Import / Export tab.
${customGroups.map(g => buildGroupHTML(g, 'custom')).join('')}
@@ -1793,32 +1854,50 @@ if (!currentConfig) return '
No configuration loaded.
'; // Use currentActiveTab to determine which tab/content should be displayed - const activeTab = currentActiveTab || 'settings'; + const activeTab = normalizeActiveTab(currentActiveTab); + currentActiveTab = activeTab; return `
+ +
+ + +
+ -
- - - - -
-
+
${buildGlobalSettingsNavigationHTML(currentGlobalSettingsSubTab)} @@ -1832,7 +1911,7 @@
-
+
${buildSectionNavigationHTML('home')} @@ -1850,40 +1929,10 @@
- -
-
Browse and import curated home screen sections from the community.
-
Submit your own Home Screen Sections here.
- -
- -
-
Collections
-
- -
-
- - -
-
Sections
- -
- Select a collection to view its sections -
-
-
-
- - +
Share your custom home screen sections or import configurations from others.
-
+
@@ -1891,6 +1940,17 @@ Import Sections
+
Browse Community Sections
+
Browse and import curated home screen sections from the community.
+
Submit your own Home Screen Sections here.
+
+
+
+
+ + +
+
+ + + + +
+
+
+

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..65f3e6e --- /dev/null +++ b/pages/games.html @@ -0,0 +1,108 @@ + + + + + + KefinTweaks Games + + + + + + 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/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..ca5e53b --- /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-bottom: 1.25rem; +} + +.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..b9da20e --- /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..de06c0b --- /dev/null +++ b/pages/movieLut.js @@ -0,0 +1,801 @@ +// 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; + } + + 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; + } + + function actorFilmography(actor) { + const items = actor.items || actor.actorItems || []; + return items.filter((m) => m && m.Id); + } + + 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(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 actorPool = shuffle(actors.filter((a) => actorFilmography(a).length >= 3)); + 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..b2a21f8 --- /dev/null +++ b/scripts/HomeScreenSection.ts @@ -0,0 +1,184 @@ +interface HomeScreenSection { + // Identification + id: string; + name: string; + caption?: string; + enabled: boolean; + order: number; + + // 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) + + // 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; + cardUrl: string; + }>; + + // 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; + source?: string; + /** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */ + pageNumber?: number; + + // Seasonal Config + startDate?: string; + endDate?: string; + + // 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') + minAge?: number; // Minimum age in days + maxAge?: number; // Maximum age in days + queryOptions?: { + // Query parameters for /Items or custom endpoints + 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 + 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; +} diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 579ff3e..5001d06 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 @@ -21,7 +102,6 @@ async function getActiveSession() { ensureApiClient(); - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const deviceId = ApiClient.deviceId(); @@ -39,7 +119,7 @@ method: 'GET', headers: { 'Accept': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': getAuthHeader() } }); @@ -71,7 +151,6 @@ const session = await getActiveSession(); const sessionId = session.Id; - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const limitedIds = ids.slice(0, MAX_PLAY_NOW_IDS); @@ -102,7 +181,7 @@ const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { method: 'POST', headers: { - 'X-Emby-Token': token, + 'Authorization': getAuthHeader(), } }); @@ -115,7 +194,6 @@ const session = await getActiveSession(); const sessionId = session.Id; - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const params = new URLSearchParams({ @@ -131,7 +209,7 @@ const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { method: 'POST', headers: { - 'X-Emby-Token': token + 'Authorization': getAuthHeader() } }); @@ -157,11 +235,12 @@ } /** - * 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(); + function getAuthHeaderForToken(token) { 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() : ''; @@ -170,11 +249,23 @@ `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 @@ -251,11 +342,36 @@ const state = { cachedServerVersion: null } + + /** + * Build a single top-level Likes query def (shared by getWatchlistItems / getWatchlistQuery). + * Callers pass IncludeItemTypes (and other options) to narrow results. + * @param {Object} options + * @returns {{ queryOptions: Object }} + */ + function buildWatchlistQueryDef(options = {}) { + return { + queryOptions: { + Recursive: true, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + ...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 @@ -274,7 +390,7 @@ ensureApiClient(); const useCache = options.useCache || false; - let ttl = 300000; + let ttl = 60000; if (Number(options.ttl) >= 0) { ttl = Number(options.ttl); } @@ -284,10 +400,9 @@ // Standard fetch function const fetchData = async () => { try { - const token = ApiClient.accessToken(); const response = await fetch(url, { headers: { - 'X-Emby-Token': token, + 'Authorization': getAuthHeader(), 'Accept': 'application/json' } }); @@ -382,8 +497,11 @@ 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...`); + /* LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`); // Cache is expired, check if we can validate it // Logic: Check SortBy @@ -403,10 +521,9 @@ checkUrl.searchParams.set('Limit', '3'); //checkUrl.searchParams.set('StartIndex', '0'); // Ensure start - const token = ApiClient.accessToken(); const checkResponse = await fetch(checkUrl.toString(), { headers: { - 'X-Emby-Token': token, + 'Authorization': getAuthHeader(), 'Accept': 'application/json' } }); @@ -474,17 +591,28 @@ } catch (e) { WARN('Error validating cache:', e); return true; // Assume stale on error - } + } */ }); - // 3. Define data promise - result.dataPromise = result.isStalePromise.then(isStale => { - if (isStale) { - LOG(`[Fetch] Refreshing data for: ${url.substring(0, 60)}...`); - return fetchData(); - } else { - LOG(`[Fetch] Using cached data for: ${url.substring(0, 60)}...`); - return result.data; // Should be set by isStalePromise logic if valid + // 3. Lazy data promise — do not start fetchData until ensureData()/dataPromise is used + result.ensureData = function() { + if (!result._dataPromise) { + result._dataPromise = result.isStalePromise.then(isStale => { + if (isStale) { + LOG(`[Fetch] Refreshing data for: ${url}...`); + return fetchData(); + } + LOG(`[Fetch] Using cached data for: ${url}...`); + return result.data; + }); + } + return result._dataPromise; + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return result.ensureData(); } }); @@ -492,6 +620,7 @@ // Initial check for cached data const entry = await cachePromise; result.data = entry ? entry.payload : null; + result.isStale = forceRefresh || !entry || (Date.now() - entry.timestamp > ttl); return result; })(); }, @@ -530,10 +659,9 @@ // Fetch from API try { - const token = ApiClient.accessToken(); const response = await fetch(url, { headers: { - 'X-Emby-Token': token, + 'Authorization': getAuthHeader(), 'Accept': 'application/json' } }); @@ -558,60 +686,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 window.dataHelper.getLibraries(); - - // We now want to include the library items with ColletionType "tvshows", or "movies" - const supportedCollectionTypes = ['tvshows', 'movies', 'homevideos', 'boxsets', 'playlists']; - const supportedLibraryItems = libraryItems.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'); - } else if (libraryItem.CollectionType === 'boxsets') { - itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'BoxSet'); - } else if (libraryItem.CollectionType === 'playlists') { - itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Playlist'); - } else if (libraryItem.CollectionType === 'homevideos') { - //itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Video'); - } else if (!libraryItem.CollectionType) { - itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie' || item === 'Series' || item === 'Season' || item === 'Episode' || item === 'Video'); - } - } - - // 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 = await 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: normalize(queryResult.data), + isStale: queryResult.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 @@ -688,7 +850,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Emby-Token': ApiClient.accessToken() + 'Authorization': getAuthHeader() }, body: JSON.stringify({ Rating: rating, @@ -764,7 +926,6 @@ throw new Error('No active session found'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); // Build NowPlayingQueue array - each item needs at least an Id @@ -784,7 +945,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': getAuthHeader() }, body: JSON.stringify(payload) }); @@ -822,9 +983,10 @@ * @param {string} serverUrl - Server URL * @returns {string|Object} - Query URL string or dataSource marker object */ - buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false) { + buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false, options = {}) { ensureApiClient(); + const sectionType = options.sectionType || ''; const queryOptions = { ...(query.queryOptions || {}) }; // Convert minAge/maxAge to premiere date range @@ -832,31 +994,29 @@ const now = new Date(); if (query.maxAge !== undefined) { - // maxAge days ago = minimum premiere date (oldest content) const minDate = new Date(now); minDate.setDate(minDate.getDate() - query.maxAge); queryOptions.MinPremiereDate = minDate.toISOString().split('T')[0]; } if (query.minAge !== undefined) { - // minAge days ago = maximum premiere date (newest content) const maxDate = new Date(now); maxDate.setDate(maxDate.getDate() - query.minAge); queryOptions.MaxPremiereDate = maxDate.toISOString().split('T')[0]; } } - // Build query based on type + const buildContext = { isSpotlight, sectionType }; + if (query.path) { - return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl); + return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl, buildContext); } if (query.dataSource) { return { dataSource: query.dataSource, options: queryOptions }; } - // Standard /Items query - return this.buildStandardQuery(queryOptions, userId, serverUrl, isSpotlight); + return this.buildStandardQuery(queryOptions, userId, serverUrl, buildContext); }, /** @@ -864,48 +1024,21 @@ * @param {Object} queryOptions - Query options * @param {string} userId - User ID * @param {string} serverUrl - Server URL - * @param {boolean} isSpotlight - Whether the query is for a spotlight section + * @param {{ isSpotlight?: boolean, sectionType?: string }} context * @returns {string} - Query URL */ - buildStandardQuery: function(queryOptions, userId, serverUrl, isSpotlight = false) { - // Fields that require pipe delimiter instead of comma - const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; - + buildStandardQuery: function(queryOptions, userId, serverUrl, context = {}) { + if (context === true || context === false) { + context = { isSpotlight: context === true }; + } const params = new URLSearchParams({ Recursive: 'true', - Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData', - UserId: userId + Fields: resolveQueryFields(queryOptions, context), }); - if (isSpotlight) { - params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios'); - } - - // Map all queryOptions to params - Object.entries(queryOptions || {}).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - if (Array.isArray(value)) { - // Use pipe delimiter for specific fields, comma for others - const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; - params.set(key, value.join(delimiter)); - } else { - // If the key is Limit and the value is 0, do not set the param - if (key === 'Limit' && value === 0) { - return; - } - // If the key is Fields and this is a spotlight query, merge the fields with the existing fields to ensure we always pull People/Genres/ParentBackdropImageTags/Studios - if (key === 'Fields' && isSpotlight) { - const newValue = params.get('Fields') + ',' + value; - params.set(key, newValue); - return; - } - - params.set(key, value); - } - } - }); - - return `${serverUrl}/Items?${params.toString()}`; + applyQueryOptionsToParams(params, queryOptions, { skipFields: true }); + + return userId ? `${serverUrl}/Users/${userId}/Items?${params.toString()}` : `${serverUrl}/Items?${params.toString()}`; }, /** @@ -914,41 +1047,41 @@ * @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) { - // Fields that require pipe delimiter instead of comma - const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; - - const params = new URLSearchParams({ - UserId: userId - }); - - // Add query options to params - Object.entries(queryOptions || {}).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - if (Array.isArray(value)) { - // Use pipe delimiter for specific fields, comma for others - const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; - params.set(key, value.join(delimiter)); - } else { - // If the key is Limit and the value is 0, do not set the param - if (key === 'Limit' && value === 0) { - return; - } - params.set(key, value); - } - } - }); - - // Add default fields for custom endpoints if not specified - if (!params.has('Fields')) { - params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,MediaSourceCount,UserData'); + 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()}`; }, @@ -1033,16 +1166,12 @@ // Combine all items from all queries const allItems = []; const itemsByQuery = []; - const allPromises = []; - const promisesByQuery = []; results.forEach(result => { if (result.data?.Items) { allItems.push(...result.data.Items); itemsByQuery.push(result.data.Items); } - allPromises.push(result.dataPromise); - promisesByQuery.push(result.dataPromise); }); // Sort merged items if sortBy specified @@ -1064,99 +1193,46 @@ }); } */ - // Create merged data promise - const dataPromisesByQuery = Promise.all(allPromises).then(allData => { - const dataPromises = []; - allData.forEach(data => { - if (data?.Items) { - dataPromises.push(data.Items); - } - }); - - // Sort merged results - /* if (sectionConfig.sortBy) { - mergedItems.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); - - 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 dataPromises; - }); + } + return dataPromise; + }; - /* const dataPromisesByQuery = Promise.all(promisesByQuery).then(allData => { - const dataPromises = []; - allData.forEach(data => { - if (data?.Items) { - dataPromises.push(data.Items); - } - }); - return dataPromises; - }); */ + 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: { - data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) }, - dataPromise: dataPromisesByQuery.then(data => window.cardBuilder.postProcessItemsByQuery(sectionConfig, data)), - isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false))) - .then(staleFlags => staleFlags.some(s => s)) - } + result: mergedResult }; }, - isAdmin: isAdmin, - - /** - * Get the current Jellyfin version from ApiClient - * Polls every 500ms for up to 5 seconds in the background if not immediately available - * @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable - */ - getJellyfinVersion: async function() { - try { - // If we have a cached value, return it - if (state.cachedServerVersion !== null) { - return state.cachedServerVersion; - } - - if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) { - return null; - } - - if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) { - state.cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion); - return state.cachedServerVersion; - } - - // Check the server version instead of app version - if (!window.ApiClient._serverVersion) { - // Wait 10s to see if it becomes ready, check every 500ms - const startTime = Date.now(); - while (Date.now() - startTime < 10000) { - if (window.ApiClient._serverVersion) { - break; - } - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - state.cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion); - return state.cachedServerVersion; - } catch (error) { - WARN('Error getting server version:', error); - return null; - } - }, + isAdmin: isAdmin, /** * Get the list of plugins from the server diff --git a/scripts/breadcrumbNav.css b/scripts/breadcrumbNav.css index 3421e5c..c02b703 100644 --- a/scripts/breadcrumbNav.css +++ b/scripts/breadcrumbNav.css @@ -1,126 +1,127 @@ /* 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; - } + .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-popover-container { - position: absolute; - top: 100%; - left: 0; - z-index: 1001; - pointer-events: all; - } + .kefinTweaks-popover-container { + position: absolute; + top: 100%; + left: 0; + z-index: 1001; + pointer-events: all; + } - /* When breadcrumbs are at bottom of page, open popover above */ - .kefinTweaks-popover-container.kefinTweaks-popover-container--above { - top: auto; - bottom: 100%; - } + /* When breadcrumbs are at bottom of page, open popover above */ + .kefinTweaks-popover-container.kefinTweaks-popover-container--above { + top: auto; + bottom: 100%; + } - .kefinTweaks-popover.kefinTweaks-popover--above { - margin-top: 0; - margin-bottom: 8px; - } + .kefinTweaks-popover.kefinTweaks-popover--above { + margin-top: 0; + margin-bottom: 8px; + } - .kefinTweaks-breadcrumbs span { - display: inline; - } + .kefinTweaks-breadcrumbs span { + display: inline; + } - .kefinTweaks-breadcrumbs span:hover { - color: rgba(255, 255, 255, 1); - } + .kefinTweaks-breadcrumbs span:hover { + color: rgba(255, 255, 255, 1); + } - /* Hover effects for clickable breadcrumbs only */ - .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover { - text-decoration: underline !important; - } + /* Hover effects for clickable breadcrumbs only */ + .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover { + text-decoration: underline !important; + } - /* 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; - } + /* 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; + } - .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-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-item:hover { - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 1); - } + .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:last-child { + border-bottom: none; + } - .kefinTweaks-popover-item.selected { - color: rgba(255, 255, 255, 1); - font-weight: bold; - } + .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; - } + .kefinTweaks-popover-item.selected::before { + content: ">"; + position: absolute; + left: 8px; + top: calc(50% - 1px); + transform: translateY(-50%); + font-weight: bold; + } - .skinHeader { - contain: none; - } + .skinHeader { + contain: none; + } - .headerLeft { - overflow: visible; - } + .headerLeft { + overflow: visible; + } } .kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) { - padding: 0 !important; - overflow-y: auto !important; - backdrop-filter: blur(50px); + padding: 0 !important; + overflow-y: auto !important; + backdrop-filter: blur(50px); } .kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) .kefinTweaks-popover-item.detailsGroupItem { - padding: .5em 2em; - margin: 0 !important; - border-radius: 0 !important; - width: initial !important; - max-width: initial !important; -} \ No newline at end of file + padding: 0.5em 2em; + margin: 0 !important; + border-radius: 0 !important; + width: initial !important; + max-width: initial !important; + white-space: nowrap; +} diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 67fd520..a90e61d 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -1,26 +1,27 @@ /* 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: 2em 0; + position: relative; + width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%))); + margin: 2em 0; } .spotlight-banner-container { - position: relative; - overflow: hidden; - border-radius: 8px; + position: relative; + overflow: hidden; + border-radius: 8px; } .layout-mobile .spotlight-banner-container { - display: flex; - justify-items: center; - align-items: center; - justify-content: center; + display: flex; + justify-items: center; + align-items: center; + justify-content: center; } .spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-banner-container { - height: 40vh; + height: 40vh; } /* Size variants */ @@ -29,843 +30,952 @@ } */ .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container { - height: 50vh + height: 50vh; } .spotlight-section[data-size="full"] .spotlight-banner-container { - height: 94vh !important; + height: 94vh !important; } .layout-mobile .spotlight-section[data-size="full"] .spotlight-banner-container { - height: 85vh !important; + 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; - contain: paint; - isolation: isolate; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + transition: opacity 0.8s ease-in-out; + contain: paint; + isolation: isolate; } .spotlight-item:not([data-active]):not([data-fade-out]) { - display: none; + display: none; } /* Crossfade: outgoing slide fades to 0 */ .spotlight-item[data-fade-out] { - opacity: 0; - pointer-events: none; + 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; + opacity: 0; } .spotlight-item[data-active]:not([data-entering]) { - opacity: 1; + 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%; + right: 0; + bottom: 0; + width: 100%; + height: 100%; } .spotlight-section[data-layout="Border"] .skeleton-spotlight-item { - border-radius: 8px; + border-radius: 8px; } /* Dual-backdrop background with masked crossfade */ .spotlight-background-dual { - position: absolute; - inset: 0; - z-index: 1; - overflow: hidden; + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; } .spotlight-section[data-layout="Border"] .spotlight-background-dual { - border-radius: 8px; + border-radius: 8px; } .spotlight-background-layer { - position: absolute; - top: 0; - bottom: 0; - overflow: hidden; + position: absolute; + top: 0; + bottom: 0; + overflow: hidden; } .spotlight-background-layer img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - pointer-events: none; + 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) 20s linear; + 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) 20s linear both; } /* Single backdrop (when tileCount is 1 or only one image) */ .spotlight-background-single { - position: absolute; - inset: 0; - z-index: 1; - overflow: hidden; + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; } .spotlight-background-single img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - pointer-events: none; + 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; + position: absolute; + inset: 0; } .spotlight-background-single.cycle-backdrops img:first-child { - z-index: 1; + z-index: 1; } .spotlight-background-single.cycle-backdrops img:last-child { - z-index: 2; + z-index: 2; } @keyframes kenBurnsZoomIn { - from { - transform: scale3d(1, 1, 0); - } - to { - transform: scale3d(1.12, 1.12, 0.1); - } - } + from { + transform: scale3d(1, 1, 0); + } + to { + transform: scale3d(1.12, 1.12, 0.1); + } +} - @keyframes kenBurnsZoomOut { - from { - transform: scale3d(1.15, 1.15, 0.1); - } - to { - transform: scale3d(1.02, 1.02, 1); - } - } +@keyframes kenBurnsZoomOut { + from { + transform: scale3d(1.15, 1.15, 0.1); + } + 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, 0.1); - } + from { + transform: scale3d(1.08, 1.08, 0); + } + to { + transform: scale3d(1.12, 1.12, 0.1); + } } @keyframes kenBurnsZoomOutFullscreen { - from { - transform: scale3d(1.15, 1.15, 0.1); - } - to { - transform: scale3d(1.08, 1.08, 1); - } + from { + transform: scale3d(1.15, 1.15, 0.1); + } + to { + transform: scale3d(1.08, 1.08, 1); + } } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes fadeInUp { - from { opacity: 0; transform: scale3d(1, 1, 1) translateY(30px); } - to { opacity: 1; transform: scale3d(1, 1, 1) translateY(0); } + from { + opacity: 0; + transform: scale3d(1, 1, 1) translateY(30px); + } + to { + opacity: 1; + transform: scale3d(1, 1, 1) translateY(0); + } } @keyframes fadeInDown { - from { opacity: 0; transform: scale3d(1, 1, 1) translateY(-30px); } - to { opacity: 1; transform: scale3d(1, 1, 1) translateY(0); } + from { + opacity: 0; + transform: scale3d(1, 1, 1) translateY(-30px); + } + to { + opacity: 1; + transform: scale3d(1, 1, 1) translateY(0); + } } - + @keyframes kenBurnsPanRight { - from { transform: scale3d(1.1, 1.1, 1) translateX(-5%); } - to { transform: scale3d(1.1, 1.1, 1) translateX(5%); } - } - - @keyframes kenBurnsPanLeft { - from { transform: scale3d(1.1, 1.1, 1) translateX(5%); } - to { transform: scale3d(1.1, 1.1, 1) translateX(-5%); } - } - - @keyframes kenBurnsPanUp { - from { transform: scale3d(1.1, 1.1, 1) translateY(5%); } - to { transform: scale3d(1.1, 1.1, 1) translateY(-5%); } - } - - @keyframes kenBurnsDiagonal { - from { transform: scale3d(1.08, 1.08, 1) translate(-5%, 5%); } - to { transform: scale3d(1.13, 1.13, 1) translate(5%, -5%); } - } - - @keyframes fadeInScale { - from { opacity: 0; transform: scale3d(0.95, 0.95, 1); } - to { opacity: 1; transform: scale3d(1, 1, 1); } - } - - @keyframes parallaxFloat { - 0%, 100% { transform: scale3d(1, 1, 1) translateY(0); } - 50% { transform: scale3d(1, 1, 1) translateY(-12px); } - } - - @keyframes depthPulse { - 0%, 100% { transform: scale3d(1, 1, 1); } - 50% { transform: scale3d(1.03, 1.03, 1); } - } + from { + transform: scale3d(1.1, 1.1, 0) translateX(-5%); + } + to { + transform: scale3d(1.1, 1.1, 1) translateX(5%); + } +} - @keyframes slowRotate { - from { transform: scale3d(1.15, 1.15, 1) rotate(0deg); } - to { transform: scale3d(1.15, 1.15, 1) rotate(3deg); } - } - - @keyframes breathe { - 0%, 100% { transform: scale3d(1, 1, 1); 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 kenBurnsPanLeft { + from { + transform: scale3d(1.1, 1.1, 0) translateX(5%); + } + to { + transform: scale3d(1.1, 1.1, 1) translateX(-5%); + } +} - @keyframes colorWash { - 0% { filter: saturate(1) brightness(1); } - 50% { filter: saturate(1.2) brightness(1.05); } - 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); } - } +@keyframes kenBurnsPanUp { + from { + transform: scale3d(1.1, 1.1, 0) translateY(5%); + } + to { + transform: scale3d(1.1, 1.1, 1) translateY(-5%); + } +} + +@keyframes kenBurnsDiagonal { + from { + transform: scale3d(1.08, 1.08, 0) translate(-5%, 5%); + } + to { + transform: scale3d(1.13, 1.13, 1) translate(5%, -5%); + } +} + +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale3d(0.95, 0.95, 1); + } + to { + opacity: 1; + transform: scale3d(1, 1, 1); + } +} + +@keyframes parallaxFloat { + 0%, + 100% { + transform: scale3d(1, 1, 1) translateY(0); + } + 50% { + transform: scale3d(1, 1, 1) translateY(-12px); + } +} + +@keyframes depthPulse { + 0%, + 100% { + transform: scale3d(1, 1, 1); + } + 50% { + transform: scale3d(1.03, 1.03, 1); + } +} + +@keyframes slowRotate { + from { + transform: scale3d(1.15, 1.15, 1) rotate(0deg); + } + to { + transform: scale3d(1.15, 1.15, 1) rotate(3deg); + } +} + +@keyframes breathe { + 0%, + 100% { + transform: scale3d(1, 1, 1); + 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.2) brightness(1.05); + } + 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%; + 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%; + 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%; + 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; -} - -.spotlight-section .spotlight-item:has(.spotlight-background-layer-center) .spotlight-background-layer { - width: 37.5%; + background: black; } /* Mobile: show only left backdrop (full width) */ .layout-mobile .spotlight-background-layer-center, .layout-mobile .spotlight-background-layer-right { - display: none !important; + display: none !important; } .layout-mobile .spotlight-background-layer-left { - width: 100% !important; - mask-image: none !important; + 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) 40%, transparent 100%); - 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; +.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 .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;} -.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 { + 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) 40%, transparent 100%); + 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 { + 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; +} +.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: 50px 40px 20px 40px; + padding: 50px 40px 20px 40px; } .spotlight-section-title { - color: white; - font-size: 18px; - font-weight: 600; - z-index: 10; - padding: 8px 16px; - letter-spacing: 2px; - word-spacing: 2px; + font-size: 18px; + font-weight: 600; + z-index: 2; + padding: 8px 16px; + letter-spacing: 2px; + word-spacing: 2px; } .layout-mobile .spotlight-section-title { - letter-spacing: 1px; + letter-spacing: 1px; } .spotlight-section-title a { - font-size: 1.1rem; + font-size: 1.1rem; } .spotlight-item-title { - margin: 0; - font-size: 3.5em; - font-weight: bold; - min-height: 0; - display: flex; - align-items: flex-end; - overflow: hidden; - max-height: 100%; + 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; + font-size: 3rem; } .layout-mobile .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-item-title { - font-size: 2rem; + font-size: 2rem; } .spotlight-item-logo { - height: 100%; - width: 100%; - object-fit: contain; - margin: 0; - background-size: contain; - background-repeat: no-repeat; - background-position: left; - max-height: 300px; + 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; + 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 { - -webkit-align-items: center; - align-items: center; - display: -webkit-flex; - display: flex; - -webkit-justify-content: center; - justify-content: center; - vertical-align: middle; + -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: #f2b01e; - font-size: 1.4em; - height: auto !important; - margin-right: .125em; - width: auto !important; + color: #f2b01e; + font-size: 1.4em; + height: auto !important; + margin-right: 0.125em; + width: auto !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; - overflow: visible; - min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + font-size: 14px; + opacity: 0.9; + overflow: visible; + min-width: 0; } .spotlight-metadata-row .itemDetailsGroup, .spotlight-metadata-row .sectionTabs { - flex-shrink: 0; + flex-shrink: 0; } .spotlight-credits-row .spotlight-truncated-list { - flex-shrink: 1; - min-width: 0; + 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; +.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; + 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; + 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; + 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; + 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; - align-items: start; - gap: 8px; - overflow: visible; - min-width: 0; - width: 100%; + 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; - overflow: hidden; - min-width: 0; - max-width: 100%; -} - -/* Allow tooltip to escape overflow when present */ -.spotlight-truncated-list:has(.spotlight-truncated-list-tooltip) { - overflow: visible; + font-size: 14px; + opacity: 0.9; + position: relative; + overflow: hidden; + min-width: 0; + max-width: 100%; } /* 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; + 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-truncated-list-tooltip-items { - display: flex; - flex-direction: column; - gap: 4px; + display: flex; + flex-direction: column; + gap: 4px; } .spotlight-truncated-list-tooltip-item { - display: block; + display: block; } .spotlight-truncated-list-tooltip.visible { - opacity: 1; - pointer-events: auto; /* Allow hovering tooltip to keep it 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; + color: inherit; + text-decoration: underline; + cursor: pointer; } .spotlight-list-separator { - opacity: 0.75; - margin: 0 0.4em; - user-select: none; + 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: 75%; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; + display: -webkit-box; + margin: 0; + font-size: 15px; + font-style: normal; + opacity: 0.9; + line-height: 1.45; + max-width: 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; + -webkit-line-clamp: 4; } .spotlight-section[data-size="full"] .spotlight-overview { - font-size: 1.05em; - -webkit-line-clamp: 4; + font-size: 1.05em; + -webkit-line-clamp: 4; } .spotlight-buttons-container { - display: flex; - gap: 5px; - align-items: center; - margin-top: auto; - position: relative !important; + 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; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.2s; } .spotlight-play-button:hover { - background: rgba(255,255,255,1); + 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; + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + color: white; + border: 2px solid rgba(255, 255, 255, 0.5); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.2s; } .spotlight-watchlist-button:hover { - background: rgba(255,255,255,0.3); + background: rgba(255, 255, 255, 0.3); } .spotlight-watchlist-button.watchlisted { - border-color: #4CAF50; + border-color: #4caf50; } .spotlight-watchlist-button.watchlisted .material-icons { - color: #4CAF50; + color: #4caf50; } .spotlight-watchlist-button:not(.watchlisted):hover { - border-color: rgba(255,255,255,0.7); + border-color: rgba(255, 255, 255, 0.7); } .spotlight-watchlist-button .material-icons { - font-size: 28px; - color: white; + font-size: 28px; + color: white; } .spotlight-info-button { - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - position: relative; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + position: relative; } .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; + text-align: left; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 12px; + padding: 16px; + background: rgba(0, 0, 0, 0.95); + color: white; + border-radius: 8px; + font-size: 14px; + line-height: 1.6; + max-width: 400px; + min-width: 300px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + word-wrap: break-word; } .spotlight-info-button:hover .spotlight-overview-tooltip { - opacity: 1; + opacity: 1; } .spotlight-nav-container { - position: absolute !important; - top: 0px; - right: 0px; - display: flex; - gap: 8px; - z-index: 10; + position: absolute !important; + top: 0px; + right: 0px; + display: flex; + gap: 8px; + z-index: 2; } .spotlight-nav-button { - background: transparent; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - margin: 0 !important; - 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); + 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; + 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; + 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; + 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, .spotlight-studio-link { - color: inherit; - text-decoration: underline; - cursor: pointer; + 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 { - -webkit-line-clamp: 1; - bottom: 60px; - } + .spotlight-nav-container { + display: none !important; + } + + .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; + background: rgba(0, 0, 0, 0.7); + } + + .spotlight-tagline { + -webkit-line-clamp: 1; + bottom: 60px; + } } .skeleton-card { - margin-bottom: 45px !important; + margin-bottom: 45px !important; } /* Tagline always 1 line - Movie-specific override kept for any edge cases */ [data-item-type="Movie"] .spotlight-tagline { - -webkit-line-clamp: 1; -} + -webkit-line-clamp: 1; +} // Remove this if we switch back to Jellyfin's default scroll buttons .emby-scrollbuttons.padded-right { @@ -903,203 +1013,446 @@ /* 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); + 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); + opacity: 1; + transform: translateY(0); } -.show-all-button { - margin-left: 10px; - padding: 4px 8px; - min-width: auto; - background: transparent; - border-radius: 4px; - cursor: pointer; - align-self: center; - border: 1px solid rgba(255, 255, 255, 0.3) !important; - color: rgba(255, 255, 255, 0.87) !important; - opacity: 0.65; - position: relative; - display: none; /* Hidden by default */ +/* 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; + align-items: center; + text-align: left; } .custom-scroller-container .sectionTitleContainer { - display: flex; - align-items: center; + display: flex; + align-items: center; + gap: 0.35em; } -/* Expanded grid view styles */ -.itemsContainer[data-expanded="true"] { - display: flex !important; - flex-wrap: wrap !important; - white-space: normal !important; - transform: none !important; - transition: none !important; +.layout-mobile .custom-scroller-container .section-controls { + margin-left: auto !important; } -/* Hide scroll buttons when expanded */ -.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) .emby-scrollbuttons { - display: none !important; +.section-controls { + display: flex; + align-items: center; + gap: 0.15em; + flex-shrink: 0; + position: relative; } -/* Refresh Button Styles */ -.section-refresh-button { - margin-left: 8px; - padding: 4px; - min-width: auto; - width: 32px; - height: 32px; - background: transparent; - border: none; - cursor: pointer; - display: inline-block; - align-items: center; - justify-content: center; - color: var(--main-text, rgba(255, 255, 255, 0.87)); - border-radius: 50%; - font-size: 18px; - transition: transform 0.3s ease, color 0.3s ease, background 0.2s ease; - opacity: 0.65; +.section-controls-inline { + display: flex; + align-items: center; + gap: 0.15em; } -.section-refresh-button:hover { - background: rgba(255, 255, 255, 0.1); +.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; +} + +.section-controls-more-popover .section-controls-more-item { + align-items: start; +} + +.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; +} + +.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 .section-controls-more { + display: inline-flex !important; + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +/* Grid (non-scrolling) item layouts */ +.itemsContainer[data-layout="grid"] { + display: flex !important; + flex-wrap: wrap !important; + white-space: normal !important; + transform: none !important; + transition: none !important; +} + +.itemsContainer[data-layout="grid"] > .card { + flex: auto; + box-sizing: border-box; + min-width: 0; +} + +/* Natural widths while counting items-per-row for last-row dummy padding */ +.itemsContainer[data-measuring="true"] > .card { + flex: 0 0 auto !important; +} + +/* Grid dummies keep aspect via padder but allow .cardBorder to paint */ +.itemsContainer[data-layout="grid"] > .card.card-layout-dummy { + visibility: hidden; + position: relative; + pointer-events: none; +} + +[data-border-style="wood"] .itemsContainer[data-layout="grid"] > .card.card-layout-dummy, +[data-border="wood"] .itemsContainer[data-layout="grid"] > .card.card-layout-dummy { + visibility: visible; +} + +.itemsContainer[data-layout="grid"] > .card.card-layout-dummy .cardPadder { + visibility: hidden; +} + +.itemsContainer[data-layout="grid"] > .card.card-layout-dummy .cardBorder { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + box-sizing: border-box; + --card-border-cream: var(--kefin-card-border-color, #e8dab2); + --card-border-fancy: var(--kefin-card-border-color, rgb(231 255 123 / 35%)); +} + +.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: 1rem 0 !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 */ +[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle, +[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle-link, +[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle-wrapper, +[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; +} + +.sectionTitle.sectionCaption { + font-size: 0.9em; + 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%)); +} + +.sectionTitle-wrapper { + display: grid; + align-items: center; + grid-template-columns: 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 > .section-controls { + grid-column: 2; + grid-row: 1; + align-self: center; } /* Spinning animation when refreshing */ [data-refreshing="true"] .section-refresh-button { - animation: spin 1s linear infinite; + animation: spin 1s linear infinite; } /* Checkmark state - green color to indicate success */ .section-refresh-button.check_circle { - animation: none; + animation: none; } -/* Smooth transition when changing icons */ +/* Keep icon-state transitions without overriding the shared hover fade */ .section-refresh-button.refresh, .section-refresh-button.check_circle { - transition: transform 0.3s ease, color 0.3s ease, background 0.2s ease; + 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 */ .section-refresh-button::after { - transition: opacity 0.2s ease; + transition: opacity 0.2s ease; } .section-refresh-button.icon-fade-out::after { - opacity: 0; + opacity: 0; } .section-refresh-button.icon-fade-in::after { - opacity: 1; + opacity: 1; } @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} -/* Ensure button remains visible when expanded (data-expandable takes precedence, but this ensures it) */ -.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) .show-all-button { - display: initial !important; + 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: 4%; - --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)); + --spotlight-fullscreen-vertical: 4%; + --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: -6rem; - margin-bottom: -6rem; + margin-top: -6rem; + margin-bottom: -6rem; } .spotlight-section[data-layout="Borderless"] .spotlight-banner-container { - border-radius: 0; - /* No padding: background fills full size so image extends edge-to-edge and into overlap zone */ - position: relative; - /* Wider mask so backdrop fades gradually at top/bottom (no visible solid edge during animation) */ - -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; + border-radius: 0; + /* No padding: background fills full size so image extends edge-to-edge and into overlap zone */ + position: relative; + /* Wider mask so backdrop fades gradually at top/bottom (no visible solid edge during animation) */ + -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; } /* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps */ .spotlight-section[data-layout="Borderless"] .spotlight-banner-container::before, .spotlight-section[data-layout="Borderless"] .spotlight-banner-container::after { - content: ''; - position: absolute; - left: 0; - right: 0; - width: 100%; - height: 22%; - pointer-events: none; - z-index: 2; + content: ""; + position: absolute; + left: 0; + right: 0; + width: 100%; + height: 22%; + pointer-events: none; + z-index: 2; } .spotlight-section[data-layout="Borderless"] .spotlight-banner-container::before { - top: 0; - background: linear-gradient( - to bottom, - rgba(0, 0, 0, 0.85) 0%, - rgba(0, 0, 0, 0.5) 40%, - transparent 100% - ); + top: 0; + background: linear-gradient(0deg, transparent, black); } .spotlight-section[data-layout="Borderless"] .spotlight-banner-container::after { - bottom: 0; - background: linear-gradient( - to top, - rgba(0, 0, 0, 0.85) 0%, - rgba(0, 0, 0, 0.5) 40%, - transparent 100% - ); + 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%; + /* 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 */ @@ -1115,119 +1468,112 @@ /* 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; - background: linear-gradient(to right, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 70%, transparent 100%); + left: 0; + width: 45%; + z-index: 3; + grid-template-rows: 1fr auto auto auto; + gap: 1.5em; + background: linear-gradient(to right, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.7) 70%, transparent 100%); } .spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-overlay { - padding: calc(var(--spotlight-fullscreen-vertical) * 1.75) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); + padding: calc(var(--spotlight-fullscreen-vertical) * 1.75) var(--spotlight-fullscreen-horizontal) + calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); } .spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-overlay { - padding: calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); - grid-template-rows: 1fr auto minmax(6rem, auto) minmax(48px, auto); - align-content: center; - gap: 1.5em; + padding: calc(var(--spotlight-fullscreen-vertical) * 2.25) var(--spotlight-fullscreen-horizontal) + calc(var(--spotlight-fullscreen-vertical) * 2.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; -} - - -.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-overlay.has-title { - padding: calc(var(--spotlight-fullscreen-vertical) * 2.25) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2.5) var(--spotlight-fullscreen-horizontal); + 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; + width: auto; + max-width: none; + font-size: 4rem; } /* Duplicate removed - clearart display:none already set for Borderless above */ .spotlight-section[data-layout="Borderless"] .spotlight-section-title-container { - left: var(--spotlight-fullscreen-horizontal); - top: calc(var(--spotlight-fullscreen-vertical) * 2.5); + left: var(--spotlight-fullscreen-horizontal); + top: calc(var(--spotlight-fullscreen-vertical) * 2.5); } .spotlight-section[data-layout="Borderless"] .spotlight-nav-container { - right: var(--spotlight-fullscreen-horizontal); - top: calc(var(--spotlight-fullscreen-vertical) * 2.5); + right: var(--spotlight-fullscreen-horizontal); + top: calc(var(--spotlight-fullscreen-vertical) * 2.5); } .spotlight-section[data-layout="Borderless"] .spotlight-dots { - bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5); + bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5); } .spotlight-section .spotlight-background-layer img, .spotlight-section .spotlight-background-single img { - object-position: center 0%; + object-position: center 0%; } .spotlight-section[data-layout="Borderless"] .spotlight-clearart { - display: none; -} - -:has(.spotlight-section[data-layout="Borderless"]) .homeSectionsContainer > *:not(.spotlight-section) { - z-index: 1; + display: none; } /* Tagline at bottom center above navigation dots (all spotlights) - always 1 line max */ .spotlight-tagline { - position: absolute !important; - left: 50%; - transform: translateX(-50%); - width: fit-content; - max-width: min(85%, 700px); - text-align: center; - font-style: italic; - font-size: 1.05em; - margin: 0; - padding: 8px 16px; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - border-radius: 6px; - z-index: 4; - pointer-events: none; - line-height: 1.4; + position: absolute !important; + left: 50%; + transform: translateX(-50%); + width: fit-content; + max-width: min(85%, 700px); + text-align: center; + font-style: italic; + font-size: 1.05em; + margin: 0; + padding: 8px 16px; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 6px; + z-index: 4; + pointer-events: none; + line-height: 1.4; } .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; + font-weight: initial !important; + transform: initial !important; + width: 100%; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + cursor: default; } /* Normal and large: tagline above dots (dots at bottom: 20px) */ .spotlight-section[data-layout="Border"] .spotlight-tagline { - bottom: 40px; + bottom: 40px; } .spotlight-section[data-layout="Borderless"] .spotlight-tagline { - bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5 + 40px); + 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; + gap: 10px; } .spotlight-section[data-size="full"] .spotlight-metadata-row { - font-size: 15px; - gap: 10px; + font-size: 15px; + gap: 10px; } /* Full-screen: uniform padding for all metadata pills (year, duration, genres, director, writer, series) */ @@ -1239,408 +1585,376 @@ } */ .spotlight-section[data-size="full"] .spotlight-genre-link { - display: inline-block; - transition: background 0.2s, border-color 0.2s; + 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; + display: none; } .spotlight-truncated-list .detailsGroupItem[data-icon], .spotlight-truncated-list .emby-tab-button[data-icon] { - display: inline-flex; - align-items: center; + 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 */ + 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); -} - -/* Director/writer/series info: no extra padding - detailsGroupItem matches other metadata pills */ -.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; + content: attr(data-icon); } .spotlight-section a:not(:hover) { - text-decoration: none !important; + 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; + 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%; + width: 95%; } - /* Mobile spotlight: uniform layout TITLE -> gap -> LOGO -> METADATA -> OVERVIEW -> BUTTONS -> DOTS */ .layout-mobile .spotlight-tagline { - display: none !important; + display: none !important; } .layout-mobile .spotlight-overlay { - width: calc(100% - 2rem) !important; - background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.85) 100%) !important; - padding: 4rem 1rem 2rem 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; + width: calc(100% - 2rem) !important; + background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.4) 40%, rgba(0, 0, 0, 0.85) 100%) !important; + padding: 4rem 1rem 2rem 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; + padding: 9rem 1rem 6rem 1rem !important; } .layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-section-title-container { - top: 6rem; + top: 6rem; } .layout-mobile .spotlight-section .spotlight-section-title-container { - top: 15px; - max-width: calc(100% - 2rem); - left: unset !important; - justify-content: center; - margin: 0 auto !important; + top: 15px; + max-width: calc(100% - 2rem); + left: unset !important; + justify-content: center; + margin: 0 auto !important; } .layout-mobile .metadata-container { - display: flex !important; + display: flex !important; } .layout-mobile .spotlight-logo-section { - height: 100% !important; - min-height: 0 !important; - align-content: end; + height: 100% !important; + min-height: 0 !important; + align-content: end; } .layout-mobile .spotlight-overview { - max-width: 100%; + max-width: 100%; } .spotlight-series-info { - text-transform: capitalize; + text-transform: capitalize; } .spotlight-section[data-size="full"] .metadata-container * { - font-size: 1.1rem; + font-size: 1.1rem; } -/* Native Jellyfin itemDetailsGroup structure for all spotlight metadata */ -.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 .itemDetailsGroup:not(.hide) .detailsGroupItem, .spotlight-section .itemDetailsGroup:not(.hide) .emby-tab-button { - margin: 0px !important; - gap: 0px !important; + margin: 0px !important; + gap: 0px !important; } .spotlight-section .spotlight-buttons-container.mainDetailButtons .btnPlay { - right: 0px !important; - margin: .3em !important; + right: 0px !important; + margin: 0.3em !important; } .spotlight-section .spotlight-buttons-container.mainDetailButtons { - margin: 0px !important; + margin: 0px !important; } .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; + 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; } .spotlight-section:not(.hide) .emby-tab-button { - height: initial !important; - padding: 0.5rem 0.5rem !important; - margin: 0px !important; - top: initial !important; - transform: initial !important; + height: initial !important; + padding: 0.5rem 0.5rem !important; + margin: 0px !important; + top: initial !important; + transform: initial !important; } .spotlight-section-title.headerTabs.sectionTabs, .spotlight-nav-container.headerTabs.sectionTabs { - display: flex !important; - flex-direction: row !important; + display: flex !important; + flex-direction: row !important; + align-self: start; + align-items: start; + justify-content: start; + width: auto; +} + +.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[data-layout="Border"] .spotlight-section-title-container { - top: 0; + top: 0; } .spotlight-nav-buttons-container { - display: flex !important; - gap: 8px !important; - font-size: 1.1rem !important; -} - -.spotlight-section:not(.hide) span.emby-tab-button.emby-tab-button-active:not(:has(a)) { - cursor: default; + display: flex !important; + gap: 8px !important; + font-size: 1.1rem !important; } .spotlight-nav-button span.material-icons { - font-size: 24px !important; - display: inline-block !important; - top: initial !important; + font-size: 24px !important; + display: inline-block !important; + top: initial !important; } .spotlight-section-title > * { - font-size: 18px !important; + font-size: 18px !important; } -.spotlight-metadata-row *:not(a) { - cursor: default; +.spotlight-metadata-row *:not(a) { + cursor: default; } .spotlight-section[data-layout="Borderless"][data-size="normal"] { - margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2)); - margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2)); + margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2)); + margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2)); } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container { - height: 55vh; + height: 55vh; } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overview-section { - display: none !important; + display: none !important; } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-buttons-section { - margin-top: -1.5em; + margin-top: -1.5em; } .spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-banner-container { - height: 72vh; + height: 72vh; } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay.has-title { - padding: calc(var(--spotlight-fullscreen-vertical) * 3) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); + padding: calc(var(--spotlight-fullscreen-vertical) * 3) var(--spotlight-fullscreen-horizontal) + calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); } .spotlight-section[data-layout="Borderless"][data-size="normal"] { - --spotlight-fullscreen-vertical: 3em; - --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); + --spotlight-fullscreen-vertical: 3em; + --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-section-title-container { - left: var(--spotlight-fullscreen-horizontal); - top: calc(var(--spotlight-fullscreen-vertical) * 1.75); + left: var(--spotlight-fullscreen-horizontal); + top: calc(var(--spotlight-fullscreen-vertical) * 2); } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-tagline { - bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5 + 40px); + 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); + bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5); } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-nav-container { - right: var(--spotlight-fullscreen-horizontal); - top: calc(var(--spotlight-fullscreen-vertical) * 2); + right: var(--spotlight-fullscreen-horizontal); + top: calc(var(--spotlight-fullscreen-vertical) * 2); } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay { - grid-template-rows: 1fr auto auto auto; + grid-template-rows: 1fr auto auto auto; } .spotlight-section[data-size="normal"] .spotlight-overlay { - grid-template-rows: var(--spotlight-logo-height) auto 1fr auto; + 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) 1fr; + grid-template-rows: 3fr auto minmax(6rem, auto) 1fr; } .spotlight-section[data-layout="Border"][data-size="full"] .spotlight-logo-section { - align-self: end; + align-self: end; } .spotlight-section[data-layout="Border"][data-size="full"] .spotlight-buttons-section { - align-self: start; + align-self: start; } .spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overlay { - display: flex; - flex-direction: column; - padding: 75px 40px 25px 40px; + 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; + 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; + 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) 1fr; + grid-template-rows: 2fr auto minmax(auto, 6rem) 1fr; } .spotlight-section[data-size="large"] .spotlight-overlay .spotlight-item-logo { - background-position: left bottom; + background-position: left bottom; } .spotlight-section[data-size="large"] .spotlight-overlay .spotlight-buttons-section { - align-items: start; + align-items: start; } .spotlight-section[data-layout="Border"] .spotlight-overlay { - padding: 85px 40px 45px 40px; + padding: 85px 40px 45px 40px; } .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay { - padding: 85px 40px 45px 40px; + padding: 85px 40px 45px 40px; } .spotlight-section[data-layout="Border"][data-size="large"] .spotlight-overlay { - padding: 100px 40px 45px 40px; + padding: 100px 40px 45px 40px; } .spotlight-section[data-layout="Border"][data-size="full"] .spotlight-overlay { - padding: 110px 40px 85px 40px; + padding: 110px 40px 85px 40px; } .spotlight-section[data-layout="Border"] .spotlight-section-title-container { - top: 15px; - left: 40px; + top: 15px; + left: 40px; } .spotlight-section[data-layout="Border"][data-size="large"] .spotlight-section-title-container, .spotlight-section[data-layout="Border"][data-size="full"] .spotlight-section-title-container { - top: 2rem; - left: 2rem; + top: 2rem; + left: 2rem; } .spotlight-section[data-layout="Border"] .spotlight-nav-container { - top: 15px; - right: 40px; + top: 15px; + right: 40px; } .spotlight-section[data-layout="Border"][data-size="large"] .spotlight-nav-container, -.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-nav-container { - top: 2rem; - right: 2rem; +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-nav-container { + top: 2rem; + right: 2rem; } .spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-section-title a { - font-size: 1rem !important; + font-size: 1rem !important; } .spotlight-section[data-layout="Border"] .spotlight-banner-container { - border: 1px solid rgb(255 255 255 / 10%); + border: 1px solid rgb(255 255 255 / 10%); } .spotlight-section[data-layout="Border"][data-size="large"] .spotlight-banner-container { - height: 60vh; + height: 60vh; } .spotlight-section[data-layout="Border"][data-size="full"] .spotlight-banner-container { - height: 80vh !important; + height: 80vh !important; } .spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overview { - -webkit-line-clamp: 2; + -webkit-line-clamp: 2; } .spotlight-section[data-size="normal"] .spotlight-background-single img, .spotlight-section[data-size="large"] .spotlight-background-single img { - object-position: center 20%; + object-position: center 20%; } .layout-mobile .sectionTitleContainer-cards .sectionTitle-link > * { - padding-top: 0 !important; + padding-top: 0 !important; } -button.show-all-button::after { - content: 'Expand'; +.layout-mobile .emby-scroller-container .sectionTitleContainer .section-controls { + order: 1; } -.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) button.show-all-button::after { - content: 'Collapse'; -} - -.layout-mobile button.show-all-button::after { - content: 'keyboard_arrow_down'; - font-family: 'Material Icons'; -} - -.layout-mobile button.show-all-button { - padding: 0; - border: none !important; - font-size: 1.5rem; -} - -.layout-mobile .emby-scroller-container:has(.itemsContainer[data-expanded="true"]) button.show-all-button::after { - content: 'keyboard_arrow_up'; -} - -.layout-mobile .emby-scroller-container .sectionTitleContainer .show-all-button { - order: 1; - margin-left: auto; - margin-right: 1rem; +.layout-mobile .section-controls-inline .show-all-button, +.layout-mobile .section-controls-inline .section-refresh-button, +.layout-mobile .section-controls-inline .section-configure-button { + opacity: 1; + visibility: visible; } .layout-mobile .spotlight-section .spotlight-nav-container { - display: none !important; + display: none !important; } .layout-mobile .spotlight-section .spotlight-overlay { - display: flex; - flex-direction: column; - justify-content: end; - align-items: center; + display: flex; + flex-direction: column; + justify-content: end; + align-items: center; } .layout-mobile .spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-overlay { - /* padding-bottom: 5rem !important; */ + /* padding-bottom: 5rem !important; */ } -.layout-mobile .spotlight-overview-section, +.layout-mobile .spotlight-overview-section, .layout-mobile .spotlight-credits-row, .layout-mobile .spotlight-series-info { - display: none; + display: none; } .layout-mobile .spotlight-logo-section { - width: 100%; - justify-content: center; + width: 100%; + justify-content: center; } .layout-mobile .spotlight-dots { - bottom: 1rem !important; + bottom: 1rem !important; } .layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-dots { - bottom: 5rem !important; + 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.sectionTabs:has(.emby-tab-button:not(.hide)) { - justify-self: center; - display: flex !important; - justify-content: center; + 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 .spotlight-section-title.sectionTabs:has(.emby-tab-button:not(.hide)) > * { - display: block !important; + max-width: 100%; + min-width: 0; + text-align: center; } /* .layout-mobile .spotlight-section[data-layout="Borderless"][data-order="1"] .spotlight-section-title { @@ -1651,73 +1965,61 @@ button.show-all-button::after { } */ .layout-mobile .spotlight-section-title .emby-tab-button { - padding: 0.25rem !important; + padding: 0.25rem !important; } -.layout-mobile .spotlight-section .emby-tab-button, -.layout-mobile .spotlight-metadata-section span a, +.layout-mobile .spotlight-section .emby-tab-button, +.layout-mobile .spotlight-metadata-section span a, .layout-mobile .spotlight-metadata-section span { - font-size: 1rem !important; + font-size: 1rem !important; } .layout-mobile .spotlight-section .spotlight-item-title { - font-size: 2rem !important; - text-align: center; + font-size: 2rem !important; + text-align: center; } .layout-mobile .spotlight-section:not(.hide) .spotlight-tagline { - display: none !important; + display: none !important; } .layout-mobile .spotlight-buttons-container.mainDetailButtons span { - font-size: 1.2rem; + font-size: 1.2rem; } .layout-mobile .spotlight-section .spotlight-metadata-row { - justify-content: center; - align-self: center; + justify-content: center; + align-self: center; } .layout-mobile .spotlight-buttons-section { - justify-self: center; -} - -.emby-scroller-container:has(.emby-scrollbuttons-button:not(.hide)) button.show-all-button { - display: flex; -} - -.layout-mobile .emby-scroller-container:has(.overflowPortraitCard:nth-child(4)) button.show-all-button, -.layout-mobile .emby-scroller-container:has(.overflowBackdropCard:nth-child(3)) button.show-all-button { - display: flex; + justify-self: center; } .emby-scroller-container[data-card-format="Button"] .cardScalable { - display: none; + display: none; } .emby-scroller-container[data-card-format="Button"] .cardText { - display: flex !important; - padding: 0 !important; + 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; + 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; + width: auto !important; } .emby-scroller-container[data-card-format="Button"] .itemsContainer { - gap: 0.5rem; - margin-top: 0.5rem; -} -.emby-scroller-container:has([data-custom-card="true"]) .section-refresh-button { - display: none; + gap: 0.5rem; + margin-top: 0.5rem; } .emby-scroller-container[data-card-format="Button"] { - margin-bottom: 1rem; + margin-bottom: 1rem; } .emby-scroller-container[data-card-format="Logo"] .cardScalable, @@ -1728,81 +2030,1960 @@ button.show-all-button::after { .emby-scroller-container[data-card-format="Disc"] .cardPadder, .emby-scroller-container[data-section-id="liveTv-channels"] .cardScalable, .emby-scroller-container[data-section-id="liveTv-channels"] .cardPadder { - background: transparent !important; - box-shadow: none !important; - border: none !important; + background: transparent !important; + box-shadow: none !important; + border: none !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; + background-size: contain !important; + box-shadow: none !important; } .emby-scroller-container[data-card-format="Banner"] .card { - width: 25vw !important; + width: 25vw !important; } .layout-mobile .emby-scroller-container[data-card-format="Banner"] .card { - width: 50vw !important; + width: 50vw !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; + 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; + 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; + opacity: 1; } -:not(.layout-mobile) .emby-scroller-container[data-card-format="Logo"] .itemsContainer, -:not(.layout-mobile) .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer, -:not(.layout-mobile) .emby-scroller-container[data-card-format="Disc"] .itemsContainer { - gap: 4rem; +: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; } -.layout-mobile .emby-scroller-container[data-card-format="Logo"] .itemsContainer, -.layout-mobile .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer, -.layout-mobile .emby-scroller-container[data-card-format="Disc"] .itemsContainer { - gap: 2rem; +.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; } .emby-scroller-container[data-hide-card-footer] .card .cardText { - display: none; + display: none; } .spotlight-section-title-container { - position: absolute; - width: 100%; - display: flex; + position: absolute; + width: 100%; + display: flex; + align-items: center; + gap: 0.35em; } -.emby-scroller-container .emby-scrollbuttons:has([data-direction="right"]:disabled):has([data-direction="left"]:disabled) { - display: none; +.layout-mobile .spotlight-section-title-container .section-controls { + margin-left: auto; + flex-shrink: 0; } .spotlight-rating-container:hover { - background-color: transparent !important; - cursor: auto; + background-color: transparent !important; + cursor: auto; +} + +.spotlight-section .emby-tab-button { + text-shadow: none !important; +} + +.spotlight-buttons-container .starRatingContainer { + margin-right: 1em !important; +} + +.custom-scroller-container { + position: relative; + z-index: 1; +} + +[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; +} + +.emby-scroller-container:has(.itemsContainer[data-layout="grid"]) .emby-scrollbuttons { + display: none !important; +} + +.emby-scroller-container:has(.emby-scrollbuttons-button:not(.hide)) .section-controls-more { + display: inline-block; +} + +.layout-mobile .emby-scroller-container:has(.overflowPortraitCard:nth-child(4)) button.show-all-button, +.layout-mobile .emby-scroller-container:has(.overflowPortraitCard:nth-child(4)) .section-controls-more, +.layout-mobile .emby-scroller-container:has(.overflowBackdropCard:nth-child(3)) button.show-all-button, +.layout-mobile .emby-scroller-container:has(.overflowBackdropCard:nth-child(3)) .section-controls-more { + display: inline-block; +} + +/* .spotlight-section .spotlight-item:has(.spotlight-background-layer-center) .spotlight-background-layer { + width: 37.5%; } .emby-scroller-container:has([data-direction="right"]:disabled):has([data-direction="left"]:disabled) button.show-all-button { display: none; } -.spotlight-tagline .emby-tab-button, -.spotlight-nav-container .spotlight-nav-buttons-container, -.spotlight-section-title .emby-tab-button { - background-color: rgb(0 0 0 / 45%); - border-radius: 5px; -} \ No newline at end of file +.emby-scroller-container .emby-scrollbuttons:has([data-direction="right"]:disabled):has([data-direction="left"]:disabled) { + display: none; +} + +.spotlight-truncated-list:has(.spotlight-truncated-list-tooltip) { + overflow: visible; +} + +.emby-scroller-container:has(.itemsContainer[data-layout="grid"]) .emby-scrollbuttons { + display: none !important; +} + +.emby-scroller-container:has(.itemsContainer[data-layout="grid"]) .show-all-button { + display: initial !important; +} + +:has(.spotlight-section[data-layout="Borderless"]) .homeSectionsContainer > *:not(.spotlight-section) { + z-index: 1; +} + +.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; +} + +.emby-scroller-container:has(.itemsContainer[data-layout="grid"]) button.show-all-button::after { + content: 'Collapse'; +} + +.layout-mobile .emby-scroller-container:has(.itemsContainer[data-layout="grid"]) button.show-all-button::after { + content: 'keyboard_arrow_up'; +} + +.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; +} + +.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; +} + +.emby-scroller-container:has(.emby-scrollbuttons-button:not(.hide)) button.show-all-button { + display: flex; +} + +.layout-mobile .emby-scroller-container:has(.overflowPortraitCard:nth-child(4)) button.show-all-button, +.layout-mobile .emby-scroller-container:has(.overflowBackdropCard:nth-child(3)) button.show-all-button { + display: flex; +} +.emby-scroller-container:has([data-custom-card="true"]) .section-refresh-button { + display: none; +} */ + +/* 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.6em 1em 1.75em; + 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: 1.75em 1em 0.85em; + 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 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-style="profile"] .card .cardTextStack:has(.cardText:nth-child(2):last-child) .cardText-first { + margin-top: auto; +} + +[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-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; +} + +.custom-scroller-container .emby-scrollbuttons:has(:first-child[disabled]):has(:last-child[disabled]) { + display: none; +} diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index a0078fd..7ab2963 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -5,10 +5,6 @@ (function() { 'use strict'; - const state = { - useEpisodeImages: false, - }; - // Global toggle for progressive section fade-in. // Developers can set this to false to disable the behavior. const FADE_IN_SECTIONS = true; @@ -41,6 +37,523 @@ return arr; } + /** 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); + }); + g.replaceChildren(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; + host.replaceChildren(); + 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:not(.card-layout-dummy) .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:not(.card-layout-dummy) .cardScalable, .card:not(.card-layout-dummy) .cardContent, .card:not(.card-layout-dummy)' + ); + 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) { + if (!element || !sectionConfig) return; + + if (sectionConfig.sectionCssClass) { + sectionConfig.sectionCssClass.split(/\s+/).filter(Boolean).forEach(cls => element.classList.add(cls)); + } + + const border = sectionConfig.borderStyle; + if (border) { + element.dataset.border = border; + element.dataset.borderStyle = border; + } else { + delete element.dataset.border; + delete element.dataset.borderStyle; + } + const borderColor = sectionConfig.borderColor && String(sectionConfig.borderColor).trim(); + if (borderColor) { + element.style.setProperty('--kefin-card-border-color', borderColor); + } else { + element.style.removeProperty('--kefin-card-border-color'); + } + ensureCardBorders(element); + + const titleColor = sectionConfig.cardTitleColor && String(sectionConfig.cardTitleColor).trim(); + if (titleColor) { + element.style.setProperty('--kefin-card-title-color', titleColor); + } else { + element.style.removeProperty('--kefin-card-title-color'); + } + + if (sectionConfig.hideName === true) { + element.dataset.hideSectionName = 'true'; + } else { + delete element.dataset.hideSectionName; + } + delete element.dataset.hideName; + + const titleVisibility = sectionConfig.cardTitleVisibility + || (sectionConfig.hideCardTitles === true ? 'hidden' : null); + if (titleVisibility && titleVisibility !== 'visible') { + element.dataset.cardTitleVisibility = titleVisibility; + } + if (sectionConfig.cardTitlePosition) { + element.dataset.cardTitlePosition = sectionConfig.cardTitlePosition; + } else { + delete element.dataset.cardTitlePosition; + } + if (sectionConfig.cardTitleCapitalization && sectionConfig.cardTitleCapitalization !== 'normal') { + element.dataset.cardTitleCapitalization = sectionConfig.cardTitleCapitalization; + } else { + delete element.dataset.cardTitleCapitalization; + } + if (sectionConfig.cardTitleFontFamily && sectionConfig.cardTitleFontFamily !== 'default') { + element.dataset.fontFamily = sectionConfig.cardTitleFontFamily; + } else { + delete element.dataset.fontFamily; + } + if (sectionConfig.cardTitleFontSize && sectionConfig.cardTitleFontSize !== 'normal') { + element.dataset.fontSize = sectionConfig.cardTitleFontSize; + } else { + delete element.dataset.fontSize; + } + + const itemsLayout = resolveItemsLayout(sectionConfig); + applyItemsLayoutState(element, itemsLayout, resolveUseGaplessCards(sectionConfig)); + + 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; + + if (sectionConfig.type) { + element.setAttribute('data-section-type', sectionConfig.type); + } else { + element.removeAttribute('data-section-type'); + } + + if (isCustomSection) element.dataset.customSection = 'true'; + else delete element.dataset.customSection; + + if (isDiscoverySection) element.dataset.discoverySection = 'true'; + else delete element.dataset.discoverySection; + } + /** * Updates the scroll buttons for a scrollable container * @param {HTMLElement} scroller - The scroller element @@ -122,6 +635,19 @@ return formats[Math.floor(Math.random() * formats.length)]; } + 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; @@ -153,24 +679,64 @@ 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 ids = new Set(); + const idToIndex = new Map(); + for (const item of items) { - if (!item.ProviderIds) { + const keys = getProviderKeys(item); + if (keys.length === 0) { deduplicated.push(item); continue; } - if (item.ProviderIds.Imdb && ids.has(item.ProviderIds.Imdb)) continue; - if (item.ProviderIds.Tmdb && ids.has(item.ProviderIds.Tmdb)) continue; - if (item.ProviderIds.Tvdb && ids.has(item.ProviderIds.Tvdb)) continue; + let existingIndex = -1; + for (const key of keys) { + if (idToIndex.has(key)) { + existingIndex = idToIndex.get(key); + break; + } + } - deduplicated.push(item); - ids.add(item.ProviderIds.Imdb); - ids.add(item.ProviderIds.Tmdb); - ids.add(item.ProviderIds.Tvdb); + 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; } @@ -267,12 +833,28 @@ 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); @@ -353,6 +935,228 @@ 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'); + 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'); + if (!titleLink) return; + if (url) { + titleLink.href = url; + return; + } + titleLink.removeAttribute('href'); + } + + 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 || {}); + } 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); + const raw = typeof merged.result?.ensureData === 'function' + ? await merged.result.ensureData() + : await (merged.result?.dataPromise ?? merged.result?.data); + return extractItemsFromQueryPayload(raw); + } + if (results.length === 1) { + const qr = results[0]; + const raw = typeof qr.ensureData === 'function' + ? await qr.ensureData() + : await (qr.dataPromise ?? qr.data); + 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 @@ -368,9 +1172,10 @@ const serverUrl = ApiClient.serverAddress(); const ApiHelper = window.apiHelper; const results = []; + const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false }); - // Process each query in the queries array - for (const query of sectionConfig.queries) { + // Process each query in the active set + for (const query of queriesToLoad) { let queryResult; if (query.dataSource) { @@ -378,7 +1183,7 @@ queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}, false); } else { // Build and execute query - const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight'); + 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 @@ -460,110 +1265,527 @@ }, 1500); } - /** - * Create refresh button for progressive sections - * @param {Object} sectionConfig - Section configuration - * @param {HTMLElement} sectionElement - The section element to refresh - * @returns {HTMLElement} - The refresh button element - */ - function createRefreshButton(sectionConfig, sectionElement) { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'section-refresh-button material-icons refresh'; - button.title = 'Refresh Section'; - button.setAttribute('aria-label', 'Refresh Section'); - - // Click handler - button.addEventListener('click', async (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Set refreshing state - sectionElement.dataset.refreshing = 'true'; - - try { - // Refresh the queries - const freshItems = await refreshSectionQueries(sectionConfig); - - if (freshItems.length === 0) { - // If no items, remove section - sectionElement.remove(); - return; - } - - // Get card format from current section - let finalCardFormat = sectionConfig.cardFormat; - const currentCardFormat = sectionElement.getAttribute('data-card-format'); - if (currentCardFormat) { - finalCardFormat = currentCardFormat; - } - - // Create new content - let content = null; - if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { - content = createSpotlightSection(freshItems, sectionConfig.name, { - viewMoreUrl: sectionConfig.viewMoreUrl, - ...sectionConfig.spotlightConfig - }); - } else { - content = createScrollableContainer(freshItems, sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.overflowCard, finalCardFormat, sectionConfig.forcedImageType); - } - - // Preserve all styles, classes, and attributes - content.style.cssText = sectionElement.style.cssText; - content.className = sectionElement.className; - Array.from(sectionElement.attributes).forEach(attr => { - content.setAttribute(attr.name, attr.value); - }); - - // Preserve data-expanded state from old itemsContainer to new one - const oldItemsContainer = sectionElement.querySelector('.itemsContainer'); - const newItemsContainer = content.querySelector('.itemsContainer'); - if (oldItemsContainer && newItemsContainer) { - const isExpanded = oldItemsContainer.getAttribute('data-expanded') === 'true'; - if (isExpanded) { - newItemsContainer.setAttribute('data-expanded', 'true'); - // Also update the show-all-button text if it exists - const showAllButton = content.querySelector('.show-all-button'); - if (showAllButton) { - showAllButton.title = 'Show items in scrollable layout'; - } - // Hide scroll buttons if they exist - const scrollButtons = content.querySelector('.emby-scrollbuttons'); - if (scrollButtons) { - scrollButtons.style.display = 'none'; - } - } - } - - // Replace the section element - sectionElement.replaceWith(content); - //checkSectionOverflow(content, true); - setTimeout(() => checkSectionOverflow(content, true), 150); + async function replaceSectionContent(sectionConfig, sectionElement, freshItems) { + if (freshItems.length === 0) { + sectionElement.remove(); + return null; + } - // Add refresh button to new content - const sectionTitleContainer = content.querySelector('.sectionTitleContainer'); - let newButton = null; - if (sectionTitleContainer) { - newButton = createRefreshButton(sectionConfig, content); - sectionTitleContainer.appendChild(newButton); + 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); + content.dataset.refreshing = 'false'; + attachSectionControlButtons(sectionConfig, content, freshItems); + return content; + } + + const content = createScrollableContainer( + 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); + }); + + 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); + invalidateLastRowPadding(sectionElement.querySelector('.itemsContainer')); + sectionElement.replaceWith(content); + applyItemsLayoutState(content, layout, gapless); + ensureCardBorders(content); + attachSectionControlButtons(sectionConfig, content, freshItems); + content.dataset.refreshing = 'false'; + return content; + } + + /** + * 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; + 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); + skeleton.dataset.refreshing = 'true'; + + 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, []); + 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'; + 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'); + if (controlsMount) { + titleContainer.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); + } + + let activePopover = null; + const closePopover = () => { + if (activePopover) { + activePopover.remove(); + activePopover = null; + } + }; + + if (!document.getElementById('kefinTweaks-multiQueryPopover-style')) { + const styleElement = document.createElement('style'); + styleElement.id = 'kefinTweaks-multiQueryPopover-style'; + styleElement.textContent = ` + .multiQueryPopover { + background-color: rgba(0, 0, 0, 0.95); + position: absolute !important; + z-index: 1001; + display: block; } - - // Set refreshing state to false + `; + document.head.appendChild(styleElement); + } + + selectButton.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (activePopover) { + closePopover(); + return; + } + + const popover = document.createElement('div'); + popover.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup'; + const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false }); + let selectedItemElement = null; + + queries.forEach((query, index) => { + const itemElement = document.createElement('div'); + itemElement.className = 'kefinTweaks-popover-item detailsGroupItem'; + if (index === selectedIndex) { + itemElement.classList.add('selected'); + selectedItemElement = itemElement; + } + itemElement.textContent = getQueryDisplayName(query, index); + itemElement.addEventListener('click', async () => { + closePopover(); + if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) { + return; + } + + sectionConfig._selectedQueryIndex = index; + + const sectionIdAttr = (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') + ? CSS.escape(String(sectionConfig.id)) + : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + // Prefer the live section node (may have been replaced by a prior switch). + 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)); + updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig)); + activeSectionEl.dataset.refreshing = 'true'; + + try { + const loaded = await loadActiveSectionQueryItems(sectionConfig); + let newContent = null; + + if (loaded.canPaintCached) { + newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items); + } else { + // Skeletons before awaiting network — cache check already finished above. + 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; + if (live) live.dataset.refreshing = 'false'; + } + }); + popover.appendChild(itemElement); + }); + + titleContainer.style.position = 'relative'; + selectButton.parentNode.insertBefore(popover, selectButton.nextSibling); + const buttonRect = selectButton.getBoundingClientRect(); + const containerRect = titleContainer.getBoundingClientRect(); + popover.style.left = `${Math.max(0, buttonRect.left - containerRect.left)}px`; + popover.style.top = '100%'; + popover.style.bottom = 'auto'; + popover.style.marginTop = '8px'; + popover.style.marginBottom = '0'; + activePopover = popover; + + if (selectedItemElement) { + setTimeout(() => { + selectedItemElement.scrollIntoView({ + behavior: 'instant', + block: 'nearest', + inline: 'nearest' + }); + }, 10); + } + + const closeHandler = (ev) => { + if (!popover.contains(ev.target) && !selectButton.contains(ev.target)) { + closePopover(); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => { + document.addEventListener('click', closeHandler); + }, 100); + }); + } + + 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 = document.createElement('button'); + moreButton.type = 'button'; + moreButton.className = 'section-controls-more material-icons more_vert'; + moreButton.title = 'More'; + moreButton.setAttribute('aria-label', 'More'); + + controls.appendChild(inline); + controls.appendChild(moreButton); + mountParent.appendChild(controls); + } else if (controls.parentNode !== mountParent) { + mountParent.appendChild(controls); + } + + return { + controls, + inline: controls.querySelector('.section-controls-inline'), + moreButton: controls.querySelector('.section-controls-more') + }; + } + + /** + * 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) { + sectionElement.dataset.refreshing = 'true'; + + try { + const freshItems = await refreshSectionQueries(sectionConfig); + + if (freshItems.length === 0) { + sectionElement.remove(); + 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); content.dataset.refreshing = 'false'; - - // Show checkmark on the new button + const newButton = attachSectionControlButtons(sectionConfig, content, freshItems); if (newButton) { showRefreshComplete(newButton); } - - } catch (error) { - console.error('[KefinTweaks CardBuilder] Error refreshing section:', error); - sectionElement.dataset.refreshing = 'false'; + return newButton; + } + + content = createScrollableContainer( + 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); + }); + + 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); + invalidateLastRowPadding(content.querySelector('.itemsContainer')); + + sectionElement.replaceWith(content); + applyItemsLayoutState(content, layout, gapless); + ensureCardBorders(content); + + const newButton = attachSectionControlButtons(sectionConfig, content, freshItems); + content.dataset.refreshing = 'false'; + if (newButton) { + showRefreshComplete(newButton); + } + return newButton; + } catch (error) { + console.error('[KefinTweaks CardBuilder] Error refreshing section:', error); + sectionElement.dataset.refreshing = '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; + + function getPrimaryUserIdForCardSync() { + return (typeof ApiClient !== 'undefined' && typeof ApiClient.getCurrentUserId === 'function') + ? ApiClient.getCurrentUserId() + : null; + } + + function extractUserDataChangePayload(event) { + const data = event?.detail ?? event; + const userId = data?.Data?.UserId ?? data?.UserId ?? null; + const list = data?.Data?.UserDataList ?? data?.UserDataList; + if (!Array.isArray(list)) return { userId, entries: [] }; + return { userId, entries: list }; + } + + function isPrimaryUserDataChange(userId) { + const primaryId = getPrimaryUserIdForCardSync(); + if (!primaryId) return false; + if (!userId) return true; + return userId === primaryId; + } + + /** + * Keep data-positionticks on visible cards aligned with primary-user playstate. + * Driven by UserDataChanged (same event Jellyfin uses for progress bars) — not DOM observation. + */ + function updateCardResumeAttributes(itemId, userData) { + if (!itemId || !userData) return; + + const escapedId = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(itemId) + : itemId.replace(/["\\]/g, '\\$&'); + const cards = document.querySelectorAll(`.card[data-id="${escapedId}"]`); + if (!cards.length) return; + + 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 button; + } + + function applyUserDataEntriesToCards(entries, userId) { + if (!isPrimaryUserDataChange(userId)) return; + entries.forEach((entry) => { + if (entry?.ItemId) { + updateCardResumeAttributes(entry.ItemId, entry); + } + }); + } + + function handleCardUserDataChangedEvent(event) { + const { userId, entries } = extractUserDataChangePayload(event); + if (!entries.length) return; + applyUserDataEntriesToCards(entries, userId); + } + + function startCardUserDataListener() { + if (cardUserDataListenerStarted) return; + + let registered = false; + + if (window.ApiClient?.addEventListener) { + window.ApiClient.addEventListener('userdatachanged', handleCardUserDataChangedEvent); + registered = true; + } + + if (window.websocketHelper?.listen) { + 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); + }); + registered = true; + } + + if (registered) { + cardUserDataListenerStarted = true; + console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes'); + return; + } + + setTimeout(startCardUserDataListener, 1000); } // Main card builder object @@ -576,6 +1798,25 @@ */ postProcessItems: postProcessItems, postProcessItemsByQuery: postProcessItemsByQuery, + getQueryDisplayName: getQueryDisplayName, + resolveActiveQueryIndex: resolveActiveQueryIndex, + resolveQueriesToLoad: resolveQueriesToLoad, + getSectionTitleForMultiQuery: getSectionTitleForMultiQuery, + getActiveViewMoreUrl: getActiveViewMoreUrl, + refreshSectionQueries: refreshSectionQueries, + loadActiveSectionQueryItems: loadActiveSectionQueryItems, + attachSectionControlButtons: attachSectionControlButtons, + ensureCardBorders: ensureCardBorders, + applyItemsLayoutState: applyItemsLayoutState, + resolveItemsLayout: resolveItemsLayout, + resolveUseGaplessCards: resolveUseGaplessCards, + invalidateLastRowPadding: invalidateLastRowPadding, + invalidateCardRadiusCache: invalidateCardRadiusCache, + ensureCardRadiusCssVar: ensureCardRadiusCssVar, + getScrollerPosition: getScrollerPosition, + setScrollerPosition: setScrollerPosition, + getItemMaterialIcon: getItemMaterialIcon, + createLibraryButtonElement: createLibraryButtonElement, /** * Main entry point function to build a Jellyfin card * @param {Object} item - The Jellyfin item object @@ -599,25 +1840,27 @@ * @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', forcedImageType = null) { - // 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]); } - - const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat, forcedImageType); + + const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); return container; }, + renderSpotlightSection: function(items, title, options = {}) { + return createSpotlightSection(items, title, options); + }, + /** * Renders a scrollable container with cards from item IDs * @param {Array} itemIds - Array of Jellyfin item IDs @@ -640,7 +1883,7 @@ * @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', forcedImageType = null, minimumItems = -1) { + 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') { @@ -652,7 +1895,7 @@ // 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, forcedImageType); + const container = createScrollableContainer(items, title, viewMoreUrl, overflowCard, resolvedFormat); return container; } @@ -696,7 +1939,7 @@ } // Generate real cards container (off-DOM) using the SAME resolved format - const realContainer = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat, forcedImageType); + const realContainer = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat); // Copy critical attributes to preserve layout/order if (skeletonContaineElement.style.order) { @@ -705,12 +1948,16 @@ 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) skeletonContaineElement.replaceWith(realContainer); - checkSectionOverflow(realContainer, true); - //setTimeout(() => checkSectionOverflow(realContainer, true), 150); + ensureCardBorders(realContainer); }).catch(error => { console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error); // Optionally remove skeleton on error or show error state @@ -730,12 +1977,17 @@ * @param {Object} options - Configuration options */ renderProgressiveSections: async function(container, sections, options = {}) { - const { revealSectionsSequentially = false, waitForContainerClass = null } = options; + const { + revealSectionsSequentially = false, + waitForContainerClass = null, + enhanceOnVisible = false, + enhanceRootMargin = '30% 0px 30% 0px', + showStaleDataBeforeRefresh = true + } = options; const renderProgressiveSectionsStartTime = performance.now(); - // Get the HTML for all of the progressive sections and append it to the container at once - let sectionElements = []; + const sectionEnhancementTargets = []; const fragment = document.createDocumentFragment(); for (const sectionPromise of sections) { const sectionPromiseStartTime = performance.now(); @@ -761,19 +2013,25 @@ } let dataItems = section.result?.data?.Items ?? section.result?.data; - - // If the data is already available, we should render the section instead of the skeleton - if (dataItems && dataItems.length > 0) { + 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')); + + // 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, sectionConfig.name, { - viewMoreUrl: sectionConfig.viewMoreUrl, + content = createSpotlightSection(dataItems, getSectionTitleForMultiQuery(sectionConfig), { + viewMoreUrl: getActiveViewMoreUrl(sectionConfig), progressiveEnhancement: true, ...sectionConfig.spotlightConfig }); } else { - content = createScrollableContainer(dataItems, sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.overflowCard, finalCardFormat, sectionConfig.forcedImageType); + content = createScrollableContainer(dataItems, getSectionTitleForMultiQuery(sectionConfig), getActiveViewMoreUrl(sectionConfig), sectionConfig.overflowCard, finalCardFormat, sectionConfig); } content.setAttribute('data-section-id', sectionConfig.id); @@ -783,15 +2041,12 @@ if (sectionConfig.hideCardFooter === true) { content.dataset.hideCardFooter = 'true'; } + applySectionPresentationAttrs(content, sectionConfig); - // Add refresh button to rendered section - const sectionTitleContainer = content.querySelector('.sectionTitleContainer'); - if (sectionTitleContainer) { - const refreshButton = createRefreshButton(sectionConfig, content); - sectionTitleContainer.appendChild(refreshButton); - } + // Add section control buttons to rendered section + attachSectionControlButtons(sectionConfig, content, dataItems); - sectionElements.push(content); + sectionEnhancementTargets.push({ element: content, section }); //container.appendChild(content); //content.style.contentVisibility = 'hidden'; @@ -812,12 +2067,12 @@ let sectionElement = null; if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { const spotlightSettings = sectionConfig.spotlightConfig ?? {}; - if (sectionConfig.viewMoreUrl) { - spotlightSettings.viewMoreUrl = sectionConfig.viewMoreUrl; + if (getActiveViewMoreUrl(sectionConfig)) { + spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig); } - sectionElement = createSkeletonSpotlightSection(sectionConfig.name, spotlightSettings); + sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings); } else { - sectionElement = createProgressivelyEnhancedScrollableContainer(sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.cardFormat, sectionConfig.overflowCard); + sectionElement = createProgressivelyEnhancedScrollableContainer(getSectionTitleForMultiQuery(sectionConfig), getActiveViewMoreUrl(sectionConfig), finalCardFormat, sectionConfig.overflowCard, sectionConfig); } sectionElement.setAttribute('data-section-id', sectionConfig.id); sectionElement.style.order = sectionConfig.order; @@ -825,6 +2080,7 @@ if (sectionConfig.hideCardFooter === true) { sectionElement.dataset.hideCardFooter = 'true'; } + applySectionPresentationAttrs(sectionElement, sectionConfig); //container.appendChild(sectionElement); // set section element content-visibility to hidden @@ -835,7 +2091,7 @@ sectionElement.style.display = 'none'; } - sectionElements.push(sectionElement); + sectionEnhancementTargets.push({ element: sectionElement, section }); fragment.appendChild(sectionElement); const sectionEndTime = performance.now(); const sectionDuration = sectionEndTime - sectionStartTime; @@ -863,6 +2119,7 @@ } container.appendChild(fragment); + syncAttachedGridTilesLayouts(container); // Update the scroll buttons for all scrollable containers /* const scrollableContainers = container.querySelectorAll('.emby-scroller'); @@ -912,156 +2169,24 @@ // Set up section reveal animations if enabled if (revealSectionsSequentially) { initializeSectionRevealObserver(); - sectionElements.forEach(section => { - section.style.display = ''; - observeSectionForReveal(section); + sectionEnhancementTargets.forEach(({ element }) => { + element.style.display = ''; + observeSectionForReveal(element); }); } - for (let index = 0; index < sectionElements.length; index++) { + const enhanceOptions = { + enhanceOnVisible, + enhanceRootMargin, + revealSectionsSequentially + }; + + for (let index = 0; index < sectionEnhancementTargets.length; index++) { const sectionStartTime = performance.now(); + const { element: sectionElement, section } = sectionEnhancementTargets[index]; - const sectionElement = sectionElements[index]; - const section = await sections[index]; + scheduleSectionProgressiveEnhancement(sectionElement, section, enhanceOptions); - section.result?.isStalePromise?.then(async isStale => { - if (isStale) { - // Add refreshing state to the section element - sectionElement.dataset.refreshing = 'true'; - return; - } - }); - - section.result?.dataPromise?.then(async result => { - // Remove refreshing state from the section element - sectionElement.dataset.refreshing = 'false'; - - const sectionConfig = section.config; - let items = result?.Items ?? result ?? []; - - // Check if the data is stale if it was previously available first - const dataItems = section.result?.data?.Items ?? section.result?.data ?? []; - - let itemsMatch = true; - - // Check if the items in the section are the same as the items in the data - const itemsInSection = sectionElement.querySelectorAll('.itemsContainer .card[data-id]'); - - // Check that each item appears in order in the section by going through the itemsInSection and making sure the itemsInData index matches - for (let i = 0; i < itemsInSection.length; i++) { - const itemInSection = itemsInSection[i]; - const item = items[i]; - if (item && itemInSection && itemInSection.getAttribute('data-id') !== item.Id) { - itemsMatch = false; - break; - } - } - - if (items.length !== dataItems.length) { - itemsMatch = false; - } - - for (const item of items) { - const dataItem = dataItems.find(dataItem => dataItem.Id === item.Id); - if (!dataItem) { - itemsMatch = false; - break; - } - - if (dataItem.UserData) { - for (const key in dataItem.UserData) { - if (dataItem.UserData[key] !== item.UserData[key]) { - itemsMatch = false; - break; - } - } - } - } - - const shouldRefresh = !itemsMatch; - - // If there are no items, remove any existing section element - if (items.length === 0) { - sectionElement.remove(); - return; - } - - if (!shouldRefresh) { - return; - } - - let content = null; - - let finalCardFormat = sectionConfig.cardFormat; - - // Get card format from skeleton container - const skeletonCardFormat = sectionElement.getAttribute('data-card-format'); - if (skeletonCardFormat) { - finalCardFormat = skeletonCardFormat; - } - - //items = postProcessItems(sectionConfig, items); - - if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { - content = createSpotlightSection(items, sectionConfig.name, { - viewMoreUrl: sectionConfig.viewMoreUrl, - ...sectionConfig.spotlightConfig - }); - } else { - content = createScrollableContainer(items, sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.overflowCard, finalCardFormat, sectionConfig.forcedImageType); - } - - //content.setAttribute('data-section-id', sectionConfig.id); - //content.style.order = sectionConfig.order; - //content.setAttribute('data-card-format', finalCardFormat); - //content.style.contentVisibility = sectionElement.style.contentVisibility ?? 'hidden'; - - // Ensure all styles and classes and attributes are retained in the new content - content.style.cssText = sectionElement.style.cssText; - content.className = sectionElement.className; - Array.from(sectionElement.attributes).forEach(attr => { - content.setAttribute(attr.name, attr.value); - }); - - // Preserve data-expanded state from old itemsContainer to new one - const oldItemsContainer = sectionElement.querySelector('.itemsContainer'); - const newItemsContainer = content.querySelector('.itemsContainer'); - if (oldItemsContainer && newItemsContainer) { - const isExpanded = oldItemsContainer.getAttribute('data-expanded') === 'true'; - if (isExpanded) { - newItemsContainer.setAttribute('data-expanded', 'true'); - // Also update the show-all-button text if it exists - const showAllButton = content.querySelector('.show-all-button'); - if (showAllButton) { - showAllButton.title = 'Show items in scrollable layout'; - } - // Hide scroll buttons if they exist - const scrollButtons = content.querySelector('.emby-scrollbuttons'); - if (scrollButtons) { - scrollButtons.style.display = 'none'; - } - } - } - - // Add refresh button to new content - const sectionTitleContainer = content.querySelector('.sectionTitleContainer'); - if (sectionTitleContainer) { - const refreshButton = createRefreshButton(sectionConfig, content); - sectionTitleContainer.appendChild(refreshButton); - } - - sectionElement.replaceWith(content); - // Update the scroll buttons for the new content - //updateScrollableContainerScrollButtons(content.querySelector('.emby-scroller')); - - //checkSectionOverflow(content, true); - //setTimeout(() => checkSectionOverflow(content, true), 150); - - // If revealSectionsSequentially is enabled and section hasn't been revealed yet, re-observe it - if (revealSectionsSequentially && content.classList.contains('cardbuilder-section-reveal') && !content.classList.contains('in-viewport')) { - observeSectionForReveal(content); - } - }); const sectionEndTime = performance.now(); const sectionDuration = sectionEndTime - sectionStartTime; console.log(`Section ${index} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); @@ -1086,7 +2211,6 @@ overflowCard = false, sortOrder = null, sortOrderDirection = 'Ascending', - forcedImageType = null, sectionId = null, order = null, expectedItemType = null, @@ -1122,7 +2246,7 @@ if (spotlight) { return createSpotlightSection(sortedItems, title, { viewMoreUrl }); } else { - return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat, forcedImageType); + return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat); } }; @@ -1148,8 +2272,7 @@ content.setAttribute(attr.name, attr.value); }); skeleton.replaceWith(content); - /* checkSectionOverflow(content, true); - setTimeout(() => checkSectionOverflow(content, true), 150); */ + ensureCardBorders(content); } else { skeleton.remove(); } @@ -1284,6 +2407,8 @@ sortItems: function(items, sortOrder, sortOrderDirection) { return sortItems(items, sortOrder, sortOrderDirection); }, + + updateCardResumeAttributes: updateCardResumeAttributes, }; @@ -1416,15 +2541,13 @@ * 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'|null - * @param {string|null} forcedImageType - e.g. 'Primary' + * @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, forcedImageType) { + function getBlurhashForCard(item, cardFormat) { const hashes = item.ImageBlurHashes; if (!hashes || typeof hashes !== 'object') return undefined; cardFormat = cardFormat?.toLowerCase() || null; - forcedImageType = forcedImageType?.toLowerCase() || null; if (cardFormat === 'backdrop') { if (item.BackdropImageTags && item.BackdropImageTags[0]) @@ -1447,14 +2570,33 @@ 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 (forcedImageType && state.useEpisodeImages && item.Type === 'Episode' && item.ImageTags?.Primary) - return hashes.Primary?.[item.ImageTags.Primary]; + 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; @@ -1467,16 +2609,102 @@ 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') return 'tv'; + if (itemType === 'Episode') return 'play_circle'; + if (itemType === 'Person') return 'person'; + if (itemType === 'MusicAlbum') return 'album'; + if (itemType === 'Audio') return 'music_note'; + if (itemType === 'Artist' || itemType === 'MusicArtist') return 'person'; + 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; + } + /** * 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 {string} forcedImageType - Force specific image type (e.g., 'Primary') * @returns {HTMLElement} - The constructed card element */ - function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null, forcedImageType = null) { + function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null) { + cardFormat = cardFormat?.toLowerCase() || null; + if (cardFormat === 'button') { + return createLibraryButtonElement(item); + } + const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); const itemId = item.Id || ''; @@ -1491,8 +2719,6 @@ // Determine card type based on cardFormat override or item type let cardClass, padderClass, imageParams; - cardFormat = cardFormat?.toLowerCase() || null; - forcedImageType = forcedImageType?.toLowerCase() || null; // Use custom footer text from item property if not explicitly provided if (!customFooterText && item.CustomFooterText) { @@ -1501,7 +2727,7 @@ if (cardFormat) { // Use specified cardFormat - if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'logo' || cardFormat === 'clear art' || forcedImageType) { + 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'; @@ -1514,7 +2740,7 @@ 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'; @@ -1546,6 +2772,7 @@ if (!isCustomCard) card.setAttribute('data-id', itemId); if (isCustomCard) card.setAttribute('data-custom-card', 'true'); card.setAttribute('data-type', itemType); + card.setAttribute('data-retro-marquee', getRetroPosterMarqueeLabel(item)); const mediaType = item.MediaType === 'Unknown' && item.ChannelId ? 'Video' : item.MediaType; card.setAttribute('data-mediatype', mediaType || 'Video'); @@ -1592,21 +2819,7 @@ cardIcon.setAttribute('aria-hidden', 'true'); // Set icon based on item type - if (itemType === 'Movie') { - cardIcon.textContent = 'movie'; - } else if (itemType === 'Series') { - cardIcon.textContent = 'tv'; - } else if (itemType === 'Episode') { - cardIcon.textContent = 'play_circle'; - } else if (itemType === 'MusicAlbum') { - cardIcon.textContent = 'album'; - } else if (itemType === 'Audio') { - cardIcon.textContent = 'music_note'; - } else if (itemType === 'Artist') { - cardIcon.textContent = 'person'; - } else { - cardIcon.textContent = 'folder'; - } + cardIcon.textContent = getItemMaterialIcon(item); cardPadder.appendChild(cardIcon); @@ -1620,23 +2833,7 @@ let libraryUrl = ''; if (item.Type === 'CollectionFolder') { - switch (item.CollectionType) { - case 'movies': - libraryUrl = `#/movies.html?topParentId=${item.Id}&collectionType=movies`; - break; - case 'tvshows': - libraryUrl = `#/tv.html?topParentId=${item.Id}&collectionType=tvshows`; - break; - case 'music': - libraryUrl = `#/music.html?topParentId=${item.Id}&collectionType=music`; - break; - case 'livetv': - libraryUrl = `#/livetv.html`; - break; - default: - libraryUrl = `#/list.html?parentId=${item.Id}&serverId=${serverId}`; - break; - } + libraryUrl = buildCollectionFolderHref(item, serverId, { useHtmlExtension: true }) || ''; } let cardUrl = item.cardUrl || libraryUrl || `#/details?id=${itemId}&serverId=${serverId}`; @@ -1646,31 +2843,24 @@ cardUrl = `#/list.html?genreId=${item.Id}&serverId=${serverId}${parentParam}`; } - // Card image container - const cardImageContainer = document.createElement('a'); - cardImageContainer.href = cardUrl; - cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast'; - cardImageContainer.setAttribute('data-action', 'link'); - cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown'); - - // Force specific image if card format is specified -/* if (forcedImageType === 'Primary' && item.ImageTags?.Primary) { - const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } else */ let imageUrl = ''; - const hasCustomImages = item.posterUrl || item.thumbUrl || item.squareUrl || item.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') { - imageUrl = `${item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`; + 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 or default + // portrait/poster/series poster or default imageUrl = `${item.posterUrl || item.imageUrl || item.thumbUrl || item.squareUrl}`; } } else if (cardFormat === 'backdrop') { @@ -1688,15 +2878,21 @@ 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) { + } else if (item.PrimaryImageTag && item.Type === 'Episode') { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`; - } else if (item.SeriesPrimaryImageTag) { - imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; } 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.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 (item.Type === 'Episode' && item.SeriesPrimaryImageTag) { imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; @@ -1707,10 +2903,39 @@ } 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}`; + } + } 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 (forcedImageType && state.useEpisodeImages && item.Type === 'Episode') { - // Explicit override for episodes – always use the episode's primary image - imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; + if (item.Type === 'Episode') { + 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 (item.ImageTags?.Thumb) { // Prefer explicit thumb on the item imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; @@ -1746,8 +2971,12 @@ 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 === 'banner') { + if (item.ImageTags?.Banner) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Banner?${imageParams}&quality=96&tag=${item.ImageTags.Banner}`; + } else { + imageUrl = `${serverAddress}/Items/${item.SeriesId || item.ParentId}/Images/Banner?${imageParams}&quality=96`; + } } 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) { @@ -1770,36 +2999,27 @@ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; - - } else { - // No image - add icon as inner element + } + + // Card image container + const cardImageContainer = document.createElement('a'); + cardImageContainer.href = cardUrl; + cardImageContainer.className = `cardImageContainer ${!imageUrl ? 'defaultCardBackground defaultCardBackground2' : ''} coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast`; + cardImageContainer.setAttribute('data-action', 'link'); + cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown'); + + // 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); } - const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat, forcedImageType) : null; + const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null; if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') { blurhashCanvas.classList.add('lazy-hidden'); } else { @@ -1825,16 +3045,6 @@ const cardIndicators = document.createElement('div'); cardIndicators.className = 'cardIndicators'; - 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); - } - if (item.UserData?.UnplayedItemCount && item.UserData?.UnplayedItemCount > 0) { const unplayedIndicator = document.createElement('div'); unplayedIndicator.className = 'countIndicator indicator'; @@ -1843,10 +3053,27 @@ } if (item.LocationType === 'Virtual') { - const virtualIndicator = document.createElement('div'); - virtualIndicator.className = 'missingIndicator'; - virtualIndicator.textContent = 'Missing'; - cardIndicators.appendChild(virtualIndicator); + 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); + } + } + + 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); } if (parentItem && parentItem.Type === 'Timer') { @@ -1860,6 +3087,13 @@ cardImageContainer.appendChild(cardIndicators); } + 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'; @@ -2102,12 +3336,25 @@ cardScalable.appendChild(blurhashCanvas); cardScalable.appendChild(cardImageContainer); cardScalable.appendChild(cardOverlayContainer); + cardScalable.appendChild(createCardBorderHost()); cardBox.appendChild(cardScalable); - cardBox.appendChild(cardTextFragment); + 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; } @@ -2141,14 +3388,16 @@ spotlightSize, tileCount: tileCountOpt, cycleBackdrops = false, - cycleBackdropsTime = 10000, + cycleBackdropsTime, + backdropsCount: backdropsCountOpt, entranceAnimationFirst = 'fadeIn', entranceAnimationSecond = 'fadeIn', entranceAnimationThird = 'fadeIn', slideAnimationFirst = 'kenBurnsZoomIn', slideAnimationSecond = 'kenBurnsZoomIn', slideAnimationThird = 'kenBurnsZoomIn', - viewMoreUrl = null + viewMoreUrl = null, + initialIndex = 0 } = options; // Backward compat: derive layout/size from fullScreen if new keys missing const fullScreen = options.fullScreen === true; @@ -2159,11 +3408,17 @@ tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; } tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); + 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(); const sectionKey = 'spotlight_' + Date.now() + '_' + Math.random().toString(36).slice(2, 9); const cycleBackdropMap = new Map(); - let currentIndex = 0; + let currentIndex = Math.max(0, Math.min(items.length - 1, parseInt(initialIndex, 10) || 0)); let autoPlayTimer = null; let cycleBackdropTimer = null; let advanceDue = false; @@ -2188,48 +3443,48 @@ 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 = 'emby-tab-button emby-tab-button-active'; - titleLink.textContent = title; - titleLink.title = 'See All'; - titleLink.style.textDecoration = 'none'; - - // Handle both URL and function - 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 { - // Regular non-clickable title - sectionTitleEl = document.createElement('div'); - sectionTitleEl.className = 'emby-tab-button emby-tab-button-active'; - sectionTitleEl.textContent = title; - } - - const sectionTitleWrapper = document.createElement('div'); - sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`; - sectionTitleWrapper.appendChild(sectionTitleEl); - + // Title container hosts section name + refresh/configure controls. + // Always create it so controls can attach even when the section has no name. + { const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'spotlight-section-title-container'; - sectionTitleContainer.appendChild(sectionTitleWrapper); - + + if (title) { + let sectionTitleEl; + + if (viewMoreUrl) { + const titleLink = document.createElement('a'); + titleLink.className = 'emby-tab-button emby-tab-button-active'; + 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'; + 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(sectionTitleContainer); } @@ -2245,7 +3500,7 @@ } // Sliding window: render current + 4 prev + 4 next (9 slides). Update DOM as user navigates. - let currentWindowIndices = getSpotlightWindowIndices(0, items.length); + let currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length); function createAndAppendSlide(item, index, eagerLoadImages) { const itemType = item.Type || 'Movie'; @@ -2413,7 +3668,7 @@ const group = document.createElement('div'); group.className = 'headerTabs sectionTabs'; const item = document.createElement(tag); - item.className = 'emby-tab-button' + (extraClasses ? ' ' + extraClasses : ''); + item.className = 'emby-tab-button emby-button-foreground' + (extraClasses ? ' ' + extraClasses : ''); if (icon) item.setAttribute('data-icon', icon); if (typeof content === 'string') { item.textContent = content; @@ -2480,7 +3735,7 @@ } const displayContainer = document.createElement('span'); - displayContainer.className = 'emby-tab-button'; + displayContainer.className = 'emby-tab-button emby-button-foreground'; if (icon) displayContainer.setAttribute('data-icon', icon); if (label) { const labelSpan = document.createElement('span'); @@ -2750,7 +4005,7 @@ // Play button (material icon span button) const playButton = document.createElement('button'); - playButton.className = 'emby-button submit-button raised button-flat btnPlay detailButton'; + playButton.className = 'emby-button raised button-flat btnPlay detailButton'; playButton.title = 'Play'; const playIcon = document.createElement('span'); playIcon.className = 'material-icons'; @@ -2951,15 +4206,15 @@ return itemDiv; } - // Initial render: create slides for window around index 0 + // Initial render: create slides for window around currentIndex currentWindowIndices.forEach((idx) => { - createAndAppendSlide(items[idx], idx, idx === 0); + createAndAppendSlide(items[idx], idx, idx === currentIndex); }); bannerContainer.appendChild(itemsContainer); - // Mark the initially visible slide (index 0) as active so only it is shown - const firstItemActive = bannerContainer.querySelector('.spotlight-item[data-index="0"]'); + // 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'); } @@ -2987,12 +4242,12 @@ } }); - // Apply initial pan animation to first slide (helper also sets up completion tracking) + // Apply initial pan animation to the starting slide (helper also sets up completion tracking) if (panAnimation && items.length > 0) { - const firstItemPan = bannerContainer.querySelector('.spotlight-item[data-index="0"]'); + const firstItemPan = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`); if (firstItemPan) { applyPanAnimationToSlide(firstItemPan); - startCycleBackdropTimer(firstItemPan, items[0].Id); + startCycleBackdropTimer(firstItemPan, items[currentIndex].Id); } } @@ -3067,14 +4322,15 @@ if (showSlideState && items.length > 1) { const dotsContainer = document.createElement('div'); dotsContainer.className = 'spotlight-dots' + (showDots ? '' : ' spotlight-dots-numeric'); - dotsContainer.setAttribute('data-index', '1'); /* 1-based for "N / X" display */ + 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 === 0 ? ' active' : ''); + dot.className = 'spotlight-dot' + (i === activeDotIndex ? ' active' : ''); dot.setAttribute('data-dot-index', i); dot.addEventListener('click', (e) => { e.stopPropagation(); @@ -3170,7 +4426,7 @@ const entry = cycleBackdropMap.get(mapKey); if (!entry || !entry.shuffledUrls.length) return; var cycleCount = 0; - var maxCycles = Math.max(0, Math.ceil(interval / cycleBackdropsTime) - 1); + var maxCycles = Math.max(0, backdropsCount - 1); if (imgs.length >= 2) { const bottomImg = imgs[0]; const topImg = imgs[1]; @@ -3206,7 +4462,7 @@ topImg.addEventListener('transitionend', handler); }; preload.src = nextUrl; - }, cycleBackdropsTime); + }, perBackdropMs); } else { const img = slideElement.querySelector('.spotlight-background-single img'); if (!img) return; @@ -3233,7 +4489,7 @@ }); }; preload.src = nextUrl; - }, cycleBackdropsTime); + }, perBackdropMs); } } @@ -3466,138 +4722,580 @@ return container; } - /** - * Check if a section's items overflow the scroller and set data-expandable for show-all button visibility. - * Can be called for any .emby-scroller-container that contains .itemsContainer (e.g. when section is detected in DOM). - * @param {HTMLElement} verticalSection - Section element (emby-scroller-container) - * @param {boolean} forceUpdate - If true, update even when data-expandable is already set (e.g. on resize) - */ - function checkSectionOverflow(verticalSection, forceUpdate = false) { - return; - if (!verticalSection || verticalSection.nodeType !== 1) return; - const itemsContainer = verticalSection.querySelector('.itemsContainer'); - const scroller = verticalSection.querySelector('.emby-scroller'); - if (!itemsContainer || !scroller) return; - if (!forceUpdate && verticalSection.hasAttribute('data-expandable')) return; - const hasOverflow = itemsContainer.scrollWidth > scroller.clientWidth; - verticalSection.setAttribute('data-expandable', hasOverflow ? 'true' : 'false'); + const ITEMS_LAYOUT_CYCLE = ['row', 'grid']; + const ITEMS_LAYOUT_ICONS = { + row: 'view_array', + grid: 'grid_view' + }; + const ITEMS_LAYOUT_NEXT_TITLE = { + row: 'Show as grid', + grid: 'Show as scrollable row' + }; + + 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; + button.classList.remove('view_array', 'grid_view', 'grid_on'); + button.classList.add(ITEMS_LAYOUT_ICONS[layout] || 'view_array'); + } + + 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:not(.card-layout-dummy)')); + } + + function getRealLayoutCards(itemsContainer) { + if (!itemsContainer) return []; + return Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.card-layout-dummy)')); + } + + function removeLayoutDummies(itemsContainer) { + if (!itemsContainer) return; + itemsContainer.querySelectorAll(':scope > .card-layout-dummy').forEach((el) => el.remove()); + } + + function invalidateLastRowPadding(itemsContainer) { + if (!itemsContainer) return; + removeLayoutDummies(itemsContainer); + itemsContainer.removeAttribute('data-last-row-padding'); + itemsContainer.removeAttribute('data-last-row-padding-layout'); + itemsContainer.removeAttribute('data-measuring'); + } + + function canMeasureLastRowPadding(itemsContainer) { + return !!(itemsContainer?.isConnected && itemsContainer.clientWidth >= 1); + } + + function createLayoutDummyCard(templateCard, layout) { + const dummy = document.createElement('div'); + dummy.className = templateCard?.className || 'card'; + dummy.classList.add('card', 'card-layout-dummy'); + dummy.classList.remove('card-hoverable'); + dummy.setAttribute('aria-hidden', 'true'); + dummy.setAttribute('tabindex', '-1'); + + // cardBox + const cardBox = document.createElement('div'); + cardBox.className = 'cardBox'; + dummy.appendChild(cardBox); + + // cardScalable + const cardScalable = document.createElement('div'); + cardScalable.className = 'cardScalable'; + cardBox.appendChild(cardScalable); + + if (layout === 'grid') { + const templatePadder = templateCard?.querySelector('.cardPadder'); + const padder = document.createElement('div'); + padder.className = templatePadder?.className + || 'cardPadder cardPadder-portrait lazy-hidden-children'; + const cardIcon = document.createElement('span'); + cardIcon.className = 'cardImageIcon material-icons'; + cardIcon.setAttribute('aria-hidden', 'true'); + cardIcon.textContent = 'folder'; + padder.appendChild(cardIcon); + cardScalable.appendChild(padder); + + const border = createCardBorderHost(); + const section = templateCard?.closest('[data-border],[data-border-style]'); + const raw = section?.dataset?.border + || section?.dataset?.borderStyle + || templateCard?.querySelector('.cardBorder')?.dataset?.style + || ''; + const style = normalizeCardBorderStyle(raw); + if (style) fillCardBorder(border, style); + cardScalable.appendChild(border); + } + + return dummy; } /** - * Creates a scrollable container with horizontal scrolling functionality - * @param {Array} items - Array of Jellyfin item objects - * @param {string} title - Title for the scrollable container - * @param {string} viewMoreUrl - Optional URL to make title clickable - * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes - * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' - * @returns {HTMLElement} - The constructed scrollable container + * Count how many real cards fit on the first row at natural (non-growing) width. + * @param {HTMLElement} itemsContainer + * @returns {number} */ - function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, forcedImageType = null) { - // Create the main vertical section container - 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); + function countItemsPerRow(itemsContainer) { + if (!canMeasureLastRowPadding(itemsContainer)) return 0; + const cards = getRealLayoutCards(itemsContainer); + if (!cards.length) return 0; + itemsContainer.setAttribute('data-measuring', 'true'); + void itemsContainer.offsetWidth; + const firstTop = cards[0].offsetTop; + let count = 0; + for (const card of cards) { + if (card.offsetTop !== firstTop) break; + count++; } + itemsContainer.removeAttribute('data-measuring'); + return count; + } - // Create section title - const sectionTitleContainer = document.createElement('div'); - sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; - - if (viewMoreUrl) { - // Create clickable title with chevron icon - const titleLink = document.createElement('a'); - titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button'; - 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(); - viewMoreUrl(); - }); - } 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); + /** + * Pad the last flex row with invisible dummy cards so grow distribution matches full rows. + * Reuses data-last-row-padding when cached for the same layout. + * @param {HTMLElement} itemsContainer + * @param {'grid'} layout + */ + function syncLastRowPadding(itemsContainer, layout) { + if (!itemsContainer || layout !== 'grid') return; + + const cachedPad = itemsContainer.getAttribute('data-last-row-padding'); + const cachedLayout = itemsContainer.getAttribute('data-last-row-padding-layout'); + const hasCache = cachedPad != null + && cachedPad !== '' + && cachedLayout === layout + && /^\d+$/.test(cachedPad); + + // Disconnected / zero-width: do not measure or cache (retry when laid out). + if (!hasCache && !canMeasureLastRowPadding(itemsContainer)) return; + + removeLayoutDummies(itemsContainer); + + let padding; + if (hasCache) { + padding = parseInt(cachedPad, 10); } else { - // Regular non-clickable title - const titleText = document.createElement('h2'); - titleText.className = 'sectionTitle sectionTitle-cards'; - titleText.textContent = title; - sectionTitleContainer.appendChild(titleText); + itemsContainer.removeAttribute('data-last-row-padding'); + itemsContainer.removeAttribute('data-last-row-padding-layout'); + const cards = getRealLayoutCards(itemsContainer); + if (!cards.length) return; + const perRow = countItemsPerRow(itemsContainer); + if (perRow <= 0) return; + const rem = cards.length % perRow; + padding = rem === 0 ? 0 : perRow - rem; + itemsContainer.setAttribute('data-last-row-padding', String(padding)); + itemsContainer.setAttribute('data-last-row-padding-layout', layout); } - // Create "Show All" button (always created, shown/hidden based on data-expandable attribute) - const showAllButton = document.createElement('button'); - showAllButton.type = 'button'; - showAllButton.className = 'show-all-button'; - showAllButton.title = 'Show all items'; - sectionTitleContainer.appendChild(showAllButton); + if (padding <= 0) return; + if (!itemsContainer.isConnected) return; + const template = getRealLayoutCards(itemsContainer)[0]; + if (!template) return; + const frag = document.createDocumentFragment(); + for (let i = 0; i < padding; i++) { + frag.appendChild(createLayoutDummyCard(template, layout)); + } + itemsContainer.appendChild(frag); + } - // Create scroller container + let lastRowPaddingResizeTimer = null; + function handleLastRowPaddingWindowResize() { + clearTimeout(lastRowPaddingResizeTimer); + lastRowPaddingResizeTimer = setTimeout(() => { + document.querySelectorAll( + '.itemsContainer[data-layout="grid"]' + ).forEach((container) => { + const layout = container.getAttribute('data-layout'); + invalidateLastRowPadding(container); + syncLastRowPadding(container, layout); + }); + }, 150); + } + if (!window.__kefinLastRowPaddingResizeBound) { + window.__kefinLastRowPaddingResizeBound = true; + window.addEventListener('resize', handleLastRowPaddingWindowResize); + } + + let lastRowPaddingRo = null; + let lastRowPaddingRoTimer = null; + const lastRowPaddingRoPending = new Set(); + + function ensureLastRowPaddingResizeObserver() { + if (lastRowPaddingRo || typeof ResizeObserver === 'undefined') return lastRowPaddingRo; + lastRowPaddingRo = new ResizeObserver((entries) => { + entries.forEach((entry) => { + if (entry?.target) lastRowPaddingRoPending.add(entry.target); + }); + clearTimeout(lastRowPaddingRoTimer); + lastRowPaddingRoTimer = setTimeout(() => { + const targets = [...lastRowPaddingRoPending]; + lastRowPaddingRoPending.clear(); + targets.forEach((el) => { + const layout = el.getAttribute?.('data-layout'); + if (layout !== 'grid') return; + invalidateLastRowPadding(el); + syncLastRowPadding(el, layout); + }); + }, 80); + }); + return lastRowPaddingRo; + } + + function observeLastRowPadding(itemsContainer) { + const ro = ensureLastRowPaddingResizeObserver(); + if (!ro || !itemsContainer) return; + try { + ro.observe(itemsContainer); + } catch (_) { /* ignore */ } + } + + function unobserveLastRowPadding(itemsContainer) { + if (!lastRowPaddingRo || !itemsContainer) return; + try { + lastRowPaddingRo.unobserve(itemsContainer); + } catch (_) { /* ignore */ } + } + + /** + * 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); + itemsContainer.removeAttribute('data-expanded'); + if (gapless === true) { + itemsContainer.setAttribute('data-gapless', 'true'); + } else if (gapless === false) { + itemsContainer.removeAttribute('data-gapless'); + } + if (resolved === 'row') { + unobserveLastRowPadding(itemsContainer); + removeLayoutDummies(itemsContainer); + 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); + } + if (resolved === 'grid') { + observeLastRowPadding(itemsContainer); + // Two frames so wrap layout is settled before measuring / padding + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (itemsContainer.getAttribute('data-layout') !== resolved) return; + syncLastRowPadding(itemsContainer, resolved); + }); + }); + } + } + + function syncAttachedGridTilesLayouts(root) { + if (!root?.querySelectorAll) return; + root.querySelectorAll( + '.itemsContainer[data-layout="grid"]' + ).forEach((itemsContainer) => { + const layout = itemsContainer.getAttribute('data-layout'); + const gapless = itemsContainer.getAttribute('data-gapless') === 'true'; + const section = itemsContainer.closest('.verticalSection, .emby-scroller-container') + || itemsContainer.parentElement; + if (section) applyItemsLayoutState(section, layout, gapless); + else { + observeLastRowPadding(itemsContainer); + invalidateLastRowPadding(itemsContainer); + syncLastRowPadding(itemsContainer, layout); + } + }); + } + + /** @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 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 Section', + 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', + getLabel: () => getShowAllControlLabel(sectionElement), + label: 'Show as grid', + 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 button = document.createElement('button'); + button.type = 'button'; + button.className = `${def.className} material-icons ${def.icon}`; + button.title = label; + button.setAttribute('aria-label', label); + button.dataset.sectionControlId = def.id; + button.addEventListener('click', (e) => { + def.onClick(e, button); + }); + inline.appendChild(button); + if (def.id === 'refresh') { + refreshButton = button; + } + }); + + const newMoreButton = moreButton.cloneNode(true); + moreButton.replaceWith(newMoreButton); + + let activeMorePopover = null; + const closeMorePopover = () => { + if (activeMorePopover) { + activeMorePopover.remove(); + activeMorePopover = null; + } + }; + + newMoreButton.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (activeMorePopover) { + closeMorePopover(); + return; + } + + const popover = document.createElement('div'); + popover.className = 'kefinTweaks-popover section-controls-more-popover itemDetailsGroup'; + + visibleDefinitions.forEach((def) => { + const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label; + 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 ${def.icon}`; + 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(); + closeMorePopover(); + def.onClick(ev, newMoreButton); + }; + item.addEventListener('click', activate); + item.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + activate(ev); + } + }); + popover.appendChild(item); + }); + + 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) { + const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0; + return raw > 0 ? raw : 16; + } + + function createScrollerElement() { const scroller = document.createElement('div'); - //scroller.setAttribute('is', 'emby-scroller'); - - const isMobile = document.documentElement.classList.contains('layout-mobile'); + const isMobile = isMobileLayout(); scroller.setAttribute('data-horizontal', 'true'); scroller.setAttribute('data-centerfocus', 'card'); - scroller.className = `padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller${isMobile ? ' scrollX hiddenScrollX' : ''}`; + scroller.className = `padded-top-focusscale padded-bottom-focusscale emby-scroller padded-left custom-scroller${isMobile ? ' scrollX hiddenScrollX' : ''}`; scroller.setAttribute('data-scroll-mode-x', 'custom'); - // Enable mobile swipe scrolling 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'; - scroller.style.setProperty('--scroll-x', '0'); + scroller.style.touchAction = 'auto'; + scroller.style.overscrollBehaviorX = 'contain'; + scroller.style.overscrollBehaviorY = 'auto'; + scroller.style.webkitOverflowScrolling = 'touch'; + scroller.style.setProperty('--scroll-x', '0'); - // Create items container + return scroller; + } + + function createItemsSliderElement() { + const isMobile = isMobileLayout(); const itemsContainer = document.createElement('div'); itemsContainer.setAttribute('is', 'emby-itemscontainer'); itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? ' animatedScrollX' : ''}`; itemsContainer.style.whiteSpace = 'nowrap'; + return itemsContainer; + } - // If the items contain more episodes than non-episodes, and the card format is Poster, set the forced image type to Primary - if (items.filter(item => item.Type === 'Episode').length > items.filter(item => item.Type !== 'Episode').length && cardFormat === 'Poster') { - forcedImageType = 'Primary'; - } - - // Add items to container - items.forEach((item, index) => { - const card = createJellyfinCardElement(item, overflowCard, cardFormat, null, forcedImageType); - card.setAttribute('data-index', index); - itemsContainer.appendChild(card); - }); - - scroller.appendChild(itemsContainer); - - // Create the left/right scroll buttons for the scroller - + /** + * 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.setAttribute('is', 'emby-scrollbuttons'); scrollButtons.className = 'emby-scrollbuttons padded-right'; const leftButton = document.createElement('button'); leftButton.type = 'button'; - //leftButton.setAttribute('is', 'paper-icon-button-light'); leftButton.setAttribute('data-ripple', 'false'); leftButton.setAttribute('data-direction', 'left'); leftButton.setAttribute('title', 'Previous'); @@ -3608,7 +5306,6 @@ leftSpan.setAttribute('aria-hidden', 'true'); leftButton.appendChild(leftSpan); - // Shared helpers for translateX-based scrolling function getCurrentPosition() { const v = scroller.style.getPropertyValue('--scroll-x'); if (v !== '' && !isNaN(parseFloat(v))) return parseFloat(v); @@ -3618,7 +5315,6 @@ } function getMaxPosition() { - // Ensure we scroll enough to see the last card return scroller.scrollWidth - scroller.clientWidth; } @@ -3627,7 +5323,6 @@ const maxPosition = getMaxPosition() + 300; const clampedPosition = Math.min(Math.max(newPosition, 0), Math.max(maxPosition, 0)); - // Control whether movement is animated or immediate if (opts.animate) { scroller.style.transition = 'transform 270ms ease-out'; } else { @@ -3637,29 +5332,26 @@ scroller.style.transform = `translateX(-${clampedPosition}px)`; scroller.style.setProperty('--scroll-x', String(clampedPosition)); - const verticalSection = scroller.closest('.emby-scroller-container'); - if (verticalSection && typeof applyScrollButtonState === 'function') { + const section = scroller.closest('.emby-scroller-container'); + if (section && typeof applyScrollButtonState === 'function') { if (opts.animate) { const onTransitionEnd = () => { scroller.removeEventListener('transitionend', onTransitionEnd); - applyScrollButtonState(verticalSection); + applyScrollButtonState(section); }; scroller.addEventListener('transitionend', onTransitionEnd); } else { - requestAnimationFrame(() => applyScrollButtonState(verticalSection)); + requestAnimationFrame(() => applyScrollButtonState(section)); } } } leftButton.addEventListener('click', () => { - // Scroll backward by viewport width const itemsContainer = scroller.querySelector('.itemsContainer'); if (!itemsContainer) return; - const scrollAmount = itemsContainer.clientWidth; - + const scrollAmount = itemsContainer.clientWidth - itemsContainer.offsetLeft; const currentPosition = getCurrentPosition(); const newPosition = scrollAmount > currentPosition ? 0 : currentPosition - scrollAmount; - setPosition(newPosition, { animate: true }); }); @@ -3667,7 +5359,6 @@ const rightButton = document.createElement('button'); rightButton.type = 'button'; - //rightButton.setAttribute('is', 'paper-icon-button-light'); rightButton.setAttribute('data-ripple', 'false'); rightButton.setAttribute('data-direction', 'right'); rightButton.setAttribute('title', 'Next'); @@ -3679,42 +5370,14 @@ rightButton.addEventListener('click', () => { const itemsContainer = scroller.querySelector('.itemsContainer'); - if (!itemsContainer) return; - - const scrollAmount = itemsContainer.clientWidth; - + const scrollAmount = itemsContainer.clientWidth - itemsContainer.offsetLeft; const currentPosition = getCurrentPosition(); - const newPosition = currentPosition + scrollAmount; - - setPosition(newPosition, { animate: true }); + setPosition(currentPosition + scrollAmount, { animate: true }); }); scrollButtons.appendChild(rightButton); - // Update button states based on scroll position - function updateScrollButtons() { - const currentPosition = getCurrentPosition(); - const maxPosition = getMaxPosition(); - - // Disable left button at start - leftButton.disabled = currentPosition === 0; - - // Disable right button at end - rightButton.disabled = currentPosition >= maxPosition; - } - - // Update on resize (for responsive layouts) - /* const resizeObserver = new ResizeObserver(() => updateScrollButtons()); - resizeObserver.observe(scroller); */ - - // Initial button state on creation - //updateScrollButtons(); - - // Call updateScrollButtons when the container is added to the DOM - /* verticalSection.addEventListener('DOMContentLoaded', () => { - updateScrollButtons(); - }); */ let dragState = { isDragging: false, startX: 0, @@ -3729,27 +5392,21 @@ momentumId: null }; - if (!isMobile) { - // Movement threshold (px) before we treat the gesture as a drag. Avoids getMaxPosition/layout work on simple clicks. const DRAG_THRESHOLD_PX = 8; - // Touch and drag support (translateX-based). Expensive drag setup runs only after pointer moves past threshold. function onPointerDown(e) { - // Only handle left mouse button for drag (button 0) if (e.type === 'mousedown' && e.button !== 0) { return; } const point = e.touches ? e.touches[0] : e; - // Cancel any existing momentum animation if (dragState.momentumId !== null) { cancelAnimationFrame(dragState.momentumId); dragState.momentumId = null; } - // Store only cheap state until we know it's a drag dragState.startX = point.clientX; dragState.startY = point.clientY; dragState.isDragging = false; @@ -3760,7 +5417,6 @@ const dy = p.clientY - dragState.startY; if (Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; - // Committing to drag: do expensive work once dragState.isDragging = true; dragState.startPosition = getCurrentPosition(); dragState.currentPosition = dragState.startPosition; @@ -3791,7 +5447,7 @@ } } - function onPointerUp(upEvent) { + function onPointerUp() { if (!dragState.isDragging) cleanupPending(); } @@ -3806,38 +5462,30 @@ } function onDragMove(e) { - console.log('onDragMove', dragState.currentPosition); if (!dragState.isDragging) return; - + const point = e.touches ? e.touches[0] : e; const deltaX = dragState.startX - point.clientX; const deltaY = dragState.startY - point.clientY; - - // Directional check: require horizontal movement to be dominant - // Only trigger horizontal scroll if: - // 1. Moved more than 10px horizontally (increased threshold) - // 2. Horizontal movement is greater than vertical movement (directional check) + if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { dragState.hasMoved = true; const rawNewPosition = dragState.startPosition + deltaX; - // Clamp using cached maxPosition to avoid repeated layout work const clampedPosition = Math.min(Math.max(rawNewPosition, 0), Math.max(dragState.maxPosition, 0)); dragState.currentPosition = clampedPosition; setPosition(clampedPosition, { animate: false }); - - // Calculate velocity for momentum scrolling + const currentTime = Date.now(); const timeDelta = currentTime - dragState.lastTime; if (timeDelta > 0) { const positionDelta = point.clientX - dragState.lastX; - dragState.velocity = positionDelta / timeDelta; // pixels per millisecond + dragState.velocity = positionDelta / timeDelta; } dragState.lastX = point.clientX; dragState.lastTime = currentTime; - - // Prevent default to stop page scrolling on touch + if (e.touches) { e.preventDefault(); } @@ -3845,10 +5493,8 @@ } function onDragEnd(e) { - console.log('onDragEnd', dragState.currentPosition); if (!dragState.isDragging) return; - - // Clean up listeners + if (e.type === 'touchend') { document.removeEventListener('touchmove', onDragMove); document.removeEventListener('touchend', onDragEnd); @@ -3856,25 +5502,20 @@ document.removeEventListener('mousemove', onDragMove); document.removeEventListener('mouseup', onDragEnd); } - - // If user dragged, prevent click events from firing + if (dragState.hasMoved) { - // Temporarily disable clicks on the scroller scroller.style.pointerEvents = 'none'; requestAnimationFrame(() => { scroller.style.pointerEvents = ''; }); } - + dragState.isDragging = false; - - // Apply momentum scrolling if there's sufficient velocity - // Tunable constants to better match native Jellyfin inertia - const friction = 0.965; // Higher = slides longer - const velocityThreshold = 0.01; // pixels per millisecond - const velocityBoost = 2.0; // Scale swipe strength to increase carry distance - - // Boost the measured velocity before starting momentum to increase travel distance + + const friction = 0.965; + const velocityThreshold = 0.01; + const velocityBoost = 2.0; + dragState.velocity *= velocityBoost; if (Math.abs(dragState.velocity) > velocityThreshold) { @@ -3883,32 +5524,28 @@ let lastTimestamp = 0; const step = (timestamp) => { - // Compute frame time in ms using rAF timestamp const frameTime = lastTimestamp ? (timestamp - lastTimestamp) : 16; lastTimestamp = timestamp; if (Math.abs(dragState.velocity) > velocityThreshold) { currentPosition -= dragState.velocity * frameTime; - - // Clamp locally to avoid extra layout work currentPosition = Math.min(Math.max(currentPosition, 0), Math.max(maxPosition, 0)); setPosition(currentPosition, { animate: false }); - // If we've hit an edge, stop the momentum if (currentPosition === 0 || currentPosition === maxPosition) { dragState.velocity = 0; dragState.momentumId = null; return; } - dragState.velocity *= friction; // Apply deceleration + dragState.velocity *= friction; dragState.momentumId = requestAnimationFrame(step); } else { dragState.velocity = 0; dragState.momentumId = null; } }; - + dragState.momentumId = requestAnimationFrame(step); } else { dragState.velocity = 0; @@ -3916,87 +5553,165 @@ } } - // Attach drag start listeners (threshold inside onPointerDown defers getMaxPosition until real drag) scroller.addEventListener('mousedown', onPointerDown); - } - // Overflow detection will be handled by ResizeObserver below + return scrollButtons; + } - // Toggle between scroll and grid view - showAllButton.addEventListener('click', () => { - const performanceTimerStart = performance.now(); + /** + * Creates a scrollable container with horizontal scrolling functionality + * @param {Array} items - Array of Jellyfin item objects + * @param {string} title - Title for the scrollable container + * @param {string} viewMoreUrl - Optional URL to make title clickable + * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' + * @returns {HTMLElement} - The constructed scrollable 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; + } - // Find the scroll buttons for this container - const scrollerContainer = showAllButton.closest('.emby-scroller-container'); + function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl }) { + const captionText = caption && String(caption).trim(); + const hasCaption = !!captionText; + let parent = sectionTitleContainer; - const performanceTimerEnd = performance.now(); - const performanceTime = performanceTimerEnd - performanceTimerStart; - console.log(`[KefinTweaks CardBuilder] Time to find scroller container: ${performanceTime}ms`); + if (hasCaption) { + const wrapper = document.createElement('div'); + wrapper.className = 'sectionTitle-wrapper'; + sectionTitleContainer.appendChild(wrapper); + parent = wrapper; + } - const performanceTimerStart2 = performance.now(); + if (viewMoreUrl) { + const titleLink = document.createElement('a'); + titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button'; + titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; - const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null; - - const performanceTimerEnd2 = performance.now(); - const performanceTime2 = performanceTimerEnd2 - performanceTimerStart2; - console.log(`[KefinTweaks CardBuilder] Time to find scroll buttons: ${performanceTime2}ms`); - - const performanceTimerStart3 = performance.now(); - - const isExpanded = itemsContainer.getAttribute('data-expanded') === 'true'; - - if (isExpanded) { - // Switch back to scroll view - itemsContainer.removeAttribute('data-expanded'); - if (scrollButtons) scrollButtons.style.display = ''; - showAllButton.title = 'Show all items in a grid layout'; - setPosition(dragState.currentPosition, { animate: true }); + if (typeof viewMoreUrl === 'function') { + titleLink.addEventListener('click', (e) => { + e.preventDefault(); + viewMoreUrl(); + }); } else { - // Switch to grid view - dragState.currentPosition = getCurrentPosition(); - setPosition(0, { animate: false }); - itemsContainer.setAttribute('data-expanded', 'true'); - if (scrollButtons) scrollButtons.style.display = 'none'; - showAllButton.title = 'Show items in scrollable layout'; + titleLink.href = viewMoreUrl; } - const performanceTimerEnd3 = performance.now(); - const performanceTime3 = performanceTimerEnd3 - performanceTimerStart3; - console.log(`[KefinTweaks CardBuilder] Time to switch view: ${performanceTime3}ms`); + 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); + parent.appendChild(titleLink); + } else { + const titleText = document.createElement('h2'); + titleText.className = 'sectionTitle sectionTitle-cards'; + titleText.textContent = title; + parent.appendChild(titleText); + } + + if (hasCaption) { + let captionEl; + if (captionUrl) { + captionEl = document.createElement('a'); + captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button'; + if (typeof captionUrl === 'function') { + captionEl.addEventListener('click', (e) => { + e.preventDefault(); + captionUrl(); + }); + } else { + captionEl.href = captionUrl; + } + } 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) { + const normalizedFormat = (cardFormat || '').toLowerCase(); + const isButtonLayout = normalizedFormat === 'button'; + + // 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 }); - // Track window size to detect actual window resize events - /* let lastWindowWidth = window.innerWidth; - let lastWindowHeight = window.innerHeight; + ensureSectionControlsMount(sectionTitleContainer); - // Window resize handler - only update data-expandable on actual window resize - let windowResizeTimer = null; - function handleWindowResize() { - const currentWidth = window.innerWidth; - const currentHeight = window.innerHeight; - - // Only update if window size actually changed - if (currentWidth !== lastWindowWidth || currentHeight !== lastWindowHeight) { - lastWindowWidth = currentWidth; - lastWindowHeight = currentHeight; - - // Debounce window resize checks - clearTimeout(windowResizeTimer); - windowResizeTimer = setTimeout(() => { - checkSectionOverflow(verticalSection, true); // Force update on window resize - }, 100); // 100ms debounce - } + if (isButtonLayout) { + const itemsContainer = document.createElement('div'); + itemsContainer.setAttribute('is', 'emby-itemscontainer'); + itemsContainer.className = 'itemsContainer padded-left padded-right focuscontainer-x'; + + items.forEach((item, index) => { + const button = createLibraryButtonElement(item); + button.setAttribute('data-index', index); + itemsContainer.appendChild(button); + }); + + /* verticalSection.appendChild(sectionTitleContainer); + verticalSection.appendChild(itemsContainer); + return verticalSection; */ } - // Listen for window resize events - window.addEventListener('resize', handleWindowResize); */ + // Create scroller container + const scroller = createScrollerElement(); + const itemsContainer = createItemsSliderElement(); - // Initial overflow check is done when section is detected in the DOM by overflowMutationObserver + // Add items to container + items.forEach((item, index) => { + const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter); + card.setAttribute('data-index', index); + itemsContainer.appendChild(card); + }); + + scroller.appendChild(itemsContainer); + + const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller); // Assemble the section verticalSection.appendChild(sectionTitleContainer); - //const scrollButtons2 = document.createElement('div'); verticalSection.appendChild(scrollButtons); verticalSection.appendChild(scroller); @@ -4018,7 +5733,7 @@ let cardClass, padderClass; cardFormat = cardFormat?.toLowerCase() || 'portrait'; - if (cardFormat === 'backdrop' || cardFormat === 'thumb') { + if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; } else if (cardFormat === 'square') { @@ -4072,39 +5787,42 @@ const bannerContainer = document.createElement('div'); bannerContainer.className = 'spotlight-banner-container'; - // Add section title (same structure as createSpotlightSection for minimal layout shift) - if (title) { - let sectionTitleEl; - 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 ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`; - sectionTitleWrapper.appendChild(sectionTitleEl); + // Title container hosts section name + refresh/configure controls. + // Always create it so controls can attach even when the section has no name. + { const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'spotlight-section-title-container'; - sectionTitleContainer.appendChild(sectionTitleWrapper); + if (title) { + let sectionTitleEl; + 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 ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`; + sectionTitleWrapper.appendChild(sectionTitleEl); + sectionTitleContainer.appendChild(sectionTitleWrapper); + } bannerContainer.appendChild(sectionTitleContainer); } @@ -4133,7 +5851,7 @@ * @param {boolean} overflowCard - Use overflow card classes * @returns {HTMLElement} - Container with skeleton cards */ - function createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl = null, cardFormat = null, overflowCard = false) { + function createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl = null, cardFormat = null, overflowCard = false, sectionConfig = null) { // Create the main vertical section container (same structure as createScrollableContainer) const verticalSection = document.createElement('div'); verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; @@ -4146,58 +5864,20 @@ // Create section title const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; - - if (viewMoreUrl) { - const titleLink = document.createElement('a'); - titleLink.className = 'sectionTitle-link sectionTitleTextButton'; - titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; - - if (typeof viewMoreUrl === 'function') { - titleLink.addEventListener('click', (e) => { - e.preventDefault(); - viewMoreUrl(); - }); - } 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); - } else { - const titleText = document.createElement('h2'); - titleText.className = 'sectionTitle sectionTitle-cards'; - titleText.textContent = title; - sectionTitleContainer.appendChild(titleText); - } - // Create scroller container - const scroller = document.createElement('div'); - 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'); - scroller.style.scrollSnapType = 'none'; - scroller.style.touchAction = 'auto'; - scroller.style.overscrollBehaviorX = 'contain'; - scroller.style.overscrollBehaviorY = 'auto'; - scroller.style.webkitOverflowScrolling = 'touch'; + appendSectionTitleContent(sectionTitleContainer, { + title, + caption: getSectionCaption(sectionConfig), + captionUrl: getSectionCaptionUrl(sectionConfig), + viewMoreUrl + }); - // Create items container - const itemsContainer = document.createElement('div'); - itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX'; - itemsContainer.style.whiteSpace = 'nowrap'; + ensureSectionControlsMount(sectionTitleContainer); - // Add skeleton cards (enough to fill viewport width) - const skeletonCount = (cardFormat === 'backdrop' || cardFormat === 'thumb') ? 8 : 10; + const scroller = createScrollerElement(); + const itemsContainer = createItemsSliderElement(); + + const skeletonCount = getSectionSkeletonCount(sectionConfig); for (let i = 0; i < skeletonCount; i++) { const skeletonCard = createSkeletonCard(cardFormat, overflowCard); skeletonCard.setAttribute('data-index', i); @@ -4205,9 +5885,15 @@ } scroller.appendChild(itemsContainer); + + const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller); + verticalSection.appendChild(sectionTitleContainer); + verticalSection.appendChild(scrollButtons); verticalSection.appendChild(scroller); + registerScrollSectionScrollButtons(verticalSection); + return verticalSection; } @@ -4266,9 +5952,6 @@ let lazyImageObserver = null; let lazyMutationObserver = null; - // Overflow check: run checkSectionOverflow when .emby-scroller-container with .itemsContainer appears in DOM - let overflowMutationObserver = null; - // Run updateScrollButtonStateForSection once when a section becomes visible (so --max-scroll is computed after layout) let scrollSectionVisibilityObserver = null; @@ -4418,45 +6101,6 @@ getScrollSectionVisibilityObserver().observe(verticalSection); } - /** - * Collect section elements (.emby-scroller-container containing .itemsContainer) from an added node - * @param {Node} node - Added node (element or fragment) - * @returns {HTMLElement[]} - Deduplicated section elements to run overflow check on - */ - function getScrollerSectionsForOverflowCheck(node) { - if (!node || node.nodeType !== 1 || !node.querySelectorAll) return []; - const sections = []; - const selfMatch = node.classList && node.classList.contains('emby-scroller-container') && node.querySelector('.itemsContainer'); - if (selfMatch) sections.push(node); - const inner = node.querySelectorAll('.emby-scroller-container'); - inner.forEach(el => { - if (el !== node && el.querySelector('.itemsContainer')) sections.push(el); - }); - return sections; - } - - /** - * Initialize the MutationObserver that runs checkSectionOverflow when sections appear in the DOM - */ - function initOverflowMutationObserver() { - if (overflowMutationObserver) return; - overflowMutationObserver = new MutationObserver((mutations) => { - const sectionsToCheck = new Set(); - mutations.forEach(mutation => { - mutation.addedNodes.forEach(node => { - getScrollerSectionsForOverflowCheck(node).forEach(section => sectionsToCheck.add(section)); - }); - }); - sectionsToCheck.forEach(section => { - requestAnimationFrame(() => { - checkSectionOverflow(section, false); - // Re-check after a short delay so images/layout can affect dimensions - setTimeout(() => checkSectionOverflow(section, true), 150); - }); - }); - }); - } - /** * Initialize the smart lazy loading system * Scans existing elements and starts both observers @@ -4466,36 +6110,20 @@ return; // Browser doesn't support observers } - // Initialize observers initLazyImageObserver(); initLazyMutationObserver(); - initOverflowMutationObserver(); - // Scan existing elements with data-src const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]'); existingLazyImages.forEach(img => { lazyImageObserver.observe(img); }); - // Run overflow check on any sections already in the DOM - document.querySelectorAll('.emby-scroller-container').forEach(section => { - if (section.querySelector('.itemsContainer')) { - requestAnimationFrame(() => checkSectionOverflow(section, false)); - } - }); - - // Start watching for new elements if (document.body) { lazyMutationObserver.observe(document.body, { childList: true, subtree: true }); - overflowMutationObserver.observe(document.body, { - childList: true, - subtree: true - }); } else { - // If body isn't ready, wait for DOMContentLoaded document.addEventListener('DOMContentLoaded', () => { if (lazyMutationObserver && document.body) { lazyMutationObserver.observe(document.body, { @@ -4503,12 +6131,6 @@ subtree: true }); } - if (overflowMutationObserver && document.body) { - overflowMutationObserver.observe(document.body, { - childList: true, - subtree: true - }); - } }); } } @@ -4520,6 +6142,784 @@ initSmartLazyLoading(); } + // Progressive section enhancement (deferred via IntersectionObserver when enhanceOnVisible) + let sectionEnhanceObserver = null; + let sectionEnhanceObserverRootMargin = null; + const sectionEnhancePending = new WeakMap(); + + /** + * 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 }} + */ + 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; + + if (keepCurrentSlide) { + 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; + // Detach before the old section is discarded so we can transplant it + activeSlide.remove(); + } + } + } + + const content = createSpotlightSection(itemsForBuild, name, { + viewMoreUrl, + ...spotlightConfig, + ...(doPreserve ? { initialIndex: preserveIndex } : {}) + }); + + if (doPreserve && activeSlide) { + transplantActiveSpotlightSlide(content, activeSlide, 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); + } + + sectionElement.replaceWith(content); + return { content, items: itemsForBuild }; + } + + function getPaintedCardIds(sectionElement) { + const cards = sectionElement.querySelectorAll('.itemsContainer > .card[data-id]:not(.card-layout-dummy):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 patchCardProgressBar(card, item) { + const imageContainer = card.querySelector('.cardImageContainer'); + if (!imageContainer) return; + + const percent = item.UserData?.PlayedPercentage; + const existingBar = imageContainer.querySelector('.itemProgressBarForeground'); + const existingFooter = existingBar?.closest('.innerCardFooter'); + + if (percent && percent > 0) { + if (existingBar) { + 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; + const v = scroller.style.getPropertyValue('--scroll-x'); + if (v !== '' && !isNaN(parseFloat(v))) return parseFloat(v); + return 0; + } + + function setScrollerPosition(scroller, position, animate) { + if (!scroller) return; + const maxPosition = Math.max(0, (scroller.scrollWidth - scroller.clientWidth) || 0); + const clamped = Math.min(Math.max(position, 0), maxPosition + 300); + if (scroller.classList.contains('scrollX')) { + scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' }); + return; + } + scroller.style.transition = animate ? 'transform 400ms ease-out' : 'none'; + scroller.style.transform = `translateX(-${clamped}px)`; + scroller.style.setProperty('--scroll-x', String(clamped)); + } + + function reindexSectionCards(itemsContainer) { + getRealLayoutCards(itemsContainer).forEach((card, index) => { + card.setAttribute('data-index', String(index)); + }); + } + + function appendCardsForItems(itemsContainer, items, overflowCard, cardFormat, startIndex = 0) { + const cards = []; + items.forEach((item, offset) => { + const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter); + card.setAttribute('data-index', String(startIndex + offset)); + itemsContainer.appendChild(card); + cards.push(card); + }); + return cards; + } + + 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) { + 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 = 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) => { + if (!item?.Id) return; + const existing = cardById.get(item.Id); + if (existing) { + seenKept = true; + nextNode = existing.nextElementSibling; + while (nextNode && (nextNode.classList.contains('card-layout-dummy') || nextNode.classList.contains('skeleton-card'))) { + nextNode = nextNode.nextElementSibling; + } + return; + } + + const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter); + if (nextNode) itemsContainer.insertBefore(card, nextNode); + else itemsContainer.appendChild(card); + inserted.push(card); + if (!seenKept) prefixInserted.push(card); + }); + + ensureCardBorders(sectionElement); + + 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 = ''; + reindexSectionCards(itemsContainer); + invalidateLastRowPadding(itemsContainer); + patchCardsUserData(sectionElement, items); + ensureCardBorders(sectionElement); + updateScrollButtonStateForSection(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); + + invalidateLastRowPadding(itemsContainer); + + 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 { + items.forEach((item, index) => { + const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter); + card.setAttribute('data-index', String(index)); + fragment.appendChild(card); + }); + } + + itemsContainer.replaceChildren(fragment); + + applyItemsLayoutState(sectionElement, layout, gapless); + ensureCardBorders(sectionElement); + attachSectionControlButtons(sectionConfig, sectionElement, items); + requestAnimationFrame(() => updateScrollButtonStateForSection(sectionElement)); + + 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.offsetHeight; + 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: sectionConfig.name, + viewMoreUrl: sectionConfig.viewMoreUrl, + spotlightConfig: sectionConfig.spotlightConfig, + dataItems: Array.isArray(dataItems) ? dataItems : [], + keepCurrentSlide: true, + sectionConfig + }); + ensureCardBorders(refreshed.content); + if (revealSectionsSequentially && refreshed.content.classList.contains('cardbuilder-section-reveal') && !refreshed.content.classList.contains('in-viewport')) { + observeSectionForReveal(refreshed.content); + } + return refreshed.content; + } + + const content = createScrollableContainer(items, sectionConfig.name, sectionConfig.viewMoreUrl, 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); + }); + + 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); + invalidateLastRowPadding(content.querySelector('.itemsContainer')); + attachSectionControlButtons(sectionConfig, content, items); + sectionElement.replaceWith(content); + applyItemsLayoutState(content, layout, gapless); + ensureCardBorders(content); + if (revealSectionsSequentially && content.classList.contains('cardbuilder-section-reveal') && !content.classList.contains('in-viewport')) { + observeSectionForReveal(content); + } + return content; + } + + function fadeReplaceSpotlight(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) { + sectionElement.classList.add('cardbuilder-items-fade'); + sectionElement.offsetHeight; + 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) { + if (!sectionElement) return; + sectionElement.dataset.enhanced = 'true'; + if (typeof sectionConfig?._onSectionEnhanced === 'function') { + sectionConfig._onSectionEnhanced(sectionElement, sectionConfig); + } + } + + 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 setupSectionProgressiveEnhancement(sectionElement, section, { revealSectionsSequentially = false } = {}) { + if (!sectionElement || !section?.result) return; + if (!hasSectionDeferredData(section.result)) return; + if (sectionElement.dataset.enhanceScheduled === 'true') return; + + sectionElement.dataset.enhanceScheduled = 'true'; + + const dataPromise = getSectionDataPromise(section.result); + if (!dataPromise) return; + + section.result.isStalePromise?.then(isStale => { + if (isStale) { + sectionElement.dataset.refreshing = 'true'; + } + }); + + dataPromise.then(result => { + sectionElement.dataset.refreshing = 'false'; + + 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')); + + if (items.length === 0) { + sectionElement.remove(); + 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 = items.map((item) => item.Id).filter(Boolean); + const match = classifyIdSequence(paintedIds, freshIds); + if (match.type === 'perfect') { + markSectionEnhanced(sectionElement, sectionConfig); + return; + } + // Soft-replace without fading the section to black (avoids visible→black→image blink on load) + const spotlightContent = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(spotlightContent, sectionConfig); + return; + } + + if (!isSkeleton && !isSpotlight) { + const paintedIds = getPaintedCardIds(sectionElement); + const freshIds = items.map((item) => item.Id).filter(Boolean); + const match = classifyIdSequence(paintedIds, freshIds); + + if (match.type === 'perfect') { + patchCardsUserData(sectionElement, items); + markSectionEnhanced(sectionElement, sectionConfig); + 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); + }); + return; + } + + const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat; + if (isButtonCardFormat(cardFormat) && match.type === 'replace') { + replaceItemsContainerContents(sectionElement, items, sectionConfig); + markSectionEnhanced(sectionElement, sectionConfig); + return; + } + + fadeReplaceRowItems(sectionElement, items, sectionConfig, () => { + markSectionEnhanced(sectionElement, sectionConfig); + }); + return; + } + + if (isSkeleton && isSpotlight) { + const spotlightContent = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(spotlightContent, sectionConfig); + return; + } + + replaceItemsContainerContents(sectionElement, items, sectionConfig); + markSectionEnhanced(sectionElement, sectionConfig); + }); + } + + 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); + setupSectionProgressiveEnhancement(target, pending.section, pending.options); + }); + }, { + threshold: 0, + rootMargin + }); + } + + function observeSectionForEnhance(sectionElement) { + if (!sectionElement || !sectionEnhancePending.has(sectionElement)) return; + + initializeSectionEnhanceObserver(sectionEnhanceObserverRootMargin || '30% 0px 30% 0px'); + + if (sectionEnhanceObserver) { + sectionEnhanceObserver.observe(sectionElement); + } else { + const pending = sectionEnhancePending.get(sectionElement); + sectionEnhancePending.delete(sectionElement); + if (pending) { + setupSectionProgressiveEnhancement(sectionElement, pending.section, 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 }; + + 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; @@ -4569,10 +6969,11 @@ } } - function initialize() { - if (!state.useEpisodeImages) { - state.useEpisodeImages = window.userHelper.useEpisodeImages(); + async function initialize() { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); } + startCardUserDataListener(); } initialize(); @@ -4581,4 +6982,4 @@ window.cardBuilder = cardBuilder; console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder'); -})(); +})(); \ No newline at end of file diff --git a/scripts/configuration.css b/scripts/configuration.css index e6c4235..6459585 100644 --- a/scripts/configuration.css +++ b/scripts/configuration.css @@ -1,184 +1,292 @@ .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: ""; + 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 { + 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; } #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; +} diff --git a/scripts/configuration.js b/scripts/configuration.js index c99960b..8685742 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -218,7 +218,6 @@ kefinTweaksRoot: '', scripts: {}, homeScreen: {}, - exclusiveElsewhere: {}, search: {}, skins: [], defaultSkin: null, @@ -663,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; @@ -1178,7 +1177,7 @@ // 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', 'thumbnailScrubber']); + const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', '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.' }, @@ -1189,21 +1188,44 @@ { 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: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' } + { 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]) + ); + const major = window.KefinTweaks?._jellyfinMajorVersion + ?? (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function' + ? window.KefinTweaks.getJellyfinMajorVersion() + : 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; @@ -2390,8 +2412,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')}
@@ -2455,6 +2477,16 @@ currentLoadedConfig = config; window.KefinTweaksCurrentConfig = config; + // Ensure Jellyfin major is cached so version-gated feature cards filter correctly + try { + if (typeof window.KefinTweaks?.getCurrentMajorServerVersion === 'function') { + const major = await window.KefinTweaks.getCurrentMajorServerVersion(); + window.KefinTweaks._jellyfinMajorVersion = major; + } + } 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'); content.className = 'modal-content-wrapper'; @@ -2494,8 +2526,11 @@ closeOnBackdrop: true, closeOnEscape: true, onOpen: (modalInstance) => { + // Check if window is less than 900px + const layoutMobile = window.innerWidth < 900; + // Update modal dialog styling for large size - if (modalInstance && modalInstance.dialog) { + if (modalInstance && modalInstance.dialog && !layoutMobile) { modalInstance.dialog.style.maxWidth = '90vw'; modalInstance.dialog.style.width = '1400px'; modalInstance.dialog.style.maxHeight = '90vh'; @@ -4080,7 +4115,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { - headers: { 'X-Emby-Token': token } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -4200,7 +4235,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { - headers: { 'X-Emby-Token': token } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -4320,7 +4355,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { - headers: { 'X-Emby-Token': token } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -4458,7 +4493,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { - headers: { 'X-Emby-Token': token } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -5096,57 +5131,36 @@ }, 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 aoiHelper.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 token = ApiClient.accessToken(); - - const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { - headers: { - 'X-Emby-Token': token - } - }); - - 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) { @@ -5155,63 +5169,16 @@ } } - // Save configuration to JavaScript Injector plugin + // Save configuration to JavaScript Injector plugin (delegates to shared utils) async function saveConfigToJavaScriptInjector(config) { try { - // Find the plugin - const pluginId = await findJavaScriptInjectorPlugin(); - - // Get fresh configuration data - const injectorConfig = await getJavaScriptInjectorConfig(pluginId); - - // Ensure CustomJavaScripts array exists - if (!injectorConfig.CustomJavaScripts) { - injectorConfig.CustomJavaScripts = []; + if (!window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) { + throw new Error('KefinTweaksUtils.saveConfigToJavaScriptInjector is not available'); } - // Create the script content - const scriptContent = `// KefinTweaks Configuration -// This file is automatically generated by KefinTweaks Configuration UI -// Do not edit manually unless you know what you're doing - -window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; - - // Check if KefinTweaks-Config already exists - const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( - script => script.Name === 'KefinTweaks-Config' - ); - - if (existingScriptIndex !== -1) { - // Update existing script - console.log('[KefinTweaks Configuration] Updating existing KefinTweaks-Config script'); - injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; - // Keep existing Enabled and RequiresAuthentication settings - } else { - // Add new script - console.log('[KefinTweaks Configuration] Adding new KefinTweaks-Config script'); - injectorConfig.CustomJavaScripts.push({ - Name: 'KefinTweaks-Config', - Script: scriptContent, - Enabled: true, - RequiresAuthentication: false - }); - } - - // POST the updated configuration back - const server = ApiClient._serverAddress; - const token = ApiClient.accessToken(); - - const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { - method: 'POST', - headers: { - 'X-Emby-Token': token, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(injectorConfig) - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + 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'); @@ -5222,6 +5189,14 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } } + // 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}"]`); @@ -5552,8 +5527,158 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } } + 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; + } + + if (typeof MutationObserver === 'undefined' || !document.body) { + console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)'); + return; + } + + appUserMenuConfigObserver = new MutationObserver(() => { + addConfigButtonToAppUserMenu(); + }); + + 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'; @@ -5567,9 +5692,9 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; button.innerHTML = `
- +
-
Configure KefinTweaks
+
KefinTweaks
`; @@ -5579,13 +5704,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // 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 = () => { @@ -5594,24 +5724,24 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // 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); @@ -5649,6 +5779,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // 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; @@ -5724,43 +5855,24 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; 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') { @@ -5817,6 +5929,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; 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/defaultSkin.css b/scripts/defaultSkin.css index e8873e5..612b4cd 100644 --- a/scripts/defaultSkin.css +++ b/scripts/defaultSkin.css @@ -28,7 +28,8 @@ div#seriesScheduleSection { @media (max-width: 768px) { .headerRight .btnRecommendations, .headerRight .btnAdmin, - .headerRight .headerAppearanceButton { + .headerRight .headerAppearanceButton, + .headerRight [data-kefin-custom-menu-top-right] { display: none; } } diff --git a/scripts/deviceManager.js b/scripts/deviceManager.js index e36a7e5..6b0e4a8 100644 --- a/scripts/deviceManager.js +++ b/scripts/deviceManager.js @@ -132,7 +132,7 @@ method: 'DELETE', headers: { 'Accept': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + '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/flattenSingleSeasonShows.js b/scripts/flattenSingleSeasonShows.js deleted file mode 100644 index 7553b8c..0000000 --- a/scripts/flattenSingleSeasonShows.js +++ /dev/null @@ -1,368 +0,0 @@ -// KefinTweaks Flatten Single Season Shows -// Flattens series with only 1 season to show episodes directly on the series details page -// Requires: cardBuilder.js, utils.js modules to be loaded before this script - -(function() { - 'use strict'; - - const LOG = (...args) => console.log('[KefinTweaks FlattenSingleSeasonShows]', ...args); - const WARN = (...args) => console.warn('[KefinTweaks FlattenSingleSeasonShows]', ...args); - const ERR = (...args) => console.error('[KefinTweaks FlattenSingleSeasonShows]', ...args); - - /** - * Fetches the Next Up episode for a given series - * @param {string} seriesId - The series ID - * @returns {Promise} - Episode number object with season and episode, or null - */ - async function fetchNextUpEpisode(seriesId) { - try { - const apiClient = window.ApiClient; - const userId = apiClient.getCurrentUserId(); - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - LOG(`Fetching Next Up episode for series: ${seriesId}`); - const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`; - const nextUpRes = await fetch(nextUpUrl, { - headers: { "Authorization": `MediaBrowser Token="${token}"` } - }); - - if (!nextUpRes.ok) { - throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`); - } - - const nextUpData = await nextUpRes.json(); - const items = nextUpData.Items || []; - - if (items.length > 0) { - const item = items[0]; - if (item.IndexNumber && item.ParentIndexNumber) { - const episodeNumber = { - season: item.ParentIndexNumber, - episode: item.IndexNumber - }; - LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`); - return episodeNumber; - } - } - - LOG('No Next Up episode found in API response'); - return null; - } catch (error) { - ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error); - return null; - } - } - - /** - * Fetches all episodes for a given series - * @param {string} seriesId - The series ID - * @returns {Promise} - Array of episode items - */ - async function fetchEpisodesForSeries(seriesId) { - try { - const apiClient = window.ApiClient; - const userId = apiClient.getCurrentUserId(); - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - LOG(`Fetching episodes for series: ${seriesId}`); - const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`; - const episodesRes = await fetch(episodesUrl, { - headers: { "Authorization": `MediaBrowser Token="${token}"` } - }); - - if (!episodesRes.ok) { - throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`); - } - - const episodesData = await episodesRes.json(); - const episodes = episodesData.Items || []; - - LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}`); - return episodes; - } catch (error) { - ERR(`Failed to fetch episodes for series ${seriesId}:`, error); - return []; - } - } - - /** - * Flattens a single-season show by creating a "Season 1" section with all episodes - * @param {string} seriesId - The series ID - */ - async function flattenShow(seriesId) { - LOG(`Flattening show: ${seriesId}`); - - // Check if already flattened for this series (avoid duplicates) - const activePage = document.querySelector('.libraryPage:not(.hide)'); - if (activePage && activePage.dataset.flattenedSeriesId === seriesId) { - LOG(`Show ${seriesId} already flattened, skipping`); - return; - } - - // Check if flattened section already exists - const existingSection = activePage?.querySelector('.flattened-season-section'); - if (existingSection) { - LOG(`Flattened section already exists, skipping`); - return; - } - - // Get all episodes for the series - const episodes = await fetchEpisodesForSeries(seriesId); - if (episodes.length === 0) { - WARN(`No episodes found for series: ${seriesId}`); - return; - } - - // Check for cardBuilder - if (!window.cardBuilder || !window.cardBuilder.renderCards) { - WARN('cardBuilder.renderCards not available'); - return; - } - - // Find the detailPageContent - const detailPageContent = activePage?.querySelector('.detailPageContent'); - if (!detailPageContent) { - WARN('detailPageContent not found'); - return; - } - - // Find the #childrenCollapsible div - let childrenCollapsible = detailPageContent.querySelector('#childrenCollapsible'); - if (!childrenCollapsible) { - WARN('#childrenCollapsible not found'); - return; - } - - - try { - // Get the season ID from the first episode's ParentId - const seasonId = episodes[0]?.SeasonId; - let viewMoreUrl = null; - - if (seasonId) { - const apiClient = window.ApiClient; - const serverId = apiClient.serverId(); - viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${seasonId}&serverId=${serverId}`; - LOG(`Setting viewMoreUrl to season details: ${viewMoreUrl}`); - } else { - WARN('Could not determine season ID from episodes'); - } - - // Check if Next Up section exists - const nextUpSection = activePage?.querySelector('.nextUpSection'); - const nextUpItemsContainer = nextUpSection?.querySelector('.itemsContainer'); - - // Extract episode info from Next Up if it exists - let targetEpisodeNumber = null; - if (nextUpSection && nextUpItemsContainer) { - LOG('Next Up section found, hiding nextUpSection'); - - // Get the existing card from Next Up and extract episode info - const existingCard = nextUpItemsContainer.querySelector('.card'); - if (existingCard) { - // Find the .cardText a element that contains the episode number - const cardTextLinks = existingCard.querySelectorAll('.cardText a'); - for (const link of cardTextLinks) { - const linkText = link.innerText || link.textContent; - // Match SX:EY pattern (may be part of longer text like "S1:E5 - Episode Name") - const match = linkText.match(/S(\d+):E(\d+)/); - if (match) { - targetEpisodeNumber = { - season: parseInt(match[1], 10), - episode: parseInt(match[2], 10) - }; - LOG(`Found Next Up episode from DOM: S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); - break; - } - } - } - - // If we couldn't find it from the DOM, fetch from API - if (!targetEpisodeNumber) { - LOG('Could not find episode number from DOM, fetching from API'); - targetEpisodeNumber = await fetchNextUpEpisode(seriesId); - } - - // Hide the entire Next Up section - nextUpSection.style.display = 'none'; - } - - const seasonName = episodes[0]?.SeasonName || `Season ${episodes[0]?.ParentIndexNumber}`; - - // Render Season 1 section using renderCards (default behavior) - const seasonSection = window.cardBuilder.renderCards( - episodes, - `${seasonName}`, - viewMoreUrl, - true, // overflowCard - null // cardFormat (use default) - ); - - // Mark as flattened section - seasonSection.classList.add('flattened-season-section'); - seasonSection.setAttribute('data-flattened-section', 'true'); - - const scrollerContainer = seasonSection.querySelector('.emby-scroller'); - if (scrollerContainer) { - scrollerContainer.classList.add('no-padding'); - } - - const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer'); - if (sectionTitleContainer) { - sectionTitleContainer.classList.add('no-padding'); - } - - // Prepend the Season 1 section right before #childrenCollapsible - childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible); - - let childrenItemsContainer = childrenCollapsible.querySelector('.itemsContainer'); - - if (!childrenItemsContainer || childrenItemsContainer.children.length === 0) { - // Check for 10.11.X children container - childrenCollapsible = activePage.querySelector('#listChildrenCollapsible'); - childrenItemsContainer = childrenCollapsible ? childrenCollapsible.querySelector('.itemsContainer') : null; - } - - // Check if childrenCollapsible has only one child in its itemsContainer, and hide it if so - if (childrenItemsContainer && childrenItemsContainer.children.length === 1) { - LOG('childrenCollapsible contains only one child, hiding it'); - childrenCollapsible.style.display = 'none'; - } - - // If we found a target episode from Next Up, scroll to it - if (targetEpisodeNumber && scrollerContainer) { - // Wait for cards to be rendered and positioned - setTimeout(() => { - const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer'); - if (!scrollerItemsContainer) { - WARN('Could not find itemsContainer in scroller'); - return; - } - - // Find the card that matches the target episode number - const cards = scrollerItemsContainer.querySelectorAll('.card'); - let targetCard = null; - - for (const card of cards) { - const cardTextLinks = card.querySelectorAll('.cardText a'); - for (const link of cardTextLinks) { - const linkText = link.innerText || link.textContent; - const match = linkText.match(/S(\d+):E(\d+)/); - if (match && - parseInt(match[1], 10) === targetEpisodeNumber.season && - parseInt(match[2], 10) === targetEpisodeNumber.episode) { - targetCard = card; - break; - } - } - if (targetCard) break; - } - - // Scroll to make the target card the leftmost visible element - if (targetCard) { - const itemsContainer = scrollerItemsContainer; - const cardOffset = targetCard.offsetLeft; - const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0; - - // Calculate the scrollToPosition value - // The card offset minus padding gives us how much to scroll - const translateX = (cardOffset - scrollerPadding); - scrollerContainer.scrollToPosition(translateX); - - // Enable the left scroll button so users can scroll back - const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons'); - if (scrollButtons) { - const leftButton = scrollButtons.querySelector('button[data-direction="left"]'); - if (leftButton) { - leftButton.removeAttribute('disabled'); - LOG('Enabled left scroll button'); - } - } - - LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode} using translateX(${translateX}px)`); - } else { - WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); - } - }, 100); - } - - // Mark as flattened for this series to avoid duplicates - if (activePage) { - activePage.dataset.flattenedSeriesId = seriesId; - } - - LOG(`Successfully flattened show ${seriesId} with ${episodes.length} episodes`); - } catch (error) { - ERR('Error rendering flattened season section:', error); - } - } - - /** - * Initialize the flatten single season shows hook - */ - function initializeFlattenHook() { - if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { - WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); - setTimeout(initializeFlattenHook, 1000); - return; - } - - LOG('Registering flatten single season shows handler with KefinTweaksUtils'); - - window.KefinTweaksUtils.onViewPage( - async (view, element, hash, itemPromise) => { - // Only handle details pages - const activePage = document.querySelector('.libraryPage:not(.hide)'); - if (!activePage) return; - - // Remove any existing flattened section and reset flag when page changes - const existingSection = activePage.querySelector('.flattened-season-section'); - if (existingSection) { - LOG('Flattened section already exists, skipping'); - return; - } - - // Await the item promise to get the actual item data - const item = await itemPromise; - - // Check if item is a Series and has only 1 ChildCount - if (item && item.Type === 'Series' && item.ChildCount === 1) { - LOG(`Found single-season series: ${item.Id} (${item.Name})`); - - // Small delay to ensure details DOM is ready - setTimeout(async () => { - await flattenShow(item.Id); - }, 100); - } - }, - { - pages: ['details'] - } - ); - - LOG('Flatten single season shows hook initialized'); - } - - // Initialize the hook when the script loads - function initialize() { - if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { - WARN('KefinTweaksUtils not available, retrying in 1 second'); - setTimeout(initialize, 1000); - return; - } - - initializeFlattenHook(); - } - - // Start initialization - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initialize); - } else { - initialize(); - } - - LOG('Script loaded successfully'); -})(); - diff --git a/scripts/games.js b/scripts/games.js new file mode 100644 index 0000000..d16f39b --- /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?.kefinTweaksRoot; + 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..f43e331 --- /dev/null +++ b/scripts/hamburgerMenu.js @@ -0,0 +1,751 @@ +// 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; +} +.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 ensureToolbarObserver() { + if (toolbarObserver || typeof MutationObserver === 'undefined' || !document.body) return; + let scheduled = false; + toolbarObserver = new MutationObserver(() => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + ensureHamburgerButton(); + }); + }); + toolbarObserver.observe(document.body, { childList: true, subtree: true }); + } + + function start() { + ensureStyles(); + ensureHamburgerButton(); + ensureToolbarObserver(); + // Retry briefly for late toolbar mount + let tries = 0; + const poll = setInterval(() => { + tries += 1; + if (ensureHamburgerButton() || tries >= 40) clearInterval(poll); + }, 250); + 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 3453069..b4312f2 100644 --- a/scripts/headerTabs.js +++ b/scripts/headerTabs.js @@ -93,7 +93,7 @@ `${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`, { headers: { - 'X-Emby-Token': accessToken + 'Authorization': window.apiHelper.getAuthHeader() } } ); @@ -397,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", - "X-Emby-Token": ApiClient._serverInfo.AccessToken || ApiClient.accessToken(), - }, - }); - 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) { diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 0e601e0..4f2f9fc 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -1,36 +1,22 @@ -@import url('https://fonts.googleapis.com/css2?family=Oswald:wght@200..700&display=swap'); +@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; - padding-bottom: 100px; - gap: 1rem; -} - -body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #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)), var(--seasonal-background-image); - background-position: center; - background-size: cover; + display: flex; + flex-direction: column; + padding-bottom: 100px; + gap: 1rem; } .snowverlay { - display: none; + display: none; } -body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay { - display: block; -} - -body:has(.homePage:not(.hide)) [data-section-id] h2.sectionTitle, -body:has(.homePage:not(.hide)) [data-section-id] .sectionTitleTextButton>.material-icons { - margin-bottom: 0 !important; - margin-top: 0 !important; +.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; } /* div#discovery-loading-indicator:not(.show)::before { @@ -47,24 +33,24 @@ body:has(.homePage:not(.hide)) [data-section-id] .sectionTitleTextButton>.materi } */ #indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before { - content: 'Discover More'; + 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; + 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; } [data-loading-discovery="true"] div#discovery-loading-indicator { - display: flex; + display: flex; } /* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { @@ -72,66 +58,70 @@ div#discovery-loading-indicator:not(.show)::after { } */ #indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after { - content: ''; + 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; + display: block; + visibility: visible; + order: 99998 !important; + margin: 2em auto; } .sections.loading-discovery .load-more-discovery-btn { - visibility: hidden !important; + visibility: hidden !important; } /* Discovery section loading indicator */ .sections.loading-discovery .discovery-loading-indicator { - visibility: visible !important; - opacity: 1 !important; + visibility: visible !important; + opacity: 1 !important; } -.discovery-loading-indicator, .load-more-discovery-btn { - height: 50px; +.discovery-loading-indicator, +.load-more-discovery-btn { + height: 50px; } .discovery-loading-indicator { - display: none; - 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; + display: none; + 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; } .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; + 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); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } /* Hide next up section if next up if KefinTweaks next up section is present */ @@ -152,124 +142,107 @@ div#discovery-loading-indicator:not(.show)::after { /* Hide scroll buttons on mobile and tablet layouts */ @media (max-width: 768px) { - .emby-scrollbuttons { - display: none !important; - } + .emby-scrollbuttons { + display: none !important; + } } /* Hide scroll buttons on mobile using Jellyfin's layout classes */ .layout-mobile .emby-scrollbuttons, .layout-tv .emby-scrollbuttons { - display: none !important; + display: none !important; } .load-more-discovery-btn { - display: block; + display: block; } .sections[data-loading-discovery="true"] .load-more-discovery-btn { - display: none !important; + display: none !important; } .sections[data-loading-discovery="true"] .discovery-loading-indicator { - display: flex !important; - opacity: 1 !important; + 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; + 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; + 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); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } .homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText { - visibility: hidden; + visibility: hidden; } /* .homeSectionsContainer:not(.kefinHomeSectionsContainer) { display: none !important; } */ -.pageTabContent:has(.kefinHomeSectionsContainer[data-kefin-home-screen]) .homeSectionsContainer:not([data-kefin-home-screen]) { - 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; } -.homeSectionsContainer .card[data-type="Folder"] .cardScalable > a.cardImageContainer::after { - content: attr(aria-label); - font-family: 'Oswald'; - font-size: 1.5rem; - line-height: 1.8rem; - max-width: calc(100% - 2rem); - white-space: pre-wrap; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - text-transform: uppercase; - position: absolute; - top: 1rem; - left: 1rem; - background: none; +.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="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; +/* .homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] .cardText { + display: none; } -.layout-mobile .homeSectionsContainer .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="Folder"] .cardText { - display: none; -} - -[data-card-format="Square"] [data-type="Person"] .cardScalable, -[data-card-format="Square"] [data-type="Actor"] .cardScalable, -[data-card-format="Square"] [data-type="Writer"] .cardScalable, +[data-card-format="Square"] [data-type="Person"] .cardScalable, +[data-card-format="Square"] [data-type="Actor"] .cardScalable, +[data-card-format="Square"] [data-type="Writer"] .cardScalable, [data-card-format="Square"] [data-type="Director"] .cardScalable { - border-radius: 50% !important; - overflow: hidden; + border-radius: 50% !important; + overflow: hidden; } [data-card-format="Square"] [data-type="Actor"] .cardText { - display: flex; - justify-content: center; -} + display: flex; + justify-content: center; +} */ .homeSectionsContainer .section0, .homeSectionsContainer .section1, @@ -281,23 +254,47 @@ div#discovery-loading-indicator:not(.show)::after { .homeSectionsContainer .section7, .homeSectionsContainer .section8, .homeSectionsContainer .section9 { - display: none !important; + display: none !important; } .homeSectionsContainer .card[data-type="TvChannel"] a.cardImageContainer:not([style])::after { - content: attr(aria-label); - font-size: 2rem; - text-wrap: auto; - text-align: center; + content: attr(aria-label); + font-size: 2rem; + text-wrap: auto; + text-align: center; } .emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer { - gap: 4rem + gap: 4rem; } .layout-mobile .emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer { - gap: 2rem; + gap: 2rem; } [data-section-id="upcoming"] .missingIndicator { - display: none; -} \ No newline at end of file + display: none; +} + +.homeSectionsContainer .section9 + .custom-scroller-container { + margin-top: 1em; +} + +/* .pageTabContent:has(.kefinHomeSectionsContainer[data-kefin-home-screen]) .homeSectionsContainer:not([data-kefin-home-screen]) { + display: none !important; +} + +body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #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)), var(--seasonal-background-image); + background-position: center; + background-size: cover; +} + +body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay { + display: block; +} */ diff --git a/scripts/homeScreen.js b/scripts/homeScreen.js index e13157e..0059926 100644 --- a/scripts/homeScreen.js +++ b/scripts/homeScreen.js @@ -2596,9 +2596,10 @@ uniqueStudios.sort((a, b) => (b.ChildCount || 0) - (a.ChildCount || 0)); // Filter by minimum shows - const filteredStudios = uniqueStudios.filter(studio => - (studio.ChildCount || 0) >= minimumShowsForNetwork - ); + const filteredStudios = uniqueStudios.filter(studio => { + const itemCount = studio.ChildCount || studio.SeriesCount || 0; + return itemCount >= minimumShowsForNetwork; + }); LOG(`Fetched ${filteredStudios.length} Popular TV Networks (filtered from ${uniqueStudios.length})`); diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index 7876111..52d1c74 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -336,7 +336,7 @@ if (nextupSectionConfig && nextupSectionConfig.enabled && nextupSectionConfig.queries) { for (const query of nextupSectionConfig.queries) { if (query.path || !query.dataSource) { - const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), nextupSectionConfig.renderMode === 'Spotlight'); + const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), nextupSectionConfig.renderMode === 'Spotlight', { sectionType: nextupSectionConfig.type }); if (queryUrl && typeof queryUrl === 'string') { ApiHelper.invalidateCache(queryUrl); } @@ -348,7 +348,7 @@ if (continueWatchingSectionConfig && continueWatchingSectionConfig.enabled && continueWatchingSectionConfig.queries) { for (const query of continueWatchingSectionConfig.queries) { if (query.path || !query.dataSource) { - const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingSectionConfig.renderMode === 'Spotlight'); + const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingSectionConfig.renderMode === 'Spotlight', { sectionType: continueWatchingSectionConfig.type }); if (queryUrl && typeof queryUrl === 'string') { ApiHelper.invalidateCache(queryUrl); } @@ -360,7 +360,7 @@ if (continueWatchingAndNextUpSectionConfig && continueWatchingAndNextUpSectionConfig.enabled && continueWatchingAndNextUpSectionConfig.queries) { for (const query of continueWatchingAndNextUpSectionConfig.queries) { if (query.path || !query.dataSource) { - const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingAndNextUpSectionConfig.renderMode === 'Spotlight'); + const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingAndNextUpSectionConfig.renderMode === 'Spotlight', { sectionType: continueWatchingAndNextUpSectionConfig.type }); if (queryUrl && typeof queryUrl === 'string') { ApiHelper.invalidateCache(queryUrl); } @@ -427,24 +427,46 @@ let performanceDuration = performanceEndTime - performanceStartTime; LOG(`Home Screen v3 Fetch display preferences initialization time: ${performanceDuration.toFixed(2)}ms`); + // Load user preferences and apply filtering/ordering performanceStartTime = performance.now(); - - // Merge configs - flatten groups first - //const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); - //const mergedHomeSections = await mergeHomeSectionConfigs(defaultHomeSections); - const mergedHomeSections = await mergeHomeSectionConfigs(); - + + const userHomeConfig = window.KefinUserHomeScreenConfig; + let filteredHomeSections; + let homeScreen; + let userDisplayPreferences = null; + + if (typeof userHomeConfig?.getConfig === 'function') { + const userConfig = await userHomeConfig.getConfig(); + filteredHomeSections = userConfig.sections || []; + homeScreen = userConfig.homeScreen; + userDisplayPreferences = await state.getDisplayPrefernces(); + //addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen); + } else { + const mergedHomeSections = await mergeHomeSectionConfigs(); + userDisplayPreferences = await state.getDisplayPrefernces(); + const customPrefs = userDisplayPreferences?.CustomPrefs || {}; + homeScreen = userHomeConfig.parseKefinTweaksHomeScreen(customPrefs); + const pinnedSections = userHomeConfig.buildPinnedSectionConfigs(homeScreen); + //addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen); + + const serverSectionsById = new Map(); + mergedHomeSections.forEach(section => { + if (section?.id) serverSectionsById.set(section.id, section); + }); + + filteredHomeSections = [...mergedHomeSections, ...pinnedSections]; + filteredHomeSections = userHomeConfig.applyUserSectionOverrides(filteredHomeSections, homeScreen, { serverSectionsById }); + filteredHomeSections = filteredHomeSections.filter(section => section.enabled !== false); + } + performanceEndTime = performance.now(); performanceDuration = performanceEndTime - performanceStartTime; - LOG(`Home Screen v3 Merge home section configs initialization time: ${performanceDuration.toFixed(2)}ms`); + LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`); - performanceStartTime = performance.now(); - - - const nextupSectionConfig = mergedHomeSections.find(s => s.id === 'nextUp'); - const continueWatchingSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatching'); - const continueWatchingAndNextUpSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatchingAndNextUp'); - const recentlyAddedSectionConfig = mergedHomeSections.find(s => s.id.startsWith('recently-added-') && s.enabled === true); + const nextupSectionConfig = filteredHomeSections.find(s => s.id === 'nextUp'); + const continueWatchingSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatching'); + const continueWatchingAndNextUpSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatchingAndNextUp'); + const recentlyAddedSectionConfig = filteredHomeSections.find(s => s.id?.startsWith?.('recently-added-') && s.enabled === true); state.kefinNextUp = nextupSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true; state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true; state.kefinLatestMedia = recentlyAddedSectionConfig?.enabled === true; @@ -456,30 +478,6 @@ }); } - performanceEndTime = performance.now(); - performanceDuration = performanceEndTime - performanceStartTime; - LOG(`Home Screen v3 Setup event listener initialization time: ${performanceDuration.toFixed(2)}ms`); - - // Load user preferences and apply filtering/ordering - performanceStartTime = performance.now(); - - const userDisplayPreferences = await state.getDisplayPrefernces(); - let userHomeScreenSections = loadUserHomeScreenSections(userDisplayPreferences); - addUserHomeScreenOrderCSS(userDisplayPreferences); - - let filteredHomeSections = mergedHomeSections.map(section => { - const userSection = userHomeScreenSections.find(s => s.id === section.id); - if (userSection) { - section.enabled = userSection.enabled; - section.order = userSection.order; - } - return section; - }); - - performanceEndTime = performance.now(); - performanceDuration = performanceEndTime - performanceStartTime; - LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`); - // Set up mutation observer BEFORE rendering, so we can react when Jellyfin renders home sections /* const handleJellyfinRender = async () => { const jellyHome = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer'); @@ -721,6 +719,7 @@ enabled: true, order: libConfig.order || 61, cardFormat: libConfig.cardFormat || 'Poster', + jellyfinId: 'latestmedia', viewMoreUrl: viewMoreUrl, queries: [{ path: `/Items/Latest`, @@ -815,7 +814,7 @@ return filteredMerged; */ } - function getDiscoverySections() { + async function getDiscoverySections() { const settings = getDiscoverySettings(); if (!settings || settings.enabled === false) { @@ -823,6 +822,18 @@ return []; } + const page = state.discoveryGroupIndex + 1; + + const filterByDiscoveryPage = (sections) => (sections || []).filter((section) => { + if (section.pageNumber == null) return true; // native discovery templates + return Number(section.pageNumber) === page; + }); + + const userHomeScreenConfig = await window.KefinUserHomeScreenConfig.getConfig(); + if (userHomeScreenConfig) { + return filterByDiscoveryPage(userHomeScreenConfig.enabledDiscoverySections); + } + const homeScreenConfig = window.KefinHomeScreen.getConfig(); // Get default discovery sections @@ -844,30 +855,35 @@ return section; }); - // Get custom discovery sections from CUSTOM_SECTION_GROUPS - // Filter for sections that have discovery-related properties (type, source: 'Dynamic', etc.) - const customSections = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.CUSTOM_SECTION_GROUPS || []) : []; - const customDiscoverySections = customSections.filter(section => { - return section.discoveryEnabled === true && section.enabled === true; - }); + const isInSeasonalPeriod = (startDate, endDate) => { + const currentDate = new Date(); + const start = new Date(startDate); + const end = new Date(endDate); + start.setFullYear(currentDate.getFullYear()); + end.setFullYear(currentDate.getFullYear()); + return currentDate >= start && currentDate <= end; + }; - // Add one additional custom discovery section that hasn't been rendered yet - if (customDiscoverySections.length > 0) { - const availableSections = customDiscoverySections.filter(section => !state.renderedDiscoveryIds.customDiscoverySections.has(section.id)); - if (availableSections.length > 0) { - const selectedSection = availableSections[Math.floor(Math.random() * availableSections.length)]; - if (selectedSection) { - mergedSections.push(selectedSection); - state.renderedDiscoveryIds.customDiscoverySections.add(selectedSection.id); - } - } - } + const customGroups = homeScreenConfig?.CUSTOM_SECTION_GROUPS + || window.KefinTweaksConfig?.homeScreenConfig?.CUSTOM_SECTION_GROUPS + || []; + const collect = window.KefinHomeScreen?.collectCustomDiscoverySections; + const customDiscoverySections = typeof collect === 'function' + ? collect(customGroups, { + isSectionActive: (s) => s.enabled === true, + isInSeasonalPeriod + }) + : []; + + mergedSections.push(...filterByDiscoveryPage(customDiscoverySections)); // Legacy support: Also check old customSections location const legacyCustomSections = window.KefinTweaksConfig?.homeScreen?.customSections || []; if (legacyCustomSections.length > 0) { legacyCustomSections.forEach(section => { if (section.enabled && section.discoveryEnabled) { + const legacyPage = section.pageNumber == null ? 1 : Number(section.pageNumber); + if (legacyPage !== page) return; if (state.renderedDiscoveryIds.customDiscoverySections.has(section.id)) { return; } @@ -982,9 +998,10 @@ /** * Add CSS for user-defined section ordering */ - function addUserHomeScreenOrderCSS(userDisplayPreferences) { + function addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen = null) { const customPrefs = userDisplayPreferences?.CustomPrefs || {}; - const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]'); + const parsedHomeScreen = homeScreen || window.KefinUserHomeScreenConfig.parseKefinTweaksHomeScreen(customPrefs); + const kefinHomeScreen = window.KefinUserHomeScreenConfig.homeScreenToLegacyArray(parsedHomeScreen); // Create a homesectionN : order value mapping for the custom CSS orders to be applied const jellyfinOrders = {}; @@ -1183,10 +1200,17 @@ sectionCache.renderedSections = [...sectionsToRender]; } + const homeConfig = await window.KefinHomeScreen.getConfig(); + const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.SHOW_STALE_DATA_BEFORE_REFRESH === true; + const targetContainer = container; const renderStartTime = performance.now(); - await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { waitForContainerClass: 'homeSectionsContainer' }); + await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { + waitForContainerClass: 'homeSectionsContainer', + enhanceOnVisible: true, + showStaleDataBeforeRefresh + }); const renderEndTime = performance.now(); const renderDuration = renderEndTime - renderStartTime; LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`); @@ -1282,19 +1306,6 @@ sectionConfig.name = sectionName; } - // Check for forced image type preference - let forcedImageType = null; - if ((sectionConfig.id === 'nextUp' || sectionConfig.id === 'continueWatching' || sectionConfig.id === 'continueWatchingAndNextUp')) { - const userDisplayPreferences = await state.getDisplayPrefernces(); - if (userDisplayPreferences && userDisplayPreferences.CustomPrefs && userDisplayPreferences.CustomPrefs.useEpisodeImagesInNextUpAndResume === 'true') { - forcedImageType = 'Primary'; - } - } - - if (forcedImageType) { - sectionConfig.forcedImageType = forcedImageType; - } - // Check renderMode first (preferred), fallback to spotlight boolean for backward compatibility const shouldRenderSpotlight = sectionConfig.renderMode === 'Spotlight' || sectionConfig.renderMode === 'Random' || sectionConfig.spotlight === true; if (shouldRenderSpotlight) { @@ -1320,6 +1331,9 @@ 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, @@ -1330,6 +1344,9 @@ squareUrl: squareUrl, imageUrl: imageUrl, cardUrl: cardUrl, + backdropUrl: backdropUrl, + bannerUrl: bannerUrl, + logoUrl: logoUrl, CustomFooterText: item.cardFooter || undefined }; }); @@ -1357,8 +1374,28 @@ const serverUrl = ApiClient.serverAddress(); const results = []; + const resolveQueries = window.cardBuilder?.resolveQueriesToLoad; + const queriesToLoad = typeof resolveQueries === 'function' + ? resolveQueries(sectionConfig, { initialize: true }) + : (() => { + if (sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) { + const randomIndex = Math.floor(Math.random() * sectionConfig.queries.length); + LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${randomIndex}`); + return [sectionConfig.queries[randomIndex]]; + } + return sectionConfig.queries; + })(); + + if (sectionConfig.useMultiQueryPicker === true && sectionConfig.queries.length > 1) { + const index = sectionConfig._selectedQueryIndex; + LOG(`Section ${sectionConfig.id}: useMultiQueryPicker — selected query index ${index}`); + } else if (queriesToLoad.length === 1 && sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) { + const index = sectionConfig.queries.indexOf(queriesToLoad[0]); + LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${index}`); + } + // Process each query in the queries array - for (const query of sectionConfig.queries) { + for (const query of queriesToLoad) { let queryResult; if (query.dataSource) { @@ -1366,7 +1403,7 @@ queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}); } else { // Build and execute query - const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight'); + const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', { sectionType: sectionConfig.type }); if (typeof queryUrl === 'string') { // Standard query @@ -1405,21 +1442,73 @@ if (window.cardBuilder.postProcessItems) { postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, items); } - return postProcessedItems; - } + return sortItemsByConfiguredIds(sectionConfig, postProcessedItems); + }; + + const queryResult = results[0]; + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + const raw = typeof queryResult.ensureData === 'function' + ? queryResult.ensureData() + : queryResult.dataPromise; + mappedDataPromise = Promise.resolve(raw).then((data) => postProcess(sectionConfig, data)); + } + return mappedDataPromise; + }; + + const result = { + data: postProcess(sectionConfig, queryResult.data), + isStale: queryResult.isStale === true, + isStalePromise: queryResult.isStalePromise, + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); // Single query result return { config: sectionConfig, queryUrl: null, - result: { - data: postProcess(sectionConfig, results[0].data), - dataPromise: results[0].dataPromise.then(data => postProcess(sectionConfig, data)), - isStalePromise: results[0].isStalePromise - } + result }; } + /** + * Jellyfin /Items?Ids= does not reliably return items in request order. + * When queryOptions.Ids is set, re-sort the item list to match that sequence. + */ + function sortItemsByConfiguredIds(sectionConfig, items) { + const list = Array.isArray(items) ? items : (items?.Items || []); + if (!Array.isArray(list) || list.length === 0) return items; + + const rawIds = sectionConfig?.queries?.[0]?.queryOptions?.Ids; + if (!rawIds) return items; + + const ids = Array.isArray(rawIds) + ? rawIds.filter(Boolean) + : String(rawIds).split(',').map((s) => s.trim()).filter(Boolean); + if (!ids.length) return items; + + const order = new Map(ids.map((id, i) => [String(id), i])); + const sorted = [...list].sort((a, b) => { + const ai = order.has(String(a?.Id)) ? order.get(String(a.Id)) : 1e9; + const bi = order.has(String(b?.Id)) ? order.get(String(b.Id)) : 1e9; + return ai - bi; + }); + + if (Array.isArray(items)) return sorted; + if (items && typeof items === 'object' && Array.isArray(items.Items)) { + return { ...items, Items: sorted }; + } + return sorted; + } + function deduplicateItems(items) { const deduplicated = []; const ids = new Set(); @@ -1444,36 +1533,7 @@ let _getWatchlistUrlPromise = null; async function getWatchlistUrl() { - const watchlistTabIndex = localStorage.getItem(`kefinTweaks_watchlistTabIndex_${ApiClient.serverId()}`); - - if (watchlistTabIndex) { - return `#/home.html?tab=${watchlistTabIndex}`; - } - - LOG('Watchlist tab index not found in local storage'); - // Fetch the tab in the background so that next time it's available - window.KefinTweaksUtils.getWatchlistTabIndex(); - return null; - - - let homePageSuffix = '.html'; - if (window.ApiClient && window.ApiClient.serverInfo && window.ApiClient.serverInfo().Version?.split('.')[1] > 10) { - homePageSuffix = ''; - } - - if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.getWatchlistTabIndex) { - WARN('KefinTweaksUtils not available'); - return null; - } - - const visibleWatchlistTabIndex = await window.KefinTweaksUtils.getWatchlistTabIndex(); - - if (visibleWatchlistTabIndex) { - return `#/home${homePageSuffix}?tab=${visibleWatchlistTabIndex}`; - } else { - WARN('Watchlist tab not found'); - return null; - } + return '#/watchlist'; } /** @@ -1509,7 +1569,7 @@ const watchlistUrl = await getWatchlistUrl(); if (watchlistUrl) { - return `${watchlistUrl}&pageTab=history`; + return `${watchlistUrl}?pageTab=history`; } return null; } @@ -1559,7 +1619,7 @@ // Check for ParentId if (queryOptions.ParentId) { - if (sectionConfig.type === 'Collection' || sectionConfig.type === 'Playlist') { + if (sectionConfig.discoveryType === 'Collection' || sectionConfig.discoveryType === 'Playlist') { return `#/details?id=${queryOptions.ParentId}&serverId=${serverId}`; } @@ -1636,7 +1696,7 @@ * Returns { id, name, metadata: {} } */ async function resolveDynamicSource(config) { - switch (config.type) { + switch (config.discoveryType) { case 'Genre': const genre = await getRandomGenre(); return genre ? { id: genre.Id, name: genre.Name, metadata: { Genre: genre.Name } } : null; @@ -1736,6 +1796,163 @@ }); } + /** + * Resolves a dynamic discovery template into a concrete section config with queries. + * Returns null when no source could be resolved (e.g. no eligible genre/person left). + * @param {Object} template - Discovery section template + * @param {Object} [options] + * @param {boolean} [options.pairSpotlight] - Reuse the paired spotlight source (home render behaviour) + * @param {boolean} [options.resolveViewMore] - Resolve the "view more" URL (may issue extra requests) + */ + async function buildDiscoverySectionInstance(template, options = {}) { + const pairSpotlight = options.pairSpotlight !== false; + const resolveViewMore = options.resolveViewMore !== false; + + const instanceConfig = { + ...template, + id: `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'discovery', + }; + if (!instanceConfig.discoveryType && template.type + && !['home', 'seasonal', 'discovery'].includes(String(template.type).toLowerCase())) { + instanceConfig.discoveryType = template.type; + } + + const dynamicResult = await resolveDynamicSource(instanceConfig); + if (!dynamicResult) return null; + + instanceConfig.source = dynamicResult.id || dynamicResult.name; + instanceConfig.metadata = dynamicResult.metadata; + instanceConfig.excludeItemId = dynamicResult.excludeItemId; + + if (pairSpotlight && getDiscoverySettings().renderSpotlightAboveMatching) { + const configId = instanceConfig.id.split('-')[0]; + + if (configId === 'genreMovies' || configId === 'spotlightGenre') { + if (state.currentDiscoveryGenre) { + instanceConfig.source = state.currentDiscoveryGenre.id; + dynamicResult.metadata = state.currentDiscoveryGenre.metadata; + state.currentDiscoveryGenre = null; + } + else { + state.currentDiscoveryGenre = dynamicResult; + } + } + if (configId === 'studioShows' || configId === 'spotlightNetwork') { + if (state.currentDiscoveryStudio) { + instanceConfig.source = state.currentDiscoveryStudio.id; + dynamicResult.metadata = state.currentDiscoveryStudio.metadata; + state.currentDiscoveryStudio = null; + } + else { + state.currentDiscoveryStudio = dynamicResult; + } + } + } + + // Create queries array with proper queryOptions + const queryOptions = { + Recursive: 'true', + Limit: instanceConfig.itemLimit || 20 + }; + + // Add IncludeItemTypes if specified + if (instanceConfig.includeItemTypes && instanceConfig.includeItemTypes.length) { + queryOptions.IncludeItemTypes = instanceConfig.includeItemTypes; + } + + // Add SortBy and SortOrder if specified + if (instanceConfig.sortOrder) { + queryOptions.SortBy = instanceConfig.sortOrder; + } + if (instanceConfig.sortOrderDirection) { + queryOptions.SortOrder = instanceConfig.sortOrderDirection; + } + + // Handle discoveryType-specific query options + const resolvedSource = instanceConfig.source; + const discoveryKind = instanceConfig.discoveryType || instanceConfig.type; + if (discoveryKind === 'Similar') { + // Similar type uses custom path + instanceConfig.queries = [{ + path: `/Items/${resolvedSource}/Similar`, + queryOptions: { + Limit: instanceConfig.itemLimit || 20, + Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData' + } + }]; + } else { + // Standard query types + switch (discoveryKind) { + case 'Genre': + if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { + queryOptions.GenreIds = [resolvedSource]; + } else if (resolvedSource) { + queryOptions.Genres = resolvedSource; + } + break; + case 'Tag': + if (resolvedSource) { + queryOptions.Tags = resolvedSource; + } + break; + case 'Person': + if (resolvedSource) { + queryOptions.PersonIds = [resolvedSource]; + } + if (instanceConfig.excludeItemId) { + queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId) + ? instanceConfig.excludeItemId + : [instanceConfig.excludeItemId]; + } + break; + case 'Studio': + if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { + queryOptions.StudioIds = [resolvedSource]; + } else if (resolvedSource) { + queryOptions.Studios = resolvedSource; + } + break; + case 'Collection': + case 'Parent': + if (resolvedSource) { + queryOptions.ParentId = resolvedSource; + } + break; + } + + // Add spotlight fields if needed + if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') { + queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios'; + + const spotlightConfig = instanceConfig.spotlightConfig || {}; + const adminSpotlightConfig = window.KefinHomeScreen.getConfig()?.SPOTLIGHT_SETTINGS || {}; + + instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig }; + } else { + queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'; + } + + instanceConfig.queries = [{ + queryOptions: queryOptions + }]; + } + + instanceConfig.name = fillTemplate(instanceConfig.name, dynamicResult.metadata); + if (instanceConfig.caption) { + instanceConfig.caption = fillTemplate(instanceConfig.caption, dynamicResult.metadata); + if (dynamicResult.excludeItemId) { + instanceConfig.captionUrl = `#/details?id=${dynamicResult.excludeItemId}&serverId=${ApiClient.serverId()}`; + } + } + instanceConfig.cardFormat = resolveCardFormat(instanceConfig); + if (resolveViewMore) { + instanceConfig.viewMoreUrl = await resolveViewMoreUrl(instanceConfig); + } + + return instanceConfig; + } + function disconnectDiscoveryInteraction() { // If we are using infinite scroll, disconnect the scroll event listener if (state.infiniteScrollHandler) { @@ -1814,7 +2031,7 @@ } // Ensure at least 1 discovery section is enabled - const discoverySections = getDiscoverySections(); + const discoverySections = await getDiscoverySections(); if (!discoverySections || discoverySections.length === 0 || discoverySections.every(section => section.enabled === false)) { LOG('No discovery sections enabled in config.'); state.discoveryEnabled = false; @@ -1869,7 +2086,11 @@ const config = await window.KefinHomeScreen.getConfig(); const revealSectionsSequentially = config.DISCOVERY_SETTINGS?.fadeInSections === true; - await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { revealSectionsSequentially }); + await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { + revealSectionsSequentially, + enhanceOnVisible: true, + showStaleDataBeforeRefresh: config.HOME_SETTINGS?.SHOW_STALE_DATA_BEFORE_REFRESH === true + }); if (!getDiscoverySettings().infiniteScroll) { setupLoadMoreButton(container); @@ -1926,7 +2147,7 @@ const selectedConfigs = []; - let discoveryTemplates = getDiscoverySections(); + let discoveryTemplates = await getDiscoverySections(); if (getDiscoverySettings().randomizeOrder) { discoveryTemplates = [...discoveryTemplates].sort(() => 0.5 - Math.random()); @@ -1940,137 +2161,8 @@ continue; } - const instanceConfig = { - ...template, - id: `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - }; - - const dynamicResult = await resolveDynamicSource(instanceConfig); - if (dynamicResult) { - instanceConfig.source = dynamicResult.id || dynamicResult.name; - instanceConfig.metadata = dynamicResult.metadata; - instanceConfig.excludeItemId = dynamicResult.excludeItemId; - - if (getDiscoverySettings().renderSpotlightAboveMatching) { - const configId = instanceConfig.id.split('-')[0]; - - if (configId === 'genreMovies' || configId === 'spotlightGenre') { - if (state.currentDiscoveryGenre) { - instanceConfig.source = state.currentDiscoveryGenre.id; - dynamicResult.metadata = state.currentDiscoveryGenre.metadata; - state.currentDiscoveryGenre = null; - } - else { - state.currentDiscoveryGenre = dynamicResult; - } - } - if (configId === 'studioShows' || configId === 'spotlightNetwork') { - if (state.currentDiscoveryStudio) { - instanceConfig.source = state.currentDiscoveryStudio.id; - dynamicResult.metadata = state.currentDiscoveryStudio.metadata; - state.currentDiscoveryStudio = null; - } - else { - state.currentDiscoveryStudio = dynamicResult; - } - } - } - - // Create queries array with proper queryOptions - const queryOptions = { - Recursive: 'true', - Limit: instanceConfig.itemLimit || 20 - }; - - // Add IncludeItemTypes if specified - if (instanceConfig.includeItemTypes && instanceConfig.includeItemTypes.length) { - queryOptions.IncludeItemTypes = instanceConfig.includeItemTypes; - } - - // Add SortBy and SortOrder if specified - if (instanceConfig.sortOrder) { - queryOptions.SortBy = instanceConfig.sortOrder; - } - if (instanceConfig.sortOrderDirection) { - queryOptions.SortOrder = instanceConfig.sortOrderDirection; - } - - // Handle type-specific query options - const resolvedSource = instanceConfig.source; - if (instanceConfig.type === 'Similar') { - // Similar type uses custom path - instanceConfig.queries = [{ - path: `/Items/${resolvedSource}/Similar`, - queryOptions: { - Limit: instanceConfig.itemLimit || 20, - Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData' - } - }]; - } else { - // Standard query types - switch (instanceConfig.type) { - case 'Genre': - if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { - queryOptions.GenreIds = [resolvedSource]; - } else if (resolvedSource) { - queryOptions.Genres = resolvedSource; - } - break; - case 'Tag': - if (resolvedSource) { - queryOptions.Tags = resolvedSource; - } - break; - case 'Person': - if (resolvedSource) { - queryOptions.PersonIds = [resolvedSource]; - } - if (instanceConfig.excludeItemId) { - queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId) - ? instanceConfig.excludeItemId - : [instanceConfig.excludeItemId]; - } - break; - case 'Studio': - if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { - queryOptions.StudioIds = [resolvedSource]; - } else if (resolvedSource) { - queryOptions.Studios = resolvedSource; - } - break; - case 'Collection': - case 'Parent': - if (resolvedSource) { - queryOptions.ParentId = resolvedSource; - } - break; - } - - // Add spotlight fields if needed - if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') { - queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios'; - - const spotlightConfig = instanceConfig.spotlightConfig || {}; - const adminSpotlightConfig = window.KefinHomeScreen.getConfig()?.SPOTLIGHT_SETTINGS || {}; - - instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig }; - } else { - queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'; - } - - instanceConfig.queries = [{ - queryOptions: queryOptions - }]; - } - - const sectionName = fillTemplate(instanceConfig.name, dynamicResult.metadata); - const cardFormat = resolveCardFormat(instanceConfig); - const viewMoreUrl = await resolveViewMoreUrl(instanceConfig); - - instanceConfig.name = sectionName; - instanceConfig.cardFormat = cardFormat; - instanceConfig.viewMoreUrl = viewMoreUrl; - + const instanceConfig = await buildDiscoverySectionInstance(template); + if (instanceConfig) { selectedConfigs.push(instanceConfig); } } @@ -2110,8 +2202,40 @@ } } + // Apply user prefs keyed by template prefix for non-custom discovery variants + try { + const userHomeConfig = window.KefinUserHomeScreenConfig; + if (userHomeConfig?.applyUserSectionOverrides && selectedConfigs.length) { + const prefs = state.getDisplayPrefernces + ? await state.getDisplayPrefernces() + : null; + const homeScreen = userHomeConfig.parseKefinTweaksHomeScreen?.(prefs?.CustomPrefs) + || userHomeConfig.createEmptyHomeScreen?.() + || { sections: [] }; + const serverSectionsById = new Map(); + selectedConfigs.forEach((section) => { + const storedId = userHomeConfig.getStoredSectionPrefId?.(section) || section.id; + if (storedId && !serverSectionsById.has(storedId)) { + serverSectionsById.set(storedId, { ...section, id: storedId }); + } + }); + const overridden = userHomeConfig.applyUserSectionOverrides( + selectedConfigs, + homeScreen, + { serverSectionsById } + ); + selectedConfigs.length = 0; + selectedConfigs.push(...overridden.filter(s => s.enabled !== false)); + } + } catch (e) { + WARN('Failed to apply user overrides to discovery sections:', e); + } + selectedConfigs.forEach((section) => { section.order = state.discoverySectionOrder++; + // Mark discovery render set so section roots get data-discovery-section + // (templated discovery rows use Genre/Studio/etc. as type, not "discovery"). + section.discoverySection = true; }); //localCache.set('discoveryBuffer', selectedConfigs); @@ -2238,7 +2362,7 @@ try { const userId = ApiClient.getCurrentUserId(); const response = await fetch(`${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`, { - headers: { 'X-Emby-Token': ApiClient.accessToken() } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); const data = await response.json(); const genres = (data.Items || []).map(g => ({ Id: g.Id, Name: g.Name, MovieCount: g.MovieCount })); @@ -2420,7 +2544,29 @@ return StudiosCache.getPopularTVNetworks(); } - window.homeScreen3 = { init: enhanceHomeScreen }; + async function refreshHomeSections() { + const container = document.querySelector('.homePage:not(.hide) #homeTab .sections'); + if (!container) { + LOG('refreshHomeSections: container not found'); + return; + } + + container.querySelectorAll('[data-section-id]').forEach(el => el.remove()); + delete container.dataset.sectionsRendered; + delete container.dataset.kefinHomeScreen; + + sectionCache.renderedSections = []; + sectionCache.fragmentCache = null; + sectionCache.isInitialRender = true; + + await enhanceHomeScreen(); + } + + window.homeScreen3 = { + init: enhanceHomeScreen, + refreshHomeSections, + resolveDiscoverySection: buildDiscoverySectionInstance + }; //enhanceHomeScreen(); diff --git a/scripts/homeScreenConfig2.js b/scripts/homeScreenConfig2.js index a5cb6bc..d647b28 100644 --- a/scripts/homeScreenConfig2.js +++ b/scripts/homeScreenConfig2.js @@ -26,10 +26,12 @@ { id: 'my-media', name: 'My Media', + jellyfinId: 'smalllibrarytiles', enabled: false, order: 5, cardFormat: 'Thumb', ttl: CACHE_CONFIG.LONG_TTL, + userConfigurable: true, queries: [ { path: '/UserViews', @@ -44,10 +46,12 @@ { id: 'my-media-small', name: 'My Media (Small)', + jellyfinId: 'librarybuttons', enabled: false, order: 6, cardFormat: 'Button', ttl: CACHE_CONFIG.LONG_TTL, + userConfigurable: true, queries: [ { path: '/UserViews', @@ -68,20 +72,24 @@ sections: [ { id: 'continueWatching', - name: 'Resume', + name: 'Continue Watching', + jellyfinId: 'resume', enabled: false, hidden: false, order: 10, cardFormat: 'Thumb', ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + userConfigurable: true, queries: [ { + path: '/UserItems/Resume', queryOptions: { - SortBy: 'DatePlayed', - SortOrder: 'Descending', - Filters: 'IsResumable', - IncludeItemTypes: ['Episode', 'Movie'], - Limit: 24 + MediaTypes: 'Video', + Fields: 'PrimaryImageAspectRatio', + Limit: 12, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } } ] @@ -89,24 +97,31 @@ { id: 'nextUp', name: 'Next Up', + jellyfinId: 'nextup', enabled: false, hidden: false, order: 11, cardFormat: 'Thumb', ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + userConfigurable: true, queries: [ { path: '/Shows/NextUp', queryOptions: { + Fields: 'PrimaryImageAspectRatio,DateCreated,Path,MediaSourceCount', Limit: 24, - EnableResumable: 'false' + EnableResumable: 'false', + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } } ] }, { id: 'continueWatchingAndNextUp', - name: 'Continue Watching', + name: 'Continue Watching and Next Up', + jellyfinId: 'resume', enabled: false, // Disabled by default - only used when merge setting is enabled order: 10, hidden: true, @@ -114,23 +129,29 @@ ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, sortBy: 'DatePlayed', // Used to sort merged results from both queries sortOrder: 'Descending', // Most recent first + userConfigurable: true, queries: [ { - // Continue Watching (Movies only when merged) + path: '/UserItems/Resume', queryOptions: { - SortBy: 'DatePlayed', - SortOrder: 'Descending', - Filters: 'IsResumable', + MediaTypes: 'Video', IncludeItemTypes: ['Movie'], - Limit: 24 + Fields: 'PrimaryImageAspectRatio', + Limit: 12, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } }, { - // Next Up (Episodes) path: '/Shows/NextUp', queryOptions: { + Fields: 'PrimaryImageAspectRatio,DateCreated,Path,MediaSourceCount', Limit: 24, - EnableResumable: 'true' + EnableResumable: 'true', + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } } ] @@ -138,17 +159,23 @@ { id: 'continueListening', name: 'Continue Listening', + jellyfinId: 'resumeaudio', enabled: false, hidden: false, order: 10, cardFormat: 'Square', ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + userConfigurable: true, queries: [ { path: '/UserItems/Resume', queryOptions: { MediaTypes: 'Audio', - Limit: 16 + Fields: 'PrimaryImageAspectRatio', + Limit: 12, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } } ] @@ -156,17 +183,23 @@ { id: 'continueReading', name: 'Continue Reading', + jellyfinId: 'resumebook', enabled: false, hidden: false, order: 10, cardFormat: 'Poster', ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + userConfigurable: true, queries: [ { path: '/UserItems/Resume', queryOptions: { MediaTypes: 'Book', - Limit: 16 + Fields: 'PrimaryImageAspectRatio', + Limit: 12, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } } ] @@ -186,6 +219,8 @@ cardFormat: 'Thumb', flattenSeries: true, ttl: CACHE_CONFIG.LONG_TTL, + userConfigurable: true, + sectionCssClass: 'hideIndicators', queries: [ { path: '/Shows/Upcoming', @@ -209,6 +244,7 @@ order: 20, cardFormat: 'Poster', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { minAge: 0, @@ -231,6 +267,7 @@ cardFormat: 'Thumb', flattenSeries: true, ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { minAge: 0, @@ -261,15 +298,20 @@ { id: 'liveTv-recordings', name: 'Recordings', + jellyfinId: 'activerecordings', enabled: false, order: 90, cardFormat: 'Poster', ttl: CACHE_CONFIG.SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Recordings', queryOptions: { - Limit: 20, + IsInProgress: true, + Fields: 'PrimaryImageAspectRatio', + Limit: 12, + EnableTotalRecordCount: 'false', } } ] @@ -281,6 +323,7 @@ order: 91, cardFormat: 'Poster', ttl: CACHE_CONFIG.SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Timers', @@ -295,9 +338,11 @@ { id: 'liveTv-browse', name: 'Live TV', + jellyfinId: 'livetv', enabled: false, order: 91, cardFormat: 'Button', + userConfigurable: true, items: [ { Name: 'Programs', @@ -328,16 +373,22 @@ { id: 'liveTv-on-now', name: 'On Now', + jellyfinId: 'livetv', enabled: false, order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs/Recommended', queryOptions: { IsAiring: true, - Limit: 24, + Fields: 'ChannelInfo,PrimaryImageAspectRatio', + Limit: 1, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + EnableTotalRecordCount: 'false', } } ] @@ -349,6 +400,7 @@ order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs', @@ -368,6 +420,7 @@ cardFormat: 'Thumb', hideCardFooter: true, ttl: CACHE_CONFIG.LONG_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Channels', @@ -385,6 +438,7 @@ order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs', @@ -408,6 +462,7 @@ order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs', @@ -427,6 +482,7 @@ order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs', @@ -446,6 +502,7 @@ order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs', @@ -465,6 +522,7 @@ order: 92, cardFormat: 'Poster', ttl: CACHE_CONFIG.VERY_SHORT_TTL, + userConfigurable: true, queries: [ { path: '/LiveTv/Programs', @@ -491,6 +549,7 @@ order: 70, cardFormat: 'Poster', ttl: CACHE_CONFIG.LONG_TTL, + userConfigurable: true, queries: [ { dataSource: 'MoviesCache.getImdbTop250Movies', @@ -520,6 +579,7 @@ sortBy: 'Random', limitBeforeSort: true, hideCardFooter: true, + userConfigurable: true, queries: [ { path: '/Studios', @@ -543,6 +603,7 @@ sortBy: 'Random', limitBeforeSort: true, hideCardFooter: true, + userConfigurable: true, queries: [ { path: '/Studios', @@ -566,6 +627,7 @@ sortBy: 'Random', limitBeforeSort: true, hideCardFooter: true, + userConfigurable: true, queries: [ { path: '/Studios', @@ -598,6 +660,7 @@ order: 73, cardFormat: 'Random', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { queryOptions: { @@ -623,6 +686,7 @@ order: 71, cardFormat: 'Poster', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { queryOptions: { @@ -648,6 +712,7 @@ order: 74, cardFormat: 'Poster', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { queryOptions: { @@ -667,6 +732,7 @@ order: 75, cardFormat: 'Poster', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { queryOptions: { @@ -685,6 +751,7 @@ order: 76, cardFormat: 'Poster', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { queryOptions: { @@ -702,6 +769,7 @@ order: 77, cardFormat: 'Poster', ttl: CACHE_CONFIG.DEFAULT_TTL, + userConfigurable: true, queries: [ { queryOptions: { @@ -723,6 +791,7 @@ id: 'thematic-story-origins', name: 'Story Origins', enabled: false, + userConfigurable: true, order: 81, cardFormat: 'Poster', sortBy: 'Random', @@ -741,6 +810,7 @@ id: 'thematic-core-concepts', name: 'Core Concepts', enabled: false, + userConfigurable: true, order: 82, cardFormat: 'Poster', sortBy: 'Random', @@ -772,6 +842,7 @@ id: 'thematic-crime-stories', name: 'Crime Stories', enabled: false, + userConfigurable: true, order: 83, cardFormat: 'Poster', sortBy: 'Random', @@ -795,6 +866,7 @@ id: 'thematic-character-perspective', name: 'Character Perspective', enabled: false, + userConfigurable: true, order: 84, cardFormat: 'Poster', sortBy: 'Random', @@ -815,6 +887,7 @@ id: 'thematic-settings', name: 'Settings', enabled: false, + userConfigurable: true, order: 85, cardFormat: 'Poster', sortBy: 'Random', @@ -836,6 +909,7 @@ id: 'thematic-tone-style', name: 'Tone & Style', enabled: false, + userConfigurable: true, order: 86, cardFormat: 'Poster', sortBy: 'Random', @@ -854,6 +928,7 @@ id: 'thematic-themes-topics', name: 'Themes & Topics', enabled: false, + userConfigurable: true, order: 87, cardFormat: 'Poster', sortBy: 'Random', @@ -886,6 +961,7 @@ id: 'seasonal.halloween.halloween-tag', name: 'Halloween Movies', enabled: true, + userConfigurable: true, startDate: '10-01', endDate: '10-31', order: 50, @@ -905,6 +981,7 @@ id: 'seasonal.halloween.halloween-horror', name: 'Horror Movies', enabled: true, + userConfigurable: true, startDate: '10-01', endDate: '10-31', order: 51, @@ -924,6 +1001,7 @@ id: 'seasonal.halloween.halloween-thriller', name: 'Thriller Movies', enabled: true, + userConfigurable: true, startDate: '10-01', endDate: '10-31', order: 52, @@ -950,6 +1028,7 @@ id: 'seasonal.christmas.christmas-movies', name: 'Christmas Movies', enabled: true, + userConfigurable: true, startDate: '12-01', endDate: '12-31', order: 50, @@ -970,6 +1049,7 @@ id: 'seasonal.christmas.christmas-episodes', name: 'Christmas Episodes', enabled: true, + userConfigurable: true, startDate: '12-01', endDate: '12-31', order: 51, @@ -996,6 +1076,7 @@ id: 'seasonal.new-years.new-years-movies', name: 'New Year\'s Movies', enabled: true, + userConfigurable: true, startDate: '01-01', endDate: '01-02', order: 50, @@ -1015,6 +1096,7 @@ id: 'seasonal.new-years.new-years-episodes', name: 'New Year\'s Episodes', enabled: true, + userConfigurable: true, startDate: '01-01', endDate: '01-02', order: 51, @@ -1041,6 +1123,7 @@ id: 'seasonal.valentines.valentines-movies', name: 'Valentine\'s Movies', enabled: true, + userConfigurable: true, startDate: '02-07', endDate: '02-14', order: 50, @@ -1060,6 +1143,7 @@ id: 'seasonal.valentines.valentines-episodes', name: 'Valentine\'s Episodes', enabled: true, + userConfigurable: true, startDate: '02-07', endDate: '02-14', order: 51, @@ -1086,6 +1170,7 @@ id: 'seasonal.thanksgiving.thanksgiving-movies', name: 'Thanksgiving Movies', enabled: true, + userConfigurable: true, startDate: '11-20', endDate: '11-30', order: 50, @@ -1105,6 +1190,7 @@ id: 'seasonal.thanksgiving.thanksgiving-episodes', name: 'Thanksgiving Episodes', enabled: true, + userConfigurable: true, startDate: '11-20', endDate: '11-30', order: 51, @@ -1135,10 +1221,12 @@ sections: [ { id: 'genreMovies', - type: 'Genre', + type: 'discovery', + discoveryType: 'Genre', source: 'Dynamic', // Will be filled with a random Genre ID/Name name: '{Genre} Movies', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1147,10 +1235,12 @@ }, { id: 'spotlightGenre', - type: 'Genre', + type: 'discovery', + discoveryType: 'Genre', source: 'Dynamic', name: 'Top Rated {Genre}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'CommunityRating', sortOrderDirection: 'Descending', @@ -1167,11 +1257,13 @@ sections: [ { id: 'directedByTopDirector', - type: 'Person', + type: 'discovery', + discoveryType: 'Person', source: 'Dynamic', // Random Director ID personType: 'Director', // Extra metadata for dynamic logic name: 'Directed by {Person}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1180,11 +1272,13 @@ }, { id: 'writtenByTopWriter', - type: 'Person', + type: 'discovery', + discoveryType: 'Person', source: 'Dynamic', // Random Writer ID personType: 'Writer', name: 'Written by {Person}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1193,11 +1287,13 @@ }, { id: 'starringTopActor', - type: 'Person', + type: 'discovery', + discoveryType: 'Person', source: 'Dynamic', // Random Actor ID personType: 'Actor', name: 'Starring {Person}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1213,12 +1309,15 @@ sections: [ { id: 'starringActorRecentlyWatched', - type: 'Person', + type: 'discovery', + discoveryType: 'Person', source: 'Dynamic', // Actor from Recently Watched personType: 'Actor', sourceType: 'watched-recent', // Hint for source generator - name: 'Starring {Person} since you watched {Title}', + name: 'Starring {Person}', + caption: 'because you recently watched {Title}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1227,12 +1326,15 @@ }, { id: 'directedByDirectorRecentlyWatched', - type: 'Person', + type: 'discovery', + discoveryType: 'Person', source: 'Dynamic', // Director from Recently Watched personType: 'Director', sourceType: 'watched-recent', - name: 'Directed by {Person} since you watched {Title}', + name: 'Directed by {Person}', + caption: 'because you recently watched {Title}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1241,12 +1343,15 @@ }, { id: 'writtenByWriterRecentlyWatched', - type: 'Person', + type: 'discovery', + discoveryType: 'Person', source: 'Dynamic', personType: 'Writer', sourceType: 'watched-recent', - name: 'Written by {Person} since you watched {Title}', + name: 'Written by {Person}', + caption: 'because you recently watched {Title}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1262,11 +1367,13 @@ sections: [ { id: 'becauseYouWatched', - type: 'Similar', + type: 'discovery', + discoveryType: 'Similar', source: 'Dynamic', // Random Watched Item ID sourceType: 'watched', name: 'Because you watched {Title}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', // Typically 'Similarity' but usually returned sorted by API includeItemTypes: ['Movie'], @@ -1274,11 +1381,13 @@ }, { id: 'becauseYouRecentlyWatched', - type: 'Similar', + type: 'discovery', + discoveryType: 'Similar', source: 'Dynamic', // Random Recently Watched ID sourceType: 'watched-recent', name: 'Because you recently watched {Title}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1294,11 +1403,13 @@ sections: [ { id: 'becauseYouLiked', - type: 'Similar', + type: 'discovery', + discoveryType: 'Similar', source: 'Dynamic', // Random Favorite Item ID sourceType: 'liked', name: 'Because you liked {Title}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1314,10 +1425,12 @@ sections: [ { id: 'studioShows', - type: 'Studio', + type: 'discovery', + discoveryType: 'Studio', source: 'Dynamic', // Random Studio ID name: 'Shows from {Studio}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', @@ -1326,10 +1439,12 @@ }, { id: 'spotlightNetwork', - type: 'Studio', + type: 'discovery', + discoveryType: 'Studio', source: 'Dynamic', name: 'Top Rated from {Studio}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'CommunityRating', sortOrderDirection: 'Descending', @@ -1346,10 +1461,12 @@ sections: [ { id: 'collections', - type: 'Collection', // Standard type, but source is dynamic here + type: 'discovery', + discoveryType: 'Collection', source: 'Dynamic', name: '{Collection}', enabled: true, + userConfigurable: true, itemLimit: 20, sortOrder: 'Random', // Order within the collection includeItemTypes: ['Movie', 'Series'], @@ -1400,6 +1517,12 @@ minimumSeriesForPopularTVNetworks: 10, fadeInSections: false, ensureThumbsForPopularTVNetworks: false, + SHOW_STALE_DATA_BEFORE_REFRESH: false, + } + + const USER_HOME_SCREEN_SETTINGS = { + inlineConfigure: true, + pinning: true } const SPOTLIGHT_SETTINGS = { @@ -1421,11 +1544,28 @@ REMOVE_CONFLICTING_SECTIONS: true, MERGE_NEXT_UP: false, HOME_SETTINGS: HOME_SETTINGS, + USER_HOME_SCREEN_SETTINGS: USER_HOME_SCREEN_SETTINGS, DISCOVERY_SETTINGS: DISCOVERY_SETTINGS, SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS, SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS }; + /* + window.KefinHomeUserConfig = { + CACHE: CACHE_CONFIG, + HOME_SECTION_GROUPS: KEFINTWEAKS_HOME_SECTION_GROUPS, // modified by user configuration + SEASONAL_SECTION_GROUPS: KEFINTWEAKS_SEASONAL_SECTION_GROUPS, // modified by user configuration` + DISCOVERY_SECTION_GROUPS: KEFINTWEAKS_DISCOVERY_SECTION_GROUPS, // modified by user configuration + CUSTOM_SECTION_GROUPS: [], // Empty by default - user-created/imported sections // modified by user configuration + REMOVE_CONFLICTING_SECTIONS: true, + MERGE_NEXT_UP: false, + HOME_SETTINGS: HOME_SETTINGS, + DISCOVERY_SETTINGS: DISCOVERY_SETTINGS, + SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS, + SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS + } + */ + console.log('[KefinTweaks HomeConfig2] Configuration loaded'); })(); \ No newline at end of file diff --git a/scripts/homeScreenPin.js b/scripts/homeScreenPin.js new file mode 100644 index 0000000..df48f61 --- /dev/null +++ b/scripts/homeScreenPin.js @@ -0,0 +1,1326 @@ +// Pin items to home screen via context menu +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeScreenPin]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreenPin]', ...args); + + const PARENT_PIN_TYPES = new Set([ + 'CollectionFolder', + 'BoxSet', + 'Playlist', + 'Series', + 'Season', + 'MusicAlbum', + 'MusicArtist', + 'MusicGenre', + 'Genre', + 'Studio', + 'Person', + 'Tag' + ]); + + let capturedItemId = null; + let capturedCard = null; + let pendingUnpinStash = null; + const UNPIN_FADE_MS = 350; + const LAST_PINNED_LIST_KEY = 'kefinTweaks_lastPinnedList'; + + function getLastPinnedListKey() { + const serverId = typeof ApiClient !== 'undefined' && ApiClient?.serverId + ? ApiClient.serverId() + : ''; + const userId = typeof ApiClient !== 'undefined' && ApiClient?.getCurrentUserId + ? ApiClient.getCurrentUserId() + : ''; + return `${LAST_PINNED_LIST_KEY}_${serverId}_${userId}`; + } + + function getLastPinnedList() { + try { + return localStorage.getItem(getLastPinnedListKey()) || ''; + } catch { + return ''; + } + } + + function setLastPinnedList(listName) { + if (!listName) return; + try { + localStorage.setItem(getLastPinnedListKey(), listName); + } catch { + /* ignore quota / private mode */ + } + } + + function getUserSettings() { + return window.KefinTweaksConfig?.homeScreenConfig?.USER_HOME_SCREEN_SETTINGS || {}; + } + + function isEnabled() { + return getUserSettings().pinning !== false; + } + + function getConfigApi() { + return window.KefinUserHomeScreenConfig; + } + + async function loadHomeScreen() { + const api = getConfigApi(); + if (!window.userHelper?.getUserDisplayPreferences) { + return api.createEmptyHomeScreen(); + } + const { promise } = await window.userHelper.getUserDisplayPreferences(); + const displayPrefs = await promise; + return api.parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs); + } + + async function saveHomeScreen(homeScreen, { refreshHome = true } = {}) { + const ok = await getConfigApi().saveKefinTweaksHomeScreen(homeScreen); + if (ok && refreshHome && window.homeScreen3?.refreshHomeSections) { + await window.homeScreen3.refreshHomeSections(); + } else if (ok && refreshHome && window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast('Pinned — refresh home to see changes.'); + } + return ok; + } + + function cloneHomeScreen(homeScreen) { + const hs = homeScreen || {}; + return { + sections: [...(hs.sections || [])], + pinnedLists: [...(hs.pinnedLists || [])], + pinnedParents: [...(hs.pinnedParents || [])] + }; + } + + function getSectionDisplayName(sectionEl, fallback) { + const title = sectionEl?.querySelector?.( + '.sectionTitle, .sectionTitle-cards, .spotlight-section-title' + )?.textContent?.trim(); + return title || fallback || 'Pinned'; + } + + function getCardDisplayName(card, fallback) { + const fromAria = card?.querySelector?.('.cardImageContainer')?.getAttribute('aria-label')?.trim(); + const fromTitle = card?.querySelector?.('.cardText-first, .cardText')?.textContent?.trim(); + return fromAria || fromTitle || fallback || 'item'; + } + + function fadeOutSection(el) { + return new Promise((resolve) => { + if (!el) { + resolve({ parent: null, nextSibling: null }); + return; + } + const parent = el.parentNode; + const nextSibling = el.nextSibling; + el.style.transition = `opacity ${UNPIN_FADE_MS}ms ease`; + el.style.opacity = '0'; + setTimeout(() => resolve({ parent, nextSibling }), UNPIN_FADE_MS); + }); + } + + function fadeInSection(el) { + if (!el) return; + el.style.transition = `opacity ${UNPIN_FADE_MS}ms ease`; + el.style.opacity = '0'; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.style.opacity = '1'; + }); + }); + } + + function showUnpinnedToast(name, onUndo) { + const toaster = window.KefinTweaksToaster; + if (!toaster?.toast) return null; + const handle = toaster.toast( + `Unpinned ${name}`, + '8', + true, + { + action: { + icon: 'undo', + label: 'Undo', + onClick: () => { + onUndo?.(); + handle?.dismiss?.(); + } + } + } + ); + return handle; + } + + async function undoUnpin() { + const stash = pendingUnpinStash; + pendingUnpinStash = null; + if (!stash?.restoreHomeScreen) { + window.homeScreen3?.refreshHomeSections?.(); + return; + } + + const ok = await saveHomeScreen(stash.restoreHomeScreen, { refreshHome: false }); + if (!ok) { + window.homeScreen3?.refreshHomeSections?.(); + return; + } + + const el = stash.element; + const parentStillValid = stash.parent && document.contains(stash.parent); + if (el && parentStillValid) { + el.style.transition = ''; + el.style.opacity = '0'; + if (stash.nextSibling && stash.nextSibling.parentNode === stash.parent) { + stash.parent.insertBefore(el, stash.nextSibling); + } else { + stash.parent.appendChild(el); + } + if (stash.runtimeEntry) { + stash.runtimeEntry.element = el; + window.KefinHomeScreenSectionRuntime?.register?.(stash.runtimeEntry); + } + el.querySelectorAll?.('.unpin-item-button[disabled]')?.forEach((btn) => { + btn.disabled = false; + }); + fadeInSection(el); + const itemsContainer = el.classList?.contains('card') + ? el.closest?.('.itemsContainer') + : el.querySelector?.('.itemsContainer'); + if (itemsContainer) updateAllReorderChevrons(itemsContainer); + return; + } + + window.homeScreen3?.refreshHomeSections?.(); + } + + async function beginSectionUnpin({ sectionId, sectionEl, sectionName, restoreHomeScreen }) { + const runtimeEntry = window.KefinHomeScreenSectionRuntime?.get?.(sectionId) || null; + const { parent, nextSibling } = await fadeOutSection(sectionEl); + pendingUnpinStash = { + sectionId, + sectionName, + element: sectionEl, + parent, + nextSibling, + restoreHomeScreen, + runtimeEntry + }; + sectionEl?.remove(); + window.KefinHomeScreenSectionRuntime?.unregister?.(sectionId); + showUnpinnedToast(sectionName || 'section', () => { + void undoUnpin(); + }); + } + + async function beginItemUnpin({ card, itemName, restoreHomeScreen }) { + const { parent, nextSibling } = await fadeOutSection(card); + pendingUnpinStash = { + sectionId: null, + sectionName: itemName, + element: card, + parent, + nextSibling, + restoreHomeScreen, + runtimeEntry: null + }; + card?.remove(); + showUnpinnedToast(itemName || 'item', () => { + void undoUnpin(); + }); + } + + function findMoreMenuButton(element) { + return element?.closest?.('button[data-action="menu"], button.btnMoreCommands.detailButton') || null; + } + + function clearCapturedPinTarget() { + capturedItemId = null; + capturedCard = null; + } + + function isItemActionSheet(actionSheetContent) { + if (!actionSheetContent) return false; + return !!( + actionSheetContent.querySelector('button[data-id="multiSelect"]') + || actionSheetContent.querySelector('button[data-id="resume"]') + || actionSheetContent.querySelector('button[data-id="addtoplaylist"]') + ); + } + + function getDetailPageItemId(detailButtons) { + if (!detailButtons) return null; + const playstateBtn = detailButtons.querySelector( + 'button.button-flat.btnPlaystate.detailButton.emby-button[data-id]' + ) || detailButtons.querySelector('button.btnPlaystate.detailButton[data-id]'); + return playstateBtn?.getAttribute('data-id') || playstateBtn?.dataset?.id || null; + } + + function capturePinTargetFromTrigger(trigger) { + if (!trigger) { + clearCapturedPinTarget(); + return false; + } + + const menuBtn = findMoreMenuButton(trigger); + const contextEl = menuBtn || trigger; + const card = contextEl.closest?.('.card[data-id]'); + if (card) { + capturedCard = card; + capturedItemId = card.getAttribute('data-id'); + if (capturedItemId) return true; + clearCapturedPinTarget(); + return false; + } + + const detailButtons = contextEl.closest?.('.mainDetailButtons'); + const detailItemId = getDetailPageItemId(detailButtons); + if (detailItemId) { + capturedCard = null; + capturedItemId = detailItemId; + return true; + } + + clearCapturedPinTarget(); + return false; + } + + function captureFromEvent(e) { + if (e.target.closest('.actionSheetContent')) return; + capturePinTargetFromTrigger(e.target); + } + + function getListNames(homeScreen) { + const names = (homeScreen.pinnedLists || []) + .map(str => getConfigApi().parsePinnedListString(str)?.name) + .filter(Boolean); + if (!names.includes(getConfigApi().DEFAULT_PINNED_LIST_NAME)) { + names.unshift(getConfigApi().DEFAULT_PINNED_LIST_NAME); + } + return [...new Set(names)]; + } + + function upsertPinnedList(homeScreen, listName, itemId) { + const api = getConfigApi(); + const idx = (homeScreen.pinnedLists || []).findIndex(str => { + const parsed = api.parsePinnedListString(str); + return parsed?.name === listName; + }); + if (idx >= 0) { + const parsed = api.parsePinnedListString(homeScreen.pinnedLists[idx]); + const ids = [...new Set([...(parsed?.ids || []), itemId])]; + homeScreen.pinnedLists[idx] = api.serializePinnedList(listName, ids); + } else { + homeScreen.pinnedLists.push(api.serializePinnedList(listName, [itemId])); + } + return homeScreen; + } + + function upsertPinnedParent(homeScreen, listName, parentId, parentType) { + const api = getConfigApi(); + const idx = (homeScreen.pinnedParents || []).findIndex(str => { + const parsed = api.parsePinnedParentString(str); + return parsed?.parentId === parentId; + }); + const entry = api.serializePinnedParent(listName, parentId, parentType); + if (idx >= 0) homeScreen.pinnedParents[idx] = entry; + else homeScreen.pinnedParents.push(entry); + return homeScreen; + } + + function removeItemFromPinnedList(homeScreen, listName, itemId) { + const api = getConfigApi(); + homeScreen.pinnedLists = (homeScreen.pinnedLists || []).map(str => { + const parsed = api.parsePinnedListString(str); + if (!parsed || parsed.name !== listName) return str; + const ids = parsed.ids.filter(id => id !== itemId); + return ids.length ? api.serializePinnedList(listName, ids) : null; + }).filter(Boolean); + return homeScreen; + } + + function removePinnedParent(homeScreen, parentId) { + homeScreen.pinnedParents = (homeScreen.pinnedParents || []).filter(str => { + const parsed = getConfigApi().parsePinnedParentString(str); + return parsed?.parentId !== parentId; + }); + return homeScreen; + } + + function getHomeSectionsContainer() { + return document.querySelector('.homePage:not(.hide) #homeTab .sections'); + } + + function findSectionById(container, sectionId) { + if (!container || !sectionId) return null; + return container.querySelector(`[data-section-id="${CSS.escape(sectionId)}"]`); + } + + function scrollToHomeSection(el) { + if (!el) return; + try { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch (_) { + el.scrollIntoView(true); + } + } + + function getPinnedDefaultOrder() { + const value = parseInt(getConfigApi()?.PINNED_DEFAULT_ORDER, 10); + return Number.isFinite(value) ? value : 5; + } + + function readSectionOrder(el) { + const fromData = parseInt(el?.dataset?.order, 10); + if (Number.isFinite(fromData)) return fromData; + const fromStyle = parseInt(el?.style?.order, 10); + if (Number.isFinite(fromStyle)) return fromStyle; + return 0; + } + + /** + * Shift live DOM/runtime section orders at or above slot by +1. + */ + function shiftSectionsAtOrAbove(container, slot) { + if (!container) return; + container.querySelectorAll('[data-section-id]').forEach((el) => { + const order = readSectionOrder(el); + if (order < slot) return; + const next = order + 1; + el.dataset.order = String(next); + el.style.order = String(next); + const runtime = window.KefinHomeScreenSectionRuntime?.get?.(el.dataset.sectionId); + if (runtime?.effectiveConfig) runtime.effectiveConfig.order = next; + }); + } + + /** + * Mutate homeScreen prefs: bump order for every section at/above slot, then + * assign the new pinned section order = slot. Uses DOM orders when a section + * has no saved pref yet. + */ + function prepareNewPinnedSectionOrder(homeScreen, container, newSectionId) { + const api = getConfigApi(); + const slot = getPinnedDefaultOrder(); + let hs = homeScreen || api.createEmptyHomeScreen(); + if (!Array.isArray(hs.sections)) hs.sections = []; + + const prefMap = new Map(); + hs.sections.forEach((str) => { + const pref = api.parseSectionPrefString(str); + if (pref?.id) prefMap.set(pref.id, pref); + }); + + const sectionOrders = new Map(); + prefMap.forEach((pref, id) => { + if (pref.order !== undefined) sectionOrders.set(id, pref.order); + }); + container?.querySelectorAll?.('[data-section-id]')?.forEach((el) => { + const id = el.dataset.sectionId; + if (!id || id === newSectionId) return; + if (!sectionOrders.has(id)) sectionOrders.set(id, readSectionOrder(el)); + }); + + sectionOrders.forEach((order, id) => { + if (id === newSectionId || order < slot) return; + const existing = prefMap.get(id); + const next = existing + ? { ...existing, order: order + 1 } + : { id, enabled: true, order: order + 1 }; + hs = api.upsertSectionPref(hs, next); + prefMap.set(id, next); + }); + + hs = api.upsertSectionPref(hs, { + id: newSectionId, + enabled: true, + order: slot + }); + return hs; + } + + function mountPinnedSection(container, config, items) { + if (!container || !config || !window.cardBuilder?.renderCards) return null; + if (!items?.length) return null; + + const el = window.cardBuilder.renderCards( + items, + config.name, + config.viewMoreUrl || null, + true, + config.cardFormat || 'Poster', + null, + 'Ascending' + ); + if (!el) return null; + + el.setAttribute('data-section-id', config.id); + el.dataset.order = String(config.order); + el.style.order = String(config.order); + + container.appendChild(el); + fadeInSection(el); + window.cardBuilder.attachSectionControlButtons?.(config, el, items); + return el; + } + + function updateRuntimeAfterListPin(sectionId, item, ids) { + const entry = window.KefinHomeScreenSectionRuntime?.get?.(sectionId); + if (!entry) return; + if (Array.isArray(entry.cachedItems) && item && !entry.cachedItems.some(i => i?.Id === item.Id)) { + entry.cachedItems = [...entry.cachedItems, item]; + } + if (Array.isArray(ids) && Array.isArray(entry.cachedItems)) { + const byId = new Map(entry.cachedItems.filter(Boolean).map((i) => [String(i.Id), i])); + entry.cachedItems = ids.map((id) => byId.get(String(id))).filter(Boolean); + } + const query = entry.effectiveConfig?.queries?.[0]; + if (query?.queryOptions && Array.isArray(ids)) { + query.queryOptions.Ids = [...ids]; + } + } + + async function applyPinItInline(item, listName, homeScreen) { + const container = getHomeSectionsContainer(); + if (!container || !item?.Id) return; + + const api = getConfigApi(); + const sectionId = api.getPinnedListSectionId(listName); + const existing = findSectionById(container, sectionId); + + if (existing) { + const itemsContainer = existing.querySelector('.itemsContainer'); + if (itemsContainer && !itemsContainer.querySelector(`.card[data-id="${CSS.escape(item.Id)}"]`)) { + const cardFormat = existing.getAttribute('data-card-format') + || window.KefinHomeScreenSectionRuntime?.get?.(sectionId)?.effectiveConfig?.cardFormat + || 'Poster'; + const card = window.cardBuilder?.buildCard?.(item, true, cardFormat); + if (card) { + itemsContainer.appendChild(card); + window.cardBuilder?.invalidateLastRowPadding?.(itemsContainer); + addUnpinButton(card); + addReorderButtons(card); + updateAllReorderChevrons(itemsContainer); + } + } + const listEntry = (homeScreen.pinnedLists || []).find(str => { + const parsed = api.parsePinnedListString(str); + return parsed && api.getPinnedListSectionId(parsed.name) === sectionId; + }); + const parsed = api.parsePinnedListString(listEntry); + updateRuntimeAfterListPin(sectionId, item, parsed?.ids); + scrollToHomeSection(existing); + return; + } + + const configs = api.buildPinnedSectionConfigs(homeScreen) || []; + const config = configs.find(c => c.id === sectionId); + if (!config) return; + const slot = getPinnedDefaultOrder(); + shiftSectionsAtOrAbove(container, slot); + config.order = slot; + const el = mountPinnedSection(container, config, [item]); + scrollToHomeSection(el); + } + + async function applyPinChildrenInline(item, homeScreen) { + const container = getHomeSectionsContainer(); + if (!container || !item?.Id) return; + + const api = getConfigApi(); + const sectionId = api.getPinnedParentSectionId(item.Id); + const existing = findSectionById(container, sectionId); + if (existing) { + scrollToHomeSection(existing); + return; + } + + const configs = api.buildPinnedSectionConfigs(homeScreen) || []; + const config = configs.find(c => c.id === sectionId); + if (!config) return; + const slot = getPinnedDefaultOrder(); + shiftSectionsAtOrAbove(container, slot); + config.order = slot; + + const parent = { + name: (item.Name || '').trim() || config.name, + parentId: item.Id, + parentType: item.Type || null + }; + config.viewMoreUrl = api.buildPinnedParentViewMoreUrl?.(parent, ApiClient.serverId()) + || `#/details?id=${encodeURIComponent(item.Id)}&serverId=${encodeURIComponent(ApiClient.serverId())}`; + + const queryOptions = api.buildPinnedParentQueryOptions?.(parent) || { + ParentId: item.Id, + Limit: 16, + SortBy: 'Random' + }; + + let items = []; + try { + const result = await ApiClient.getItems(ApiClient.getCurrentUserId(), queryOptions); + items = result?.Items || []; + } catch (err) { + WARN('Failed to load pinned parent children:', err); + return; + } + if (!items.length) return; + + const el = mountPinnedSection(container, config, items); + scrollToHomeSection(el); + } + + function openPinItModal(item) { + const modalId = 'kefin-pin-it-modal'; + loadHomeScreen().then(homeScreen => { + const listNames = getListNames(homeScreen); + const lastList = getLastPinnedList(); + const defaultList = listNames.includes(lastList) + ? lastList + : (listNames[0] || getConfigApi().DEFAULT_PINNED_LIST_NAME); + const options = listNames.map((name) => { + const selected = name === defaultList ? ' selected' : ''; + return ``; + }).join('') + + ''; + + const content = ` +
+ + + +
+ + +
+
+ `; + + window.ModalSystem.create({ + id: modalId, + content, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modal) => { + const select = modal.dialogContent.querySelector('#kefin-pin-list-select'); + const newWrap = modal.dialogContent.querySelector('#kefin-pin-new-list-wrap'); + select?.addEventListener('change', () => { + if (newWrap) newWrap.style.display = select.value === '__new__' ? 'block' : 'none'; + }); + modal.dialogContent.querySelector('#kefin-pin-it-confirm')?.addEventListener('click', async () => { + let listName = select?.value || getConfigApi().DEFAULT_PINNED_LIST_NAME; + if (listName === '__new__') { + listName = modal.dialogContent.querySelector('#kefin-pin-new-list-name')?.value?.trim(); + if (!listName) return; + } + const api = getConfigApi(); + const sectionId = api.getPinnedListSectionId(listName); + const container = getHomeSectionsContainer(); + let hs = await loadHomeScreen(); + const listAlreadyExists = (hs.pinnedLists || []).some((str) => { + const parsed = api.parsePinnedListString(str); + return parsed + && api.getPinnedListSectionId(parsed.name) === sectionId + && parsed.ids.length > 0; + }); + const isNewSection = !findSectionById(container, sectionId) && !listAlreadyExists; + hs = upsertPinnedList(hs, listName, item.Id); + if (isNewSection) { + hs = prepareNewPinnedSectionOrder(hs, container, sectionId); + } + const ok = await saveHomeScreen(hs, { refreshHome: false }); + if (ok) { + setLastPinnedList(listName); + window.ModalSystem.close(modalId); + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(`Pinned to ${listName}`); + } + try { + await applyPinItInline(item, listName, hs); + } catch (err) { + WARN('Inline pin list update failed:', err); + } + } + }); + } + }); + }); + } + + async function pinChildrenImmediate(item, modalIdToClose = null) { + if (!item?.Id) return false; + if (modalIdToClose) window.ModalSystem.close(modalIdToClose); + + const api = getConfigApi(); + const name = (item.Name || '').trim() || 'Pinned Items'; + const sectionId = api.getPinnedParentSectionId(item.Id); + const container = getHomeSectionsContainer(); + let hs = await loadHomeScreen(); + const parentAlreadyExists = (hs.pinnedParents || []).some((str) => { + const parsed = api.parsePinnedParentString(str); + return parsed?.parentId === item.Id; + }); + const isNewSection = !findSectionById(container, sectionId) && !parentAlreadyExists; + hs = upsertPinnedParent(hs, name, item.Id, item.Type); + if (isNewSection) { + hs = prepareNewPinnedSectionOrder(hs, container, sectionId); + } + const ok = await saveHomeScreen(hs, { refreshHome: false }); + if (ok && window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(`Pinned children as "${name}"`); + } + if (ok) { + try { + await applyPinChildrenInline(item, hs); + } catch (err) { + WARN('Inline pin children update failed:', err); + } + } + return ok; + } + + function openPinChoiceModal(item) { + const modalId = 'kefin-pin-choice-modal'; + const content = ` +
+
+ + +
+
+ `; + window.ModalSystem.create({ + id: modalId, + content, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modal) => { + modal.dialogContent.querySelector('#kefin-pin-choice-it')?.addEventListener('click', () => { + window.ModalSystem.close(modalId); + openPinItModal(item); + }); + modal.dialogContent.querySelector('#kefin-pin-choice-children')?.addEventListener('click', async () => { + await pinChildrenImmediate(item, modalId); + }); + } + }); + } + + async function handlePinAction() { + if (!capturedItemId || !window.ApiClient?.getItem) return; + try { + const item = await window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), capturedItemId); + if (!item) return; + if (PARENT_PIN_TYPES.has(item.Type)) { + openPinChoiceModal(item); + } else { + openPinItModal(item); + } + } catch (e) { + WARN('Failed to load item for pin:', e); + } + } + + function insertPinActionSheetItem(actionSheetContent, pinButton) { + const scroller = actionSheetContent.querySelector('.actionSheetScroller') || actionSheetContent; + const dividers = scroller.querySelectorAll('.actionsheetDivider, .actionSheetDivider'); + + if (dividers.length >= 2) { + scroller.insertBefore(pinButton, dividers[1]); + return; + } + + if (dividers.length >= 1) { + scroller.insertBefore(pinButton, dividers[0]); + return; + } + + const menuButtons = scroller.querySelectorAll('button.actionSheetMenuItem'); + if (menuButtons.length > 0) { + const lastButton = menuButtons[menuButtons.length - 1]; + if (lastButton.nextSibling) { + scroller.insertBefore(pinButton, lastButton.nextSibling); + } else { + scroller.appendChild(pinButton); + } + return; + } + + scroller.appendChild(pinButton); + } + + function offsetActionSheetForPinButton(actionSheetContent, pinButton) { + const dialog = actionSheetContent.closest('.focuscontainer.dialog.actionSheet') + || actionSheetContent.closest('.focuscontainer.dialog'); + if (!dialog || !pinButton) return; + + const clampToViewport = () => { + const margin = 8; + const maxBottom = window.innerHeight - margin; + const availableHeight = Math.max(0, window.innerHeight - (margin * 2)); + + const scroller = actionSheetContent.querySelector('.actionSheetScroller') || actionSheetContent; + let rect = dialog.getBoundingClientRect(); + + // If taller than the viewport, scroll internally so top/bottom stay on-screen + if (rect.height > availableHeight && availableHeight > 0) { + const chrome = Math.max(0, rect.height - scroller.clientHeight); + const scrollerMax = Math.max(40, availableHeight - chrome); + scroller.style.maxHeight = `${scrollerMax}px`; + scroller.style.overflowY = 'auto'; + rect = dialog.getBoundingClientRect(); + } + + let top = parseFloat(dialog.style.top); + if (Number.isNaN(top)) top = rect.top; + + // Prefer keeping the bottom in view after the pin row grew the menu + if (rect.bottom > maxBottom) { + top -= (rect.bottom - maxBottom); + } + if (top < margin) { + top = margin; + } + + dialog.style.top = `${top}px`; + + // Re-check after applying top (height may still overflow if max-height wasn't needed earlier) + rect = dialog.getBoundingClientRect(); + if (rect.bottom > maxBottom) { + top -= (rect.bottom - maxBottom); + if (top < margin) top = margin; + dialog.style.top = `${top}px`; + } + if (rect.top < margin || dialog.getBoundingClientRect().top < margin) { + dialog.style.top = `${margin}px`; + } + }; + + // Double rAF so pin button height is included in layout before measuring + requestAnimationFrame(() => { + requestAnimationFrame(clampToViewport); + }); + } + + function closeOpenActionSheet(fromEl) { + const dialog = fromEl?.closest?.('.focuscontainer.dialog') + || fromEl?.closest?.('.dialog'); + if (!dialog) return; + window.Dashboard?.dialogHelper?.close?.(dialog); + } + + function injectPinActionSheetItem(actionSheetContent) { + if (!isEnabled()) return; + if (!isItemActionSheet(actionSheetContent)) { + clearCapturedPinTarget(); + return; + } + if (!capturedItemId) return; + if (actionSheetContent.querySelector('[data-kefin-pin-action]')) return; + + const pinButton = document.createElement('button'); + pinButton.setAttribute('is', 'emby-button'); + pinButton.type = 'button'; + pinButton.className = 'listItem listItem-button actionSheetMenuItem emby-button'; + pinButton.setAttribute('data-kefin-pin-action', 'true'); + pinButton.innerHTML = ` + +
+
Pin to Home Screen
+
+ `; + pinButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closeOpenActionSheet(pinButton); + handlePinAction(); + }); + + insertPinActionSheetItem(actionSheetContent, pinButton); + offsetActionSheetForPinButton(actionSheetContent, pinButton); + } + + function setupContextMenuCapture() { + document.addEventListener('mousedown', (e) => { + const menuBtn = findMoreMenuButton(e.target); + if (menuBtn) { + capturePinTargetFromTrigger(menuBtn); + } else if (!e.target.closest?.('.actionSheetContent')) { + // Opening a non-item menu should not reuse a previous item id + clearCapturedPinTarget(); + } + }, true); + + document.addEventListener('contextmenu', captureFromEvent, true); + + const observer = new MutationObserver((mutations) => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + const sheets = node.matches?.('.actionSheetContent') + ? [node] + : [...(node.querySelectorAll?.('.actionSheetContent') || [])]; + sheets.forEach(injectPinActionSheetItem); + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + function isPinnedSectionId(sectionId) { + return sectionId?.startsWith('pinned-list-') || sectionId?.startsWith('pinned-parent-'); + } + + function isPinnedListSectionId(sectionId) { + return sectionId?.startsWith('pinned-list-'); + } + + function getRealPinnedCards(itemsContainer) { + if (!itemsContainer) return []; + return [...itemsContainer.querySelectorAll(':scope > .card[data-id]:not(.card-layout-dummy)')]; + } + + function updateReorderChevronState(card) { + if (!card) return; + const itemsContainer = card.closest('.itemsContainer'); + const cards = getRealPinnedCards(itemsContainer); + const idx = cards.indexOf(card); + const left = card.querySelector('.pin-reorder-left'); + const right = card.querySelector('.pin-reorder-right'); + if (left) { + const hide = idx <= 0; + left.hidden = hide; + left.disabled = hide; + left.setAttribute('aria-hidden', hide ? 'true' : 'false'); + } + if (right) { + const hide = idx < 0 || idx >= cards.length - 1; + right.hidden = hide; + right.disabled = hide; + right.setAttribute('aria-hidden', hide ? 'true' : 'false'); + } + } + + function updateAllReorderChevrons(itemsContainer) { + getRealPinnedCards(itemsContainer).forEach(updateReorderChevronState); + } + + async function persistPinnedListOrder(sectionEl) { + const sectionId = sectionEl?.dataset?.sectionId; + if (!isPinnedListSectionId(sectionId)) return false; + + const itemsContainer = sectionEl.querySelector('.itemsContainer'); + const orderedIds = getRealPinnedCards(itemsContainer) + .map((c) => c.getAttribute('data-id')) + .filter(Boolean); + if (!orderedIds.length) return false; + + const api = getConfigApi(); + let hs = await loadHomeScreen(); + const idx = (hs.pinnedLists || []).findIndex((str) => { + const parsed = api.parsePinnedListString(str); + return parsed && api.getPinnedListSectionId(parsed.name) === sectionId; + }); + if (idx < 0) return false; + + const parsed = api.parsePinnedListString(hs.pinnedLists[idx]); + hs.pinnedLists[idx] = api.serializePinnedList(parsed.name, orderedIds); + const ok = await saveHomeScreen(hs, { refreshHome: false }); + if (ok) { + updateRuntimeAfterListPin(sectionId, null, orderedIds); + window.cardBuilder?.invalidateLastRowPadding?.(itemsContainer); + } + return ok; + } + + async function movePinnedCard(card, direction) { + const sectionEl = card?.closest?.('[data-section-id]'); + const itemsContainer = card?.closest?.('.itemsContainer'); + if (!sectionEl || !itemsContainer) return; + + const cards = getRealPinnedCards(itemsContainer); + const idx = cards.indexOf(card); + const swapIdx = direction === 'left' ? idx - 1 : idx + 1; + if (idx < 0 || swapIdx < 0 || swapIdx >= cards.length) return; + + const other = cards[swapIdx]; + if (direction === 'left') { + itemsContainer.insertBefore(card, other); + } else { + itemsContainer.insertBefore(card, other.nextSibling); + } + + updateAllReorderChevrons(itemsContainer); + try { + await persistPinnedListOrder(sectionEl); + } catch (err) { + WARN('Pinned list reorder save failed:', err); + } + } + + function addReorderButtons(cardOrOverlay) { + if (!isEnabled()) return; + + const card = cardOrOverlay?.classList?.contains('card') + ? cardOrOverlay + : cardOrOverlay?.closest?.('.card'); + if (!card || card.classList.contains('card-layout-dummy')) return; + if (card.querySelector(':scope > .pin-reorder-left')) return; + + const sectionEl = card.closest('[data-section-id]'); + const sectionId = sectionEl?.dataset?.sectionId; + if (!isPinnedListSectionId(sectionId)) return; + if (!card.getAttribute('data-id')) return; + + const makeBtn = (cls, iconName, title, direction) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `pin-reorder-btn paper-icon-button-light ${cls}`; + btn.setAttribute('data-action', 'none'); + btn.title = title; + btn.setAttribute('aria-label', title); + const icon = document.createElement('span'); + icon.className = 'material-icons'; + icon.textContent = iconName; + icon.setAttribute('aria-hidden', 'true'); + btn.appendChild(icon); + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (btn.disabled || btn.hidden) return; + void movePinnedCard(card, direction); + }); + return btn; + }; + + const leftBtn = makeBtn('pin-reorder-left', 'chevron_left', 'Move left', 'left'); + const rightBtn = makeBtn('pin-reorder-right', 'chevron_right', 'Move right', 'right'); + // Attach to the card host so edges stay correct with large border styles + card.appendChild(leftBtn); + card.appendChild(rightBtn); + updateReorderChevronState(card); + } + + function addUnpinButton(cardOrOverlay) { + if (!isEnabled()) return; + + const card = cardOrOverlay?.classList?.contains('card') + ? cardOrOverlay + : cardOrOverlay?.closest?.('.card'); + if (!card || card.classList.contains('card-layout-dummy')) return; + if (card.querySelector(':scope > .unpin-item-button')) return; + + const sectionEl = card.closest('[data-section-id]'); + const sectionId = sectionEl?.dataset?.sectionId; + if (!isPinnedSectionId(sectionId)) return; + if (sectionId.startsWith('pinned-parent-')) return; + + const itemId = card.getAttribute('data-id'); + if (!itemId) return; + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'unpin-item-button paper-icon-button-light'; + removeButton.setAttribute('data-action', 'none'); + removeButton.title = 'Unpin from Home Screen'; + removeButton.setAttribute('aria-label', 'Unpin from Home Screen'); + + const icon = document.createElement('span'); + icon.className = 'material-icons'; + icon.textContent = 'push_pin'; + icon.setAttribute('aria-hidden', 'true'); + removeButton.appendChild(icon); + + removeButton.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + removeButton.disabled = true; + try { + const api = getConfigApi(); + let hs = await loadHomeScreen(); + const restoreHomeScreen = cloneHomeScreen(hs); + const realCards = sectionEl + ? [...sectionEl.querySelectorAll('.itemsContainer > .card[data-id]:not(.card-layout-dummy)')] + : []; + const isLastInSection = realCards.length <= 1; + let listName = null; + + if (sectionId.startsWith('pinned-list-')) { + const listEntry = (hs.pinnedLists || []).find(str => { + const parsed = api.parsePinnedListString(str); + return parsed && api.getPinnedListSectionId(parsed.name) === sectionId; + }); + const parsed = api.parsePinnedListString(listEntry); + if (parsed) { + listName = parsed.name; + hs = removeItemFromPinnedList(hs, parsed.name, itemId); + } + } else if (sectionId.startsWith('pinned-parent-')) { + const parentId = sectionId.replace('pinned-parent-', ''); + hs = removePinnedParent(hs, parentId); + } + + const ok = await saveHomeScreen(hs, { refreshHome: false }); + if (!ok) { + removeButton.disabled = false; + return; + } + + if (isLastInSection && sectionEl) { + const sectionName = getSectionDisplayName(sectionEl, listName); + await beginSectionUnpin({ + sectionId, + sectionEl, + sectionName, + restoreHomeScreen + }); + } else { + const itemName = getCardDisplayName(card, 'item'); + await beginItemUnpin({ + card, + itemName, + restoreHomeScreen + }); + const itemsContainer = sectionEl?.querySelector('.itemsContainer'); + if (itemsContainer) updateAllReorderChevrons(itemsContainer); + } + } catch (err) { + WARN('Unpin failed:', err); + removeButton.disabled = false; + } + }); + + card.appendChild(removeButton); + } + + function setupUnpinObserver() { + const style = document.createElement('style'); + style.textContent = ` + .unpin-item-button { + position: absolute !important; + top: 8px !important; + right: 8px !important; + z-index: 20 !important; + width: 32px !important; + height: 32px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + transition: all 0.2s ease !important; + opacity: 0 !important; + transform: scale(0.8) !important; + padding: 0 !important; + pointer-events: auto !important; + } + .card:hover > .unpin-item-button, + .unpin-item-button:focus-visible { + opacity: 1 !important; + transform: scale(1) !important; + } + .unpin-item-button:hover { + background: rgba(0, 0, 0, 0.15) !important; + transform: scale(1.1) !important; + } + .unpin-item-button .material-icons { + font-size: 18px !important; + } + .pin-reorder-btn { + position: absolute !important; + top: 50% !important; + z-index: 20 !important; + margin-top: -14px !important; + width: 28px !important; + height: 28px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease !important; + opacity: 0 !important; + transform: scale(0.85) !important; + padding: 0 !important; + pointer-events: auto !important; + } + [data-section-id^="pinned-list-"] .card[data-id]:not(.card-layout-dummy) { + position: relative !important; + } + .pin-reorder-left { + left: 6px !important; + } + .pin-reorder-right { + right: 6px !important; + } + .card:hover > .pin-reorder-btn:not([hidden]), + .pin-reorder-btn:focus-visible:not([hidden]) { + opacity: 0.92 !important; + transform: scale(1) !important; + } + .pin-reorder-btn:hover:not([hidden]):not(:disabled) { + background: rgba(0, 0, 0, 0.15) !important; + opacity: 1 !important; + transform: scale(1.08) !important; + } + .pin-reorder-btn[hidden] { + display: none !important; + } + .pin-reorder-btn .material-icons { + font-size: 20px !important; + line-height: 1 !important; + } + [data-modal-id="kefin-pin-choice-modal"] .formDialog { + min-width: min(92vw, 28rem); + } + [data-modal-id="kefin-pin-it-modal"] .formDialog { + min-width: min(92vw, 18rem); + } + [data-modal-id="kefin-pin-it-modal"] .kefin-pin-it-actions { + display: flex; + justify-content: flex-end; + gap: 0.5em; + margin-top: 1em; + } + [data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-grid { + display: flex; + flex-direction: column; + gap: 1em; + } + [data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-card { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + width: 100%; + 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; + color: inherit; + } + [data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-card:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.08); + } + [data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-title { + margin: 0 0 0.5em; + font-size: 1.125rem; + font-weight: 600; + line-height: 1.25; + } + [data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-desc { + margin: 0; + font-size: 0.9375rem; + line-height: 1.6; + } + `; + document.head.appendChild(style); + + const processOverlay = (overlayContainer) => { + if (overlayContainer?.querySelector?.('.cardOverlayButton-br') || overlayContainer?.classList?.contains('cardOverlayContainer')) { + const card = overlayContainer.closest?.('.card'); + if (card) { + addUnpinButton(card); + addReorderButtons(card); + } + } + }; + + const processCard = (card) => { + if (!card?.classList?.contains('card')) return; + addUnpinButton(card); + addReorderButtons(card); + }; + + document.querySelectorAll('.cardOverlayContainer').forEach(processOverlay); + document.querySelectorAll('[data-section-id^="pinned-list-"] .card[data-id]:not(.card-layout-dummy)').forEach(processCard); + + const observer = new MutationObserver((mutations) => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + if (node.classList?.contains('cardOverlayContainer')) processOverlay(node); + node.querySelectorAll?.('.cardOverlayContainer').forEach(processOverlay); + if (node.classList?.contains('card')) processCard(node); + node.querySelectorAll?.('.card[data-id]').forEach(processCard); + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + function isPinnedParentSection(sectionId) { + return sectionId?.startsWith('pinned-parent-'); + } + + async function unpinParentSection(sectionId, sectionElement) { + if (!isPinnedParentSection(sectionId)) return false; + const parentId = sectionId.replace('pinned-parent-', ''); + try { + let hs = await loadHomeScreen(); + const restoreHomeScreen = cloneHomeScreen(hs); + const parentEntry = (hs.pinnedParents || []).find((str) => { + const parsed = getConfigApi().parsePinnedParentString(str); + return parsed?.parentId === parentId; + }); + const parsedParent = getConfigApi().parsePinnedParentString(parentEntry); + const sectionName = getSectionDisplayName(sectionElement, parsedParent?.name); + + hs = removePinnedParent(hs, parentId); + const ok = await saveHomeScreen(hs, { refreshHome: false }); + if (!ok) return false; + + if (sectionElement) { + await beginSectionUnpin({ + sectionId, + sectionEl: sectionElement, + sectionName, + restoreHomeScreen + }); + } else { + window.KefinHomeScreenSectionRuntime?.unregister?.(sectionId); + } + return true; + } catch (err) { + WARN('Unpin parent failed:', err); + return false; + } + } + + function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function escapeAttr(str) { + return escapeHtml(str).replace(/'/g, '''); + } + + if (isEnabled()) { + setupContextMenuCapture(); + setupUnpinObserver(); + } + + window.KefinHomeScreenPin = { + isEnabled, + isPinnedParentSection, + unpinParentSection + }; + + LOG('Module loaded'); +})(); diff --git a/scripts/homeScreenSectionConfigure.js b/scripts/homeScreenSectionConfigure.js new file mode 100644 index 0000000..c74ea85 --- /dev/null +++ b/scripts/homeScreenSectionConfigure.js @@ -0,0 +1,3102 @@ +// Inline home section configure popover +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeSectionConfigure]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeSectionConfigure]', ...args); + + const runtime = new Map(); + let activePopover = null; + let saveTimeout = null; + let pendingHomeScreen = null; + const pendingSaves = new Map(); + /** @type {{ entry: object, parent: Node, nextSibling: Node|null, serverDefaults: object }|null} */ + let pendingHideStash = null; + const OPTION_MENU_MODAL_ID = 'kefin-section-option-menu'; + const CARD_TITLE_MENU_MODAL_ID = 'kefin-section-card-title-menu'; + const RESTORE_SECTION_MODAL_ID = 'kefin-section-restore-defaults'; + const PUBLISH_SECTION_MODAL_ID = 'kefin-section-publish-defaults'; + + /** True when the current pointer gesture started inside a Pickr app (drag may end outside). */ + let pickrGestureFromApp = false; + + function isPickrAppEventTarget(target) { + return !!target?.closest?.('.pcr-app, .kefin-pickr-app'); + } + + function onPickrGesturePointerDown(e) { + if (!activePopover) return; + pickrGestureFromApp = isPickrAppEventTarget(e.target); + } + + function onPickrGesturePointerUp() { + if (!activePopover) { + pickrGestureFromApp = false; + return; + } + // click fires after mouseup/pointerup; clear after that turn so capture click still sees the flag + setTimeout(() => { + pickrGestureFromApp = false; + }, 0); + } + + /** + * Drag in Pickr + release outside synthesizes a click whose target is outside .pcr-app. + * Stop that click so configure outsideHandler and Pickr's own dismiss do not run. + */ + function onPickrGestureClickCapture(e) { + if (!activePopover || !pickrGestureFromApp) return; + if (isPickrAppEventTarget(e.target)) return; + e.stopImmediatePropagation(); + e.preventDefault(); + } + + document.addEventListener('pointerdown', onPickrGesturePointerDown, true); + document.addEventListener('pointerup', onPickrGesturePointerUp, true); + document.addEventListener('pointercancel', onPickrGesturePointerUp, true); + document.addEventListener('click', onPickrGestureClickCapture, true); + + /** Accept hex (3/4/6/8), rgb(a), hsl(a), or other non-empty CSS color strings. */ + function isCssColorString(value) { + if (value == null) return false; + const v = String(value).trim(); + if (!v) return false; + return /^(#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})|rgba?\(|hsla?\(|[a-zA-Z][\w-]*)$/i.test(v) + || /^(rgb|hsl)a?\(/i.test(v); + } + + function destroyPickrInstance(pickr) { + if (!pickr) return; + 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 */ } + // Remove any orphaned Pickr apps left after a hard close mid-interaction. + document.querySelectorAll('.pcr-app.kefin-pickr-app').forEach((el) => { + try { el.remove(); } catch (__) { /* ignore */ } + }); + } + + /** + * Create a nano-theme Pickr on el. Returns null if Pickr is unavailable. + * @param {HTMLElement} el + * @param {{ defaultColor?: string, useAsButton?: boolean, onChange?: (css: string) => void, onSave?: (css: string) => void }} opts + */ + function createPickrInstance(el, opts = {}) { + if (!el || typeof window.Pickr?.create !== 'function') { + WARN('Pickr unavailable'); + return null; + } + // Pickr needs a laid-out anchor; zero-size / detached hosts crash in _rePositioningPicker. + if (!el.isConnected) { + WARN('Pickr host is not connected'); + return null; + } + const defaultColor = isCssColorString(opts.defaultColor) ? String(opts.defaultColor).trim() : '#e8dab2'; + let pickr; + try { + pickr = window.Pickr.create({ + el, + theme: 'nano', + default: defaultColor, + defaultRepresentation: 'HEXA', + useAsButton: opts.useAsButton === true, + appClass: 'kefin-pickr-app', + components: { + preview: true, + opacity: true, + hue: true, + interaction: { + hex: true, + rgba: true, + input: true, + save: true + } + } + }); + } catch (error) { + WARN('Pickr.create failed', error); + return null; + } + + const toCss = (color) => { + if (!color) return ''; + try { + return color.toHEXA().toString(); + } catch (_) { + try { + return color.toRGBA().toString(); + } catch (__) { + return ''; + } + } + }; + + pickr.on('change', (color) => { + const css = toCss(color); + if (css) opts.onChange?.(css); + }); + pickr.on('save', (color) => { + const css = toCss(color); + if (css) opts.onSave?.(css); + pickr.hide(); + }); + + // Keep our anchored menus from eating Pickr clicks (transparent modal backdrop / outside handler). + if (typeof opts.onShow === 'function' || typeof opts.onHide === 'function') { + pickr.on('show', () => opts.onShow?.(pickr)); + pickr.on('hide', () => opts.onHide?.(pickr)); + } + + return pickr; + } + + function setAnchoredMenuBackdropPassThrough(modalInstance, passThrough) { + if (!modalInstance) return; + if (modalInstance.backdrop) { + modalInstance.backdrop.style.pointerEvents = passThrough ? 'none' : ''; + } + if (modalInstance.dialogContainer) { + // Anchored menus already use none; when Pickr is open keep that and ensure it stays none. + if (passThrough) modalInstance.dialogContainer.style.pointerEvents = 'none'; + } + } + + function getUserSettings() { + return window.KefinTweaksConfig?.homeScreenConfig?.USER_HOME_SCREEN_SETTINGS || {}; + } + + function isEnabled() { + return getUserSettings().inlineConfigure !== false; + } + + function getConfigApi() { + return window.KefinUserHomeScreenConfig; + } + + function getCachePresetOptions() { + const presets = window.KefinHomeScreenAdvancedEditor?.getCachePresets?.() + || buildFallbackCachePresets(); + return presets.filter(p => p.key !== 'CUSTOM' && p.ms != null && Number.isFinite(Number(p.ms))); + } + + function findCachePresetForTtl(ttl) { + if (ttl == null || ttl === '') return null; + const ttlNum = Number(ttl); + if (!Number.isFinite(ttlNum)) return null; + const presets = getCachePresetOptions(); + const exact = presets.find(p => Number(p.ms) === ttlNum); + if (exact) return exact; + + // Same human label as a preset (e.g. both round to "6 hours") → treat as that preset. + // Prefer the closest ms when multiple presets share a label. + const label = formatDurationLabel(ttlNum); + const sameLabel = presets.filter(p => formatDurationLabel(Number(p.ms)) === label); + if (!sameLabel.length) return null; + return sameLabel.reduce((best, p) => ( + Math.abs(Number(p.ms) - ttlNum) < Math.abs(Number(best.ms) - ttlNum) ? p : best + )); + } + + function buildCacheSelectOptionsHtml(currentTtl) { + const presets = getCachePresetOptions(); + const matched = findCachePresetForTtl(currentTtl); + const ttlNum = currentTtl == null || currentTtl === '' ? NaN : Number(currentTtl); + let html = presets + .map((preset) => { + const selected = matched && Number(preset.ms) === Number(matched.ms) ? ' selected' : ''; + return ``; + }) + .join(''); + // Only show Custom when the value doesn't correspond to any preset (exact or same label). + if (Number.isFinite(ttlNum) && !matched) { + html += ``; + } + return html; + } + + function buildFallbackCachePresets() { + const cache = window.KefinHomeConfig2?.CACHE || {}; + 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 } + ]; + } + + function pluralUnit(count, singular, plural) { + return count === 1 ? singular : plural; + } + + function formatDurationLabel(ms) { + if (ms == null) return 'Default'; + if (ms === 0) return 'No Cache'; + if (ms < 60000) { + const n = Math.round(ms / 1000); + return `${n} ${pluralUnit(n, 'second', 'seconds')}`; + } + if (ms < 3600000) { + const n = Math.round(ms / 60000); + return `${n} ${pluralUnit(n, 'minute', 'minutes')}`; + } + if (ms < 86400000) { + const n = Math.round(ms / 3600000); + return `${n} ${pluralUnit(n, 'hour', 'hours')}`; + } + const n = Math.round(ms / 86400000); + return `${n} ${pluralUnit(n, 'day', 'days')}`; + } + + function buildMinimalToggleSwitch(id, checked) { + if (window.KefinTweaksUI?.buildToggleSlider) { + return window.KefinTweaksUI.buildToggleSlider(id, checked, '', { + includeHiddenCheckbox: true, + wrapInLabel: false, + cssClass: 'kefin-toggle-switch', + knob: 'css' + }); + } + const isEnabled = checked !== false; + const safeId = escapeHtml(id); + return ` + + + `; + } + + function bindToggleSwitch(toggleBtn, onChange) { + if (!toggleBtn) return; + toggleBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const next = toggleBtn.getAttribute('data-enabled') !== 'true'; + if (window.KefinTweaksUI?.updateToggleSwitchUI) { + window.KefinTweaksUI.updateToggleSwitchUI(toggleBtn, next); + } else { + toggleBtn.dataset.enabled = String(next); + toggleBtn.setAttribute('aria-pressed', String(next)); + const checkboxId = toggleBtn.dataset.checkboxId; + if (checkboxId) { + const checkbox = document.getElementById(checkboxId); + if (checkbox) checkbox.checked = next; + } + } + onChange(next); + }); + } + + function registerRuntime(entry) { + runtime.set(entry.sectionId, entry); + } + + function getRuntime(sectionId) { + return runtime.get(sectionId); + } + + function updateRuntimeItemsLayout(sectionId, layout) { + const entry = getRuntime(sectionId); + if (!entry?.effectiveConfig) return; + const resolved = layout === 'grid' ? 'grid' + : (layout === true ? 'grid' : 'row'); + if (resolved === 'row') { + delete entry.effectiveConfig.itemsLayout; + delete entry.effectiveConfig.gridExpanded; + } else { + entry.effectiveConfig.itemsLayout = resolved; + entry.effectiveConfig.gridExpanded = true; + } + } + + function updateRuntimeUseGaplessCards(sectionId, useGaplessCards) { + const entry = getRuntime(sectionId); + if (!entry?.effectiveConfig) return; + if (useGaplessCards === true) entry.effectiveConfig.useGaplessCards = true; + else delete entry.effectiveConfig.useGaplessCards; + } + + /** @deprecated Use updateRuntimeItemsLayout */ + function updateRuntimeGridExpanded(sectionId, expanded) { + updateRuntimeItemsLayout(sectionId, expanded === true ? 'grid' : 'row'); + } + + window.KefinHomeScreenSectionRuntime = { + register: registerRuntime, + get: getRuntime, + unregister(sectionId) { runtime.delete(sectionId); }, + getAll() { return [...runtime.values()]; } + }; + + async function loadHomeScreen() { + if (pendingHomeScreen) return pendingHomeScreen; + const api = getConfigApi(); + if (!window.userHelper?.getUserDisplayPreferences) { + return api.createEmptyHomeScreen(); + } + const { promise } = await window.userHelper.getUserDisplayPreferences(); + const displayPrefs = await promise; + return api.parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs); + } + + function buildPrefOverrides(cfg) { + const spotlight = cfg.spotlightConfig || {}; + return { + order: cfg.order, + ttl: cfg.ttl, + cardFormat: cfg.cardFormat, + animationEnabled: spotlight.panAnimation, + hideName: cfg.hideName === true, + hideCardTitles: cfg.cardTitleVisibility === 'hidden' || cfg.hideCardTitles === true, + cardTitlePosition: cfg.cardTitlePosition, + borderStyle: cfg.borderStyle, + borderColor: cfg.borderColor, + spotlightLayout: spotlight.spotlightLayout, + spotlightSize: spotlight.spotlightSize, + spotlightTileCount: spotlight.tileCount, + itemsLayout: cfg.itemsLayout === 'grid' || cfg.itemsLayout === 'row' + ? cfg.itemsLayout + : (cfg.gridExpanded === true ? 'grid' : 'row'), + useGaplessCards: cfg.useGaplessCards === true, + cardTitleCapitalization: cfg.cardTitleCapitalization, + cardTitleFontFamily: cfg.cardTitleFontFamily, + cardTitleFontSize: cfg.cardTitleFontSize, + cardTitleColor: cfg.cardTitleColor + }; + } + + function resolveStoredSectionPrefId(sectionId, effectiveConfig) { + const api = getConfigApi(); + if (api?.getStoredSectionPrefId && effectiveConfig) { + return api.getStoredSectionPrefId({ ...effectiveConfig, id: sectionId }); + } + return sectionId; + } + + function sectionHasUserOverrides(entry, serverDefaults) { + const api = getConfigApi(); + const cfg = entry?.effectiveConfig; + const defaults = serverDefaults || entry?.serverDefaults || {}; + if (!api?.serializeSectionPref || !cfg) return false; + const prefStr = api.serializeSectionPref( + resolveStoredSectionPrefId(entry.sectionId, cfg), + cfg.enabled !== false, + buildPrefOverrides(cfg), + defaults + ); + const parts = String(prefStr || '').split(';'); + if (parts[1] === 'false') return true; + return parts.slice(2).some((part) => part !== ''); + } + + function applyServerDefaultsToConfig(cfg, defaults) { + if (!cfg || !defaults) return; + cfg.order = defaults.order; + cfg.ttl = defaults.ttl; + cfg.cardFormat = defaults.cardFormat; + cfg.cardTitlePosition = defaults.cardTitlePosition; + cfg.cardTitleCapitalization = defaults.cardTitleCapitalization; + cfg.cardTitleFontFamily = defaults.cardTitleFontFamily; + cfg.cardTitleFontSize = defaults.cardTitleFontSize; + cfg.enabled = true; + + if (defaults.hideName) cfg.hideName = true; + else delete cfg.hideName; + + if (defaults.hideCardTitles) { + cfg.cardTitleVisibility = 'hidden'; + delete cfg.hideCardTitles; + } else { + delete cfg.cardTitleVisibility; + delete cfg.hideCardTitles; + } + + if (defaults.borderStyle) cfg.borderStyle = defaults.borderStyle; + else delete cfg.borderStyle; + + if (defaults.borderColor) cfg.borderColor = defaults.borderColor; + else delete cfg.borderColor; + + if (defaults.cardTitleColor) cfg.cardTitleColor = defaults.cardTitleColor; + else delete cfg.cardTitleColor; + + if (defaults.itemsLayout && defaults.itemsLayout !== 'row') { + cfg.itemsLayout = defaults.itemsLayout === 'grid' ? 'grid' : 'row'; + if (cfg.itemsLayout === 'grid') cfg.gridExpanded = true; + else delete cfg.gridExpanded; + } else { + delete cfg.itemsLayout; + delete cfg.gridExpanded; + } + + if (defaults.useGaplessCards === true) cfg.useGaplessCards = true; + else delete cfg.useGaplessCards; + + cfg.spotlightConfig = { + ...(cfg.spotlightConfig || {}), + panAnimation: defaults.animationEnabled !== false && defaults.panAnimation !== false, + spotlightLayout: defaults.spotlightLayout ?? 'Border', + spotlightSize: defaults.spotlightSize ?? 'normal', + tileCount: defaults.spotlightTileCount || 1 + }; + } + + function syncRestoreDefaultsButton(btn, dirty) { + if (!btn) return; + btn.disabled = !dirty; + btn.classList.toggle('is-disabled', !dirty); + btn.setAttribute('aria-disabled', String(!dirty)); + } + + function openRestoreSectionDefaultsConfirm(onConfirm) { + if (!window.ModalSystem?.create) { + WARN('ModalSystem unavailable for restore confirm'); + return null; + } + closeCardTitleMenu(); + if (window.ModalSystem.isOpen?.(RESTORE_SECTION_MODAL_ID)) { + window.ModalSystem.close(RESTORE_SECTION_MODAL_ID); + } + const modalId = RESTORE_SECTION_MODAL_ID; + const content = `

This will restore the default settings for this section. Would you like to continue?

`; + const footer = ` + + + `; + return window.ModalSystem.create({ + id: modalId, + title: 'Restore Defaults', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + const confirmBtn = modal.dialogFooter?.querySelector('#kefin-section-restore-defaults-confirm'); + confirmBtn?.addEventListener('click', async () => { + confirmBtn.disabled = true; + try { + await onConfirm?.(); + window.ModalSystem.close(modalId); + } catch (e) { + WARN('Failed to restore section defaults:', e); + confirmBtn.disabled = false; + } + }); + } + }); + } + + function openPublishSectionDefaultsConfirm({ onUpdate, onOk }) { + if (!window.ModalSystem?.create) { + WARN('ModalSystem unavailable for publish confirm'); + return null; + } + closeCardTitleMenu(); + if (window.ModalSystem.isOpen?.(PUBLISH_SECTION_MODAL_ID)) { + window.ModalSystem.close(PUBLISH_SECTION_MODAL_ID); + } + const modalId = PUBLISH_SECTION_MODAL_ID; + const content = `

Apply these settings as the section's default?

`; + const footer = ` + + + + `; + return window.ModalSystem.create({ + id: modalId, + title: 'Publish Defaults', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + const updateBtn = modal.dialogFooter?.querySelector('#kefin-section-publish-update'); + const okBtn = modal.dialogFooter?.querySelector('#kefin-section-publish-ok'); + const run = async (fn, btn) => { + if (updateBtn) updateBtn.disabled = true; + if (okBtn) okBtn.disabled = true; + try { + await fn?.(); + window.ModalSystem.close(modalId); + } catch (e) { + WARN('Failed to publish section defaults:', e); + if (updateBtn) updateBtn.disabled = false; + if (okBtn) okBtn.disabled = false; + } + }; + updateBtn?.addEventListener('click', () => run(onUpdate, updateBtn)); + okBtn?.addEventListener('click', () => run(onOk, okBtn)); + } + }); + } + + async function restoreSectionToServerDefaults(entry, serverDefaults) { + const defaults = serverDefaults || entry.serverDefaults || {}; + applyServerDefaultsToConfig(entry.effectiveConfig, defaults); + + pendingSaves.delete(entry.sectionId); + clearTimeout(saveTimeout); + saveTimeout = null; + + const api = getConfigApi(); + let homeScreen = await loadHomeScreen(); + if (pendingSaves.size > 0) { + pendingSaves.forEach(({ effectiveConfig, serverDefaults: sd }, id) => { + const prefStr = api.serializeSectionPref( + resolveStoredSectionPrefId(id, effectiveConfig), + effectiveConfig.enabled !== false, + buildPrefOverrides(effectiveConfig), + sd + ); + homeScreen = api.upsertSectionPref(homeScreen, prefStr); + }); + pendingSaves.clear(); + } + const storedId = resolveStoredSectionPrefId(entry.sectionId, entry.effectiveConfig); + homeScreen.sections = (homeScreen.sections || []).filter( + (s) => api.parseSectionPrefString?.(s)?.id !== storedId + ); + pendingHomeScreen = homeScreen; + await api.saveKefinTweaksHomeScreen(homeScreen); + pendingHomeScreen = null; + + if (entry.element && defaults.order != null) { + entry.element.style.order = defaults.order; + entry.element.dataset.order = String(defaults.order); + } + + applyPresentationToElement(entry); + rerenderSection(entry); + closePopover(); + } + + function scheduleSave(sectionId, effectiveConfig, serverDefaults) { + pendingSaves.set(sectionId, { effectiveConfig, serverDefaults }); + clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + const batch = new Map(pendingSaves); + pendingSaves.clear(); + try { + const api = getConfigApi(); + let homeScreen = await loadHomeScreen(); + batch.forEach(({ effectiveConfig, serverDefaults }, id) => { + const prefStr = api.serializeSectionPref( + resolveStoredSectionPrefId(id, effectiveConfig), + effectiveConfig.enabled !== false, + buildPrefOverrides(effectiveConfig), + serverDefaults + ); + homeScreen = api.upsertSectionPref(homeScreen, prefStr); + }); + pendingHomeScreen = homeScreen; + await api.saveKefinTweaksHomeScreen(homeScreen); + pendingHomeScreen = null; + } catch (e) { + WARN('Failed to save section preferences:', e); + } + }, 300); + } + + const HIDE_NAME_DESCRIPTIONS = { + on: 'The section name will not be visible.', + off: 'The section name will be visible.' + }; + + 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' } + ]; + + function getEditorConstants() { + return window.KefinHomeScreenEditorConstants || {}; + } + + function getCardFormats() { + return getEditorConstants().CARD_FORMATS + || ['Poster', 'Thumb', 'Series Thumb', 'Series Poster', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc']; + } + + function getCardTitleHorizontalAlignOptions() { + return [ + { value: 'default', label: 'Default' }, + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' } + ]; + } + + function getCardTitleVerticalAlignOptions() { + return [ + { value: 'default', label: 'Default' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, + { value: 'bottom', label: 'Bottom' } + ]; + } + + function parseCardTitleAlign(position) { + const p = position && String(position).trim() ? String(position).trim() : ''; + if (!p || p === 'default') { + return { vertical: 'default', horizontal: 'default' }; + } + if (!p.startsWith('overlay-')) { + const horizontal = (p === 'left' || p === 'center' || p === 'right') ? p : 'default'; + return { vertical: 'default', horizontal }; + } + const rest = p.slice('overlay-'.length); + if (rest === 'center') { + return { vertical: 'center', horizontal: 'center' }; + } + const parts = rest.split('-'); + if (parts.length >= 2) { + const vertical = parts[0]; + const horizontal = parts.slice(1).join('-'); + return { + vertical: (vertical === 'top' || vertical === 'center' || vertical === 'bottom') ? vertical : 'default', + horizontal: (horizontal === 'center' || horizontal === 'right') ? horizontal : 'left' + }; + } + return { vertical: 'default', horizontal: 'left' }; + } + + function composeCardTitlePosition(horizontal, vertical) { + const v = (vertical === 'top' || vertical === 'center' || vertical === 'bottom') ? vertical : 'default'; + if (v === 'default') { + if (horizontal === 'default' || !horizontal) return ''; + if (horizontal === 'left') return 'left'; + if (horizontal === 'center' || horizontal === 'right') return horizontal; + return ''; + } + const h = (horizontal === 'center' || horizontal === 'right') ? horizontal : 'left'; + if (v === 'center' && h === 'center') return 'overlay-center'; + return `overlay-${v}-${h}`; + } + + function setCardTitlePositionFromAlign(cfg, horizontal, vertical) { + let h = horizontal; + const v = vertical; + if (v !== 'default' && h === 'default') { + h = 'left'; + } + const next = composeCardTitlePosition(h, v); + if (next) cfg.cardTitlePosition = next; + else delete cfg.cardTitlePosition; + } + + function getCardTitleCapitalizationOptions() { + return [ + { value: 'normal', label: 'Normal' }, + { value: 'uppercase', label: 'Uppercase' }, + { value: 'lowercase', label: 'Lowercase' }, + { value: 'capitalize', label: 'Capitalize' } + ]; + } + + function getCardTitleFontFamilyOptions() { + return [ + { 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' } + ]; + } + + function getCardTitleFontSizeOptions() { + return [ + { value: 'normal', label: 'Normal' }, + { value: 'small', label: 'Small' }, + { value: 'large', label: 'Large' } + ]; + } + + function getSpotlightLayoutOptions() { + return getEditorConstants().SPOTLIGHT_LAYOUT_OPTIONS || [ + { value: 'Border', label: 'Border' }, + { value: 'Borderless', label: 'Borderless' } + ]; + } + + function getSpotlightSizeOptions() { + return getEditorConstants().SPOTLIGHT_SIZE_OPTIONS || [ + { value: 'normal', label: 'Normal' }, + { value: 'large', label: 'Large' }, + { value: 'full', label: 'Full' } + ]; + } + + function isSpotlightSection(cfg) { + return cfg?.renderMode === 'Spotlight' || cfg?.spotlight === true; + } + + function applyPresentationToElement(entry) { + const cfg = entry.effectiveConfig; + const el = entry.element; + if (!el || !cfg) return; + + const titleContainer = el.querySelector('.sectionTitleContainer, .spotlight-section-title-container'); + if (titleContainer && titleContainer.style.display === 'none') { + titleContainer.style.display = ''; + } + if (cfg.hideName === true) { + el.dataset.hideSectionName = 'true'; + } else { + delete el.dataset.hideSectionName; + } + // Prefer the explicit attribute name used by CSS; clear any legacy attr. + delete el.dataset.hideName; + + const hideTitles = cfg.cardTitleVisibility === 'hidden' || cfg.hideCardTitles === true; + if (hideTitles) { + el.dataset.cardTitleVisibility = 'hidden'; + } else { + delete el.dataset.cardTitleVisibility; + } + + if (cfg.borderStyle) { + el.dataset.border = cfg.borderStyle; + el.dataset.borderStyle = cfg.borderStyle; + } else { + delete el.dataset.border; + delete el.dataset.borderStyle; + } + const borderColor = cfg.borderColor && String(cfg.borderColor).trim(); + if (borderColor) { + el.style.setProperty('--kefin-card-border-color', borderColor); + } else { + el.style.removeProperty('--kefin-card-border-color'); + } + if (window.cardBuilder?.ensureCardBorders) { + window.cardBuilder.ensureCardBorders(el); + } + + const titleColor = cfg.cardTitleColor && String(cfg.cardTitleColor).trim(); + if (titleColor) { + el.style.setProperty('--kefin-card-title-color', titleColor); + } else { + el.style.removeProperty('--kefin-card-title-color'); + } + + if (cfg.cardTitlePosition) { + el.dataset.cardTitlePosition = cfg.cardTitlePosition; + } else { + delete el.dataset.cardTitlePosition; + } + + if (cfg.cardTitleCapitalization && cfg.cardTitleCapitalization !== 'normal') { + el.dataset.cardTitleCapitalization = cfg.cardTitleCapitalization; + } else { + delete el.dataset.cardTitleCapitalization; + } + + if (cfg.cardTitleFontFamily && cfg.cardTitleFontFamily !== 'default') { + el.dataset.fontFamily = cfg.cardTitleFontFamily; + } else { + delete el.dataset.fontFamily; + } + + if (cfg.cardTitleFontSize && cfg.cardTitleFontSize !== 'normal') { + el.dataset.fontSize = cfg.cardTitleFontSize; + } else { + delete el.dataset.fontSize; + } + + if (isSpotlightSection(cfg)) { + const spotlight = cfg.spotlightConfig || {}; + if (spotlight.spotlightLayout) { + el.dataset.layout = spotlight.spotlightLayout; + } + if (spotlight.spotlightSize) { + el.dataset.size = spotlight.spotlightSize; + } + if (spotlight.tileCount != null) { + el.dataset.tileCount = String(spotlight.tileCount); + } + el.querySelectorAll('.spotlight-overlay').forEach(overlay => { + const hasVisibleTitle = cfg.hideName !== true + && !!el.querySelector('.spotlight-section-title'); + overlay.classList.toggle('has-title', hasVisibleTitle); + }); + } + } + + function replaceSectionElement(entry, newContent) { + const cfg = entry.effectiveConfig; + const isSpotlight = isSpotlightSection(cfg); + let preservedLayout = null; + let preservedGapless = null; + if (!isSpotlight) { + const oldItemsContainer = entry.element.querySelector('.itemsContainer'); + const layoutFromDom = oldItemsContainer?.getAttribute('data-layout'); + preservedLayout = (layoutFromDom === 'grid' || layoutFromDom === 'row') + ? layoutFromDom + : (window.cardBuilder?.resolveItemsLayout?.(cfg) || 'row'); + preservedGapless = oldItemsContainer?.getAttribute('data-gapless') === 'true' + || cfg.useGaplessCards === true + || window.cardBuilder?.resolveUseGaplessCards?.(cfg) === true; + } + + newContent.style.cssText = entry.element.style.cssText; + newContent.className = entry.element.className; + Array.from(entry.element.attributes).forEach(attr => { + newContent.setAttribute(attr.name, attr.value); + }); + if (cfg.order != null) { + newContent.dataset.order = String(cfg.order); + newContent.style.order = cfg.order; + } + if (cfg.cardFormat) { + newContent.setAttribute('data-card-format', cfg.cardFormat); + } + entry.element.replaceWith(newContent); + entry.element = newContent; + registerRuntime(entry); + applyPresentationToElement(entry); + + if (!isSpotlight && window.cardBuilder?.applyItemsLayoutState) { + window.cardBuilder.invalidateLastRowPadding?.(newContent.querySelector('.itemsContainer')); + window.cardBuilder.applyItemsLayoutState(newContent, preservedLayout || 'row', !!preservedGapless); + } + + const titleContainer = newContent.querySelector('.sectionTitleContainer, .spotlight-section-title-container'); + if (titleContainer && window.cardBuilder?.attachSectionControlButtons) { + window.cardBuilder.attachSectionControlButtons(cfg, newContent, entry.cachedItems); + } + if (activePopover?.entry?.sectionId === entry.sectionId) { + activePopover.entry = entry; + activePopover.sectionElement = entry.element; + activePopover.anchorButton = getConfigureAnchorButton(entry); + const mountElement = preparePopoverMount(activePopover.anchorButton, entry.element); + activePopover.mountElement = mountElement; + if (activePopover.popover.parentElement !== mountElement) { + mountElement.appendChild(activePopover.popover); + } + requestAnimationFrame(() => repositionActivePopover()); + } + } + + function rerenderSection(entry) { + const cfg = entry.effectiveConfig; + const items = entry.cachedItems; + if (!items?.length) { + applyPresentationToElement(entry); + return; + } + + let newContent = null; + if (isSpotlightSection(cfg) && window.cardBuilder?.renderSpotlightSection) { + const activeSlide = entry.element?.querySelector('.spotlight-item[data-active]'); + const initialIndex = parseInt(activeSlide?.getAttribute('data-index'), 10); + newContent = window.cardBuilder.renderSpotlightSection(items, cfg.name, { + viewMoreUrl: cfg.viewMoreUrl, + ...(cfg.spotlightConfig || {}), + initialIndex: Number.isNaN(initialIndex) ? 0 : initialIndex + }); + } else if (window.cardBuilder?.renderCards) { + newContent = window.cardBuilder.renderCards( + items, + cfg.name, + cfg.viewMoreUrl, + cfg.overflowCard, + cfg.cardFormat, + null, + 'Ascending' + ); + } + if (!newContent) return; + replaceSectionElement(entry, newContent); + } + + function positionFormatMenu(menu, anchorButton) { + const btnRect = anchorButton.getBoundingClientRect(); + const menuHeight = menu.offsetHeight; + const gap = 4; + const margin = 8; + const spaceBelow = window.innerHeight - btnRect.bottom; + const spaceAbove = btnRect.top; + + let top; + if (spaceBelow >= menuHeight + gap || spaceBelow >= spaceAbove) { + top = btnRect.bottom + gap; + } else { + top = btnRect.top - menuHeight - gap; + } + top = Math.min(Math.max(top, margin), window.innerHeight - menuHeight - margin); + + let left = btnRect.left; + const maxLeft = window.innerWidth - menu.offsetWidth - margin; + left = Math.min(Math.max(left, margin), maxLeft); + + menu.style.top = `${top}px`; + menu.style.left = `${left}px`; + } + + function styleAnchoredPopoverDialog(modal, anchorBtn) { + if (!modal?.dialog || !anchorBtn) return; + modal.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY'); + modal.dialog.style.position = 'fixed'; + modal.dialog.style.margin = '0'; + modal.dialog.style.maxHeight = 'none'; + modal.dialog.style.minWidth = '8rem'; + modal.dialog.style.width = 'auto'; + modal.dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; + if (modal.dialogContent) { + modal.dialogContent.style.padding = '0.25em 0'; + modal.dialogContent.style.overflow = 'hidden'; + modal.dialogContent.style.minHeight = '0'; + } + if (modal.backdrop) { + modal.backdrop.style.background = 'transparent'; + } + if (modal.dialogContainer) { + modal.dialogContainer.style.pointerEvents = 'none'; + } + modal.dialog.style.pointerEvents = 'auto'; + requestAnimationFrame(() => positionFormatMenu(modal.dialog, anchorBtn)); + } + + function closeActiveFormatMenu() { + if (window.ModalSystem?.isOpen?.(OPTION_MENU_MODAL_ID)) { + window.ModalSystem.close(OPTION_MENU_MODAL_ID); + } + if (activePopover) { + activePopover.formatMenu = null; + activePopover.formatMenuAnchor = null; + activePopover.formatMenuModalId = null; + } + } + + function closeCardTitleMenuOnly() { + if (window.ModalSystem?.isOpen?.(CARD_TITLE_MENU_MODAL_ID)) { + window.ModalSystem.close(CARD_TITLE_MENU_MODAL_ID); + } + if (activePopover) { + activePopover.cardTitleMenu = null; + activePopover.cardTitleMenuAnchor = null; + activePopover.cardTitleMenuModalId = null; + } + } + + function closeCardTitleMenu() { + closeActiveFormatMenu(); + closeCardTitleMenuOnly(); + } + + function trackFormatMenu(modal, anchorBtn, modalId) { + if (!activePopover) return; + activePopover.formatMenu = modal?.dialog || null; + activePopover.formatMenuAnchor = anchorBtn; + activePopover.formatMenuModalId = modalId; + if (modal) { + modal.onClose = () => { + if (activePopover?.formatMenuModalId === modalId) { + activePopover.formatMenu = null; + activePopover.formatMenuAnchor = null; + activePopover.formatMenuModalId = null; + } + }; + } + } + + function trackCardTitleMenu(modal, anchorBtn) { + if (!activePopover) return; + activePopover.cardTitleMenu = modal?.dialog || null; + activePopover.cardTitleMenuAnchor = anchorBtn; + activePopover.cardTitleMenuModalId = CARD_TITLE_MENU_MODAL_ID; + if (modal) { + modal.onClose = () => { + closeActiveFormatMenu(); + if (activePopover?.cardTitleMenuModalId === CARD_TITLE_MENU_MODAL_ID) { + activePopover.cardTitleMenu = null; + activePopover.cardTitleMenuAnchor = null; + activePopover.cardTitleMenuModalId = null; + } + }; + } + } + + /** + * Anchored option menu (card format, border style, spotlight size/tiles) via ModalSystem. + * @param {HTMLElement} anchorBtn + * @param {Array<{value:string,label:string}>} options + * @param {{ + * activeValue?: string, + * onSelect: (value: string) => void, + * colorControls?: { + * value?: string, + * onChange: (value: string) => void, + * onClear: () => void + * } + * }} opts + */ + function openOptionMenu(anchorBtn, options, opts) { + if (!window.ModalSystem?.create) { + WARN('ModalSystem unavailable for option menu'); + return null; + } + closeActiveFormatMenu(); + if (!opts?.preserveCardTitleMenu) { + closeCardTitleMenuOnly(); + } + const active = opts?.activeValue != null ? String(opts.activeValue) : ''; + const colorControls = opts?.colorControls; + const gaplessControls = opts?.gaplessControls; + const colorEnabled = !!active; + const colorValue = colorControls?.value && isCssColorString(colorControls.value) + ? String(colorControls.value).trim() + : '#e8dab2'; + + const renderOption = (opt) => ` + `; + + let optionsHtml; + if (colorControls) { + const headerOpt = (options || [])[0]; + const listOpts = (options || []).slice(1); + optionsHtml = ` +
+ ${headerOpt ? renderOption(headerOpt) : ''} +
+ + +
+
+
+ ${listOpts.map(renderOption).join('')} +
`; + } else if (gaplessControls) { + const gaplessChecked = gaplessControls.value === true; + optionsHtml = ` +
+ + +
+
+ ${(options || []).map(renderOption).join('')} +
`; + } else { + optionsHtml = (options || []).map(renderOption).join(''); + } + + const content = ` +
+ ${optionsHtml} +
+ `; + + let borderPickr = null; + + const modal = window.ModalSystem.create({ + id: OPTION_MENU_MODAL_ID, + title: null, + content, + footer: null, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + styleAnchoredPopoverDialog(modalInstance, anchorBtn); + const root = modalInstance.dialogContent; + const colorRow = root?.querySelector('.kefin-section-format-color-row'); + const colorSwatch = root?.querySelector('.kefin-section-color-swatch'); + const colorReset = root?.querySelector('.kefin-section-color-reset'); + + const syncColorRowEnabled = (styleValue) => { + if (!colorRow) return; + const enabled = !!styleValue; + colorRow.classList.toggle('is-disabled', !enabled); + if (colorReset) colorReset.disabled = !enabled; + if (colorSwatch) colorSwatch.disabled = !enabled; + if (borderPickr) { + if (enabled) borderPickr.enable(); + else borderPickr.disable(); + } + }; + + const syncColorResetVisibility = (hasCustomColor) => { + colorReset?.classList.toggle('is-hidden', !hasCustomColor); + }; + + const setSwatchColor = (css) => { + if (colorSwatch) colorSwatch.style.setProperty('--kefin-swatch-color', css || '#e8dab2'); + }; + + // Lazy-init Pickr on first swatch click so opening the menu never depends on Pickr layout. + const ensureBorderPickr = () => { + if (borderPickr || !colorSwatch) return borderPickr; + borderPickr = createPickrInstance(colorSwatch, { + defaultColor: colorValue, + useAsButton: true, + onChange: (css) => { + setSwatchColor(css); + syncColorResetVisibility(true); + colorControls.onChange?.(css); + }, + onSave: (css) => { + setSwatchColor(css); + syncColorResetVisibility(true); + colorControls.onChange?.(css); + }, + onShow: () => setAnchoredMenuBackdropPassThrough(modalInstance, true), + onHide: () => setAnchoredMenuBackdropPassThrough(modalInstance, false) + }); + if (!colorEnabled) borderPickr?.disable(); + return borderPickr; + }; + + colorSwatch?.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (colorSwatch.disabled) return; + if (!borderPickr) { + const pickr = ensureBorderPickr(); + // Same-event listeners added by Pickr won't run; open on next frame. + requestAnimationFrame(() => pickr?.show()); + } + // After init, Pickr's useAsButton handler toggles open/close. + }); + + root?.querySelectorAll('.kefin-section-format-option').forEach((optionBtn) => { + optionBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + const value = optionBtn.dataset.value ?? ''; + root.querySelectorAll('.kefin-section-format-option').forEach((btn) => { + btn.classList.toggle('is-active', (btn.dataset.value ?? '') === value); + }); + if (colorControls) { + syncColorRowEnabled(value); + if (!value) { + setSwatchColor('#e8dab2'); + try { borderPickr?.setColor('#e8dab2', true); } catch (_) { /* ignore */ } + syncColorResetVisibility(false); + } + } + opts?.onSelect?.(value); + }); + }); + + if (colorControls) { + colorReset?.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (colorReset.disabled) return; + setSwatchColor('#e8dab2'); + try { borderPickr?.setColor('#e8dab2', true); } catch (_) { /* ignore */ } + syncColorResetVisibility(false); + colorControls.onClear?.(); + }); + } + + const gaplessInput = root?.querySelector('.kefin-section-format-gapless-input'); + gaplessInput?.addEventListener('change', (ev) => { + ev.stopPropagation(); + gaplessControls?.onChange?.(!!gaplessInput.checked); + }); + gaplessInput?.addEventListener('click', (ev) => ev.stopPropagation()); + } + }); + trackFormatMenu(modal, anchorBtn, OPTION_MENU_MODAL_ID); + const prevFormatOnClose = modal.onClose; + modal.onClose = () => { + destroyPickrInstance(borderPickr); + borderPickr = null; + prevFormatOnClose?.(); + }; + return modal; + } + + function areCardTitlesVisible(cfg) { + return !(cfg?.cardTitleVisibility === 'hidden' || cfg?.hideCardTitles === true); + } + + function syncCardTitleVisibilityButton(btn, visible) { + if (!btn) return; + const isOn = visible !== false; + const icon = btn.querySelector('.material-icons'); + if (icon) icon.textContent = isOn ? 'visibility' : 'visibility_off'; + btn.classList.toggle('is-hidden-visibility', !isOn); + const label = isOn ? 'Hide card titles' : 'Show card titles'; + btn.title = label; + btn.setAttribute('aria-label', label); + btn.setAttribute('aria-pressed', String(isOn)); + } + + /** + * Anchored Card Title options menu: visibility + color, then nested icon option menus. + */ + function openCardTitleOptionsMenu(anchorBtn, entry, serverDefaults) { + if (!window.ModalSystem?.create) { + WARN('ModalSystem unavailable for card title options'); + return null; + } + closeCardTitleMenu(); + + const cfg = entry.effectiveConfig; + const titlesVisible = areCardTitlesVisible(cfg); + const titleColor = cfg.cardTitleColor && isCssColorString(cfg.cardTitleColor) + ? String(cfg.cardTitleColor).trim() + : '#ffffff'; + const hasCustomColor = !!(cfg.cardTitleColor && String(cfg.cardTitleColor).trim()); + + const content = ` +
+
+ + + +
+ + +
+ + + +
+
+ `; + + let titlePickr = null; + + const modal = window.ModalSystem.create({ + id: CARD_TITLE_MENU_MODAL_ID, + title: null, + content, + footer: null, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + styleAnchoredPopoverDialog(modalInstance, anchorBtn); + const root = modalInstance.dialogContent; + const visibilityBtn = root?.querySelector('.kefin-section-card-title-visibility-btn'); + const colorRow = root?.querySelector('.kefin-section-card-title-color-row'); + const colorBtn = root?.querySelector('.kefin-section-card-title-color-btn'); + const colorReset = root?.querySelector('.kefin-section-card-title-color-reset'); + + const syncColorEnabled = (visible) => { + if (!colorRow || !colorReset) return; + colorRow.classList.toggle('is-disabled', !visible); + colorReset.disabled = !visible; + if (colorBtn) colorBtn.disabled = !visible; + if (titlePickr) { + if (visible) titlePickr.enable(); + else titlePickr.disable(); + } + }; + + const syncColorResetVisibility = (hasColor) => { + colorReset?.classList.toggle('is-hidden', !hasColor); + }; + + const applyTitleChange = () => { + persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); + }; + + // Lazy-init on click — creating Pickr during onOpen was breaking the popover. + const ensureTitlePickr = () => { + if (titlePickr || !colorBtn) return titlePickr; + titlePickr = createPickrInstance(colorBtn, { + defaultColor: titleColor, + useAsButton: true, + onChange: (css) => { + entry.effectiveConfig.cardTitleColor = css; + syncColorResetVisibility(true); + applyTitleChange(); + }, + onSave: (css) => { + entry.effectiveConfig.cardTitleColor = css; + syncColorResetVisibility(true); + applyTitleChange(); + }, + onShow: () => setAnchoredMenuBackdropPassThrough(modalInstance, true), + onHide: () => setAnchoredMenuBackdropPassThrough(modalInstance, false) + }); + if (!titlesVisible) titlePickr?.disable(); + return titlePickr; + }; + + visibilityBtn?.addEventListener('click', (ev) => { + ev.stopPropagation(); + closeActiveFormatMenu(); + const nextVisible = !areCardTitlesVisible(entry.effectiveConfig); + if (nextVisible) { + delete entry.effectiveConfig.cardTitleVisibility; + delete entry.effectiveConfig.hideCardTitles; + } else { + entry.effectiveConfig.cardTitleVisibility = 'hidden'; + delete entry.effectiveConfig.hideCardTitles; + } + syncCardTitleVisibilityButton(visibilityBtn, nextVisible); + syncColorEnabled(nextVisible); + applyTitleChange(); + }); + + colorBtn?.addEventListener('click', (ev) => { + ev.stopPropagation(); + closeActiveFormatMenu(); + if (colorBtn.disabled) return; + if (!titlePickr) { + const pickr = ensureTitlePickr(); + requestAnimationFrame(() => pickr?.show()); + } + // After init, Pickr's useAsButton handler toggles open/close. + }); + + colorReset?.addEventListener('click', (ev) => { + ev.stopPropagation(); + closeActiveFormatMenu(); + if (colorReset.disabled) return; + try { titlePickr?.setColor('#ffffff', true); } catch (_) { /* ignore */ } + delete entry.effectiveConfig.cardTitleColor; + syncColorResetVisibility(false); + applyTitleChange(); + }); + + const openNested = (btn, options, getActiveValue, applyValue) => { + if (!btn) return; + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + openOptionMenu(btn, options, { + preserveCardTitleMenu: true, + activeValue: getActiveValue(), + onSelect: (value) => { + applyValue(value); + applyTitleChange(); + } + }); + }); + }; + + openNested( + root?.querySelector('.kefin-section-card-title-font-btn'), + getCardTitleFontFamilyOptions(), + () => entry.effectiveConfig.cardTitleFontFamily || 'default', + (value) => { + if (value && value !== 'default') entry.effectiveConfig.cardTitleFontFamily = value; + else delete entry.effectiveConfig.cardTitleFontFamily; + } + ); + openNested( + root?.querySelector('.kefin-section-card-title-size-btn'), + getCardTitleFontSizeOptions(), + () => entry.effectiveConfig.cardTitleFontSize || 'normal', + (value) => { + if (value && value !== 'normal') entry.effectiveConfig.cardTitleFontSize = value; + else delete entry.effectiveConfig.cardTitleFontSize; + } + ); + openNested( + root?.querySelector('.kefin-section-card-title-horizontal-btn'), + getCardTitleHorizontalAlignOptions(), + () => parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition).horizontal, + (value) => { + const align = parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition); + setCardTitlePositionFromAlign(entry.effectiveConfig, value, align.vertical); + } + ); + openNested( + root?.querySelector('.kefin-section-card-title-vertical-btn'), + getCardTitleVerticalAlignOptions(), + () => parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition).vertical, + (value) => { + const align = parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition); + setCardTitlePositionFromAlign(entry.effectiveConfig, align.horizontal, value); + } + ); + openNested( + root?.querySelector('.kefin-section-card-title-caps-btn'), + getCardTitleCapitalizationOptions(), + () => entry.effectiveConfig.cardTitleCapitalization || 'normal', + (value) => { + if (value && value !== 'normal') entry.effectiveConfig.cardTitleCapitalization = value; + else delete entry.effectiveConfig.cardTitleCapitalization; + } + ); + } + }); + trackCardTitleMenu(modal, anchorBtn); + const prevCardTitleOnClose = modal.onClose; + modal.onClose = () => { + destroyPickrInstance(titlePickr); + titlePickr = null; + prevCardTitleOnClose?.(); + }; + return modal; + } + + function syncSpotlightLayoutButton(btn, layout) { + if (!btn) return; + const isBorderless = layout === 'Borderless'; + const icon = btn.querySelector('.material-icons'); + if (icon) icon.textContent = isBorderless ? 'crop_landscape' : 'fullscreen'; + const nextLabel = isBorderless ? 'Switch to Border layout' : 'Switch to Borderless layout'; + btn.title = nextLabel; + btn.setAttribute('aria-label', nextLabel); + btn.dataset.layout = isBorderless ? 'Borderless' : 'Border'; + } + + function normalizeSpotlightTileCount(tileCount) { + const n = parseInt(tileCount, 10); + return n === 2 || n === 3 ? n : 1; + } + + function nextSpotlightTileCount(tileCount) { + const current = normalizeSpotlightTileCount(tileCount); + return current === 1 ? 2 : current === 2 ? 3 : 1; + } + + function syncSpotlightTilesButton(btn, tileCount) { + if (!btn) return; + const current = normalizeSpotlightTileCount(tileCount); + const next = nextSpotlightTileCount(current); + const icon = btn.querySelector('.material-icons'); + if (icon) { + icon.textContent = next === 1 ? 'looks_one' : next === 2 ? 'looks_two' : 'looks_3'; + } + const label = next === 1 ? '1 tile' : `${next} tiles`; + btn.title = label; + btn.setAttribute('aria-label', label); + btn.dataset.tileCount = String(current); + } + + function syncVisibilityButton(btn, enabled) { + if (!btn) return; + const isOn = enabled !== false; + const icon = btn.querySelector('.material-icons'); + if (icon) icon.textContent = isOn ? 'visibility' : 'visibility_off'; + btn.classList.toggle('is-hidden-visibility', !isOn); + const label = isOn ? 'Hide section' : 'Show section'; + btn.title = label; + btn.setAttribute('aria-label', label); + btn.setAttribute('aria-pressed', String(isOn)); + } + + function buildSelectOptionsHtml(options, selectedValue) { + return options.map(opt => ` + + `).join(''); + } + + function buildConfigureRow(label, controlHtml, descriptionHtml = '', options = {}) { + const { stacked = false, controlId = '' } = options; + if (stacked) { + const labelFor = controlId ? ` for="${escapeHtml(controlId)}"` : ''; + return ` +
+ + ${descriptionHtml ? `
${descriptionHtml}
` : ''} +
${controlHtml}
+
+ `; + } + return ` +
+
+ ${escapeHtml(label)} + ${descriptionHtml ? `${descriptionHtml}` : ''} +
+
${controlHtml}
+
+ `; + } + + function buildToggleRow(id, label, checked, descriptionOn, descriptionOff) { + const description = checked ? descriptionOn : descriptionOff; + return ` +
+
+
${escapeHtml(label)}
+ ${description ? `
${escapeHtml(description)}
` : ''} +
+
+
+ `; + } + + function persistAndApply(entry, serverDefaults, applyFn) { + scheduleSave(entry.sectionId, entry.effectiveConfig, serverDefaults); + if (typeof applyFn === 'function') applyFn(); + if (activePopover?.entry === entry) { + syncRestoreDefaultsButton( + activePopover.popover?.querySelector('.kefin-section-restore-defaults-btn'), + sectionHasUserOverrides(entry, serverDefaults) + ); + } + } + + function readSectionOrder(el) { + const fromDataset = parseInt(el.dataset.order, 10); + if (!Number.isNaN(fromDataset)) return fromDataset; + const fromStyle = parseInt(el.style.order, 10); + if (!Number.isNaN(fromStyle)) return fromStyle; + return 0; + } + + function getSectionsInVisualOrder(sectionElement) { + const container = sectionElement?.parentElement; + if (!container) return []; + return [...container.querySelectorAll('[data-section-id]')] + .map(el => ({ + element: el, + sectionId: el.dataset.sectionId, + order: readSectionOrder(el), + runtime: runtime.get(el.dataset.sectionId) + })) + .sort((a, b) => { + const topDiff = a.element.getBoundingClientRect().top - b.element.getBoundingClientRect().top; + if (Math.abs(topDiff) > 0.5) return topDiff; + const position = a.element.compareDocumentPosition(b.element); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; + }); + } + + function applySequentialOrders(orderedSections) { + orderedSections.forEach((section, idx) => { + section.element.style.order = idx; + section.element.dataset.order = String(idx); + if (!section.runtime) return; + section.runtime.effectiveConfig.order = idx; + scheduleSave( + section.sectionId, + section.runtime.effectiveConfig, + section.runtime.serverDefaults + ); + }); + if (activePopover?.entry) { + syncRestoreDefaultsButton( + activePopover.popover?.querySelector('.kefin-section-restore-defaults-btn'), + sectionHasUserOverrides(activePopover.entry, activePopover.entry.serverDefaults) + ); + } + } + + function findPageScrollRoot(sectionElement) { + const start = sectionElement?.closest('.homeSectionsContainer')?.parentElement + || sectionElement?.parentElement; + let node = start; + while (node && node !== document.body) { + const style = getComputedStyle(node); + if (/(auto|scroll|overlay)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) { + return node; + } + node = node.parentElement; + } + return null; + } + + function resolveScrollRoot(sectionElement) { + return findPageScrollRoot(sectionElement) || window; + } + + function getScrollY(root) { + if (!root || root === window) { + return window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0; + } + return root.scrollTop || 0; + } + + function setScrollY(root, y) { + const top = Math.max(0, y); + if (!root || root === window) { + try { + window.scrollTo({ top, left: 0, behavior: 'instant' }); + } catch (_) { + // Older engines may not accept the options object / 'instant'. + window.scrollTo(0, top); + } + return; + } + root.scrollTop = top; + } + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function prefersReducedMotion() { + return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; + } + + function captureSectionRects(elements) { + return new Map(elements.map(el => [el, el.getBoundingClientRect()])); + } + + function setOverflowAnchorNone(elements) { + elements.forEach((el) => { + if (!el) return; + if (!el.hasAttribute('data-kefin-overflow-anchor')) { + el.setAttribute('data-kefin-overflow-anchor', el.style.overflowAnchor || ''); + } + el.style.overflowAnchor = 'none'; + }); + } + + function clearOverflowAnchorNone(elements) { + elements.forEach((el) => { + if (!el?.hasAttribute('data-kefin-overflow-anchor')) return; + const prev = el.getAttribute('data-kefin-overflow-anchor') || ''; + el.style.overflowAnchor = prev; + el.removeAttribute('data-kefin-overflow-anchor'); + }); + } + + function setSectionFlipStyles(el, invertY, isPrimary) { + el.style.willChange = 'transform'; + el.style.transition = 'none'; + el.style.transform = `translateY(${invertY}px)`; + el.style.position = 'relative'; + el.style.zIndex = isPrimary ? '3' : '2'; + } + + function clearSectionFlipStyles(el) { + el.style.transform = ''; + el.style.transition = ''; + el.style.willChange = ''; + el.style.position = ''; + el.style.zIndex = ''; + } + + function animateSectionReorderFlip({ primaryEl, elements, firstRects, scrollRoot, startScroll, anchorEls }) { + void primaryEl.offsetHeight; + + const invertPrimary = firstRects.get(primaryEl).top - primaryEl.getBoundingClientRect().top; + const scrollDelta = -invertPrimary; + const duration = 320; + const flipItems = (elements || []) + .filter(Boolean) + .map((el) => ({ + el, + invertY: firstRects.get(el).top - el.getBoundingClientRect().top, + isPrimary: el === primaryEl + })) + .filter(({ invertY, isPrimary }) => isPrimary || Math.abs(invertY) > 0.5); + + flipItems.forEach(({ el, invertY, isPrimary }) => setSectionFlipStyles(el, invertY, isPrimary)); + if (activePopover) activePopover.isAnimating = true; + + const startTime = performance.now(); + + return new Promise((resolve) => { + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + flipItems.forEach(({ el }) => clearSectionFlipStyles(el)); + clearOverflowAnchorNone(anchorEls || []); + if (activePopover) activePopover.isAnimating = false; + resolve(); + }; + + const frame = (now) => { + if (finished) return; + const t = Math.min(1, (now - startTime) / duration); + const eased = easeOutCubic(t); + + flipItems.forEach(({ el, invertY }) => { + el.style.transform = `translateY(${invertY * (1 - eased)}px)`; + }); + setScrollY(scrollRoot, startScroll + scrollDelta * eased); + + if (t < 1) requestAnimationFrame(frame); + else finish(); + }; + + // First sample immediately so transform + scroll stay locked from frame 0. + //frame(startTime); + requestAnimationFrame(frame); + }); + } + + function swapSectionOrder(sectionId, direction) { + const entry = getRuntime(sectionId); + if (!entry?.element) return; + if (activePopover?.isAnimating) return; + + const ordered = getSectionsInVisualOrder(entry.element); + const index = ordered.findIndex(s => s.sectionId === sectionId); + if (index === -1) return; + + const swapIndex = direction === 'up' ? index - 1 : index + 1; + if (swapIndex < 0 || swapIndex >= ordered.length) return; + + const currentEl = ordered[index].element; + const partnerEl = ordered[swapIndex].element; + const container = currentEl.parentElement; + const scrollRoot = resolveScrollRoot(currentEl); + const startScroll = getScrollY(scrollRoot); + const anchorEls = [container, currentEl, partnerEl].filter(Boolean); + + const firstRects = captureSectionRects([currentEl, partnerEl]); + + setOverflowAnchorNone(anchorEls); + [ordered[index], ordered[swapIndex]] = [ordered[swapIndex], ordered[index]]; + applySequentialOrders(ordered); + + if (prefersReducedMotion()) { + const delta = firstRects.get(currentEl).top - currentEl.getBoundingClientRect().top; + setScrollY(scrollRoot, getScrollY(scrollRoot) - delta); + void currentEl.offsetHeight; + clearOverflowAnchorNone(anchorEls); + return; + } + + animateSectionReorderFlip({ + primaryEl: currentEl, + elements: [currentEl, partnerEl], + firstRects, + scrollRoot, + startScroll, + anchorEls + }); + } + + function isDiscoverySectionElement(el) { + return el?.dataset?.discoverySection === 'true'; + } + + function moveSectionToBoundary(sectionId, boundary) { + const entry = getRuntime(sectionId); + if (!entry?.element) return; + if (activePopover?.isAnimating) return; + + const ordered = getSectionsInVisualOrder(entry.element); + const index = ordered.findIndex(s => s.sectionId === sectionId); + if (index === -1) return; + + const next = ordered.slice(); + const [item] = next.splice(index, 1); + if (!item) return; + + if (boundary === 'top') { + next.unshift(item); + } else { + let lastNonDiscovery = -1; + for (let i = 0; i < next.length; i++) { + if (isDiscoverySectionElement(next[i].element)) continue; + lastNonDiscovery = i; + } + next.splice(lastNonDiscovery + 1, 0, item); + } + + const targetIndex = next.findIndex(s => s.sectionId === sectionId); + if (targetIndex === index) return; + + const currentEl = item.element; + const movingEls = next.map(s => s.element).filter(Boolean); + const container = currentEl.parentElement; + const scrollRoot = resolveScrollRoot(currentEl); + const startScroll = getScrollY(scrollRoot); + const anchorEls = [container, ...movingEls].filter(Boolean); + const firstRects = captureSectionRects(movingEls); + + setOverflowAnchorNone(anchorEls); + applySequentialOrders(next); + + if (prefersReducedMotion()) { + const delta = firstRects.get(currentEl).top - currentEl.getBoundingClientRect().top; + setScrollY(scrollRoot, getScrollY(scrollRoot) - delta); + void currentEl.offsetHeight; + clearOverflowAnchorNone(anchorEls); + return; + } + + animateSectionReorderFlip({ + primaryEl: currentEl, + elements: movingEls, + firstRects, + scrollRoot, + startScroll, + anchorEls + }); + } + + const LONG_PRESS_MS = 450; + + function bindOrderButton(button, { onClick, onLongPress }) { + if (!button) return; + let timer = null; + let longPressed = false; + let activePointerId = null; + + const clearTimer = () => { + if (timer != null) { + clearTimeout(timer); + timer = null; + } + }; + + button.addEventListener('pointerdown', (e) => { + if (e.pointerType === 'mouse' && e.button !== 0) return; + longPressed = false; + activePointerId = e.pointerId; + clearTimer(); + timer = setTimeout(() => { + timer = null; + longPressed = true; + try { + button.setPointerCapture?.(e.pointerId); + } catch (_) { /* ignore */ } + onLongPress?.(e); + }, LONG_PRESS_MS); + }); + + const endPress = (e) => { + if (activePointerId != null && e.pointerId !== activePointerId) return; + clearTimer(); + activePointerId = null; + try { + if (button.hasPointerCapture?.(e.pointerId)) { + button.releasePointerCapture(e.pointerId); + } + } catch (_) { /* ignore */ } + }; + + button.addEventListener('pointerup', endPress); + button.addEventListener('pointercancel', endPress); + button.addEventListener('lostpointercapture', endPress); + button.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + if (longPressed) { + longPressed = false; + return; + } + onClick?.(e); + }); + } + + function rerenderNormalSection(entry) { + rerenderSection(entry); + } + + function setSpotlightAnimation(entry, enabled) { + entry.effectiveConfig.spotlightConfig = { + ...(entry.effectiveConfig.spotlightConfig || {}), + panAnimation: enabled + }; + entry.element.dataset.panAnimation = enabled ? 'true' : 'false'; + + const imgs = entry.element.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img'); + imgs.forEach(img => { + img.style.transform = ''; + img.classList.remove('animate'); + if (enabled) { + img.classList.add('animate'); + } + }); + } + + async function refreshSectionCache(entry) { + if (!window.cardBuilder?.refreshSectionQueries) return; + try { + entry.element.dataset.refreshing = 'true'; + const freshItems = await window.cardBuilder.refreshSectionQueries(entry.effectiveConfig); + entry.cachedItems = freshItems; + entry.effectiveConfig.ttl = entry.effectiveConfig.ttl; + rerenderSection(entry); + entry.element.dataset.refreshing = 'false'; + } catch (e) { + WARN('Cache refresh failed:', e); + entry.element.dataset.refreshing = 'false'; + } + } + + function getConfigureAnchorButton(entry) { + return entry?.element?.querySelector('.section-configure-button') || null; + } + + function resolvePopoverMount(anchorButton, sectionElement) { + return anchorButton?.closest( + '.sectionTitleContainer, .spotlight-section-title-container' + ) || sectionElement; + } + + function preparePopoverMount(anchorButton, sectionElement) { + const mountElement = resolvePopoverMount(anchorButton, sectionElement); + mountElement.classList.add('kefin-section-has-configure-popover'); + sectionElement?.classList.add('kefin-section-configure-popover-open'); + return mountElement; + } + + function cleanupPopoverMount(mountElement, sectionElement) { + mountElement?.classList.remove('kefin-section-has-configure-popover'); + sectionElement?.classList.remove('kefin-section-configure-popover-open'); + } + + function repositionActivePopover() { + if (!activePopover || activePopover.isAnimating) return; + // Once placed, never move — scrolling/resize/reorder must not shift targets mid-click. + if (activePopover.placementLocked) return; + const anchor = activePopover.anchorButton || getConfigureAnchorButton(activePopover.entry); + const mount = activePopover.mountElement + || resolvePopoverMount(anchor, activePopover.entry?.element); + if (!anchor || !mount || !document.body.contains(anchor) || !document.body.contains(mount)) return; + activePopover.anchorButton = anchor; + activePopover.mountElement = mount; + if (activePopover.entry?.element) { + activePopover.entry.element = anchor.closest('[data-section-id]') || activePopover.entry.element; + activePopover.sectionElement = activePopover.entry.element; + } + positionConfigurePopover(activePopover.popover, anchor, mount); + activePopover.placementLocked = true; + if (activePopover.formatMenu && activePopover.formatMenuAnchor) { + positionFormatMenu(activePopover.formatMenu, activePopover.formatMenuAnchor); + } + } + + function startPopoverTracking() { + // Placement is fixed for the life of the open popover. + return () => {}; + } + + function closePopover() { + if (!activePopover) return; + closeCardTitleMenu(); + if (window.ModalSystem?.isOpen?.(RESTORE_SECTION_MODAL_ID)) { + window.ModalSystem.close(RESTORE_SECTION_MODAL_ID); + } + if (window.ModalSystem?.isOpen?.(PUBLISH_SECTION_MODAL_ID)) { + window.ModalSystem.close(PUBLISH_SECTION_MODAL_ID); + } + const { popover, entry, stopTracking, mountElement, sectionElement } = activePopover; + stopTracking?.(); + popover.remove(); + cleanupPopoverMount(mountElement, sectionElement || entry?.element); + document.removeEventListener('click', activePopover.outsideHandler, true); + pickrGestureFromApp = false; + activePopover = null; + } + + /** + * Fade out, remove from DOM, stash for undo, and show restore toast. + */ + function hideSectionFromHome(entry, serverDefaults) { + const el = entry?.element; + if (!el) return; + const parent = el.parentNode; + const nextSibling = el.nextSibling; + const defaults = serverDefaults || entry.serverDefaults; + el.style.transition = 'opacity 0.35s ease'; + el.style.opacity = '0'; + setTimeout(() => { + if (parent) { + pendingHideStash = { + entry, + parent, + nextSibling, + serverDefaults: defaults + }; + } + el.remove(); + runtime.delete(entry.sectionId); + showHiddenSectionToast(entry, defaults); + }, 350); + } + + function navigateToHomeScreenPreferences() { + const href = 'mypreferenceshome.html'; + if (window.Dashboard?.navigate) { + window.Dashboard.navigate(href); + return; + } + window.location.hash = `#/${href}`; + } + + function showHiddenSectionToast(entry, serverDefaults) { + const toaster = window.KefinTweaksToaster; + if (!toaster?.toast) return; + const handle = toaster.toast( + 'You can restore this section in your user Home Screen settings.', + '8', + true, + { + link: { + label: 'user Home Screen settings', + href: 'mypreferenceshome.html', + onClick: navigateToHomeScreenPreferences + }, + action: { + icon: 'undo', + label: 'Undo', + onClick: () => { + undoHiddenSection(entry, serverDefaults); + handle?.dismiss?.(); + } + } + } + ); + } + + function undoHiddenSection(entry, serverDefaults) { + if (!entry?.effectiveConfig) return; + const defaults = serverDefaults || entry.serverDefaults || {}; + entry.effectiveConfig.enabled = true; + scheduleSave(entry.sectionId, entry.effectiveConfig, defaults); + + const stash = pendingHideStash?.entry?.sectionId === entry.sectionId + ? pendingHideStash + : null; + pendingHideStash = null; + + const el = entry.element; + if (!el) { + window.homeScreen3?.refreshHomeSections?.(); + return; + } + + el.style.transition = ''; + el.style.opacity = '1'; + + const parent = stash?.parent; + const nextSibling = stash?.nextSibling; + const parentStillValid = parent && document.contains(parent); + if (parentStillValid) { + if (nextSibling && nextSibling.parentNode === parent) { + parent.insertBefore(el, nextSibling); + } else { + parent.appendChild(el); + } + registerRuntime(entry); + return; + } + + scheduleSave(entry.sectionId, entry.effectiveConfig, defaults); + window.homeScreen3?.refreshHomeSections?.(); + } + + function getPopoverPlacementHeight(popover, expanded) { + const footer = popover.querySelector('.kefin-section-configure-footer'); + const inner = popover.querySelector('.kefin-section-configure-more-panel-inner'); + const separator = popover.querySelector('.kefin-section-configure-separator'); + const styles = getComputedStyle(popover); + const paddingV = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.paddingBottom) || 0); + const footerHeight = footer?.offsetHeight || 0; + let moreHeight = 0; + if (expanded && inner) { + const fontSize = parseFloat(styles.fontSize) || 16; + moreHeight = inner.scrollHeight + (0.65 * fontSize); + if (separator) { + const sepStyles = getComputedStyle(separator); + moreHeight += (separator.offsetHeight || 0) + + (parseFloat(sepStyles.marginTop) || 0) + + (parseFloat(sepStyles.marginBottom) || 0); + } + } + return paddingV + footerHeight + moreHeight; + } + + function positionConfigurePopover(popover, anchorButton, mountElement) { + const sectionElement = mountElement.closest('[data-section-id]') + || activePopover?.entry?.element + || mountElement; + const mountRect = mountElement.getBoundingClientRect(); + const sectionRect = sectionElement.getBoundingClientRect(); + const buttonRect = anchorButton.getBoundingClientRect(); + const popoverWidth = popover.offsetWidth || popover.getBoundingClientRect().width; + const margin = 8; + const gap = 8; + + // Center on the configure button in viewport space first. + let leftViewport = buttonRect.left + (buttonRect.width / 2) - (popoverWidth / 2); + + // Keep inside the section when possible; always keep inside the viewport. + const minLeft = Math.max(sectionRect.left + margin, margin); + const maxLeft = Math.min(sectionRect.right - popoverWidth - margin, window.innerWidth - popoverWidth - margin); + + if (maxLeft >= minLeft) { + leftViewport = Math.min(Math.max(leftViewport, minLeft), maxLeft); + } else { + // Section narrower than popover (common when section name is hidden): + // pin to the section's left edge, then clamp to the viewport. + leftViewport = Math.min( + Math.max(sectionRect.left + margin, margin), + Math.max(margin, window.innerWidth - popoverWidth - margin) + ); + } + + popover.style.left = `${leftViewport - mountRect.left}px`; + + // Spotlights: always open below so the popover stays inside the banner + // (overflow/mask on .spotlight-banner-container would clip anything above). + // Normal sections: decide once from expanded height; never flip after open. + let placeBelow; + if (activePopover?.anchorMode === 'above' || activePopover?.anchorMode === 'below') { + placeBelow = activePopover.anchorMode === 'below'; + } else { + const isSpotlight = !!mountElement.closest('.spotlight-section') + || activePopover?.entry?.element?.classList.contains('spotlight-section'); + const expandedHeight = getPopoverPlacementHeight(popover, true); + const spaceAbove = mountRect.top - margin; + placeBelow = isSpotlight || spaceAbove < expandedHeight + gap; + if (activePopover) activePopover.anchorMode = placeBelow ? 'below' : 'above'; + } + + popover.classList.toggle('is-below', placeBelow); + popover.style.top = ''; + popover.style.bottom = ''; + } + + function openConfigurePopover(sectionConfig, sectionElement, anchorButton) { + if (!isEnabled()) return; + closePopover(); + + const api = getConfigApi(); + const serverDefaults = sectionConfig._userPrefDefaults + || api.getServerSectionDefaults(sectionConfig); + const entry = getRuntime(sectionConfig.id) || { + sectionId: sectionConfig.id, + element: sectionElement, + serverConfig: { ...sectionConfig }, + effectiveConfig: { ...sectionConfig }, + cachedItems: null, + serverDefaults + }; + if (!entry.effectiveConfig.spotlightConfig) { + entry.effectiveConfig.spotlightConfig = { ...(sectionConfig.spotlightConfig || {}) }; + } + entry.serverDefaults = serverDefaults; + registerRuntime(entry); + + const cfg = entry.effectiveConfig; + const spotlight = cfg.spotlightConfig || {}; + const isSpotlight = isSpotlightSection(cfg); + const isPinnedParent = sectionConfig.id?.startsWith('pinned-parent-'); + const sectionDirty = sectionHasUserOverrides(entry, serverDefaults); + + const currentTtl = cfg.ttl ?? serverDefaults.ttl; + const cacheOptions = buildCacheSelectOptionsHtml(currentTtl); + + const moreModeRows = ` +
+
+ +
+
+ +
+
+ ${buildToggleRow( + 'hide-section-name', + 'Hide Section Name', + cfg.hideName === true, + HIDE_NAME_DESCRIPTIONS.on, + HIDE_NAME_DESCRIPTIONS.off + )} + ${isSpotlight + ? buildToggleRow( + 'spotlight-animate', + 'Animate Slides', + spotlight.panAnimation !== false, + 'Apply pan and zoom animations to each slide.', + 'Slides will remain static without animation.' + ) + : ''} + `; + + const spotlightLayout = spotlight.spotlightLayout || 'Border'; + const spotlightTileCount = normalizeSpotlightTileCount(spotlight.tileCount); + const spotlightNextTileCount = nextSpotlightTileCount(spotlightTileCount); + const spotlightTilesIcon = spotlightNextTileCount === 1 + ? 'looks_one' + : spotlightNextTileCount === 2 + ? 'looks_two' + : 'looks_3'; + const spotlightTilesLabel = spotlightNextTileCount === 1 + ? '1 tile' + : `${spotlightNextTileCount} tiles`; + const popover = document.createElement('div'); + popover.className = 'kefin-section-configure-popover dialog'; + popover.innerHTML = ` +
+
+

${escapeHtml(sectionConfig.name)}

+ + +
+ +
+
+
+ ${moreModeRows} +
+
+ + + `; + + const mountElement = preparePopoverMount(anchorButton, sectionElement); + mountElement.appendChild(popover); + applyPresentationToElement(entry); + + const openEditorBtn = popover.querySelector('.kefin-section-open-editor-btn'); + const publishBtn = popover.querySelector('.kefin-section-publish-btn'); + if (openEditorBtn) { + openEditorBtn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + closePopover(); + try { + await window.KefinHomeScreen?.openSectionEditorForId?.(sectionConfig.id); + } catch (err) { + WARN('Open section editor failed:', err); + } + }); + } + if (publishBtn) { + publishBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const presentation = buildPrefOverrides(entry.effectiveConfig); + const storedId = resolveStoredSectionPrefId(sectionConfig.id, entry.effectiveConfig); + openPublishSectionDefaultsConfirm({ + onOk: async () => { + const ok = await window.KefinHomeScreen?.publishSectionPresentationDefaults?.(storedId, presentation); + if (!ok) throw new Error('Failed to publish section defaults'); + closePopover(); + }, + onUpdate: async () => { + const ok = await window.KefinHomeScreen?.publishSectionPresentationDefaults?.(storedId, presentation); + if (!ok) throw new Error('Failed to publish section defaults'); + await getConfigApi()?.updateUserHomeScreenSectionConfiguration?.(storedId); + closePopover(); + } + }); + }); + } + void (async () => { + try { + const canEdit = await window.KefinHomeScreen?.canOpenSectionEditor?.(sectionConfig.id); + if (!canEdit) return; + if (openEditorBtn?.isConnected) openEditorBtn.hidden = false; + if (publishBtn?.isConnected) publishBtn.hidden = false; + } catch (err) { + WARN('Section editor eligibility check failed:', err); + } + })(); + + const updateToggleRowDescription = (toggleKey, descriptions, enabled) => { + const row = popover.querySelector(`[data-toggle-row="${toggleKey}"]`) + || popover.querySelector(`[data-toggle-for="${toggleKey}"]`)?.closest('.kefin-section-configure-toggle-card, .kefin-section-configure-row'); + const desc = row?.querySelector('.kefin-section-configure-row-desc, .kefin-section-configure-toggle-card-desc'); + if (desc) desc.textContent = enabled ? descriptions.on : descriptions.off; + }; + + const bindRowToggle = (toggleKey, getValue, setValue, descriptions, onApply) => { + const container = popover.querySelector(`[data-toggle-for="${toggleKey}"]`); + if (!container) return; + const toggleId = `kefin-cfg-${toggleKey}-${sectionConfig.id}`; + container.innerHTML = buildMinimalToggleSwitch(toggleId, getValue()); + bindToggleSwitch(container.querySelector('.kefin-toggle-switch'), (next) => { + setValue(next); + if (descriptions) updateToggleRowDescription(toggleKey, descriptions, next); + persistAndApply(entry, serverDefaults, onApply); + }); + }; + + popover.querySelector('.kefin-section-visibility-btn')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const willEnable = entry.effectiveConfig.enabled === false; + entry.effectiveConfig.enabled = willEnable; + scheduleSave(sectionConfig.id, entry.effectiveConfig, serverDefaults); + + if (!willEnable) { + closePopover(); + hideSectionFromHome(entry, serverDefaults); + return; + } + + syncVisibilityButton(popover.querySelector('.kefin-section-visibility-btn'), true); + syncRestoreDefaultsButton( + popover.querySelector('.kefin-section-restore-defaults-btn'), + sectionHasUserOverrides(entry, serverDefaults) + ); + }); + + bindRowToggle( + 'hide-section-name', + () => cfg.hideName === true, + (next) => { entry.effectiveConfig.hideName = next ? true : undefined; if (!next) delete entry.effectiveConfig.hideName; }, + HIDE_NAME_DESCRIPTIONS, + () => applyPresentationToElement(entry) + ); + + bindRowToggle( + 'spotlight-animate', + () => spotlight.panAnimation !== false, + (next) => { + entry.effectiveConfig.spotlightConfig = { + ...(entry.effectiveConfig.spotlightConfig || {}), + panAnimation: next + }; + }, + null, + () => setSpotlightAnimation(entry, entry.effectiveConfig.spotlightConfig.panAnimation !== false) + ); + + const cacheSelect = popover.querySelector('.kefin-section-cache-select'); + if (cacheSelect && currentTtl != null) { + const matched = findCachePresetForTtl(currentTtl); + cacheSelect.value = String(matched ? Number(matched.ms) : Number(currentTtl)); + } + cacheSelect?.addEventListener('change', () => { + entry.effectiveConfig.ttl = parseInt(cacheSelect.value, 10); + persistAndApply(entry, serverDefaults, () => refreshSectionCache(entry)); + }); + + const formatBtn = popover.querySelector('.kefin-section-format-btn'); + formatBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + openOptionMenu( + formatBtn, + getCardFormats().map((fmt) => ({ value: fmt, label: fmt })), + { + activeValue: entry.effectiveConfig.cardFormat || '', + onSelect: (value) => { + if (value) entry.effectiveConfig.cardFormat = value; + else delete entry.effectiveConfig.cardFormat; + persistAndApply(entry, serverDefaults, () => rerenderSection(entry)); + }, + gaplessControls: { + value: entry.effectiveConfig.useGaplessCards === true, + onChange: (checked) => { + if (checked) entry.effectiveConfig.useGaplessCards = true; + else delete entry.effectiveConfig.useGaplessCards; + updateRuntimeUseGaplessCards(entry.sectionId, checked === true); + persistAndApply(entry, serverDefaults, () => { + const layout = window.cardBuilder?.resolveItemsLayout?.(entry.effectiveConfig) || 'row'; + window.cardBuilder?.applyItemsLayoutState?.(entry.element, layout, checked === true); + }); + } + } + } + ); + }); + + const borderStyleBtn = popover.querySelector('.kefin-section-border-style-btn'); + borderStyleBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + openOptionMenu(borderStyleBtn, BORDER_STYLE_OPTIONS, { + activeValue: entry.effectiveConfig.borderStyle || '', + onSelect: (value) => { + if (value) entry.effectiveConfig.borderStyle = value; + else { + delete entry.effectiveConfig.borderStyle; + delete entry.effectiveConfig.borderColor; + } + persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); + }, + colorControls: { + value: entry.effectiveConfig.borderColor || '', + onChange: (value) => { + entry.effectiveConfig.borderColor = value; + persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); + }, + onClear: () => { + delete entry.effectiveConfig.borderColor; + persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); + } + } + }); + }); + + const cardTitleBtn = popover.querySelector('.kefin-section-card-title-btn'); + cardTitleBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + openCardTitleOptionsMenu(cardTitleBtn, entry, serverDefaults); + }); + + const spotlightLayoutBtn = popover.querySelector('.kefin-section-spotlight-layout-btn'); + if (spotlightLayoutBtn) { + syncSpotlightLayoutButton( + spotlightLayoutBtn, + entry.effectiveConfig.spotlightConfig?.spotlightLayout || 'Border' + ); + spotlightLayoutBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = entry.effectiveConfig.spotlightConfig?.spotlightLayout || 'Border'; + const next = current === 'Borderless' ? 'Border' : 'Borderless'; + entry.effectiveConfig.spotlightConfig = { + ...(entry.effectiveConfig.spotlightConfig || {}), + spotlightLayout: next + }; + syncSpotlightLayoutButton(spotlightLayoutBtn, next); + persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); + }); + } + + const spotlightSizeBtn = popover.querySelector('.kefin-section-spotlight-size-btn'); + spotlightSizeBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + openOptionMenu(spotlightSizeBtn, getSpotlightSizeOptions(), { + activeValue: entry.effectiveConfig.spotlightConfig?.spotlightSize || 'normal', + onSelect: (value) => { + entry.effectiveConfig.spotlightConfig = { + ...(entry.effectiveConfig.spotlightConfig || {}), + spotlightSize: value + }; + persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); + } + }); + }); + + const spotlightTilesBtn = popover.querySelector('.kefin-section-spotlight-tiles-btn'); + if (spotlightTilesBtn) { + syncSpotlightTilesButton( + spotlightTilesBtn, + entry.effectiveConfig.spotlightConfig?.tileCount || 1 + ); + spotlightTilesBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = normalizeSpotlightTileCount( + entry.effectiveConfig.spotlightConfig?.tileCount + ); + const next = nextSpotlightTileCount(current); + entry.effectiveConfig.spotlightConfig = { + ...(entry.effectiveConfig.spotlightConfig || {}), + tileCount: next + }; + syncSpotlightTilesButton(spotlightTilesBtn, next); + persistAndApply(entry, serverDefaults, () => rerenderSection(entry)); + }); + } + + bindOrderButton(popover.querySelector('.kefin-section-order-up'), { + onClick: () => swapSectionOrder(sectionConfig.id, 'up'), + onLongPress: () => moveSectionToBoundary(sectionConfig.id, 'top') + }); + bindOrderButton(popover.querySelector('.kefin-section-order-down'), { + onClick: () => swapSectionOrder(sectionConfig.id, 'down'), + onLongPress: () => moveSectionToBoundary(sectionConfig.id, 'bottomNonDiscovery') + }); + + popover.querySelector('.kefin-section-unpin-button')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + btn.disabled = true; + try { + closePopover(); + const ok = window.KefinHomeScreenPin?.unpinParentSection + && await window.KefinHomeScreenPin.unpinParentSection(sectionConfig.id, entry.element); + if (!ok) { + btn.disabled = false; + } + } catch (err) { + WARN('Unpin failed:', err); + btn.disabled = false; + } + }); + + popover.querySelector('.kefin-section-restore-defaults-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + if (!sectionHasUserOverrides(entry, serverDefaults)) return; + openRestoreSectionDefaultsConfirm(() => restoreSectionToServerDefaults(entry, serverDefaults)); + }); + + popover.querySelector('.kefin-section-more-toggle')?.addEventListener('click', (e) => { + e.stopPropagation(); + const willExpand = !popover.classList.contains('is-expanded'); + popover.classList.toggle('is-expanded', willExpand); + const moreBtn = popover.querySelector('.kefin-section-more-toggle'); + if (moreBtn) moreBtn.setAttribute('aria-expanded', String(willExpand)); + }); + + const outsideHandler = (e) => { + // Drag started in Pickr but released outside — not a real dismiss click + if (pickrGestureFromApp) return; + if (popover.contains(e.target) || anchorButton.contains(e.target)) return; + // Pickr mounts its app on document.body (outside our modals) + if (e.target.closest?.('.pcr-app, .pickr, .kefin-pickr-app')) return; + const modalHost = e.target.closest?.('[data-modal-id]'); + const modalId = modalHost?.getAttribute('data-modal-id'); + if (modalId === OPTION_MENU_MODAL_ID || modalId === CARD_TITLE_MENU_MODAL_ID || modalId === RESTORE_SECTION_MODAL_ID || modalId === PUBLISH_SECTION_MODAL_ID) return; + if (e.target.closest( + '.kefin-section-format-btn, .kefin-section-border-style-btn, .kefin-section-card-title-btn, .kefin-section-spotlight-layout-btn, .kefin-section-spotlight-size-btn, .kefin-section-spotlight-tiles-btn' + )) return; + closePopover(); + }; + setTimeout(() => document.addEventListener('click', outsideHandler, true), 0); + + const stopTracking = startPopoverTracking(); + + activePopover = { + popover, + entry, + anchorButton, + mountElement, + sectionElement, + outsideHandler, + stopTracking, + formatMenu: null, + formatMenuAnchor: null, + formatMenuModalId: null, + placementLocked: false, + anchorMode: null + }; + + requestAnimationFrame(() => repositionActivePopover()); + } + + function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function createConfigureButton(sectionConfig, sectionElement) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'section-configure-button material-icons settings'; + button.title = 'Configure Section'; + button.setAttribute('aria-label', 'Configure Section'); + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openConfigurePopover(sectionConfig, sectionElement, button); + }); + return button; + } + + function getConfigureSectionControl(sectionConfig, sectionElement, cachedItems) { + if (!isEnabled() || sectionConfig.userConfigurable === false) return null; + + const api = getConfigApi(); + const serverDefaults = sectionConfig._userPrefDefaults + || api.getServerSectionDefaults(sectionConfig); + + registerRuntime({ + sectionId: sectionConfig.id, + element: sectionElement, + serverConfig: { ...sectionConfig }, + effectiveConfig: { ...sectionConfig }, + cachedItems: cachedItems || null, + serverDefaults + }); + + return { + id: 'configure', + icon: 'settings', + label: 'Configure Section', + className: 'section-configure-button', + isVisible: () => true, + onClick: (e, anchorButton) => { + e.preventDefault(); + e.stopPropagation(); + openConfigurePopover(sectionConfig, sectionElement, anchorButton); + } + }; + } + + function attachSectionControls(sectionConfig, sectionElement, cachedItems, mountContainer) { + if (!isEnabled() || sectionConfig.userConfigurable === false) return; + getConfigureSectionControl(sectionConfig, sectionElement, cachedItems); + } + + const style = document.createElement('style'); + style.textContent = ` + .custom-scroller-container:hover .show-all-button, + .custom-scroller-container:hover .section-refresh-button, + .custom-scroller-container:hover .section-configure-button, + .custom-scroller-container:hover .section-controls-more, + .emby-scroller-container:hover .show-all-button, + .emby-scroller-container:hover .section-refresh-button, + .emby-scroller-container:hover .section-configure-button, + .emby-scroller-container:hover .section-controls-more, + .spotlight-section:hover .section-configure-button, + .spotlight-section:hover .section-refresh-button, + .spotlight-section:hover .section-controls-more { + opacity: 0.65; + visibility: visible; + pointer-events: auto; + z-index: 1; + transition: + opacity 0.25s ease, + visibility 0s linear 0s, + transform 0.3s ease, + color 0.3s ease, + background 0.2s ease; + } + .spotlight-section .section-refresh-button, + .spotlight-section .section-configure-button { + opacity: 0; + visibility: hidden; + pointer-events: none; + } + .kefin-section-configure-popover-open { + overflow: visible; + } + .kefin-section-configure-popover { + position: absolute; + bottom: calc(100% - 15px); + top: auto; + z-index: 12000; + box-sizing: border-box; + display: flex; + flex-direction: column; + width: auto; + min-width: 14rem; + max-width: min(100vw - 16px, 380px); + padding: 0.5em; + overflow: hidden; + } + .kefin-section-configure-popover.is-below { + top: 55px; + bottom: auto; + flex-direction: column-reverse; + } + .kefin-section-configure-popover.is-below .kefin-section-configure-title-container { + order: 1; + } + .kefin-section-configure-title-container hr { + display: block; + } + .kefin-section-configure-footer { + flex-shrink: 0; + } + .kefin-section-configure-separator { + display: none; + width: 100%; + margin: 0.35em 0; + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.14); + } + .kefin-section-configure-popover.is-expanded .kefin-section-configure-separator { + display: block; + } + .kefin-section-configure-more-panel { + flex-shrink: 0; + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.25s ease; + } + .kefin-section-configure-popover.is-expanded .kefin-section-configure-more-panel { + grid-template-rows: 1fr; + } + .kefin-section-configure-more-panel-inner { + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0.75em; + padding-bottom: 0; + padding-top: 0; + transition: padding-bottom 0.25s ease, padding-top 0.25s ease; + } + .kefin-section-configure-popover.is-expanded:not(.is-below) .kefin-section-configure-more-panel-inner { + padding: 0.35em; + } + .kefin-section-configure-popover.is-expanded.is-below .kefin-section-configure-more-panel-inner { + padding: 0.35em; + } + .kefin-section-configure-primary-row { + display: flex; + align-items: center; + gap: 0.2em; + } + .kefin-section-configure-order { + display: flex; + gap: 0; + align-items: center; + } + .kefin-section-configure-primary-row .paper-icon-button-light { + margin: 0; + min-width: 2.25rem; + width: 2.25rem; + height: 2.25rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + .kefin-section-configure-primary-row .paper-icon-button-light .material-icons { + font-size: 1.25rem; + } + .kefin-section-configure-primary-row .kefin-section-restore-defaults-btn { + margin-left: auto; + } + .kefin-section-visibility-btn.is-hidden-visibility { + opacity: 0.45; + } + .kefin-section-restore-defaults-btn.is-disabled, + .kefin-section-restore-defaults-btn:disabled { + opacity: 0.4; + pointer-events: none; + } + .kefin-section-more-toggle[aria-expanded="true"] { + opacity: 1; + } + .kefin-section-configure-field { + display: flex; + flex-direction: column; + gap: 0.25em; + width: 100%; + min-width: 0; + } + .kefin-section-configure-field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65em 0.75em; + width: 100%; + } + .kefin-section-configure-field-label { + display: block; + margin-bottom: 0; + font-size: 0.9em; + line-height: 1.25; + } + .kefin-section-configure-field-desc { + font-size: 0.78em; + line-height: 1.35; + } + .kefin-section-configure-field-control { + width: 100%; + } + .kefin-section-configure-field-control .emby-input, + .kefin-section-configure-field-control .emby-select { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + .kefin-section-configure-toggle-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + width: 100%; + box-sizing: border-box; + padding: 0.85em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + } + .kefin-section-configure-toggle-card-text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + } + .kefin-section-configure-toggle-card-label { + font-weight: 500; + line-height: 1.35; + } + .kefin-section-configure-toggle-card-desc, + .kefin-section-configure-row-desc { + font-size: 0.8125rem; + margin-top: 0.25em; + line-height: 1.45; + } + .kefin-section-configure-cache-control { + flex: 0 0 auto; + min-width: 7.5rem; + max-width: 11rem; + display: flex; + align-items: center; + justify-content: flex-end; + } + .kefin-section-configure-cache-control .emby-input, + .kefin-section-configure-cache-control .kefin-section-cache-select { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + .kefin-section-configure-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em; + } + .kefin-section-configure-row-copy { + display: flex; + flex-direction: column; + gap: 0.15em; + min-width: 0; + flex: 1 1 auto; + } + .kefin-section-configure-row-label { + font-size: 0.9em; + line-height: 1.25; + } + .kefin-section-configure-row-control { + flex: 0 0 auto; + min-width: 7.5rem; + display: flex; + align-items: center; + justify-content: flex-end; + } + .kefin-section-configure-row-control .emby-input { + width: 100%; + min-width: 0; + } + .kefin-section-configure-toggle { + display: flex; + align-items: center; + min-height: 28px; + flex-shrink: 0; + } + .kefin-section-format-menu-body { + min-width: 8rem; + padding: 0.15em 0; + } + .kefin-section-format-menu-body.has-color-controls { + display: flex; + flex-direction: column; + max-height: none; + padding-bottom: 0.25em; + } + .kefin-section-format-menu-header { + padding-bottom: 0.15em; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + } + .kefin-section-format-menu-options { + padding: 0.15em 0; + display: grid; + grid-template-columns: 1fr 1fr; + } + .kefin-section-format-gapless { + display: flex; + align-items: center; + gap: 0.4em; + padding: 0.35em 0.75em; + margin: 0; + cursor: pointer; + font: inherit; + color: inherit; + user-select: none; + } + .kefin-section-format-gapless-input { + margin: 0; + cursor: pointer; + } + .kefin-section-format-option { + display: block; + width: 100%; + padding: 0.45em 0.75em; + border: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + font: inherit; + } + .kefin-section-format-option:hover, + .kefin-section-format-option.is-active { + background: rgba(0, 164, 220, 0.15); + } + .kefin-section-format-color-row { + display: flex; + align-items: center; + gap: 0.35em; + padding: 0.35em 0.75em 0.45em; + } + .kefin-section-color-swatch { + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + background: var(--kefin-swatch-color, #e8dab2); + cursor: pointer; + flex-shrink: 0; + } + .kefin-section-color-swatch:disabled { + cursor: not-allowed; + } + .kefin-section-card-title-menu-body { + min-width: auto; + padding: 0.25em 0.35em; + display: flex; + flex-direction: column; + gap: 0; + } + .kefin-section-card-title-menu-icons { + display: flex; + align-items: center; + justify-content: center; + gap: 0.1em; + } + .kefin-section-card-title-menu-icons .kefin-section-format-color-row { + padding: 0; + gap: 0.1em; + } + .kefin-pickr-app, + .pcr-app.kefin-pickr-app { + z-index: 2147483000 !important; + pointer-events: auto !important; + } + .kefin-section-card-title-menu-icons .paper-icon-button-light { + margin: 0; + min-width: 2.25rem; + width: 2.25rem; + height: 2.25rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + .kefin-section-card-title-menu-icons .paper-icon-button-light .material-icons { + font-size: 1.25rem; + } + .kefin-section-card-title-visibility-btn.is-hidden-visibility { + opacity: 0.45; + } + .kefin-section-format-color-row.is-disabled { + opacity: 0.4; + pointer-events: none; + } + .kefin-section-color-reset { + min-width: auto; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + .kefin-section-color-reset.is-hidden { + display: none !important; + } + .kefin-section-configure-title-container h3 { + margin: 0; + flex: 1 1 auto; + min-width: 0; + } + .kefin-section-configure-title-row { + display: flex; + align-items: center; + gap: 0.15rem; + margin: 0 0 0.5rem 0.5rem; + } + .kefin-section-configure-title-row .kefin-section-open-editor-btn, + .kefin-section-configure-title-row .kefin-section-publish-btn { + flex: 0 0 auto; + opacity: 0.85; + padding: 0; + margin: 0; + width: 2.25rem; + height: 2.25rem; + } + .kefin-section-configure-title-row .kefin-section-open-editor-btn .material-icons, + .kefin-section-configure-title-row .kefin-section-publish-btn .material-icons { + font-size: 1.25rem; + } + .kefin-section-configure-title-row .kefin-section-open-editor-btn[hidden], + .kefin-section-configure-title-row .kefin-section-publish-btn[hidden] { + display: none !important; + } + .kefin-section-configure-title-row .kefin-section-open-editor-btn:hover, + .kefin-section-configure-title-row .kefin-section-publish-btn:hover { + opacity: 1; + } + `; + document.head.appendChild(style); + + window.KefinHomeScreenSectionConfigure = { + isEnabled, + attachSectionControls, + createConfigureButton, + getConfigureSectionControl, + updateRuntimeItemsLayout, + updateRuntimeGridExpanded + }; + + LOG('Module loaded'); +})(); diff --git a/scripts/infiniteScroll.js b/scripts/infiniteScroll.js index 9722cd1..5f18a3d 100644 --- a/scripts/infiniteScroll.js +++ b/scripts/infiniteScroll.js @@ -140,10 +140,7 @@ const style = document.createElement('style'); style.id = styleId; style.textContent = ` - #moviesTab .listPaging>div, - #seriesTab .listPaging>div { - display: none !important; - } + /* Infinite scroll loading indicator */ .infinite-scroll-loading { @@ -189,8 +186,7 @@ function waitForApiClient() { return new Promise((resolve) => { if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) { - userId = ApiClient._serverInfo.UserId; - LOG('ApiClient ready, userId:', userId); + LOG('ApiClient ready, userId:', ApiClient._serverInfo.UserId); resolve(); } else { LOG('Waiting for ApiClient...'); @@ -345,20 +341,8 @@ } // Update pagination display to show current range - function updatePaginationDisplay() { - const page = getCurrentPage(); - if (!page) return; - - let activeTab = null; - if (page === 'tv.html') { - activeTab = document.querySelector("#seriesTab.is-active"); - } else if (page === 'movies.html') { - activeTab = document.querySelector("#moviesTab.is-active"); - } - - if (!activeTab) return; - - const listPagings = activeTab.querySelectorAll(".listPaging"); + function updatePaginationDisplay() { + const listPagings = document.querySelectorAll(".page:not(.hide) .pageTabContent.is-active .listPaging"); if (!listPagings.length) return; listPagings.forEach(listPaging => { @@ -387,9 +371,9 @@ let activeTab = null; if (page === 'tv.html' || page === 'tv') { - activeTab = libraryPage.querySelector("#seriesTab.is-active"); + activeTab = libraryPage.querySelector("#seriesTab.is-active") || document.querySelector("#tvshowsPage:not(.hide)"); } else if (page === 'movies.html' || page === 'movies') { - activeTab = libraryPage.querySelector("#moviesTab.is-active"); + activeTab = libraryPage.querySelector("#moviesTab.is-active") || document.querySelector("#moviesPage:not(.hide)"); } if (!activeTab) { @@ -404,6 +388,31 @@ return container; } + /** + * Infer card format from existing cards in the items container. + * @param {Element} container + * @returns {string|null} 'backdrop'|'square'|'banner'|'portrait'|null + */ + function inferCardFormatFromContainer(container) { + if (!container) return null; + const existing = container.querySelector('.card'); + if (!existing) return null; + + if (existing.classList.contains('backdropCard') || existing.classList.contains('overflowBackdropCard')) { + return 'backdrop'; + } + if (existing.classList.contains('squareCard') || existing.classList.contains('overflowSquareCard')) { + return 'square'; + } + if (existing.classList.contains('bannerCard') || existing.classList.contains('overflowBannerCard')) { + return 'banner'; + } + if (existing.classList.contains('portraitCard') || existing.classList.contains('overflowPortraitCard')) { + return 'portrait'; + } + return null; + } + async function loadMore() { LOG('LoadMore called:', { loading, hasMore, isRequestInProgress, isScrollTriggered }); @@ -521,7 +530,7 @@ LOG('Added alpha picker filter:', alphaPicker); } - const url = ApiClient.getUrl("Users/" + userId + "/Items", apiParams); + const url = ApiClient.getUrl("Users/" + ApiClient.getCurrentUserId() + "/Items", apiParams); LOG('Loading more items:', { page, tab, startIndex, limit, url }); @@ -553,8 +562,10 @@ }); // Build each card individually and append to container + const cardFormat = inferCardFormatFromContainer(container); + LOG('Inferred card format from container:', cardFormat); result.Items.forEach((item, index) => { - const card = window.cardBuilder.buildCard(item); + const card = window.cardBuilder.buildCard(item, false, cardFormat); card.setAttribute('data-index', startIndex + index); container.appendChild(card); }); @@ -734,6 +745,8 @@ // Check if we should initialize infinite scroll function shouldInitialize() { + if (hasInitialized) return false; + const page = getCurrentPage(); const container = getContainer(); @@ -746,25 +759,16 @@ return false; } - // Double-check that we have the correct active tab - let activeTab = null; - if (page === 'tv.html' || page === 'tv') { - activeTab = document.querySelector("#seriesTab.is-active"); - } else if (page === 'movies.html' || page === 'movies') { - activeTab = document.querySelector("#moviesTab.is-active"); + if (page === 'tv.html' || page === 'tv' || page === 'movies.html' || page === 'movies') { + return true; } - - if (!activeTab) { - LOG('No active tab found for page, skipping initialization:', page); - return false; - } - - return true; + + return false; } // Initialize infinite scroll async function initialize() { - if (!shouldInitialize() || hasInitialized || isInitializing) { + if (hasInitialized || isInitializing) { return; } @@ -788,9 +792,6 @@ reset(); //loadMore(); // load first page setupScrollWatcher(); - - // Update pagination display with initial state - updatePaginationDisplay(); } finally { isInitializing = false; } diff --git a/scripts/itemDetailsCollections.js b/scripts/itemDetailsCollections.js index c213058..27acc1f 100644 --- a/scripts/itemDetailsCollections.js +++ b/scripts/itemDetailsCollections.js @@ -1,7 +1,4 @@ // KefinTweaks Item Details Collections -// Adds related collections to item details pages -// Requires: indexedDBCache.js, utils.js, cardBuilder.js modules to be loaded before this script - (function() { 'use strict'; @@ -11,20 +8,90 @@ const COLLECTIONS_CHILD_COUNT_KEY = `kefinTweaks_${ApiClient.getCurrentUserId()}_allCollectionsChildCount`; const CACHE_NAME = 'collections'; + const COLLECTION_FETCH_CHUNK_SIZE = 8; const state = { allCollections: null, + populateInFlight: null + }; + + /** + * Yields execution to allow the UI to repaint and respond to user inputs. + * Prefers the modern scheduler.yield API when available. + */ + function yieldToMain() { + if ('scheduler' in window && typeof window.scheduler.yield === 'function') { + return window.scheduler.yield(); + } + return new Promise((resolve) => setTimeout(resolve, 0)); } - // Populate collections cache - async function populateCollectionsCache(invalidate = false) { - // Check if user is logged in and poll for 5 seconds if not - if (ApiClient._loggedIn === false) { - return; + function buildCacheEntries(results) { + const cacheData = []; + for (const { collection: coll, items } of results) { + const itemIds = (items?.Items || []).map((item) => item.Id); + if (itemIds.length === 0) continue; + cacheData.push({ + CollectionItem: { + Id: coll.Id, + Type: coll.Type, + Name: coll.Name, + ParentIndexNumber: coll.ParentIndexNumber, + IndexNumber: coll.IndexNumber, + ImageTags: coll.ImageTags, + BackdropImageTags: coll.BackdropImageTags, + ProductionYear: coll.ProductionYear, + PremiereDate: coll.PremiereDate + }, + ItemIds: itemIds + }); } + return cacheData; + } + + async function fetchCollectionChildren(coll) { + try { + const items = await ApiClient.getItems(ApiClient.getCurrentUserId(), { + ParentId: coll.Id + }); + return { collection: coll, items }; + } catch (err) { + WARN(`Failed to fetch items for collection ${coll.Id}`, err); + return { collection: coll, items: { Items: [] } }; + } + } + + async function fetchCollectionChildrenBatched(collections) { + const results = []; + const items = collections || []; + + for (let i = 0; i < items.length; i += COLLECTION_FETCH_CHUNK_SIZE) { + const chunk = items.slice(i, i + COLLECTION_FETCH_CHUNK_SIZE); + const chunkResults = await Promise.all(chunk.map(fetchCollectionChildren)); + results.push(...chunkResults); + + // Yield back to the browser to paint frames & maintain high FPS + if (i + COLLECTION_FETCH_CHUNK_SIZE < items.length) { + await yieldToMain(); + } + } + return results; + } + + async function writeCollectionsCache(cache, collections) { + const startTime = performance.now(); + const collectionResults = await fetchCollectionChildrenBatched(collections); + const cacheData = buildCacheEntries(collectionResults); + await cache.set(CACHE_NAME, cacheData); + const duration = performance.now() - startTime; + LOG(`Collections cache populated: ${cacheData.length} collections in ${duration.toFixed(2)}ms`); + return cacheData; + } + + async function populateCollectionsCache(invalidate = false) { + if (ApiClient._loggedIn === false) return; const cache = window.IndexedDBCache; - if (!cache) { WARN('IndexedDBCache instance not available'); return; @@ -38,9 +105,9 @@ }); } - const allCollectionsChildCount = state.allCollections.Items.map(collection => collection.ChildCount).reduce((a, b) => a + b, 0); + const allCollectionsChildCount = (state.allCollections.Items || []) + .reduce((sum, coll) => sum + (coll.ChildCount || 0), 0); - // Get saved Collections Child Count const cachedAllCollectionsChildCount = Number(localStorage.getItem(COLLECTIONS_CHILD_COUNT_KEY)); if (allCollectionsChildCount !== cachedAllCollectionsChildCount) { @@ -49,173 +116,73 @@ LOG('Collections Child Count has changed, invalidating cache'); } - // Check if cache is valid if (!invalidate && await cache.isCacheValid(CACHE_NAME)) { LOG('Collections cache is still valid'); return; } - LOG('Populating collections cache...'); - - try { + const currentCacheData = await cache.get(CACHE_NAME); + const hasExistingCache = Array.isArray(currentCacheData) && currentCacheData.length > 0; - // Fetch all collection items in parallel - const collectionPromises = state.allCollections.Items.map(coll => - ApiClient.getItems(ApiClient.getCurrentUserId(), { - ParentId: coll.Id, - }).then(items => ({ - collection: coll, - items: items - })) - ); - - // Check if cache already contains data - // If so, process the cache refresh in parallel and return the currently cached data instead of waiting - // If no cache exists, wait for all fetches to complete - const currentCacheData = await cache.get(CACHE_NAME); - - if (currentCacheData && currentCacheData.length > 0) { - // Refreshing the cache Collections cache in the background - LOG('Refreshing collections cache in the background'); - Promise.all(collectionPromises).then(async (results) => { - LOG('Collections cache refreshed'); - const cacheData = []; - for (const { collection: coll, items } of results) { - const collectionItemData = { - Id: coll.Id, - Type: coll.Type, - Name: coll.Name, - ParentIndexNumber: coll.ParentIndexNumber, - IndexNumber: coll.IndexNumber, - ImageTags: coll.ImageTags, - BackdropImageTags: coll.BackdropImageTags, - ProductionYear: coll.ProductionYear, - PremiereDate: coll.PremiereDate - }; - const itemIds = items.Items.map(item => item.Id); - if (itemIds.length > 0) { - cacheData.push({ - CollectionItem: collectionItemData, - ItemIds: itemIds - }); - } - } - await cache.set(CACHE_NAME, cacheData); - LOG(`Collections cache populated: ${cacheData.length} collections`); - }); - return; - } - - // Setup performance timer - const startTime = performance.now(); - - // Wait for all fetches to complete - const collectionResults = await Promise.all(collectionPromises); - - const endTime = performance.now(); - const duration = endTime - startTime; - LOG(`Collections cache populated in ${duration.toFixed(2)}ms`); - - const cacheData = []; - - // Process all results - for (const { collection: coll, items } of collectionResults) { - // Store collection item data needed for renderCards - const collectionItemData = { - Id: coll.Id, - Type: coll.Type, - Name: coll.Name, - ParentIndexNumber: coll.ParentIndexNumber, - IndexNumber: coll.IndexNumber, - ImageTags: coll.ImageTags, - BackdropImageTags: coll.BackdropImageTags, - ProductionYear: coll.ProductionYear, - PremiereDate: coll.PremiereDate - }; - - // Store item IDs in this collection for lookup - const itemIds = items.Items.map(item => item.Id); - - if (itemIds.length > 0) { - cacheData.push({ - CollectionItem: collectionItemData, - ItemIds: itemIds - }); - } - } - - await cache.set(CACHE_NAME, cacheData); - LOG(`Collections cache populated: ${cacheData.length} collections`); - } catch (error) { - ERR('Error populating collections cache:', error); + if (state.populateInFlight) { + if (hasExistingCache) return; + return state.populateInFlight; } + + LOG(hasExistingCache + ? 'Refreshing collections cache in background' + : 'Populating collections cache (First run)...'); + + const work = writeCollectionsCache(cache, state.allCollections.Items) + .catch((error) => { + ERR('Error populating collections cache:', error); + }) + .finally(() => { + state.populateInFlight = null; + }); + + state.populateInFlight = work; + + if (hasExistingCache) return; + await work; // Wait on initial load so caller can render right after } - // Get parent collections for an item ID (returns collection items that can be passed to renderCards) async function getParentCollections(itemId) { const cache = window.IndexedDBCache; - - if (!cache) { - WARN('IndexedDBCache instance not available'); - return []; - } + if (!cache) return []; const cacheData = await cache.get(CACHE_NAME); + if (!cacheData || !Array.isArray(cacheData)) return []; - if (!cacheData || !Array.isArray(cacheData)) { - return []; - } - - // Find collections that contain this item and return the collection item objects return cacheData .filter(entry => entry.ItemIds && entry.ItemIds.includes(itemId)) .map(entry => entry.CollectionItem); } - // Render collections section on details page async function renderCollectionsSection(item) { - if (!item || !item.Id) { - return; - } + if (!item || !item.Id) return; const activePage = document.querySelector('.libraryPage:not(.hide)'); - if (!activePage) { - return; - } + if (!activePage) return; - if (activePage.dataset.collectionsChecked === 'true') { - return; - } + // Prevent redundant runs on the same page element + if (activePage.dataset.collectionsChecked === 'true') return; - activePage.dataset.collectionsChecked = 'true'; - - // Check if section already rendered - const existingSection = activePage.querySelector('.collections-section'); - if (existingSection) { - return; - } - - // Get parent collections (returns collection item objects) const parentCollectionItems = await getParentCollections(item.Id); - if (!parentCollectionItems || parentCollectionItems.length === 0) { - return; - } + if (!parentCollectionItems || parentCollectionItems.length === 0) return; - // Check for cardBuilder if (!window.cardBuilder || !window.cardBuilder.renderCards) { WARN('cardBuilder.renderCards not available'); return; } - // Find detailPageContent const detailPageContent = activePage.querySelector('.detailPageContent'); - if (!detailPageContent) { - WARN('detailPageContent not found'); - return; - } + if (!detailPageContent) return; + + // Ensure we don't render duplicate sections + if (activePage.querySelector('.collections-section')) return; try { - // Render collections section using renderCards with cached item data const collectionsSection = window.cardBuilder.renderCards( parentCollectionItems, 'Included In', @@ -224,21 +191,15 @@ null ); - // Mark as collections section collectionsSection.classList.add('collections-section'); collectionsSection.setAttribute('data-collections-section', 'true'); const scrollerContainer = collectionsSection.querySelector('.emby-scroller'); - if (scrollerContainer) { - scrollerContainer.classList.add('no-padding'); - } + if (scrollerContainer) scrollerContainer.classList.add('no-padding'); const sectionTitleContainer = collectionsSection.querySelector('.sectionTitleContainer'); - if (sectionTitleContainer) { - sectionTitleContainer.classList.add('no-padding'); - } + if (sectionTitleContainer) sectionTitleContainer.classList.add('no-padding'); - // Add to detailPageContent const similarSection = detailPageContent.querySelector('#similarCollapsible'); if (similarSection) { similarSection.before(collectionsSection); @@ -246,13 +207,13 @@ detailPageContent.appendChild(collectionsSection); } + activePage.dataset.collectionsChecked = 'true'; LOG(`Rendered ${parentCollectionItems.length} collection(s) for item ${item.Id}`); } catch (error) { ERR('Error rendering collections section:', error); } } - // Initialize onViewPage hook function initializeCollectionsHook() { if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); @@ -264,32 +225,23 @@ window.KefinTweaksUtils.onViewPage( async (view, element, hash, itemPromise) => { - if (ApiClient._loggedIn === false) { - return; - } + if (ApiClient._loggedIn === false) return; - // Only handle details pages const activePage = document.querySelector('.libraryPage:not(.hide)'); if (!activePage) return; - // Await the item promise to get the actual item data const item = await itemPromise; + const supportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'Book', 'AudioBook', 'MusicVideo']; - const supportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'Book', 'AudioBook', 'MusicVideo' ]; - - if (!supportedTypes.includes(item.Type)) { - LOG(`Item type ${item.Type} not supported for collections, skipping...`); - return; - } + if (!supportedTypes.includes(item?.Type)) return; + // 1. Populate/Update cache (waits on first load, runs in background on updates) await populateCollectionsCache(); - // Small delay to ensure details DOM is ready - setTimeout(async () => { - if (item && item.Id) { - await renderCollectionsSection(item); - } - }, 100); + // 2. Render cards using populated IndexedDB cache + if (item && item.Id) { + await renderCollectionsSection(item); + } }, { pages: ['details'], @@ -300,33 +252,17 @@ LOG('Collections hook initialized'); } - const maxInitAttempts = 10; - let initAttempts = 0; - - // Initialize cache population on script load function initialize() { if (!window.ApiClient) { - WARN('Dependencies not available, retrying in 1 second'); - if (initAttempts < maxInitAttempts) { - setTimeout(initialize, 1000); - initAttempts++; - } else { - ERR('Dependencies not available after 10 seconds, giving up'); - } + setTimeout(initialize, 1000); return; } - - // Initialize collections hook initializeCollectionsHook(); } - // Start initialization if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } - - LOG('Script loaded successfully'); -})(); - +})(); \ No newline at end of file diff --git a/scripts/modal.js b/scripts/modal.js index 12077fa..f00c153 100644 --- a/scripts/modal.js +++ b/scripts/modal.js @@ -21,6 +21,7 @@ window.ModalSystem = (function() { * @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true) * @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true) * @param {boolean} options.showCloseButton - Whether to show close button in header (default: true if title exists) + * @param {boolean} [options.fixedSize] - Use dialog-fixedSize. If undefined, enable when window width < 900. * @returns {Object} Modal instance */ function createModal(options = {}) { @@ -33,7 +34,8 @@ window.ModalSystem = (function() { onOpen, closeOnBackdrop = true, closeOnEscape = true, - showCloseButton = true + showCloseButton = true, + fixedSize } = options; if (!id) { @@ -43,6 +45,9 @@ window.ModalSystem = (function() { // Remove existing modal if it exists removeModal(id); + const useFixedSize = fixedSize === true + || (fixedSize !== false && typeof window !== 'undefined' && window.innerWidth < 900); + // Create modal elements const backdrop = document.createElement('div'); backdrop.className = 'dialogBackdrop dialogBackdropOpened'; @@ -53,7 +58,7 @@ window.ModalSystem = (function() { dialogContainer.setAttribute('data-modal-id', id); const dialog = document.createElement('div'); - dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened'; + dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened`; dialog.setAttribute('data-history', 'true'); dialog.setAttribute('data-autofocus', 'true'); dialog.setAttribute('data-removeonclose', 'true'); @@ -61,7 +66,9 @@ window.ModalSystem = (function() { dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; dialog.style.display = 'flex'; dialog.style.flexDirection = 'column'; - dialog.style.maxHeight = '90vh'; + if (!useFixedSize) { + dialog.style.maxHeight = '90vh'; + } // Create header if title is provided let dialogHeader = null; @@ -103,7 +110,7 @@ window.ModalSystem = (function() { // Create scrollable content area const dialogContent = document.createElement('div'); - dialogContent.style.padding = title || footer ? '1.25em 1.5em 0' : '1.25em 1.5em 1.5em'; + dialogContent.style.padding = '1.25em 1.5em'; dialogContent.style.overflowY = 'auto'; dialogContent.style.flex = '1'; dialogContent.style.minHeight = '0'; @@ -227,7 +234,10 @@ window.ModalSystem = (function() { dialogContainer.appendChild(dialog); // Add to DOM - document.body.appendChild(backdrop); + // Check if the modal backdrop is already in the DOM + if (!document.body.querySelector('.dialogBackdrop')) { + document.body.appendChild(backdrop); + } document.body.appendChild(dialogContainer); // Create modal instance diff --git a/scripts/moviesCache.js b/scripts/moviesCache.js index 144c7a4..b7dc5f7 100644 --- a/scripts/moviesCache.js +++ b/scripts/moviesCache.js @@ -16,6 +16,12 @@ let imdbTop250Movies = null; let isInitializing = false; + /** + * Fetches and caches all movies across all libraries. + * You should not use this for anything related to UserData + * The cache for this is long, and this is intended to simply be an "archive" of all movies across all libraries. + * @returns {Promise} - Array of movie objects + */ async function fetchAndCacheMovies() { // IndexedDBCache const cacheName = 'movies_library'; @@ -29,8 +35,14 @@ } try { - LOG('Fetching Movies...'); + LOG('Fetching Movies...'); + // We need to refactor this to pull the data in chunks of 100 items at a time in order to backfill the cache + // We will populate 100 items at a time into the library cache. + // Each group will contain the index of the last item in the group so we can resume from there + // When resuming, we should first verify if the previous item based on the same sorting and startindex/limit is still the same + // We just check the last item in the indexed db cache and make the same query starting at that index + // If it's the same then we can resume from there, if not then we need to start from the beginning const allMoviesQuery = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds`; const movieResult = await apiHelper.getQuery(allMoviesQuery); diff --git a/scripts/multiUserSession.js b/scripts/multiUserSession.js new file mode 100644 index 0000000..efd6d80 --- /dev/null +++ b/scripts/multiUserSession.js @@ -0,0 +1,1480 @@ +/** + * KefinTweaks Multi-User Sessions / Watch Together + * Account-scoped roster in CustomPrefs.kefinTweaks.watchTogether; + * device-local enabledUserIds + lastSelectionAt; sync primary playstate to enabled users. + */ +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks MultiUserSession]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks MultiUserSession]', ...args); + const ERR = (...args) => console.error('[KefinTweaks MultiUserSession]', ...args); + + const DEVICE_STORAGE_PREFIX = 'kefinTweaks_watchTogether'; + const FEATURE_KEY = 'watchTogether'; + const MODAL_ID = 'kefin-multi-user-session-modal'; + const ADD_USER_MODAL_ID = 'kefin-multi-user-session-add-modal'; + const WATCH_TOGETHER_MODAL_ID = 'kefin-watch-together-prompt'; + const REMOVE_CONFIRM_MODAL_ID = 'kefin-watch-together-remove-confirm'; + const SYNC_DEBOUNCE_MS = 250; + const DEFAULT_SESSION_TIMEOUT_MINUTES = 30; + + let syncStarted = false; + let activeModal = null; + let addUserModal = null; + let watchTogetherModal = null; + /** Refreshes the open Watch Together picker in place (avatars + pending selection). */ + let refreshWatchTogetherPicker = null; + const syncDebounceTimers = new Map(); + let syncMirrorDepth = 0; + + /** Cached CustomPrefs roster: { users: [{ userId, name, accessToken }] } */ + let groupCache = { users: [] }; + let groupHydrated = false; + + let inContinuousVideoSession = false; + let isAddingOsdButton = false; + + function getUi() { + return window.KefinTweaksUI || {}; + } + + function injectStyles() { + if (document.getElementById('kefin-multi-user-session-styles')) return; + const style = document.createElement('style'); + style.id = 'kefin-multi-user-session-styles'; + style.textContent = ` + .headerUserButton[data-multi-user] .material-icons.person::before, + [data-multi-user].material-icons.person::before { + content: "\\e7ef"; + } + .mus-watch-together-avatars { + display: flex; + flex-wrap: wrap; + gap: 1em; + justify-content: center; + padding-top: 1em; + } + .mus-watch-together-avatar-wrap { + position: relative; + } + .mus-watch-together-avatar { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4em; + cursor: pointer; + background: none; + border: none; + padding: 0; + color: inherit; + } + .mus-watch-together-avatar .mus-avatar-circle { + width: 96px; + height: 96px; + border-radius: 50%; + overflow: hidden; + border: 3px solid transparent; + box-sizing: border-box; + background: rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease; + opacity: 0.45; + } + .mus-watch-together-avatar.is-enabled .mus-avatar-circle { + border-color: rgba(0, 164, 220, 0.95); + box-shadow: 0 0 0 2px rgba(0, 164, 220, 0.35); + opacity: 1; + } + .mus-watch-together-avatar .mus-avatar-circle img { + width: 100%; + height: 100%; + object-fit: cover; + } + .mus-watch-together-avatar .mus-avatar-fallback { + font-size: 28px; + opacity: 0.85; + } + .mus-watch-together-avatar .mus-avatar-name { + font-size: 0.75em; + text-align: center; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mus-avatar-remove { + position: absolute; + top: -8px; + right: -8px; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.75); + color: #fff; + z-index: 2; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + .mus-watch-together-avatar-wrap:hover .mus-avatar-remove, + .mus-watch-together-avatar-wrap:focus-within .mus-avatar-remove { + opacity: 1; + pointer-events: auto; + } + .mus-avatar-remove .material-icons { + font-size: 14px; + line-height: 1; + } + .mus-wt-footer-actions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5em; + width: 100%; + justify-content: flex-end; + align-items: center; + } + .mus-wt-footer-actions .emby-button { + margin: 0; + } + .material-icons.group_off::before { + content: "\\e747"; + } + #videoOsdPage:not(.hide) button.btnWatchTogether { + margin: 0; + } + `; + document.head.appendChild(style); + } + + function escapeHtml(str) { + if (typeof getUi().escapeHtml === 'function') return getUi().escapeHtml(str); + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function getPrimaryUserId() { + return (typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId) + ? ApiClient.getCurrentUserId() + : null; + } + + function getPrimaryUserName() { + if (typeof ApiClient !== 'undefined') { + if (ApiClient._currentUser?.Name) return ApiClient._currentUser.Name; + const id = getPrimaryUserId(); + if (id) return id; + } + return 'User'; + } + + function getDeviceStorageKey() { + const serverId = (typeof ApiClient !== 'undefined' && ApiClient.serverId) + ? (ApiClient.serverId() || '') + : ''; + const primaryUserId = getPrimaryUserId() || ''; + if (!serverId || !primaryUserId) return null; + return `${DEVICE_STORAGE_PREFIX}:${serverId}:${primaryUserId}`; + } + + function readDeviceState() { + const key = getDeviceStorageKey(); + if (!key) return { enabledUserIds: [], lastSelectionAt: 0 }; + try { + const raw = localStorage.getItem(key); + if (!raw) return { enabledUserIds: [], lastSelectionAt: 0 }; + const parsed = JSON.parse(raw); + const enabledUserIds = Array.isArray(parsed?.enabledUserIds) + ? parsed.enabledUserIds.filter(Boolean) + : []; + const lastSelectionAt = Number(parsed?.lastSelectionAt) || 0; + return { enabledUserIds, lastSelectionAt }; + } catch (e) { + WARN('Failed to read device watch-together state:', e); + return { enabledUserIds: [], lastSelectionAt: 0 }; + } + } + + function writeDeviceState(state) { + const key = getDeviceStorageKey(); + if (!key) return false; + try { + const enabledUserIds = Array.isArray(state?.enabledUserIds) + ? [...new Set(state.enabledUserIds.filter(Boolean))] + : []; + const lastSelectionAt = Number(state?.lastSelectionAt) || 0; + localStorage.setItem(key, JSON.stringify({ enabledUserIds, lastSelectionAt })); + updateHeaderMultiUserIndicator(); + updateSyncListenerState(); + try { + refreshPreferencesMenuLinks(); + } catch (uiErr) { + WARN('Failed to refresh preferences menu links:', uiErr); + } + return true; + } catch (e) { + ERR('Failed to write device watch-together state:', e); + return false; + } + } + + function stampLastSelectionAt(enabledUserIds) { + const current = readDeviceState(); + return writeDeviceState({ + enabledUserIds: enabledUserIds != null ? enabledUserIds : current.enabledUserIds, + lastSelectionAt: Date.now() + }); + } + + function normalizeWatchTogether(raw) { + const users = Array.isArray(raw?.users) ? raw.users : []; + return { + users: users + .filter((u) => u && u.userId && (u.accessToken || u.viaAdmin === true)) + .map((u) => { + const entry = { + userId: u.userId, + name: u.name || '' + }; + if (u.viaAdmin === true) { + entry.viaAdmin = true; + } else if (u.accessToken) { + entry.accessToken = u.accessToken; + } + return entry; + }) + }; + } + + async function hydrateGroupCache() { + try { + const raw = await window.userHelper?.getKefinTweaksFeature?.(FEATURE_KEY, { users: [] }); + groupCache = normalizeWatchTogether(raw); + groupHydrated = true; + return groupCache; + } catch (e) { + WARN('Failed to hydrate watchTogether group:', e); + groupCache = { users: [] }; + groupHydrated = true; + return groupCache; + } + } + + async function persistGroup(nextGroup) { + const normalized = normalizeWatchTogether(nextGroup); + const ok = await window.userHelper?.setKefinTweaksFeature?.(FEATURE_KEY, normalized); + if (ok) { + groupCache = normalized; + groupHydrated = true; + updateHeaderMultiUserIndicator(); + updateSyncListenerState(); + try { + refreshPreferencesMenuLinks(); + } catch (uiErr) { + WARN('Failed to refresh preferences menu links:', uiErr); + } + } + return ok; + } + + function getSessionUsers() { + return (groupCache.users || []).slice(); + } + + function getEnabledSessionUsers() { + const primaryId = getPrimaryUserId(); + const { enabledUserIds } = readDeviceState(); + const enabledSet = new Set(enabledUserIds); + return getSessionUsers().filter( + (u) => enabledSet.has(u.userId) + && u.userId + && u.userId !== primaryId + && (u.accessToken || u.viaAdmin === true) + ); + } + + async function upsertSessionUser(entry) { + const users = getSessionUsers(); + const idx = users.findIndex((u) => u.userId === entry.userId); + const next = { + userId: entry.userId, + name: entry.name || '' + }; + if (entry.viaAdmin === true) { + next.viaAdmin = true; + } else if (entry.accessToken) { + next.accessToken = entry.accessToken; + } else { + ERR('upsertSessionUser requires accessToken or viaAdmin'); + return false; + } + if (idx >= 0) users[idx] = next; + else users.push(next); + const ok = await persistGroup({ users }); + if (ok) { + const device = readDeviceState(); + if (!device.enabledUserIds.includes(entry.userId)) { + writeDeviceState({ + enabledUserIds: [...device.enabledUserIds, entry.userId], + lastSelectionAt: device.lastSelectionAt + }); + } + } + return ok; + } + + function setUserEnabled(userId, enabled) { + const device = readDeviceState(); + const set = new Set(device.enabledUserIds); + if (enabled) set.add(userId); + else set.delete(userId); + return writeDeviceState({ + enabledUserIds: [...set], + lastSelectionAt: device.lastSelectionAt + }); + } + + async function removeSessionUser(userId) { + const users = getSessionUsers().filter((u) => u.userId !== userId); + const ok = await persistGroup({ users }); + const device = readDeviceState(); + writeDeviceState({ + enabledUserIds: device.enabledUserIds.filter((id) => id !== userId), + lastSelectionAt: device.lastSelectionAt + }); + return ok; + } + + function getSessionTimeoutMs() { + const minutes = window.KefinTweaksConfig?.multiUserSession?.sessionTimeout; + const n = Number(minutes); + const use = Number.isFinite(n) && n > 0 ? n : DEFAULT_SESSION_TIMEOUT_MINUTES; + return use * 60 * 1000; + } + + function getHeaderUserButtonTitle() { + const enabledUsers = getEnabledSessionUsers(); + if (!enabledUsers.length) return null; + const others = enabledUsers.map((u) => u.name || u.userId).join(', '); + return `${getPrimaryUserName()} and ${others}`; + } + + function updateHeaderMultiUserIndicator() { + const hasMulti = getEnabledSessionUsers().length > 0; + const multiTitle = hasMulti ? getHeaderUserButtonTitle() : null; + + document.querySelectorAll('.headerUserButton').forEach((btn) => { + if (hasMulti) { + if (!btn.hasAttribute('data-mus-original-title')) { + const existing = btn.getAttribute('title'); + if (existing) btn.setAttribute('data-mus-original-title', existing); + } + if (multiTitle) btn.setAttribute('title', multiTitle); + btn.setAttribute('data-multi-user', 'true'); + btn.querySelectorAll('.material-icons.person').forEach((icon) => { + icon.setAttribute('data-multi-user', 'true'); + }); + } else { + if (btn.hasAttribute('data-mus-original-title')) { + const original = btn.getAttribute('data-mus-original-title'); + if (original) btn.setAttribute('title', original); + else btn.removeAttribute('title'); + btn.removeAttribute('data-mus-original-title'); + } + btn.removeAttribute('data-multi-user'); + btn.querySelectorAll('[data-multi-user]').forEach((el) => { + el.removeAttribute('data-multi-user'); + }); + } + }); + } + + function authHeaderForToken(token) { + if (window.apiHelper?.getAuthHeaderForToken) { + return window.apiHelper.getAuthHeaderForToken(token); + } + return ''; + } + + /** Primary admin token for viaAdmin members; stored token for password-linked members. */ + function authHeaderForSessionUser(user) { + if (user?.viaAdmin === true || !user?.accessToken) { + if (window.apiHelper?.getAuthHeader) { + return window.apiHelper.getAuthHeader(); + } + return authHeaderForToken( + typeof ApiClient !== 'undefined' && ApiClient.accessToken + ? ApiClient.accessToken() + : '' + ); + } + return authHeaderForToken(user.accessToken); + } + + async function authenticateByName(username, password) { + const serverUrl = ApiClient.serverAddress(); + if (!serverUrl) throw new Error('Server address unavailable'); + + const response = await fetch(`${serverUrl}/Users/AuthenticateByName`, { + method: 'POST', + headers: { + Authorization: authHeaderForToken(''), + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ + Username: username, + Pw: password + }) + }); + + if (!response.ok) { + let detail = `${response.status}`; + try { + const errBody = await response.json(); + if (errBody?.message || errBody?.Message) { + detail = errBody.message || errBody.Message; + } + } catch (_) { /* ignore */ } + throw new Error(detail); + } + + return response.json(); + } + + async function logoutSessionToken(accessToken) { + if (!accessToken) return; + try { + const serverUrl = ApiClient.serverAddress(); + if (!serverUrl) return; + await fetch(`${serverUrl}/Sessions/Logout`, { + method: 'POST', + headers: { + Authorization: authHeaderForToken(accessToken), + 'Content-Type': 'application/json' + } + }); + } catch (e) { + WARN('Best-effort logout failed:', e); + } + } + + function getSessionUserIds() { + return new Set(getSessionUsers().map((u) => u.userId)); + } + + function isPrimaryUserDataEvent(msg) { + const eventUserId = msg?.Data?.UserId; + const primaryId = getPrimaryUserId(); + if (!primaryId || !eventUserId) return false; + if (eventUserId !== primaryId) return false; + return !getSessionUserIds().has(eventUserId); + } + + async function markItemPlayed(user, itemId) { + const serverUrl = ApiClient.serverAddress(); + const url = `${serverUrl}/Users/${encodeURIComponent(user.userId)}/PlayedItems/${encodeURIComponent(itemId)}`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: authHeaderForSessionUser(user), + 'Content-Type': 'application/json' + } + }); + if (response.status === 401 || response.status === 403) { + setUserEnabled(user.userId, false); + WARN(`Disabled session user ${user.name || user.userId} after ${response.status} on PlayedItems`); + return false; + } + if (!response.ok) { + throw new Error(`PlayedItems ${response.status}`); + } + return true; + } + + async function mirrorUserData(user, itemId, userData) { + const serverUrl = ApiClient.serverAddress(); + const url = `${serverUrl}/Users/${encodeURIComponent(user.userId)}/Items/${encodeURIComponent(itemId)}/UserData`; + const body = { + PlaybackPositionTicks: userData.PlaybackPositionTicks ?? 0, + PlayedPercentage: userData.PlayedPercentage, + PlayCount: userData.PlayCount, + IsFavorite: userData.IsFavorite, + LastPlayedDate: userData.LastPlayedDate + }; + if (userData.Played === true) { + body.Played = true; + } + Object.keys(body).forEach((k) => { + if (body[k] === undefined) delete body[k]; + }); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: authHeaderForSessionUser(user), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + if (response.status === 401 || response.status === 403) { + setUserEnabled(user.userId, false); + WARN(`Disabled session user ${user.name || user.userId} after ${response.status} on UserData`); + return false; + } + if (!response.ok) { + throw new Error(`UserData ${response.status}`); + } + return true; + } + + async function syncUserDataToSessionUsers(userData) { + if (!userData?.ItemId) return; + const itemId = userData.ItemId; + const targets = getEnabledSessionUsers(); + if (!targets.length) return; + + const hasProgress = typeof userData.PlaybackPositionTicks === 'number' + && userData.PlaybackPositionTicks > 0; + const shouldPushUserData = hasProgress + || userData.PlaybackPositionTicks === 0 + || userData.PlayedPercentage != null; + const shouldMarkPlayed = userData.Played === true; + + if (!shouldMarkPlayed && !shouldPushUserData) return; + + syncMirrorDepth += 1; + try { + await Promise.all(targets.map(async (user) => { + try { + if (shouldMarkPlayed) { + await markItemPlayed(user, itemId); + } + if (shouldPushUserData) { + await mirrorUserData(user, itemId, userData); + } + } catch (e) { + WARN(`Sync failed for ${user.name || user.userId}:`, e); + } + })); + } finally { + syncMirrorDepth = Math.max(0, syncMirrorDepth - 1); + } + } + + function scheduleSyncUserData(userData) { + if (!userData?.ItemId) return; + const itemId = userData.ItemId; + clearTimeout(syncDebounceTimers.get(itemId)); + syncDebounceTimers.set(itemId, setTimeout(() => { + syncDebounceTimers.delete(itemId); + syncUserDataToSessionUsers(userData).catch((e) => WARN('Debounced sync error:', e)); + }, SYNC_DEBOUNCE_MS)); + } + + function handleUserDataChangedMessage(msg) { + if (syncMirrorDepth > 0) return; + if (!isPrimaryUserDataEvent(msg)) return; + + const list = msg?.Data?.UserDataList; + if (!Array.isArray(list) || !list.length) return; + if (!getEnabledSessionUsers().length) return; + list.forEach((entry) => scheduleSyncUserData(entry)); + } + + function stopSync() { + if (!syncStarted) return; + if (window.websocketHelper?.unlisten) { + window.websocketHelper.unlisten('UserDataChanged', handleUserDataChangedMessage); + } + syncStarted = false; + syncDebounceTimers.forEach((timer) => clearTimeout(timer)); + syncDebounceTimers.clear(); + LOG('Playstate sync stopped (no enabled session users)'); + } + + function startSync() { + if (syncStarted) return; + if (!getEnabledSessionUsers().length) return; + if (!window.websocketHelper?.listen) { + WARN('websocketHelper not available; retrying sync start'); + setTimeout(updateSyncListenerState, 1000); + return; + } + window.websocketHelper.listen('UserDataChanged', handleUserDataChangedMessage); + syncStarted = true; + LOG('Playstate sync listening for UserDataChanged'); + } + + function updateSyncListenerState() { + if (getEnabledSessionUsers().length > 0) { + startSync(); + } else { + stopSync(); + } + } + + function getUserAvatarUrl(userId) { + const server = ApiClient?.serverAddress?.() || ''; + if (!server || !userId) return ''; + return `${server}/Users/${encodeURIComponent(userId)}/Images/Primary`; + } + + /** + * Shared Watch Together account picker UI (playback prompt + manage modal). + * @param {Array} users + * @param {string[]} enabledUserIds + * @param {{ showRemove?: boolean }} [options] + */ + function buildAvatarToggleHtml(users, enabledUserIds, options = {}) { + const { showRemove = false } = options; + const enabledSet = new Set(enabledUserIds); + if (!users.length) { + return `
No accounts in your Watch Together group yet.
`; + } + const items = users.map((user) => { + const enabled = enabledSet.has(user.userId); + const name = escapeHtml(user.name || user.userId); + const src = escapeHtml(getUserAvatarUrl(user.userId)); + const removeBtn = showRemove + ? `` + : ''; + return ` +
+ ${removeBtn} + +
+ `; + }).join(''); + return `
${items}
`; + } + + function buildWatchTogetherPickerBodyHtml(users, enabledUserIds, options = {}) { + const { showRemove = false, hint = 'Tap accounts to include them in watch-state sync for this session.' } = options; + return ` +
+
+ ${escapeHtml(hint)} +
+ + ${buildAvatarToggleHtml(users, enabledUserIds, { showRemove })} +
+ `; + } + + /** + * @param {HTMLElement} root + * @param {string[]} pendingEnabledIds - mutated in place for pending selection + * @param {{ applyImmediately?: boolean, onRemove?: Function }} [options] + */ + function bindAvatarToggleHandlers(root, pendingEnabledIds, options = {}) { + const { applyImmediately = false, onRemove = null } = options; + + root.querySelectorAll('.mus-watch-together-avatar').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const userId = btn.getAttribute('data-user-id'); + if (!userId) return; + const idx = pendingEnabledIds.indexOf(userId); + if (idx >= 0) pendingEnabledIds.splice(idx, 1); + else pendingEnabledIds.push(userId); + const enabled = pendingEnabledIds.includes(userId); + btn.classList.toggle('is-enabled', enabled); + btn.setAttribute('aria-pressed', String(enabled)); + if (applyImmediately) { + setUserEnabled(userId, enabled); + stampLastSelectionAt([...pendingEnabledIds]); + updateWatchStateSyncListIcon( + document.querySelector('.userPreferencesPage:not(.hide) [data-kefin-watch-state-sync]') + ); + } + }); + }); + + root.querySelectorAll('.mus-avatar-remove').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const userId = btn.getAttribute('data-user-id'); + if (!userId) return; + const user = getSessionUsers().find((u) => u.userId === userId); + const label = user?.name || userId; + const readdHint = user?.viaAdmin + ? 'You can re-add them from the user list.' + : 'They will need to be authenticated again to re-add.'; + const confirmed = await confirmRemoveWatchTogetherUser(label, readdHint); + if (!confirmed) return; + const token = user?.accessToken; + await removeSessionUser(userId); + if (token) await logoutSessionToken(token); + const idx = pendingEnabledIds.indexOf(userId); + if (idx >= 0) pendingEnabledIds.splice(idx, 1); + if (typeof onRemove === 'function') { + onRemove(userId); + } else { + btn.closest('.mus-watch-together-avatar-wrap')?.remove(); + if (!root.querySelector('.mus-watch-together-avatar-wrap')) { + const host = root.querySelector('.mus-watch-together-avatars'); + if (host) { + host.outerHTML = buildAvatarToggleHtml([], [], { showRemove: true }); + } + } + } + }); + }); + } + + /** + * Jellyfin-style confirm for removing a Watch Together member. + * @returns {Promise} + */ + function confirmRemoveWatchTogetherUser(label, readdHint) { + return new Promise((resolve) => { + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + + if (!window.ModalSystem?.create) { + settle(window.confirm(`Remove ${label} from Watch Together? ${readdHint}`)); + return; + } + + if (window.ModalSystem.isOpen?.(REMOVE_CONFIRM_MODAL_ID)) { + window.ModalSystem.close(REMOVE_CONFIRM_MODAL_ID); + } + + const modal = window.ModalSystem.create({ + id: REMOVE_CONFIRM_MODAL_ID, + title: 'Remove User', + content: ` +
+ Remove ${escapeHtml(label)} from Watch Together? +
+
+ ${escapeHtml(readdHint)} +
+ `, + footer: ` + + `, + // Avoid Escape also closing the parent Watch Together modal (both listen on document). + closeOnBackdrop: true, + closeOnEscape: false, + showCloseButton: true, + onOpen: (modalInstance) => { + if (modalInstance?.dialog) { + modalInstance.dialog.style.maxWidth = '420px'; + modalInstance.dialog.style.width = '90vw'; + } + modalInstance.dialogFooter?.querySelector('#mus-remove-cancel')?.addEventListener('click', (e) => { + e.preventDefault(); + settle(false); + modalInstance.close(); + }); + modalInstance.dialogFooter?.querySelector('#mus-remove-confirm')?.addEventListener('click', (e) => { + e.preventDefault(); + settle(true); + modalInstance.close(); + }); + } + }); + if (modal) { + modal.onClose = () => settle(false); + } + }); + } + + function openWatchTogetherPrompt(options = {}) { + const { + onComplete, + title = 'Watch Together', + showRemove = false, + applyImmediately = false, + hint = 'Watch state will sync to the active accounts in your group as you watch new content.' + } = options; + + if (!window.ModalSystem?.create) { + onComplete?.(); + return; + } + if (watchTogetherModal?.isOpen) { + watchTogetherModal.close(); + } + + const users = getSessionUsers(); + // Manage / prefs entry can open with an empty group so users can add accounts. + if (!users.length && !showRemove) { + onComplete?.(); + return; + } + + const pendingEnabledIds = readDeviceState().enabledUserIds + .filter((id) => users.some((u) => u.userId === id)); + + const contentHtml = buildWatchTogetherPickerBodyHtml(users, pendingEnabledIds, { + showRemove, + hint + }); + + const showAddUser = options.showAddUser !== false; + const modalId = showRemove ? MODAL_ID : WATCH_TOGETHER_MODAL_ID; + + watchTogetherModal = window.ModalSystem.create({ + id: modalId, + title, + content: contentHtml, + footer: showAddUser + ? ` + + ` + : null, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + watchTogetherModal = modalInstance; + if (showRemove) activeModal = modalInstance; + if (modalInstance?.dialog) { + modalInstance.dialog.style.maxWidth = '480px'; + modalInstance.dialog.style.width = '92vw'; + } + const root = modalInstance.dialogContent; + + const rebind = () => { + const nextUsers = getSessionUsers(); + const deviceEnabled = new Set(readDeviceState().enabledUserIds); + // Drop removed users from pending selection + for (let i = pendingEnabledIds.length - 1; i >= 0; i--) { + if (!nextUsers.some((u) => u.userId === pendingEnabledIds[i])) { + pendingEnabledIds.splice(i, 1); + } + } + // Pick up newly added / device-enabled users (e.g. after Add User) + nextUsers.forEach((u) => { + if (deviceEnabled.has(u.userId) && !pendingEnabledIds.includes(u.userId)) { + pendingEnabledIds.push(u.userId); + } + }); + const body = root.querySelector('.mus-watch-together-prompt'); + if (!body) return; + body.outerHTML = buildWatchTogetherPickerBodyHtml(nextUsers, pendingEnabledIds, { + showRemove, + hint + }); + bindAvatarToggleHandlers(root, pendingEnabledIds, { + applyImmediately, + onRemove: () => rebind() + }); + refreshPreferencesMenuLinks(); + }; + + refreshWatchTogetherPicker = rebind; + + bindAvatarToggleHandlers(root, pendingEnabledIds, { + applyImmediately, + onRemove: () => rebind() + }); + + modalInstance.dialogFooter?.querySelector('#mus-wt-add-user')?.addEventListener('click', (e) => { + e.preventDefault(); + openAddUserModal(); + }); + } + }); + if (watchTogetherModal) { + watchTogetherModal.onClose = () => { + // Dismiss (X / backdrop / Escape) applies the current selection + session stamp. + stampLastSelectionAt([...pendingEnabledIds]); + watchTogetherModal = null; + refreshWatchTogetherPicker = null; + if (showRemove) activeModal = null; + onComplete?.(); + }; + } + } + + async function fetchCandidateUsersForAdmin() { + const primaryId = getPrimaryUserId(); + const existing = getSessionUserIds(); + let users = []; + try { + if (typeof ApiClient?.getUsers === 'function') { + users = await ApiClient.getUsers(); + } else { + const serverUrl = ApiClient.serverAddress(); + const response = await fetch(`${serverUrl}/Users`, { + method: 'GET', + headers: { + Authorization: window.apiHelper?.getAuthHeader?.() || authHeaderForToken(ApiClient.accessToken?.() || ''), + Accept: 'application/json' + } + }); + if (!response.ok) throw new Error(`Users ${response.status}`); + users = await response.json(); + } + } catch (e) { + ERR('Failed to load users for admin Watch Together add:', e); + throw e; + } + if (!Array.isArray(users)) users = []; + return users + .filter((u) => u?.Id && u.Id !== primaryId && !existing.has(u.Id)) + .map((u) => ({ userId: u.Id, name: u.Name || u.Id })) + .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' })); + } + + function buildAddUserModalContentHtml() { + const { buildTextInput } = getUi(); + const usernameField = typeof buildTextInput === 'function' + ? buildTextInput('mus-add-username', '', 'Username', 'text') + : ''; + const passwordField = typeof buildTextInput === 'function' + ? buildTextInput('mus-add-password', '', 'Password', 'password') + : ''; + + return ` +
+ ${usernameField} + ${passwordField} + +
+ + +
+
+ `; + } + + function buildAdminAddUserModalContentHtml() { + return ` +
+
+ +
+ +
+ + +
+
+ `; + } + + function populateAdminUserSelect(root, candidates) { + const select = root?.querySelector('#mus-add-user-select'); + const addBtn = root?.querySelector('#mus-add-admin-add'); + if (!select) return; + select.innerHTML = ''; + if (!candidates.length) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'No users available to add'; + select.appendChild(opt); + select.disabled = true; + if (addBtn) addBtn.disabled = true; + return; + } + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = 'Select a user…'; + select.appendChild(placeholder); + candidates.forEach((u) => { + const opt = document.createElement('option'); + opt.value = u.userId; + opt.textContent = u.name; + opt.setAttribute('data-name', u.name); + select.appendChild(opt); + }); + select.disabled = false; + if (addBtn) addBtn.disabled = false; + } + + async function submitAdminAddUser(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + const select = root.querySelector('#mus-add-user-select'); + const errorEl = root.querySelector('#mus-add-user-error'); + const addBtn = root.querySelector('#mus-add-admin-add'); + const userId = select?.value || ''; + const name = select?.selectedOptions?.[0]?.getAttribute('data-name') + || select?.selectedOptions?.[0]?.textContent + || userId; + + if (errorEl) { + errorEl.style.display = 'none'; + errorEl.textContent = ''; + } + if (!userId) { + if (errorEl) { + errorEl.textContent = 'Select a user to add.'; + errorEl.style.display = 'block'; + } + return; + } + if (userId === getPrimaryUserId()) { + if (errorEl) { + errorEl.textContent = 'Cannot add the currently signed-in user to the session'; + errorEl.style.display = 'block'; + } + return; + } + + if (addBtn) addBtn.disabled = true; + try { + await upsertSessionUser({ userId, name, viaAdmin: true }); + modalInstance.close(); + if (typeof refreshWatchTogetherPicker === 'function' && watchTogetherModal?.isOpen) { + refreshWatchTogetherPicker(); + } + window.KefinTweaksToaster?.toast?.(`Added ${name} to Watch Together group`); + } catch (err) { + if (errorEl) { + errorEl.textContent = err?.message || 'Failed to add user'; + errorEl.style.display = 'block'; + } + ERR('Admin add user failed:', err); + if (addBtn) addBtn.disabled = false; + } + } + + async function submitAddUserAuth(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + const username = root.querySelector('#mus-add-username')?.value?.trim() || ''; + const password = root.querySelector('#mus-add-password')?.value ?? ''; + const errorEl = root.querySelector('#mus-add-user-error'); + const authBtn = root.querySelector('#mus-add-authenticate'); + + if (errorEl) { + errorEl.style.display = 'none'; + errorEl.textContent = ''; + } + if (!username) { + if (errorEl) { + errorEl.textContent = 'Username is required.'; + errorEl.style.display = 'block'; + } + return; + } + + if (authBtn) authBtn.disabled = true; + try { + const result = await authenticateByName(username, password); + const userId = result?.User?.Id || result?.UserId; + const name = result?.User?.Name || username; + const accessToken = result?.AccessToken || result?.accessToken; + if (!userId || !accessToken) { + throw new Error('Authentication response missing user or token'); + } + if (userId === getPrimaryUserId()) { + throw new Error('Cannot add the currently signed-in user to the session'); + } + await upsertSessionUser({ userId, name, accessToken }); + const pwEl = root.querySelector('#mus-add-password'); + const userEl = root.querySelector('#mus-add-username'); + if (pwEl) pwEl.value = ''; + if (userEl) userEl.value = ''; + modalInstance.close(); + if (typeof refreshWatchTogetherPicker === 'function' && watchTogetherModal?.isOpen) { + refreshWatchTogetherPicker(); + } + window.KefinTweaksToaster?.toast?.(`Added ${name} to Watch Together group`); + } catch (err) { + if (errorEl) { + errorEl.textContent = err?.message || 'Authentication failed'; + errorEl.style.display = 'block'; + } + ERR('Add user failed:', err); + } finally { + if (authBtn) authBtn.disabled = false; + } + } + + function bindAddUserModalHandlers(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + root.querySelector('#mus-add-cancel')?.addEventListener('click', (e) => { + e.preventDefault(); + modalInstance.close(); + }); + + root.querySelector('#mus-add-authenticate')?.addEventListener('click', (e) => { + e.preventDefault(); + submitAddUserAuth(modalInstance); + }); + + const handleEnter = (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + submitAddUserAuth(modalInstance); + }; + root.querySelector('#mus-add-username')?.addEventListener('keydown', handleEnter); + root.querySelector('#mus-add-password')?.addEventListener('keydown', handleEnter); + + setTimeout(() => root.querySelector('#mus-add-username')?.focus(), 50); + } + + function bindAdminAddUserModalHandlers(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + root.querySelector('#mus-add-cancel')?.addEventListener('click', (e) => { + e.preventDefault(); + modalInstance.close(); + }); + + root.querySelector('#mus-add-admin-add')?.addEventListener('click', (e) => { + e.preventDefault(); + submitAdminAddUser(modalInstance); + }); + + root.querySelector('#mus-add-user-select')?.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + submitAdminAddUser(modalInstance); + }); + } + + async function openAddUserModal() { + if (!window.ModalSystem?.create) { + alert('Modal system not available.'); + return; + } + if (addUserModal?.isOpen) { + addUserModal.close(); + } + + let isAdminUser = false; + try { + isAdminUser = !!(await window.apiHelper?.isAdmin?.()); + } catch (_) { + isAdminUser = false; + } + + addUserModal = window.ModalSystem.create({ + id: ADD_USER_MODAL_ID, + title: null, + content: isAdminUser ? buildAdminAddUserModalContentHtml() : buildAddUserModalContentHtml(), + footer: null, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: async (modalInstance) => { + addUserModal = modalInstance; + if (modalInstance?.dialog) { + modalInstance.dialog.style.maxWidth = '400px'; + modalInstance.dialog.style.width = '90vw'; + } + if (isAdminUser) { + bindAdminAddUserModalHandlers(modalInstance); + const errorEl = modalInstance.dialogContent?.querySelector('#mus-add-user-error'); + try { + const candidates = await fetchCandidateUsersForAdmin(); + populateAdminUserSelect(modalInstance.dialogContent, candidates); + setTimeout(() => modalInstance.dialogContent?.querySelector('#mus-add-user-select')?.focus(), 50); + } catch (err) { + populateAdminUserSelect(modalInstance.dialogContent, []); + if (errorEl) { + errorEl.textContent = err?.message || 'Failed to load users'; + errorEl.style.display = 'block'; + } + } + } else { + bindAddUserModalHandlers(modalInstance); + } + } + }); + if (addUserModal) { + addUserModal.onClose = () => { + addUserModal = null; + }; + } + } + + function openManageModal() { + openWatchTogetherPrompt({ + title: 'Watch Together', + showRemove: true, + applyImmediately: true + }); + } + + function getVisiblePreferencesPage() { + return document.querySelector('.userPreferencesPage:not(.hide)'); + } + + function getVisiblePreferencesUserSection(preferencesPage = null) { + const page = preferencesPage || getVisiblePreferencesPage(); + if (!page) return null; + return page.querySelector('.userSection.verticalSection'); + } + + function getWatchTogetherMenuIcon() { + return 'group'; + } + + function updateWatchStateSyncListIcon(link) { + const iconEl = link?.querySelector('.listItemIcon'); + if (!iconEl) return; + iconEl.className = `material-icons listItemIcon listItemIcon-transparent ${getWatchTogetherMenuIcon()}`; + } + + function createPreferencesListLink({ markerAttr, icon, title, titleHtml, subtitle, subtitleHtml, onClick }) { + const link = document.createElement('a'); + link.is = 'emby-linkbutton'; + link.setAttribute('is', 'emby-linkbutton'); + link.setAttribute('data-ripple', 'false'); + link.href = '#'; + link.style.cssText = 'display:block;padding:0;margin:0'; + link.className = 'listItem-border emby-button'; + link.setAttribute(markerAttr, 'true'); + const titleContent = titleHtml + ? `
${titleHtml}
` + : `
${escapeHtml(title)}
`; + const subtitleContent = subtitleHtml + ? `
${subtitleHtml}
` + : (subtitle ? `
${escapeHtml(subtitle)}
` : ''); + link.innerHTML = ` +
+ +
+ ${titleContent} + ${subtitleContent} +
+
+ `; + link.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + }); + return link; + } + + function refreshPreferencesMenuLinks() { + const preferencesPage = getVisiblePreferencesPage(); + if (!preferencesPage) return; + + const userSection = getVisiblePreferencesUserSection(preferencesPage); + if (!userSection) return; + + // Legacy prefs entry removed from the menu; clean up if still present. + preferencesPage.querySelector('[data-kefin-add-user-session]')?.remove(); + + let syncLink = preferencesPage.querySelector('[data-kefin-watch-state-sync]'); + if (!syncLink) { + syncLink = createPreferencesListLink({ + markerAttr: 'data-kefin-watch-state-sync', + icon: getWatchTogetherMenuIcon(), + title: 'Watch Together', + onClick: openManageModal + }); + const signOut = userSection.querySelector('.btnLogout'); + if (signOut && signOut.parentNode === userSection) { + userSection.insertBefore(syncLink, signOut); + } else { + userSection.appendChild(syncLink); + } + return; + } + + updateWatchStateSyncListIcon(syncLink); + } + + function injectPreferencesMenuLinks() { + const preferencesPage = getVisiblePreferencesPage(); + if (!preferencesPage) return; + + const userSection = getVisiblePreferencesUserSection(preferencesPage); + if (!userSection) return; + + refreshPreferencesMenuLinks(); + LOG('Preferences menu links injected'); + } + + function registerPreferencesPageHandler() { + if (!window.KefinTweaksUtils?.onViewPage) { + setTimeout(registerPreferencesPageHandler, 1000); + return; + } + + window.KefinTweaksUtils.onViewPage((view, element, hash) => { + if (!hash || !( + hash.includes('mypreferencesmenu') + || hash.includes('userpreferences') + || hash.includes('preferences') + )) { + return; + } + setTimeout(injectPreferencesMenuLinks, 300); + setTimeout(injectPreferencesMenuLinks, 800); + }, { + pages: ['mypreferencesmenu'] + }); + + LOG('Preferences onViewPage handler registered'); + } + + function shouldPromptWatchTogether() { + if (!getSessionUsers().length) return false; + if (inContinuousVideoSession) return false; + const { lastSelectionAt } = readDeviceState(); + if (lastSelectionAt && (Date.now() - lastSelectionAt) < getSessionTimeoutMs()) { + return false; + } + return true; + } + + function handleVideoPageEnter() { + inContinuousVideoSession = true; + addWatchTogetherOsdButton(); + + if (!shouldPromptWatchTogether()) { + updateSyncListenerState(); + return; + } + + openWatchTogetherPrompt({ + onComplete: () => { + updateSyncListenerState(); + addWatchTogetherOsdButton(); + } + }); + } + + function handleVideoPageLeave() { + inContinuousVideoSession = false; + } + + function watchTogetherOsdButtonExists() { + return !!document.querySelector('#videoOsdPage:not(.hide) button.btnWatchTogether'); + } + + function createWatchTogetherOsdButton() { + const button = document.createElement('button'); + button.setAttribute('is', 'paper-icon-button-light'); + button.className = 'btnWatchTogether autoSize paper-icon-button-light'; + button.title = 'Watch Together'; + + const icon = document.createElement('span'); + icon.className = 'xlargePaperIconButton material-icons group'; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openManageModal(); + }); + return button; + } + + function addWatchTogetherOsdButton() { + if (isAddingOsdButton) return; + if (watchTogetherOsdButtonExists()) return; + if (!getSessionUsers().length && !getEnabledSessionUsers().length) { + // Still show button if group empty so users can manage from OSD after adding via prefs; + // only skip when feature has no group AND we're not on video — actually always show if hydrated group could be empty. + } + isAddingOsdButton = true; + try { + const buttonsContainer = document.querySelector('#videoOsdPage:not(.hide) .osdControls > .buttons'); + if (!buttonsContainer) return; + const ratingButton = buttonsContainer.querySelector('.btnUserRating'); + if (!ratingButton) return; + const btn = createWatchTogetherOsdButton(); + buttonsContainer.insertBefore(btn, ratingButton); + } finally { + isAddingOsdButton = false; + } + } + + function registerVideoPageHandler() { + if (!window.KefinTweaksUtils?.onViewPage) { + setTimeout(registerVideoPageHandler, 1000); + return; + } + + window.KefinTweaksUtils.onViewPage((view, element, hash) => { + const onVideo = hash && (hash.includes('#/video') || hash.includes('/video')); + if (onVideo) { + setTimeout(handleVideoPageEnter, 400); + setTimeout(addWatchTogetherOsdButton, 900); + } else if (inContinuousVideoSession) { + handleVideoPageLeave(); + } + }, { + pages: ['video'] + }); + + if (window.location.hash.includes('#/video') || window.location.hash.includes('/video')) { + setTimeout(handleVideoPageEnter, 500); + } + + LOG('Video onViewPage handler registered'); + } + + async function initialize() { + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + injectStyles(); + await hydrateGroupCache(); + updateHeaderMultiUserIndicator(); + updateSyncListenerState(); + registerPreferencesPageHandler(); + registerVideoPageHandler(); + LOG('Initialized'); + } catch (e) { + ERR('Initialization failed:', e); + } + } + + window.KefinMultiUserSession = { + openManageModal, + openWatchTogetherPrompt, + getSessionUsers, + getEnabledSessionUsers, + updateSyncListenerState, + hydrateGroupCache + }; + + initialize(); +})(); diff --git a/scripts/peopleCache.js b/scripts/peopleCache.js index 15ed469..cba0c1a 100644 --- a/scripts/peopleCache.js +++ b/scripts/peopleCache.js @@ -12,9 +12,11 @@ // State let moviesTopPeople = null; - let isInitializing = false; let isComplete = false; - let isFetchingTopPeople = false; + /** @type {Promise<*>|null} Shared so concurrent callers await the same paginated fetch */ + let fetchPromise = null; + /** @type {Promise|null} Shared so concurrent callers await the same build */ + let initPromise = null; /** * Extracts necessary item data for caching (similar to watchlist format) @@ -149,121 +151,144 @@ * Fetches and processes top people data from all movies using pagination */ async function fetchTopPeople() { - if (isFetchingTopPeople) return; - isFetchingTopPeople = true; - - try { - LOG('Starting paginated fetch of movies for people data processing...'); - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - const indexedDBCache = window.IndexedDBCache; - - let startIndex = 0; - const limit = 500; - let hasMoreData = true; - let peopleMap = null; - - // Load existing raw people data if available (for resuming) - const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId); - if (existingRawData && !existingRawData.isComplete) { - const moviesProcessed = existingRawData.moviesProcessedCount; - if (moviesProcessed > 0) { - startIndex = moviesProcessed; - peopleMap = new Map(existingRawData.peopleData || []); - LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`); - } - } - - while (hasMoreData) { - const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`; - - LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); - - const data = await window.apiHelper.getQuery(url); - const movies = data.Items || []; - - if (movies.length === 0) { - hasMoreData = false; - } else { - peopleMap = processPeopleDataRaw(movies, peopleMap); - startIndex += movies.length; - - // Update global state with partial results for immediate use - const filteredData = filterPeopleData(peopleMap); - if (filteredData) { - moviesTopPeople = filteredData; + if (fetchPromise) { + return fetchPromise; + } + + fetchPromise = (async () => { + try { + LOG('Starting paginated fetch of movies for people data processing...'); + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const userId = apiClient.getCurrentUserId(); + const indexedDBCache = window.IndexedDBCache; + + let startIndex = 0; + const limit = 500; + let hasMoreData = true; + let peopleMap = null; + + // Load existing raw people data if available (for resuming) + const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId); + if (existingRawData && !existingRawData.isComplete) { + const moviesProcessed = existingRawData.moviesProcessedCount; + if (moviesProcessed > 0) { + startIndex = moviesProcessed; + peopleMap = new Map(existingRawData.peopleData || []); + LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`); } - - // Checkpoint raw data - const rawDataToCache = { - isComplete: movies.length < limit, - moviesProcessedCount: startIndex, - peopleData: Array.from(peopleMap.entries()) - }; - await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000); - - if (movies.length < limit) { + } + + while (hasMoreData) { + const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`; + + LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); + + const data = await window.apiHelper.getQuery(url); + const movies = (data && data.Items) || []; + + if (movies.length === 0) { hasMoreData = false; isComplete = true; + } else { + peopleMap = processPeopleDataRaw(movies, peopleMap); + if (!peopleMap) { + throw new Error('processPeopleDataRaw failed'); + } + startIndex += movies.length; + + // Update global state with partial results for immediate use + const filteredData = filterPeopleData(peopleMap); + if (filteredData) { + moviesTopPeople = filteredData; + } + + // Checkpoint raw data (non-fatal if IndexedDB rejects large payloads) + const rawDataToCache = { + isComplete: movies.length < limit, + moviesProcessedCount: startIndex, + peopleData: Array.from(peopleMap.entries()) + }; + try { + await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000); + } catch (checkpointErr) { + WARN('Failed to checkpoint raw people data (continuing):', checkpointErr); + } + + if (movies.length < limit) { + hasMoreData = false; + isComplete = true; + } } } - } - - const finalPeopleData = filterPeopleData(peopleMap); - if (finalPeopleData) { + + const finalPeopleData = filterPeopleData(peopleMap) || { + actors: [], + directors: [], + writers: [] + }; finalPeopleData.isComplete = true; moviesTopPeople = finalPeopleData; - - await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); - await indexedDBCache.clear('movies_top_people_raw', userId); - - LOG('People cache complete and saved'); + isComplete = true; + + try { + await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); + await indexedDBCache.clear('movies_top_people_raw', userId); + } catch (saveErr) { + WARN('Failed to persist complete people cache (in-memory still available):', saveErr); + } + + LOG(peopleMap ? 'People cache complete and saved' : 'People cache complete (empty library)'); + return finalPeopleData; + } catch (err) { + ERR('Failed to fetch top people data:', err); + return null; + } finally { + fetchPromise = null; } - - return finalPeopleData; - - } catch (err) { - ERR('Failed to fetch top people data:', err); - return null; - } finally { - isFetchingTopPeople = false; - } + })(); + + return fetchPromise; } /** - * Initializes the people cache in the background + * Initializes the people cache (awaits build; concurrent callers share one promise) */ async function initialize() { - if (isInitializing) return; - - // Initialize background fetch - isInitializing = true; - try { - - if (moviesTopPeople !== null && isComplete) { - return; - } - - const indexedDBCache = window.IndexedDBCache; - const userId = window.ApiClient.getCurrentUserId(); - - // Check for valid complete filtered data - const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId); - if (validComplete) { - const cachedData = await indexedDBCache.get('movies_top_people', userId); - if (cachedData && cachedData.isComplete) { - moviesTopPeople = cachedData; - isComplete = true; - LOG('Loaded complete top people data from IndexedDB'); + if (moviesTopPeople !== null && isComplete) { + return; + } + if (initPromise) { + return initPromise; + } + + initPromise = (async () => { + try { + if (moviesTopPeople !== null && isComplete) { return; } + + const indexedDBCache = window.IndexedDBCache; + const userId = window.ApiClient.getCurrentUserId(); + + // Check for valid complete filtered data + const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId); + if (validComplete) { + const cachedData = await indexedDBCache.get('movies_top_people', userId); + if (cachedData && cachedData.isComplete) { + moviesTopPeople = cachedData; + isComplete = true; + LOG('Loaded complete top people data from IndexedDB'); + return; + } + } + await fetchTopPeople(); + } finally { + initPromise = null; } - fetchTopPeople(); - } finally { - isInitializing = false; - } + })(); + + return initPromise; } async function getTopPeople() { @@ -283,21 +308,21 @@ if (!moviesTopPeople) { await initialize(); } - return moviesTopPeople.actors; + return moviesTopPeople?.actors ?? []; } async function getTopDirectors() { if (!moviesTopPeople) { await initialize(); } - return moviesTopPeople.directors; + return moviesTopPeople?.directors ?? []; } async function getTopWriters() { if (!moviesTopPeople) { await initialize(); } - return moviesTopPeople.writers; + return moviesTopPeople?.writers ?? []; } function isCacheComplete() { diff --git a/scripts/removeContinue.js b/scripts/removeContinue.js index b9e69d1..0d42f8c 100644 --- a/scripts/removeContinue.js +++ b/scripts/removeContinue.js @@ -162,7 +162,7 @@ const response = await fetch(userDataUrl, { method: 'GET', headers: { - "Authorization": `MediaBrowser Token="${token}"`, + "Authorization": window.apiHelper.getAuthHeader(), "Content-Type": "application/json" } }); @@ -196,7 +196,7 @@ const response = await fetch(userDataUrl, { method: 'POST', headers: { - "Authorization": `MediaBrowser Token="${token}"`, + "Authorization": window.apiHelper.getAuthHeader(), "Content-Type": "application/json" }, body: JSON.stringify(updatedUserData) diff --git a/scripts/search.css b/scripts/search.css index 67cef31..cdbae22 100644 --- a/scripts/search.css +++ b/scripts/search.css @@ -1,73 +1,91 @@ .searchFields { - position: relative; + position: relative; } .padded-left.padded-right.searchFields { - display: flex; - flex-direction: column-reverse; - align-items: center; + display: flex; + flex-direction: column-reverse; + align-items: center; } .layout-desktop .searchFieldsInner { - width: 100%; + padding-left: calc(30px + 1em); } div#smart-search-wrapper:not([style*="display: none"]) ~ #jellyseerr-search-icon { - display: none; + transform: unset; + position: relative; + top: 0.4em; + right: unset; +} + +.inputContainer.flex-grow:has(.smart-search-wrapper) { + display: grid; + grid-template-columns: 1fr 30px; + gap: 1em; } .smart-search-mode .searchFieldsInner { - position: relative; + position: relative; } div#searchPage { - padding-top: 3rem !important; + padding-top: 3rem !important; } @media (min-width: 600px) { -div#searchPage { - padding-top: 5rem !important; - } + div#searchPage { + padding-top: 5rem !important; + } } div#searchPage { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } -div#searchPage>* { - order: 1; +div#searchPage > * { + order: 1; } -div#searchPage>.jellyseerr-section { - order: 2; - padding-left: max(env(safe-area-inset-left), 3.3%); +div#searchPage > .jellyseerr-section { + order: 2; + padding-left: max(env(safe-area-inset-left), 3.3%); } div#smart-search-results { - padding-bottom: 0 !important; - padding-right: 0 !important; + padding-bottom: 0 !important; + padding-right: 0 !important; } label[for="searchTextInput"] { - display: none; + display: none; } -.searchFieldsInner>.searchfields-icon { - display: none; +.searchfields-icon { + -webkit-align-self: flex-end; + align-self: flex-end; + font-size: 2em; + margin-bottom: 0.1em; + margin-right: 0.25em; +} + +.searchFieldsInner > .searchfields-icon { + display: none; } .input-wrapper { - position: relative; + position: relative; } span.searchfields-icon { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); } -.searchResults\,:not(.smart-search-results), .searchResults:not(.smart-search-results) { - display: none; -} \ No newline at end of file +.searchResults\,:not(.smart-search-results), +.searchResults:not(.smart-search-results) { + display: none; +} diff --git a/scripts/search.js b/scripts/search.js index 7a0234d..45e4bd7 100644 --- a/scripts/search.js +++ b/scripts/search.js @@ -51,28 +51,26 @@ const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'MusicArtist', 'MusicVideo']; const BOOKS_TYPES = ['Book', 'AudioBook']; const OTHER_TYPES = ['Playlist', 'Photo', 'PhotoAlbum', 'LiveTvChannel', 'LiveTvProgram', 'TvChannel', 'TvProgram', 'BoxSet']; + const SEARCH_CACHE_TTL = 60000; + const SEARCH_ITEM_FIELDS = 'PrimaryImageAspectRatio,DateCreated,ProductionYear,RecursiveItemCount,ChildCount,ProviderIds,MediaSourceCount'; - // Cache for search results by search term and type - const searchCache = new Map(); + // Live UI search type for stale-paint guards (`videos` maps to internal `core`) + let activeSearchType = 'videos'; - // Function to generate cache key - function getCacheKey(searchTerm, searchType) { - return `${searchTerm.toLowerCase().trim()}_${searchType}`; + function toInternalSearchType(uiOrInternalType) { + if (!uiOrInternalType || uiOrInternalType === 'videos') return 'core'; + return uiOrInternalType; } - // Function to check cache and return cached results if available - function getCachedResults(searchTerm, searchType) { - const cacheKey = getCacheKey(searchTerm, searchType); - const cached = searchCache.get(cacheKey); - LOG('Cache check:', { searchTerm, searchType, cacheKey, cached: !!cached }); - return cached; + function getLiveSearchTerm() { + return (document.getElementById('searchTextInput')?.value || '').trim(); } - // Function to cache results - function cacheResults(searchTerm, searchType, results) { - const cacheKey = getCacheKey(searchTerm, searchType); - searchCache.set(cacheKey, results); - LOG('Cached results:', { searchTerm, searchType, cacheKey, resultCount: results.total }); + /** True when this run's term+type still match what the user has selected. */ + function isSearchStillWanted(term, searchType) { + const liveTerm = getLiveSearchTerm(); + if (!term || !liveTerm || term !== liveTerm) return false; + return toInternalSearchType(searchType) === toInternalSearchType(activeSearchType); } // Function to clear search results except jellyseerr-section @@ -86,6 +84,136 @@ } } + function getTypeGroups(searchType) { + if (searchType === 'music') return MUSIC_TYPES; + if (searchType === 'books') return BOOKS_TYPES; + if (searchType === 'all') return [...CORE_TYPES, ...MUSIC_TYPES, ...BOOKS_TYPES, ...OTHER_TYPES]; + return CORE_TYPES; + } + + function postProcessSearchItems(itemType, data) { + const items = Array.isArray(data) + ? data.slice() + : (Array.isArray(data?.Items) ? data.Items.slice() : []); + if (!items.length) return items; + + if (itemType === 'MusicArtist') { + return items.sort((a, b) => { + const aIsFolder = a.IsFolder ? 1 : 0; + const bIsFolder = b.IsFolder ? 1 : 0; + return bIsFolder - aIsFolder; + }); + } + + return items.sort((a, b) => { + const aHasImage = a.ImageTags?.Primary ? 1 : 0; + const bHasImage = b.ImageTags?.Primary ? 1 : 0; + return bHasImage - aHasImage; + }); + } + + function buildSearchQueryDef(itemType, searchTerm) { + const term = searchTerm.trim(); + if (itemType === 'MusicArtist') { + return { + path: '/Artists', + queryOptions: { + Limit: 100, + SearchTerm: term, + Fields: 'PrimaryImageAspectRatio', + ImageTypeLimit: 1, + EnableTotalRecordCount: false + } + }; + } + if (itemType === 'Person') { + return { + path: '/Persons', + queryOptions: { + Limit: 100, + SearchTerm: term, + Fields: 'PrimaryImageAspectRatio', + ImageTypeLimit: 1, + EnableTotalRecordCount: false + } + }; + } + return { + queryOptions: { + SearchTerm: term, + IncludeItemTypes: itemType, + Recursive: true, + Limit: 100, + Fields: SEARCH_ITEM_FIELDS, + ImageTypeLimit: 1, + EnableTotalRecordCount: false + } + }; + } + + async function buildSearchSection(itemType, searchTerm, order) { + const apiHelper = window.apiHelper; + if (!apiHelper?.getQuery || !apiHelper?.buildQueryFromSection) { + throw new Error('apiHelper.getQuery / buildQueryFromSection unavailable'); + } + + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const query = buildSearchQueryDef(itemType, searchTerm); + const url = apiHelper.buildQueryFromSection(query, userId, serverUrl); + if (typeof url !== 'string') { + throw new Error(`Invalid search query URL for ${itemType}`); + } + + const queryResult = await apiHelper.getQuery(url, { + useCache: true, + ttl: SEARCH_CACHE_TTL + }); + + const termKey = searchTerm.toLowerCase().trim().replace(/[^a-z0-9]+/gi, '-').slice(0, 48); + const sectionConfig = { + id: `search-${itemType}-${termKey}`, + name: getTypeDisplayName(itemType), + enabled: true, + order, + queries: [query], + ttl: SEARCH_CACHE_TTL, + userConfigurable: false + }; + + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + const raw = typeof queryResult.ensureData === 'function' + ? queryResult.ensureData() + : queryResult.dataPromise; + mappedDataPromise = Promise.resolve(raw) + .then((data) => postProcessSearchItems(itemType, data)) + .catch((err) => { + WARN('search ensureData failed for', itemType, err); + return []; + }); + } + return mappedDataPromise; + }; + + const result = { + data: postProcessSearchItems(itemType, queryResult.data), + isStale: queryResult.isStale === true, + isStalePromise: queryResult.isStalePromise, + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + return { config: sectionConfig, result }; + } + // styles (dedupe by id) function addCustomStyles() { if (document.getElementById('smart-search-styles')) return; @@ -93,7 +221,6 @@ const style = document.createElement('style'); style.id = 'smart-search-styles'; style.textContent = ` - .smart-search-wrapper { margin: 20px 0; } .smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; } .smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; } .smart-search-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; } @@ -107,6 +234,7 @@ z-index: 1; right: 0; cursor: pointer; + font-size: 2em; } `; document.head.appendChild(style); @@ -133,147 +261,6 @@ } } - // build URL helper - function buildSearchUrl(searchTerm, itemTypes, additionalParams = {}) { - try { - const userId = ApiClient.getCurrentUserId(); - const baseUrl = ApiClient.serverAddress(); - const params = new URLSearchParams({ - userId, - limit: '100', - recursive: 'true', - searchTerm: searchTerm.trim(), - fields: 'PrimaryImageAspectRatio,CanDelete', - imageTypeLimit: '1', - enableTotalRecordCount: 'false', - ...additionalParams - }); - itemTypes.forEach(t => params.append('includeItemTypes', t)); - const url = `${baseUrl}/Items?${params.toString()}`; - LOG('buildSearchUrl ->', url); - return url; - } catch (e) { - ERR('buildSearchUrl error', e); - throw e; - } - } - - // perform smart search - async function performSmartSearch(searchTerm, searchType = 'core') { - LOG('performSmartSearch()', { searchTerm, searchType }); - let results = { groupedItems: {}, total: 0 }; - - const searchPage = document.getElementById('searchPage'); - if (!searchPage) { - ERR('searchPage not found'); - return results; - } - - // Hide search suggestions - const searchSuggestions = searchPage.querySelector('.searchSuggestions'); - if (searchSuggestions) { - searchSuggestions.style.display = 'none'; - } - - const noItemsMessage = searchPage.querySelector('.noItemsMessage.dummy-section'); - if (!noItemsMessage) { - const noItemsMessage = document.createElement('div'); - noItemsMessage.className = 'noItemsMessage dummy-section'; - noItemsMessage.style.display = 'none'; - searchPage.appendChild(noItemsMessage); - } - - // Update URL with search query and type - const urlType = searchType === 'core' ? 'videos' : searchType; - updateSearchUrl(searchTerm, urlType); - - // Handle Jellyseerr-only search - if (searchType === 'request') { - return results; - } - - // Show loading spinner - Dashboard.showLoadingMsg(); - - try { - const typeGroups = searchType === 'music' - ? MUSIC_TYPES - : (searchType === 'books' - ? BOOKS_TYPES - : (searchType === 'all' ? [...CORE_TYPES, ...MUSIC_TYPES, ...BOOKS_TYPES, ...OTHER_TYPES] : CORE_TYPES)); - - const promises = typeGroups.map(type => { - let url; - const userId = ApiClient.getCurrentUserId(); - const baseUrl = ApiClient.serverAddress(); - if (type === 'MusicArtist') { - // Use dedicated Artists endpoint for better results - url = `${baseUrl}/Artists?limit=100&searchTerm=${encodeURIComponent(searchTerm)}&fields=PrimaryImageAspectRatio&fields=CanDelete&imageTypeLimit=1&userId=${userId}&enableTotalRecordCount=false`; - } else if (type === 'Person') { - url = `${baseUrl}/Persons?limit=100&searchTerm=${encodeURIComponent(searchTerm)}&fields=PrimaryImageAspectRatio&fields=CanDelete&&imageTypeLimit=1&userId=${userId}&enableTotalRecordCount=false`; - } else { - url = buildSearchUrl(searchTerm, [type]); - } - - return ApiClient.fetch({ url, method: 'GET' }) - .then(resp => resp.json()) - .catch(err => { - WARN('fetch failed for', type, err); - return { Items: [] }; - }); - }); - - const start = performance.now(); - const responses = await Promise.all(promises); - const end = performance.now(); - - // Group results by type - responses.forEach(async (r, idx) => { - if (idx < typeGroups.length) { - // Regular Jellyfin results - const type = typeGroups[idx]; - if (r && r.Items && r.Items.length > 0) { - // For Artists, use all items (they often don't have primary images) - // For other types, filter to show only those with images - let filteredItems; - if (type === 'MusicArtist') { - // Artists: show all items, they often don't have primary images - // Instead, sort the artists by IsFolder - filteredItems = r.Items.sort((a, b) => { - const aIsFolder = a.IsFolder ? 1 : 0; - const bIsFolder = b.IsFolder ? 1 : 0; - return bIsFolder - aIsFolder; - }); - } else { - // Other types: sort so items with images appear first - filteredItems = r.Items.sort((a, b) => { - const aHasImage = a.ImageTags?.Primary ? 1 : 0; - const bHasImage = b.ImageTags?.Primary ? 1 : 0; - return bHasImage - aHasImage; // Items with images first - }); - } - - results.groupedItems[type] = filteredItems; - results.total += filteredItems.length; - } - } - }); - - displaySmartResults(results, Math.round(end - start)); - - // Cache the results - cacheResults(searchTerm, searchType, results); - - return results; - } catch (e) { - ERR('performSmartSearch error', e); - return results; - } finally { - // Hide loading spinner - Dashboard.hideLoadingMsg() - } - } - function getTypeDisplayName(itemType) { const typeMap = { 'Movie': 'Movies', @@ -283,12 +270,15 @@ 'MusicAlbum': 'Albums', 'Audio': 'Songs', 'MusicArtist': 'Artists', + 'MusicVideo': 'Music Videos', 'Playlist': 'Playlists', 'Book': 'Books', 'AudioBook': 'Audiobooks', 'Photo': 'Photos', 'PhotoAlbum': 'Photo Albums', 'TvChannel': 'TV Channels', + 'TvProgram': 'TV Programs', + 'LiveTvChannel': 'Live TV Channels', 'LiveTvProgram': 'Live TV', 'BoxSet': 'Collections' }; @@ -314,53 +304,98 @@ return smartResults; } - function displaySmartResults(results, ms) { - const resultsContainer = ensureSmartResultsContainer(); - const stats = document.getElementById('smart-search-stats'); + // perform smart search via progressive sections (Request/Jellyseerr stays separate) + async function performSmartSearch(searchTerm, searchType = 'core') { + LOG('performSmartSearch()', { searchTerm, searchType }); - if (!stats) return; - - resultsContainer.innerHTML = ''; - - // Check if we have any grouped results - if (!results.groupedItems || Object.keys(results.groupedItems).length === 0) { - resultsContainer.innerHTML = '

No results found

'; - stats.textContent = `Search completed in ${ms}ms - 0 results`; + const searchPage = document.getElementById('searchPage'); + if (!searchPage) { + ERR('searchPage not found'); return; } - // Create sections for each item type that has results - const frag = document.createDocumentFragment(); - let totalShown = 0; - - // Add hidden dummy section for testing/placeholder - const dummySection = document.createElement('div'); - dummySection.className = 'verticalSection emby-scroller-container'; - dummySection.style.display = 'none'; // Hidden dummy section - dummySection.setAttribute('data-dummy', 'true'); - - const dummyTitle = document.createElement('h2'); - dummyTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right'; - dummyTitle.textContent = 'Hidden Dummy Section'; - dummySection.appendChild(dummyTitle); - - frag.appendChild(dummySection); - - // Define the order to display sections - const sectionOrder = ['Movie', 'Series', 'Episode', 'Person', 'MusicArtist', 'MusicAlbum', 'Audio', 'Book', 'AudioBook', 'Photo', 'PhotoAlbum', 'BoxSet', 'Playlist', 'TvChannel', 'TvProgram', 'LiveTvChannel', 'LiveTvProgram']; - - sectionOrder.forEach((itemType) => { - if (results.groupedItems[itemType] && results.groupedItems[itemType].length > 0) { - const items = results.groupedItems[itemType]; - const title = getTypeDisplayName(itemType); - const section = window.cardBuilder.renderCards(items, title, null); - frag.appendChild(section); - totalShown += items.length; - } - }); + const trimmed = (searchTerm || '').trim(); + const internalType = toInternalSearchType(searchType); + const resultsContainer = ensureSmartResultsContainer(); - resultsContainer.appendChild(frag); - stats.textContent = `Search completed in ${ms}ms - ${results.total} results found`; + // Hide search suggestions while searching + const searchSuggestions = searchPage.querySelector('.searchSuggestions'); + if (searchSuggestions) { + searchSuggestions.style.display = trimmed ? 'none' : 'block'; + } + + let noItemsMessage = searchPage.querySelector('.noItemsMessage.dummy-section'); + if (!noItemsMessage) { + noItemsMessage = document.createElement('div'); + noItemsMessage.className = 'noItemsMessage dummy-section'; + noItemsMessage.style.display = 'none'; + searchPage.appendChild(noItemsMessage); + } + + // Update URL with search query and type + const urlType = internalType === 'core' ? 'videos' : internalType; + updateSearchUrl(trimmed, urlType); + + // Handle Jellyseerr-only search + if (internalType === 'request' || searchType === 'request') { + return; + } + + if (!trimmed) { + resultsContainer.innerHTML = ''; + return; + } + + // Drop previous results as soon as a new search is committed + resultsContainer.innerHTML = ''; + + if (!window.cardBuilder?.renderProgressiveSections) { + ERR('cardBuilder.renderProgressiveSections unavailable'); + return; + } + + if (!isSearchStillWanted(trimmed, internalType)) { + LOG('Skipping search; UI already changed', { trimmed, internalType }); + return; + } + + try { + const typeGroups = getTypeGroups(internalType); + const sectionPromises = typeGroups.map((itemType, index) => + buildSearchSection(itemType, trimmed, index).catch((err) => { + WARN('Failed to build search section for', itemType, err); + return null; + }) + ); + + // Resolve section setup (cache lookup / getQuery shell) before paint + const sections = await Promise.all(sectionPromises); + if (!isSearchStillWanted(trimmed, internalType)) { + LOG('Skipping stale search render after fetch', { trimmed, internalType }); + return; + } + + const validSections = sections.filter(Boolean).map((section) => Promise.resolve(section)); + if (!isSearchStillWanted(trimmed, internalType)) { + LOG('Skipping stale search paint', { trimmed, internalType }); + return; + } + + resultsContainer.innerHTML = ''; + await window.cardBuilder.renderProgressiveSections(resultsContainer, validSections, { + showStaleDataBeforeRefresh: true + }); + + if (!isSearchStillWanted(trimmed, internalType)) { + LOG('Search superseded after paint; leaving newer owner in charge', { trimmed, internalType }); + // Empty clear owns the DOM — remove anything this run may have painted + if (!getLiveSearchTerm()) { + resultsContainer.innerHTML = ''; + } + } + } catch (e) { + ERR('performSmartSearch error', e); + } } // main init @@ -416,7 +451,7 @@ const btnRow = document.createElement('div'); btnRow.className = 'smart-search-buttons'; - const smartSearchButtonClass = 'smart-search-btn emby-button raised'; + const smartSearchButtonClass = 'smart-search-btn emby-button flat'; const btnAll = document.createElement('button'); btnAll.id = 'smart-search-all'; btnAll.className = smartSearchButtonClass; @@ -525,52 +560,37 @@ function setSearchType(type) { LOG('setSearchType called:', { type, currentSearchType }); currentSearchType = type; + activeSearchType = type; updateButtonStates(type); - - // If there's a search term, check cache first + const searchTerm = smartInput.value.trim(); LOG('Search term from input:', searchTerm); - if (searchTerm) { - // Map URL type to internal type - const internalType = type === 'videos' ? 'core' : type; - LOG('Internal type mapping:', { type, internalType }); - - // For request type, don't use cache and always perform fresh search - if (type === 'request') { - LOG('Request type - clearing results and performing fresh Jellyseerr search'); - // Clear existing search results except jellyseerr-section - clearSearchResultsExceptJellyseerr(); - performSmartSearch(searchTerm, 'request'); - return; - } - - // Check if we have cached results for this search term and type - const cachedResults = getCachedResults(searchTerm, internalType); - if (cachedResults) { - LOG('Using cached results for', searchTerm, internalType); - displaySmartResults(cachedResults, 0); // 0ms since it's cached - - // Update URL with search query and type even for cached results - const urlType = type === 'videos' ? 'videos' : type; - updateSearchUrl(searchTerm, urlType); - return; - } - - // No cache hit, perform new search - LOG('No cache hit, performing new search'); - performSmartSearch(searchTerm, internalType); + if (!searchTerm) return; + + const internalType = toInternalSearchType(type); + LOG('Internal type mapping:', { type, internalType }); + + if (type === 'request') { + LOG('Request type - clearing results and performing fresh Jellyseerr search'); + clearSearchResultsExceptJellyseerr(); + performSmartSearch(searchTerm, 'request'); + return; } + + performSmartSearch(searchTerm, internalType); } if (!wrapper.dataset.initialized) { wrapper.dataset.initialized = 'true'; let timer = null; const doSmartSearchDebounced = () => { + if (timer) clearTimeout(timer); const term = (smartInput.value || '').trim(); - if (!term) { + if (!term) { + timer = null; const resultsContainer = ensureSmartResultsContainer(); - resultsContainer.innerHTML=''; - Dashboard.hideLoadingMsg(); + resultsContainer.innerHTML = ''; + updateSearchUrl('', currentSearchType); // Show search suggestions const searchSuggestions = document.querySelector('.searchSuggestions'); @@ -578,10 +598,9 @@ searchSuggestions.style.display = 'block'; } - return; + return; } - if (timer) clearTimeout(timer); - const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType; + const internalType = toInternalSearchType(currentSearchType); timer = setTimeout(() => performSmartSearch(term, internalType), 300); }; @@ -589,7 +608,8 @@ smartInput.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ e.preventDefault(); - const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType; + if (timer) clearTimeout(timer); + const internalType = toInternalSearchType(currentSearchType); performSmartSearch((smartInput.value||'').trim(), internalType); } }); @@ -612,13 +632,14 @@ // Set initial search type and update UI currentSearchType = typeFromUrl; + activeSearchType = typeFromUrl; updateButtonStates(typeFromUrl); // Handle URL query parameter - populate smart search and trigger search if (queryFromUrl) { smartInput.value = queryFromUrl; // Trigger immediate search with the URL query - const internalType = typeFromUrl === 'videos' ? 'core' : typeFromUrl; + const internalType = toInternalSearchType(typeFromUrl); performSmartSearch(queryFromUrl, internalType); } } diff --git a/scripts/seasonalOverlay.js b/scripts/seasonalOverlay.js new file mode 100644 index 0000000..9458b6c --- /dev/null +++ b/scripts/seasonalOverlay.js @@ -0,0 +1,144 @@ +function addSnowverlay() { + // Check if snowverlay canvas is already added + const snowverlayCanvas = document.querySelector('.snowverlay'); + if (snowverlayCanvas) { + return; + } + + // Configuration constants + const CANVAS_HEIGHT_PX = 160; + const SNOWFLAKE_AMOUNT = 15; + const SNOWFLAKE_SIZE = { min: 10, max: 22 }; + const SNOWFLAKE_SPEED = { min: 0.2, max: 0.7 }; + + // Dynamically inject styles + const style = document.createElement('style'); + style.textContent = ` + body { + height: 100svh; + margin: 0; + } + .snowverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: ${CANVAS_HEIGHT_PX}px; + pointer-events: none; + z-index: 1000; + } + `; + document.head.appendChild(style); + + // Create and inject canvas + const canvas = document.createElement('canvas'); + canvas.className = 'snowverlay'; + document.body.appendChild(canvas); + const ctx = canvas.getContext('2d'); + + // Resize handler + const resizeCanvas = () => { + canvas.width = window.innerWidth; + canvas.height = CANVAS_HEIGHT_PX; + }; + + window.addEventListener('resize', resizeCanvas); + resizeCanvas(); + + // Snowflake factory + const createSnowflake = (isAnimated = true, index = 0) => ({ + x: Math.random() * canvas.width, + y: isAnimated + ? -20 - (index * canvas.height) / SNOWFLAKE_AMOUNT + : Math.random() * canvas.height, + size: Math.random() * (SNOWFLAKE_SIZE.max - SNOWFLAKE_SIZE.min) + SNOWFLAKE_SIZE.min, + speed: Math.random() * (SNOWFLAKE_SPEED.max - SNOWFLAKE_SPEED.min) + SNOWFLAKE_SPEED.min, + opacity: isAnimated ? null : Math.random() * 0.5 + 0.2, + drift: Math.random() * 0.4 - 0.2, // Random horizontal drift (wind) + rotation: Math.random() * 360, + rotationSpeed: Math.random() * 2 - 1 // Degrees per frame + }); + + // Draw single snowflake + const drawSnowflake = (flake) => { + ctx.save(); + ctx.translate(flake.x, flake.y); + ctx.rotate((flake.rotation * Math.PI) / 180); + ctx.font = `${flake.size}px sans-serif`; + ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity ?? (1 - flake.y / canvas.height)})`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('❤', 0, 0); + ctx.restore(); + }; + + // Animation control + let animationFrame; + const stopAnimation = () => { + if (animationFrame) cancelAnimationFrame(animationFrame); + }; + + // Static snow renderer + const renderStaticSnow = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + Array.from({ length: SNOWFLAKE_AMOUNT }, () => createSnowflake(false)) + .forEach(drawSnowflake); + }; + + // Animated snow renderer + const startSnowAnimation = () => { + const snowflakes = Array.from( + { length: SNOWFLAKE_AMOUNT }, + (_, i) => createSnowflake(true, i) + ); + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + snowflakes.forEach(flake => { + flake.y += flake.speed; + flake.x += flake.drift; + flake.rotation += flake.rotationSpeed; + + if (flake.y > canvas.height) { + flake.y = -10; + flake.x = Math.random() * canvas.width; + } + + drawSnowflake(flake); + }); + + animationFrame = requestAnimationFrame(animate); + }; + + animate(); + return stopAnimation; + }; + + // Motion preference handler + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + let currentAnimation = null; + + const handleMotionChange = (e) => { + stopAnimation(); + if (currentAnimation) currentAnimation(); + + if (e.matches) { + renderStaticSnow(); + currentAnimation = null; + } else { + currentAnimation = startSnowAnimation(); + } + }; + + mediaQuery.addEventListener('change', handleMotionChange); + handleMotionChange(mediaQuery); + + // Cleanup on unload + window.addEventListener('beforeunload', () => { + mediaQuery.removeEventListener('change', handleMotionChange); + stopAnimation(); + }); +} + +addSnowverlay(); \ No newline at end of file diff --git a/scripts/seriesEpisodes.css b/scripts/seriesEpisodes.css index bac446c..6fb5c9b 100644 --- a/scripts/seriesEpisodes.css +++ b/scripts/seriesEpisodes.css @@ -1,3 +1,16 @@ .nextUpSection { - display: none; -} \ No newline at end of file + display: none; +} + +.series-episodes-section .sectionTitleContainer, +.series-episodes-section .emby-scroller { + padding-left: 0; +} + +.detailSection:has(.series-episodes-section) { + overflow: hidden; +} +.series-episodes-section { + padding-left: 0 !important; + margin: 0 !important; +} diff --git a/scripts/seriesEpisodes.js b/scripts/seriesEpisodes.js index b6a1f82..0562f2f 100644 --- a/scripts/seriesEpisodes.js +++ b/scripts/seriesEpisodes.js @@ -9,6 +9,333 @@ const WARN = (...args) => console.warn('[KefinTweaks SeriesEpisodes]', ...args); const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes]', ...args); + const EPISODES_CACHE_TTL = 60000; + + const episodeHookStateByHost = new WeakMap(); + + function getEpisodesMount(host) { + return host?.querySelector('.series-episodes-mount'); + } + + function setEpisodeHookState(host, state) { + if (host) episodeHookStateByHost.set(host, state); + } + + function getEpisodeHookState(host) { + return host ? episodeHookStateByHost.get(host) : null; + } + + function findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber) { + if (!scrollerContainer || !targetEpisodeNumber) return null; + + const itemsContainer = scrollerContainer.querySelector('.itemsContainer'); + if (!itemsContainer) return null; + + if (targetEpisodeNumber.id) { + const byId = itemsContainer.querySelector(`.card[data-id="${targetEpisodeNumber.id}"]`); + if (byId && !byId.hasAttribute('data-skeleton') && !byId.classList.contains('skeleton-card')) { + return byId; + } + } + + for (const card of itemsContainer.querySelectorAll('.card')) { + if (card.hasAttribute('data-skeleton') || card.classList.contains('skeleton-card')) { + continue; + } + for (const link of card.querySelectorAll('.cardText a')) { + const linkText = link.innerText || link.textContent; + const match = linkText.match(/S(\d+):E(\d+)/); + if (match + && parseInt(match[1], 10) === targetEpisodeNumber.season + && parseInt(match[2], 10) === targetEpisodeNumber.episode) { + return card; + } + } + } + + return null; + } + + function seasonId(season) { + return season?.Id ?? season?.id; + } + + function seasonName(season) { + return season?.Name ?? season?.name; + } + + function seasonIndex(season) { + return season?.IndexNumber ?? season?.indexNumber; + } + + function buildEpisodesQueryDef(seriesId, seasonIdValue) { + return { + path: `/Shows/${seriesId}/Episodes`, + queryOptions: { + SeasonId: seasonIdValue, + SortBy: 'IndexNumber', + SortOrder: 'Ascending', + Fields: 'UserData,MediaSourceCount,PrimaryImageAspectRatio', + ImageTypeLimit: 1, + EnableTotalRecordCount: false + } + }; + } + + function postProcessEpisodes(data) { + const items = Array.isArray(data) + ? data.slice() + : (Array.isArray(data?.Items) ? data.Items.slice() : []); + return items.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); + } + + function formatSeasonName(seasonNameValue, seasonNumber) { + if (!seasonNameValue) { + return `Season ${seasonNumber}`; + } + return seasonNameValue; + } + + function buildSeasonViewMoreUrl(season) { + const apiClient = window.ApiClient; + const serverId = apiClient.serverId(); + const sid = seasonId(season); + return `/web/#/details?id=${sid}&serverId=${serverId}`; + } + + async function buildSeriesEpisodesSection(seriesId, seasons, initialSeason, onMultiQueryChange) { + const apiHelper = window.apiHelper; + if (!apiHelper?.getQuery || !apiHelper?.buildQueryFromSection) { + throw new Error('apiHelper.getQuery / buildQueryFromSection unavailable'); + } + + const initialIndex = Math.max(0, seasons.findIndex((season) => seasonId(season) === seasonId(initialSeason))); + const hasMultiple = seasons.length > 1; + const queries = seasons.map((season) => { + const sid = seasonId(season); + return { + name: formatSeasonName(seasonName(season), seasonIndex(season)), + viewMoreUrl: buildSeasonViewMoreUrl(season), + ...buildEpisodesQueryDef(seriesId, sid) + }; + }); + + const initialSeasonObj = seasons[initialIndex]; + const sectionConfig = { + id: `series-episodes-${seriesId}`, + type: 'series-episodes', + name: formatSeasonName(seasonName(initialSeasonObj), seasonIndex(initialSeasonObj)), + enabled: true, + order: 0, + viewMoreUrl: buildSeasonViewMoreUrl(initialSeasonObj), + overflowCard: true, + ttl: EPISODES_CACHE_TTL, + userConfigurable: false, + queries, + _selectedQueryIndex: initialIndex + }; + + try { + const storedLayout = await window.KefinUserHomeScreenConfig?.loadSectionItemsLayout?.( + sectionConfig.id, + { type: sectionConfig.type } + ); + if (storedLayout === 'grid' || storedLayout === 'row') { + sectionConfig.itemsLayout = storedLayout; + } + } catch (e) { + WARN('Failed to restore series-episodes itemsLayout from sectionState', e); + } + + if (hasMultiple) { + sectionConfig.useMultiQueryPicker = true; + sectionConfig.useQueryNamesForSection = true; + sectionConfig.multiQueryPickerLabel = 'Select Season'; + } + + if (typeof onMultiQueryChange === 'function') { + sectionConfig._onMultiQueryChange = onMultiQueryChange; + } + + const initialQuery = queries[initialIndex]; + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const url = apiHelper.buildQueryFromSection(initialQuery, userId, serverUrl); + if (typeof url !== 'string') { + throw new Error(`Invalid episodes query URL for season ${seasonId(initialSeasonObj)}`); + } + + const queryResult = await apiHelper.getQuery(url, { + useCache: true, + ttl: EPISODES_CACHE_TTL + }); + + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + const raw = typeof queryResult.ensureData === 'function' + ? queryResult.ensureData() + : queryResult.dataPromise; + mappedDataPromise = Promise.resolve(raw) + .then((data) => postProcessEpisodes(data)) + .catch((err) => { + WARN('episodes ensureData failed for season', seasonId(initialSeasonObj), err); + return []; + }); + } + return mappedDataPromise; + }; + + const result = { + data: postProcessEpisodes(queryResult.data), + isStale: queryResult.isStale === true, + isStalePromise: queryResult.isStalePromise, + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + return { config: sectionConfig, result }; + } + + function getSectionTitleContainer(host) { + return host?.querySelector('.series-episodes-section .sectionTitleContainer') + || host?.querySelector('.sectionTitleContainer'); + } + + async function renderEpisodesProgressive(seriesId, seasons, initialSeason, host, onMultiQueryChange, configureSection) { + if (!window.cardBuilder?.renderProgressiveSections) { + WARN('cardBuilder.renderProgressiveSections unavailable'); + return null; + } + + const mount = getEpisodesMount(host); + if (!mount) { + WARN('Episodes mount not found in host'); + return null; + } + + const section = await buildSeriesEpisodesSection(seriesId, seasons, initialSeason, onMultiQueryChange); + if (typeof configureSection === 'function') { + configureSection(section.config); + } + const stagingMount = document.createElement('div'); + await window.cardBuilder.renderProgressiveSections( + stagingMount, + [Promise.resolve(section)], + { showStaleDataBeforeRefresh: true } + ); + + const seasonSection = stagingMount.querySelector('.emby-scroller-container') || stagingMount.firstElementChild; + if (!seasonSection) { + WARN('Progressive episodes section did not render'); + return null; + } + + seasonSection.classList.add('series-episodes-section'); + const scrollerContainer = seasonSection.querySelector('.emby-scroller'); + if (scrollerContainer) { + scrollerContainer.classList.add('no-padding'); + } + const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer'); + if (sectionTitleContainer) { + sectionTitleContainer.classList.add('no-padding'); + } + + mount.innerHTML = ''; + mount.appendChild(seasonSection); + + return { sectionEl: seasonSection, section }; + } + + function applyEpisodeScrollAndBadge(host, sectionEl, targetEpisodeNumber, activeSeasonIndex, nextUpHeaderText) { + const scrollerContainer = sectionEl?.querySelector('.emby-scroller') + || host?.querySelector('.series-episodes-section .emby-scroller'); + if (!scrollerContainer) return; + + if (targetEpisodeNumber && activeSeasonIndex !== undefined + && targetEpisodeNumber.season === activeSeasonIndex) { + ensureNextUpEpisodeStyle(nextUpHeaderText); + updateNextUpItem(targetEpisodeNumber, scrollerContainer); + return; + } + + window.cardBuilder.setScrollerPosition(scrollerContainer, 0, false); + LOG('Scrolled to beginning of season (not NextUp season)'); + } + + function ensureNextUpEpisodeStyle(nextUpHeaderText) { + if (document.getElementById('kefinTweaks-nextUpEpisode-style')) { + return; + } + const style = document.createElement('style'); + style.id = 'kefinTweaks-nextUpEpisode-style'; + const escapedHeaderText = (nextUpHeaderText || 'Next Up').replace(/'/g, "\\'").replace(/"/g, '\\"'); + style.textContent = ` + .nextUpEpisode:not(.nextUpEpisode ~ .nextUpEpisode) .cardScalable::after { + content: '${escapedHeaderText}'; + position: absolute; + transform: translateY(0); + top: 0.5em; + left: 0.5em; + padding: 0.25em 0.5em; + text-align: center; + background: rgb(0 0 0 / 85%); + font-size: 1.1em; + border: 1px solid rgb(255 255 255 / 40%); + border-radius: 5px; + pointer-events: none; + } + `; + document.head.appendChild(style); + LOG('Added nextUpEpisode CSS with header text:', nextUpHeaderText); + } + + function insertEpisodesHost(activePage) { + const nextUpSection = activePage.querySelector('.nextUpSection'); + const detailPageContent = activePage.querySelector('.detailPageContent'); + + let host = activePage.querySelector('.series-episodes-section'); + if (host) { + if (!getEpisodesMount(host)) { + const mount = document.createElement('div'); + mount.className = 'series-episodes-mount'; + while (host.firstChild) { + mount.appendChild(host.firstChild); + } + host.appendChild(mount); + } + return host; + } + + host = document.createElement('div'); + host.className = 'series-episodes-section'; + + const mount = document.createElement('div'); + mount.className = 'series-episodes-mount'; + + host.appendChild(mount); + + if (nextUpSection && nextUpSection.parentNode) { + nextUpSection.parentNode.insertBefore(host, nextUpSection.nextSibling); + } else if (detailPageContent) { + const childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible') + || activePage.querySelector('#childrenCollapsible'); + if (childrenCollapsible && childrenCollapsible.parentNode) { + childrenCollapsible.parentNode.insertBefore(host, childrenCollapsible); + } else { + detailPageContent.insertBefore(host, detailPageContent.firstChild); + } + } + + return host; + } + /** * Fetches the Next Up episode for a given series * @param {string} seriesId - The series ID @@ -19,12 +346,10 @@ const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - LOG(`Fetching Next Up episode for series: ${seriesId}`); const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`; const nextUpRes = await fetch(nextUpUrl, { - headers: { "Authorization": `MediaBrowser Token="${token}"` } + headers: { "Authorization": window.apiHelper.getAuthHeader() } }); if (!nextUpRes.ok) { @@ -58,12 +383,10 @@ const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - LOG(`Fetching season data for season ID: ${seasonId}`); const seasonUrl = `${serverUrl}/Items/${seasonId}?UserId=${userId}`; const seasonRes = await fetch(seasonUrl, { - headers: { "Authorization": `MediaBrowser Token="${token}"` } + headers: { "Authorization": window.apiHelper.getAuthHeader() } }); if (!seasonRes.ok) { @@ -79,45 +402,6 @@ } } - /** - * Fetches all episodes for a given series - * @param {string} seriesId - The series ID - * @param {string} [seasonId] - Optional season ID to filter episodes - * @returns {Promise} - Array of episode items - */ - async function fetchEpisodesForSeries(seriesId, seasonId = null) { - try { - const apiClient = window.ApiClient; - const userId = apiClient.getCurrentUserId(); - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - LOG(`Fetching episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`); - const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`; - const episodesRes = await fetch(episodesUrl, { - headers: { "Authorization": `MediaBrowser Token="${token}"` } - }); - - if (!episodesRes.ok) { - throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`); - } - - const episodesData = await episodesRes.json(); - let episodes = episodesData.Items || []; - - // Filter by season ID if specified - if (seasonId !== null) { - episodes = episodes.filter(ep => ep.SeasonId === seasonId); - } - - LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`); - return episodes; - } catch (error) { - ERR(`Failed to fetch episodes for series ${seriesId}:`, error); - return []; - } - } - /** * Extracts season data from the rendered DOM * @param {HTMLElement} activePage - The active page element @@ -197,213 +481,44 @@ return seasons; } - /** - * Formats a season name, adding season number in parentheses if not in "Season XX" format - * @param {string} seasonName - The season name - * @param {number} seasonNumber - The season number - * @returns {string} - Formatted season name - */ - function formatSeasonName(seasonName, seasonNumber) { - if (!seasonName) { - return `Season ${seasonNumber}`; - } - - return seasonName; - } - - /** - * Creates a season selector button with popover for multi-season shows - * @param {HTMLElement} sectionTitleContainer - The section title container element - * @param {Array} seasons - Array of season objects - * @param {Object} currentSeason - The currently selected season - * @param {Function} onSeasonSelect - Callback when a season is selected - */ - function createSeasonSelector(sectionTitleContainer, seasons, currentSeason, onSeasonSelect) { - // Create select season button - const selectButton = document.createElement('button'); - selectButton.className = 'emby-button raised season-selector-button'; - selectButton.textContent = 'Select Season'; - selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;'; - selectButton.setAttribute('aria-label', 'Select season'); - - // Find the tag link (titleLink) and append button after it - const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link'); - if (titleLink) { - titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling); - } else { - const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle'); - if (sectionTitle) { - sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling); - } else { - sectionTitleContainer.appendChild(selectButton); - } - } - - let activePopover = null; - - const closePopover = () => { - if (activePopover) { - activePopover.remove(); - activePopover = null; - } - }; - - const createPopover = () => { - const popover = document.createElement('div'); - popover.className = 'kefinTweaks-popover seasonPopover itemDetailsGroup'; - - const seasonPopoverCss = ` - .seasonPopover { - background-color: rgba(0, 0, 0, 0.95); - position: absolute !important; - z-index: 1001; - display: block; - } - `; - const styleElement = document.createElement('style'); - styleElement.textContent = seasonPopoverCss; - document.head.appendChild(styleElement); - - // Calculate left position based on right edge of the tag link - const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link'); - if (titleLink) { - const titleLinkRect = titleLink.getBoundingClientRect(); - const sectionTitleContainerRect = sectionTitleContainer.getBoundingClientRect(); - const leftOffset = titleLinkRect.right - sectionTitleContainerRect.left; - popover.style.left = `${leftOffset}px`; - } else { - popover.style.left = '0'; - } - - // Support both API shape (Id, Name, IndexNumber) and constructed shape (id, name, indexNumber) - const seasonId = (s) => s.Id != null ? s.Id : s.id; - const seasonName = (s) => s.Name != null ? s.Name : s.name; - const seasonIndex = (s) => s.IndexNumber != null ? s.IndexNumber : s.indexNumber; - - let selectedItemElement = null; - seasons.forEach(season => { - const itemElement = document.createElement('div'); - itemElement.className = 'kefinTweaks-popover-item detailsGroupItem'; - - const isSelected = currentSeason && (seasonId(season) === seasonId(currentSeason)); - if (isSelected) { - itemElement.classList.add('selected'); - selectedItemElement = itemElement; - } - - const displayText = formatSeasonName(`${seasonName(season)}`, seasonIndex(season)); - itemElement.textContent = displayText; - itemElement.addEventListener('click', () => { - LOG(`Season selected: ${displayText}`); - onSeasonSelect(season); - closePopover(); - }); - - popover.appendChild(itemElement); - }); - - // Store reference to selected item for scrolling - popover._selectedItem = selectedItemElement; - - return popover; - }; - - selectButton.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - - if (activePopover) { - closePopover(); - } else { - const popover = createPopover(); - sectionTitleContainer.style.position = 'relative'; - selectButton.parentNode.insertBefore(popover, selectButton.nextSibling); - popover.style.top = '100%'; - popover.style.bottom = 'auto'; - popover.style.marginTop = '8px'; - popover.style.marginBottom = '0'; - - activePopover = popover; - - // Scroll to selected season if it exists - if (popover._selectedItem) { - setTimeout(() => { - popover._selectedItem.scrollIntoView({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest' - }); - LOG('Auto-scrolled to selected season:', popover._selectedItem.textContent); - }, 10); - } - - const closeHandler = (e) => { - if (!popover.contains(e.target) && !selectButton.contains(e.target)) { - closePopover(); - document.removeEventListener('click', closeHandler); - } - }; - - setTimeout(() => { - document.addEventListener('click', closeHandler); - }, 100); - } - }); - } - - /** - * Scrolls to a specific episode in the scroller container - * @param {HTMLElement} scrollerContainer - The scroller container element - * @param {Object} targetEpisodeNumber - Object with season and episode numbers - */ function scrollToEpisode(scrollerContainer, targetEpisodeNumber) { if (!scrollerContainer || !targetEpisodeNumber) return; - setTimeout(() => { - const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer'); - if (!scrollerItemsContainer) { - WARN('Could not find itemsContainer in scroller'); + const doScroll = () => { + let targetCard = findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber); + + if (!targetCard) { + requestAnimationFrame(() => { + targetCard = findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber); + if (targetCard) { + performEpisodeScroll(scrollerContainer, targetCard, targetEpisodeNumber); + } else { + WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + } + }); return; } - const cards = scrollerItemsContainer.querySelectorAll('.card'); - let targetCard = null; - - for (const card of cards) { - const cardTextLinks = card.querySelectorAll('.cardText a'); - for (const link of cardTextLinks) { - const linkText = link.innerText || link.textContent; - const match = linkText.match(/S(\d+):E(\d+)/); - if (match && - parseInt(match[1], 10) === targetEpisodeNumber.season && - parseInt(match[2], 10) === targetEpisodeNumber.episode) { - targetCard = card; - break; - } - } - if (targetCard) break; - } + performEpisodeScroll(scrollerContainer, targetCard, targetEpisodeNumber); + }; - if (targetCard) { - const itemsContainer = scrollerItemsContainer; - const cardOffset = targetCard.offsetLeft; - const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0; - const translateX = (cardOffset - scrollerPadding); - scrollerContainer.scrollToPosition(translateX); - - const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons'); - if (scrollButtons) { - const leftButton = scrollButtons.querySelector('button[data-direction="left"]'); - if (leftButton) { - leftButton.removeAttribute('disabled'); - } - } - - LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); - } else { - WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + requestAnimationFrame(doScroll); + } + + function performEpisodeScroll(scrollerContainer, targetCard, targetEpisodeNumber) { + const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0; + const translateX = targetCard.offsetLeft - scrollerPadding; + window.cardBuilder.setScrollerPosition(scrollerContainer, translateX, false); + + const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons'); + if (scrollButtons) { + const leftButton = scrollButtons.querySelector('button[data-direction="left"]'); + if (leftButton) { + leftButton.removeAttribute('disabled'); } - }, 100); + } + + LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); } /** @@ -465,265 +580,122 @@ return; } - // Check if already rendered if (activePage.querySelector('.series-episodes-section')) { LOG('Episodes section already rendered, skipping'); return; } - // Determine target season parameters - let targetSeasonId, targetSeasonIndex, targetSeasonName; - + let targetSeason = null; if (targetEpisode) { - targetSeasonId = targetEpisode.SeasonId; - targetSeasonIndex = targetEpisode.ParentIndexNumber; - // Try to find matching season in seasons list for consistent naming, otherwise use API data - const matchingSeason = seasons.find(s => s.Id === targetSeasonId); - targetSeasonName = matchingSeason ? matchingSeason.Name : (targetEpisode.SeasonName || `Season ${targetSeasonIndex}`); - } else { - // Fallback to first season - if (seasons.length > 0) { - targetSeasonId = seasons[0].Id; - targetSeasonIndex = seasons[0].IndexNumber; - targetSeasonName = seasons[0].Name; - } else { - WARN('No seasons available to render'); - return; + if (targetEpisode.SeasonId) { + targetSeason = seasons.find(s => s.Id === targetEpisode.SeasonId); + } + if (!targetSeason && targetEpisode.ParentIndexNumber !== undefined) { + targetSeason = seasons.find(s => s.IndexNumber === targetEpisode.ParentIndexNumber); } } - - // Construct targetSeason object for selector - const targetSeason = { - id: targetSeasonId, - name: targetSeasonName, - indexNumber: targetSeasonIndex - }; - - // Fetch episodes for the target season - const episodes = await fetchEpisodesForSeries(seriesId, targetSeasonId); - if (episodes.length === 0) { - WARN(`No episodes found for season ${targetSeasonId}`); + if (!targetSeason) { + targetSeason = seasons[0]; + } + if (!targetSeason) { + WARN('No seasons available to render'); return; } - // Check for cardBuilder - if (!window.cardBuilder || !window.cardBuilder.renderCards) { - WARN('cardBuilder.renderCards not available'); - return; - } - - // Format season name - const seasonName = formatSeasonName(targetSeasonName, targetSeasonIndex); - - // Build viewMoreUrl - const apiClient = window.ApiClient; - const serverId = apiClient.serverId(); - const viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${targetSeasonId}&serverId=${serverId}`; - - // Render season section using renderCards - const seasonSection = window.cardBuilder.renderCards( - episodes, - seasonName, - viewMoreUrl, - true, // overflowCard - null // cardFormat (use default) - ); - - // Define targetEpisodeNumber for internal usage (scrolling/highlighting) const targetEpisodeNumber = targetEpisode ? { season: targetEpisode.ParentIndexNumber, episode: targetEpisode.IndexNumber, id: targetEpisode.Id } : null; - // Mark as series episodes section - seasonSection.classList.add('series-episodes-section'); - - const hideSingleSeasonContainer = window.KefinTweaksConfig?.flattenSingleSeasonShows?.hideSingleSeasonContainer === true; - - const scrollerContainer = seasonSection.querySelector('.emby-scroller'); - if (scrollerContainer) { - scrollerContainer.classList.add('no-padding'); - } - - const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer'); - if (sectionTitleContainer) { - sectionTitleContainer.classList.add('no-padding'); - } - - // Hide NextUp section if it exists const nextUpSection = activePage.querySelector('.nextUpSection'); if (nextUpSection) { nextUpSection.style.display = 'none'; } - // Insert episodes section right after NextUp section (or at the beginning if no NextUp) - const detailPageContent = activePage.querySelector('.detailPageContent'); - if (detailPageContent) { - if (nextUpSection && nextUpSection.parentNode) { - nextUpSection.parentNode.insertBefore(seasonSection, nextUpSection.nextSibling); - } else { - // Find a good insertion point - after NextUp or before childrenCollapsible - const childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible') || - activePage.querySelector('#childrenCollapsible'); - if (childrenCollapsible && childrenCollapsible.parentNode) { - childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible); - } else { - detailPageContent.insertBefore(seasonSection, detailPageContent.firstChild); - } - } + const host = insertEpisodesHost(activePage); + if (!host || !host.parentNode) { + WARN('Could not insert episodes host'); + return; } - // Add season selector if more than one season - if (seasons.length > 1) { - const handleSeasonChange = async (selectedSeason) => { - LOG(`Switching to season: ${selectedSeason.Name}`); - - const newEpisodes = await fetchEpisodesForSeries(seriesId, selectedSeason.Id); - if (newEpisodes.length === 0) { - WARN(`No episodes found for Season ID: ${selectedSeason.Id}`); - return; - } - - // Update section title - const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle'); - if (sectionTitle) { - const newSeasonName = formatSeasonName(selectedSeason.Name, selectedSeason.IndexNumber); - sectionTitle.textContent = newSeasonName; - } - - // Update viewMoreUrl - const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link'); - if (titleLink && selectedSeason.id) { - const newViewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${selectedSeason.id}&serverId=${serverId}`; - titleLink.href = newViewMoreUrl; - LOG(`Updated viewMoreUrl to: ${newViewMoreUrl}`); - } - - // Update season selector button - const oldButton = sectionTitleContainer.querySelector('.season-selector-button'); - if (oldButton) { - oldButton.remove(); - } - createSeasonSelector(sectionTitleContainer, seasons, selectedSeason, handleSeasonChange); - - // Replace episodes - const itemsContainer = scrollerContainer?.querySelector('.itemsContainer'); - if (itemsContainer && window.cardBuilder && window.cardBuilder.buildCard) { - itemsContainer.innerHTML = ''; - - newEpisodes.forEach(episode => { - const card = window.cardBuilder.buildCard(episode, true); - if (card) { - itemsContainer.appendChild(card); - } - }); - - // Check if NextUp is in this season - if (targetEpisodeNumber && targetEpisodeNumber.season === selectedSeason.indexNumber) { - if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) { - setTimeout(() => { - const cards = itemsContainer.querySelectorAll('.card'); - for (const card of cards) { - const cardTextLinks = card.querySelectorAll('.cardText a'); - for (const link of cardTextLinks) { - const linkText = link.innerText || link.textContent; - const match = linkText.match(/S(\d+):E(\d+)/); - if (match && - parseInt(match[1], 10) === targetEpisodeNumber.season && - parseInt(match[2], 10) === targetEpisodeNumber.episode) { - card.classList.add('nextUpEpisode'); - LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); - break; - } - } - } - }, 50); - } - scrollToEpisode(scrollerContainer, targetEpisodeNumber); - } else { - // Scroll to beginning if this season doesn't have NextUp - if (scrollerContainer) { - setTimeout(() => { - scrollerContainer.scrollToPosition(0); - LOG('Scrolled to beginning of season (not NextUp season)'); - }, 100); - } - } - } + const hookState = { + targetEpisodeNumber, + nextUpHeaderText, + activeSeasonIndex: seasonIndex(targetSeason) + }; + setEpisodeHookState(host, hookState); + + const configureSection = (sectionConfig) => { + sectionConfig._onSectionEnhanced = (sectionEl) => { + const state = getEpisodeHookState(host); + if (!state) return; + const liveSection = sectionEl?.isConnected + ? sectionEl + : (host.querySelector('.emby-scroller-container') || host.querySelector('[data-section-id]')); + applyEpisodeScrollAndBadge( + host, + liveSection, + state.targetEpisodeNumber, + state.activeSeasonIndex, + state.nextUpHeaderText + ); }; - - createSeasonSelector(sectionTitleContainer, seasons, targetSeason, handleSeasonChange); + }; + + const onMultiQueryChange = (queryIndex, sectionConfig, newContent) => { + const sectionEl = newContent + || host.querySelector('.emby-scroller-container') + || host.querySelector('[data-section-id]'); + const activeSeason = seasons[queryIndex]; + if (!sectionEl || !activeSeason) return; + + LOG(`Season switched via query picker: ${seasonName(activeSeason)}`); + + applyEpisodeScrollAndBadge( + host, + sectionEl, + hookState.targetEpisodeNumber, + seasonIndex(activeSeason), + hookState.nextUpHeaderText + ); + }; + + let renderResult; + try { + renderResult = await renderEpisodesProgressive( + seriesId, + seasons, + targetSeason, + host, + onMultiQueryChange, + configureSection + ); + } catch (err) { + ERR('Failed to render progressive episodes section', err); + return; + } + if (!renderResult) { + return; } - // Add NextUp episode styling and class if needed - if (targetEpisodeNumber && scrollerContainer) { - if (targetEpisodeNumber.season === targetSeason.indexNumber) { - // Add CSS for nextUpEpisode class if not already added - if (!document.getElementById('kefinTweaks-nextUpEpisode-style')) { - const style = document.createElement('style'); - style.id = 'kefinTweaks-nextUpEpisode-style'; - const escapedHeaderText = nextUpHeaderText.replace(/'/g, "\\'").replace(/"/g, '\\"'); - style.textContent = ` - .nextUpEpisode:not(.nextUpEpisode ~ .nextUpEpisode) .cardScalable::after { - content: '${escapedHeaderText}'; - position: absolute; - transform: translateY(0); - top: 0.5em; - left: 0.5em; - padding: 0.25em 0.5em; - text-align: center; - background: rgb(0 0 0 / 85%); - font-size: 1.1em; - border: 1px solid rgb(255 255 255 / 40%); - border-radius: 5px; - pointer-events: none; - } - `; - document.head.appendChild(style); - LOG('Added nextUpEpisode CSS with header text:', nextUpHeaderText); - } - - // Add nextUpEpisode class if not S1E1 - updateNextUpItem(targetEpisodeNumber, scrollerContainer); - } - } - - LOG(`Successfully rendered episodes section for season ${targetSeason.indexNumber} with ${episodes.length} episodes`); + LOG(`Successfully rendered progressive episodes section for season ${seasonIndex(targetSeason)}`); } - function updateNextUpItem(targetEpisodeNumber, scrollerContainer) { - // Remove existing nextUpEpisode class + function updateNextUpItem(targetEpisodeNumber, scrollerContainer) { const existingNextUpEpisodes = scrollerContainer.querySelectorAll('.nextUpEpisode'); - if (existingNextUpEpisodes.length > 0) { - for (const existingNextUpEpisode of existingNextUpEpisodes) { - existingNextUpEpisode.classList.remove('nextUpEpisode'); - LOG(`Removed nextUpEpisode class from card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); - } - } - - // Add nextUpEpisode class if not S1E1 + for (const existingNextUpEpisode of existingNextUpEpisodes) { + existingNextUpEpisode.classList.remove('nextUpEpisode'); + } + if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) { - const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer'); - if (scrollerItemsContainer) { - const cards = scrollerItemsContainer.querySelectorAll('.card'); - for (const card of cards) { - const cardTextLinks = card.querySelectorAll('.cardText a'); - for (const link of cardTextLinks) { - const linkText = link.innerText || link.textContent; - const match = linkText.match(/S(\d+):E(\d+)/); - if (match && - parseInt(match[1], 10) === targetEpisodeNumber.season && - parseInt(match[2], 10) === targetEpisodeNumber.episode) { - card.classList.add('nextUpEpisode'); - LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); - break; - } - } - } + const targetCard = findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber); + if (targetCard) { + targetCard.classList.add('nextUpEpisode'); + LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); } } - + scrollToEpisode(scrollerContainer, targetEpisodeNumber); } diff --git a/scripts/skinConfig.js b/scripts/skinConfig.js index 3a181eb..38e0c95 100644 --- a/scripts/skinConfig.js +++ b/scripts/skinConfig.js @@ -112,8 +112,8 @@ enabled: false }, { - name: 'Show Section Controls On Hover Only', - url: `${getKefinTweaksRoot()}skins/optional/global/sectionControlsOnHover.css`, + name: 'Show Section Controls Always', + url: `${getKefinTweaksRoot()}skins/optional/global/sectionControlsAlways.css`, enabled: false }, { @@ -135,6 +135,16 @@ name: 'Left Aligned Card Footer Text', url: `${getKefinTweaksRoot()}skins/optional/global/leftAlignCardFooterText.css`, enabled: false + }, + { + name: '16:9 Episode Thumbnails on Season Page', + url: `${getKefinTweaksRoot()}skins/optional/global/episodeThumbnailSize.css`, + enabled: false + }, + { + name: 'Hide Missing And Unaired Indicators On Home Screen', + url: `${getKefinTweaksRoot()}skins/optional/global/hideMissingAndUnairedIndicatorsOnHomeScreen.css`, + enabled: false } ], @@ -168,7 +178,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css', `${getKefinTweaksRoot()}skins/elegant-kefin.css`,, @@ -229,7 +239,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css', `${getKefinTweaksRoot()}skins/neutralfin-kefin.css`, @@ -300,7 +310,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@latest/Theme/GlassFin-Theme-latest-stable.css', 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@latest/Theme/PluginStyling/MediaBar-Plugin-latest.css', @@ -382,7 +392,7 @@ author: 'ya0903', url: [ { - majorServerVersions: [10, 11], + majorServerVersions: [10, 11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/ya0903/NetFin@main/netfin.css', ] @@ -428,31 +438,38 @@ url: [ // TODO: Update this when we get the specific 10.10.X / 10.11.X versions { majorServerVersions: [10], - urls: ['https://cdn.jsdelivr.net/gh/ranaldsgift/Jellypane@main/jellypane.css'] + urls: [ + 'https://cdn.jsdelivr.net/gh/ranaldsgift/Jellypane@main/jellypane.css', + `${getKefinTweaksRoot()}skins/jellypane-kefin.css` + ] }, { - majorServerVersions: [11], - urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css'] + majorServerVersions: [11, 12], + urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css', + `${getKefinTweaksRoot()}skins/jellypane-kefin.css` + ] }, ], colorSchemes: [] }, { - name: 'Finimalism', + name: 'Finimalism 7', author: 'tedhinklater', url: [ { majorServerVersions: [10], urls: [ 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism7.css', - `${getKefinTweaksRoot()}skins/finimalism-kefin.css` + `${getKefinTweaksRoot()}skins/finimalism-kefin.css`, + `${getKefinTweaksRoot()}skins/finimalism-7-kefin.css` ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism10.11.css', - `${getKefinTweaksRoot()}skins/finimalism-kefin.css` + `${getKefinTweaksRoot()}skins/finimalism-kefin.css`, + `${getKefinTweaksRoot()}skins/finimalism-7-kefin.css` ] } ], @@ -475,7 +492,7 @@ author: 'tedhinklater', url: [ { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/Finimalism11.css', `${getKefinTweaksRoot()}skins/finimalism-11-kefin.css` @@ -496,7 +513,7 @@ author: 'tedhinklater', url: [ { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/Finimalism12.css', `${getKefinTweaksRoot()}skins/finimalism-12-kefin.css` @@ -513,7 +530,7 @@ urls: ['https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css'] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css', 'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/10.11_fixes.css' @@ -603,7 +620,7 @@ author: 'alexyle', url: [ { - majorServerVersions: [10, 11], + majorServerVersions: [10, 11, 12], urls: ['https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css'] } ], @@ -714,7 +731,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css', `${getKefinTweaksRoot()}skins/scyfin-kefin.css` @@ -756,7 +773,7 @@ author: 'Catppuccin', url: [ { - majorServerVersions: [10, 11], + majorServerVersions: [10, 11, 12], urls: ['https://jellyfin.catppuccin.com/theme.css'] } ], @@ -791,7 +808,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css', 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Compatibility.css', @@ -889,7 +906,7 @@ author: 'CTalvio', url: [ { - majorServerVersions: [10, 11], + majorServerVersions: [10, 11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/base.css', 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/accentlist.css', @@ -1030,7 +1047,7 @@ author: 'everviolet', url: [ { - majorServerVersions: [10, 11], + majorServerVersions: [10, 11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/everviolet/jellyfin@latest/themes/evergarden-winter.css', ] @@ -1072,7 +1089,7 @@ author: 'buesche87', url: [ { - majorServerVersions: [10, 11], + majorServerVersions: [10, 11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/buesche87/infinitv@latest/infinitv.css', `${getKefinTweaksRoot()}skins/infinitv-kefin.css` @@ -1148,7 +1165,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/theme.css', ] @@ -1193,7 +1210,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/Tetrax-10/jellyfin-better-styles@dist/theme.css', `${getKefinTweaksRoot()}skins/better-styles-kefin-11.css` @@ -1213,7 +1230,7 @@ ] }, { - majorServerVersions: [11], + majorServerVersions: [11, 12], urls: [ 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/theme.css', `${getKefinTweaksRoot()}skins/zesty-kefin.css` @@ -1262,6 +1279,20 @@ } ] }, + { + //Abyss by ArumGupta + //@import url('https://cdn.jsdelivr.net/gh/AumGupta/abyss-jellyfin@main/abyss.css'); + name: 'Abyss', + author: 'ArumGupta', + url: [ + { + majorServerVersions: [10, 11, 12], + urls: [ + 'https://cdn.jsdelivr.net/gh/AumGupta/abyss-jellyfin@main/abyss.css', + ] + }, + ], + }, // Add more skins here as needed ] }; diff --git a/scripts/skinManager.js b/scripts/skinManager.js index d09d328..de5d339 100644 --- a/scripts/skinManager.js +++ b/scripts/skinManager.js @@ -25,6 +25,12 @@ function getMajorServerVersion(version) { const versionParts = version.split('.'); + + if (versionParts[0] !== '10') { + const majorVersion = parseInt(versionParts[0], 10); + return majorVersion; + } + if (versionParts.length >= 2) { const majorVersion = parseInt(versionParts[1], 10); if (!isNaN(majorVersion)) { @@ -90,12 +96,15 @@ // New structure: array of objects with majorServerVersions and urls if (Array.isArray(skin.url) && skin.url.length > 0 && typeof skin.url[0] === 'object' && skin.url[0].majorServerVersions) { - // Find all URL objects that match the current server version + // Find all URL objects that match the current server version. + // majorServerVersions including 12 means any major version >= 12. const matchingUrlObjects = skin.url.filter(urlObj => { if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) { return false; } - return currentMajorVersion !== null && urlObj.majorServerVersions.includes(currentMajorVersion); + if (currentMajorVersion === null) return false; + if (urlObj.majorServerVersions.includes(currentMajorVersion)) return true; + return urlObj.majorServerVersions.includes(12) && currentMajorVersion >= 12; }); if (matchingUrlObjects.length === 0) { @@ -145,11 +154,18 @@ } return skins.filter(skin => { + const matchesMajorVersions = (versions) => { + if (!versions || !Array.isArray(versions)) return false; + if (versions.includes(currentMajorVersion)) return true; + // 12 is a floor: configs that include 12 apply to any major >= 12 + return versions.includes(12) && currentMajorVersion >= 12; + }; + // Check if skin has URL configuration if (!skin.url) { // Skin with no URL (like Default) - check old majorServerVersions field for backward compatibility if (skin.majorServerVersions && Array.isArray(skin.majorServerVersions)) { - const isSupported = skin.majorServerVersions.includes(currentMajorVersion); + const isSupported = matchesMajorVersions(skin.majorServerVersions); if (!isSupported) { LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion} (supports: ${skin.majorServerVersions.join(', ')})`); } @@ -162,12 +178,9 @@ // New structure: check majorServerVersions in URL objects if (Array.isArray(skin.url) && skin.url.length > 0 && typeof skin.url[0] === 'object' && skin.url[0].majorServerVersions) { // Check if any URL object supports the current version - const hasMatchingVersion = skin.url.some(urlObj => { - if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) { - return false; - } - return urlObj.majorServerVersions.includes(currentMajorVersion); - }); + const hasMatchingVersion = skin.url.some(urlObj => + matchesMajorVersions(urlObj.majorServerVersions) + ); if (!hasMatchingVersion) { LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion}`); @@ -177,7 +190,7 @@ // Old structure: check top-level majorServerVersions field (backward compatibility) if (skin.majorServerVersions && Array.isArray(skin.majorServerVersions)) { - const isSupported = skin.majorServerVersions.includes(currentMajorVersion); + const isSupported = matchesMajorVersions(skin.majorServerVersions); if (!isSupported) { LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion} (supports: ${skin.majorServerVersions.join(', ')})`); } @@ -353,43 +366,23 @@ WARN('ApiClient not available, cannot create backup'); return false; } - - const server = window.ApiClient._serverAddress; - const token = window.ApiClient.accessToken(); - - // Find JavaScript Injector plugin - const pluginsResponse = await fetch(`${server}/Plugins`, { - headers: { 'X-Emby-Token': token } - }); - - if (!pluginsResponse.ok) { - WARN(`Failed to get plugins for backup: ${pluginsResponse.statusText}`); + + if (!window.KefinTweaksUtils?.resolvePluginId || !window.KefinTweaksUtils?.getPluginConfiguration) { + WARN('KefinTweaksUtils plugin helpers not available, cannot create backup'); return false; } - const pluginsData = await pluginsResponse.json(); - const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); - const plugin = pluginsList.find(p => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector'); - - if (!plugin) { + const server = window.ApiClient._serverAddress; + const injectorAliases = ['JavaScript Injector', 'JS Injector']; + + const pluginId = await window.KefinTweaksUtils.resolvePluginId(injectorAliases); + if (!pluginId) { WARN('JavaScript Injector plugin not found, cannot create backup'); return false; } - - const pluginId = plugin.Id; - - // Get current injector config + + const injectorConfig = await window.KefinTweaksUtils.getPluginConfiguration(injectorAliases); const configUrl = `${server}/Plugins/${pluginId}/Configuration`; - const configResponse = await fetch(configUrl, { - headers: { 'X-Emby-Token': token } - }); - - if (!configResponse.ok) { - WARN(`Failed to get plugin config for backup: ${configResponse.statusText}`); - return false; - } - - const injectorConfig = await configResponse.json(); // Ensure CustomJavaScripts array exists if (!injectorConfig.CustomJavaScripts) { @@ -420,7 +413,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; const saveResponse = await fetch(configUrl, { method: 'POST', headers: { - 'X-Emby-Token': token, + 'Authorization': window.apiHelper.getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(injectorConfig) @@ -618,6 +611,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; window.KefinTweaksSkinManager = window.KefinTweaksSkinManager || {}; window.KefinTweaksSkinManager.getSkinSourceInfo = getSkinSourceInfo; window.KefinTweaksSkinManager.getAllSkinSources = getAllSkinSources; + window.KefinTweaksSkinManager.toggleAppearancePopover = toggleAppearancePopover; // Load and merge theme configurations async function loadThemeConfig() { @@ -1043,7 +1037,6 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; // Store unregister functions for handlers let unregisterDisplayPreferencesHandler = null; - let unregisterAnyPageHandler = null; /** * Create and show a tooltip @@ -1197,16 +1190,36 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; return true; } + /** + * Inject one-off skin manager UI styles. + */ + function ensureSkinManagerStyles() { + if (document.getElementById('kefin-skin-manager-styles')) return; + const style = document.createElement('style'); + style.id = 'kefin-skin-manager-styles'; + style.textContent = ` +#displayPreferencesPage:has(.MuiStack-root) .fldSkin { + margin-top: 2em; + margin-bottom: -1.8em; +} +`; + (document.head || document.documentElement).appendChild(style); + } + /** * Initialize the skin manager with verification and retry logic */ - function initialize(retryCount = 0) { + async function initialize(retryCount = 0) { const MAX_RETRIES = 10; if (!window.KefinTweaksUtils) { ERR('KefinTweaksUtils not available - skin manager cannot initialize'); return; } + + ensureSkinManagerStyles(); + + await window.KefinTweaksUtils.waitForApiClient(); // Load and merge skin configurations loadSkinConfig(); @@ -1242,7 +1255,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; } /** - * Register the onViewPage handlers + * Register the onViewPage handlers and Appearance header control */ function registerHandlers() { // Only register if not already registered @@ -1255,11 +1268,8 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; unregisterDisplayPreferencesHandler = window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, { pages: ['mypreferencesdisplay'] }); - - // Register for all pages to add the appearance button - unregisterAnyPageHandler = window.KefinTweaksUtils.onViewPage(handleAnyPage, { - pages: [] - }); + + registerAppearanceMenuLink(); LOG('SkinManager handlers registered'); } @@ -1282,72 +1292,36 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; loadSelectedTheme(); }, 100); } - + /** - * Handle any page to add the appearance button - * @param {string} view - The view name - * @param {Element} element - The view element + * Register Appearance in header-right via custom menu links */ - function handleAnyPage(view, element) { - // Skip the display preferences page as it's handled separately - if (view === 'mypreferencesdisplay') { + async function registerAppearanceMenuLink() { + if (!window.KefinTweaksUtils?.addCustomMenuLink) { + WARN('addCustomMenuLink not available; Appearance button not registered'); return; } - - // Wait for the page to be fully loaded - setTimeout(() => { - addAppearanceButton(); - }, 100); - } - - /** - * Add the appearance button to the header - */ - function addAppearanceButton() { - const headerRight = document.querySelector('.headerRight'); - if (!headerRight) { - WARN('Header right section not found'); - return; - } - - // Check if appearance button already exists - if (headerRight.querySelector('.headerAppearanceButton')) { - LOG('Appearance button already exists'); - return; - } - - // Create the appearance button - const appearanceButton = document.createElement('button'); - appearanceButton.type = 'button'; - appearanceButton.setAttribute('is', 'paper-icon-button-light'); - appearanceButton.className = 'headerButton headerButtonRight headerAppearanceButton paper-icon-button-light'; - appearanceButton.title = 'Appearance'; - - // Create the icon span - const iconSpan = document.createElement('span'); - iconSpan.className = 'material-icons palette'; - iconSpan.setAttribute('aria-hidden', 'true'); - - appearanceButton.appendChild(iconSpan); - - // Add click event listener - appearanceButton.addEventListener('click', toggleAppearancePopover); - - // Insert before the search button (or at the end if no search button) - const searchButton = headerRight.querySelector('.headerSearchButton'); - if (searchButton) { - headerRight.insertBefore(appearanceButton, searchButton); - } else { - headerRight.appendChild(appearanceButton); - } - - LOG('Appearance button added successfully'); - - // Unregister the handleAnyPage handler since button is now added and persistent - if (unregisterAnyPageHandler) { - unregisterAnyPageHandler(); - unregisterAnyPageHandler = null; - LOG('Unregistered handleAnyPage handler - button is now persistent'); + + window.KefinTweaksSkinManager = window.KefinTweaksSkinManager || {}; + window.KefinTweaksSkinManager.toggleAppearancePopover = toggleAppearancePopover; + + try { + await window.KefinTweaksUtils.addCustomMenuLink( + 'Appearance', + 'palette', + '', + false, + { + sideMenu: false, + topNavigation: 'right', + userMenu: false, + action: 'KefinTweaksSkinManager.toggleAppearancePopover', + order: 1 + } + ); + LOG('Appearance menu link registered (topNavigation: right)'); + } catch (err) { + ERR('Failed to register Appearance menu link:', err); } } @@ -1355,8 +1329,10 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; * Toggle the appearance popover */ function toggleAppearancePopover(event) { - event.preventDefault(); - event.stopPropagation(); + if (event) { + event.preventDefault(); + event.stopPropagation(); + } // Remove existing popover if it exists const existingPopover = document.querySelector('.kefin-appearance-popover'); @@ -1364,9 +1340,15 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; existingPopover.remove(); return; } + + const anchor = (event && ( + event.currentTarget + || event.target?.closest?.('[data-kefin-custom-menu-top-right], .headerAppearanceButton') + || event.target + )) || document.querySelector('[data-kefin-custom-menu-top-right], .headerAppearanceButton'); // Create the popover - createAppearancePopover(event.target); + createAppearancePopover(anchor); } /** @@ -2186,7 +2168,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; if (backdrop && dialogContainer && !dialogContainer.contains(event.target) && - !event.target.closest('.headerAppearanceButton')) { + !event.target.closest('.headerAppearanceButton, [data-kefin-custom-menu-top-right]')) { // Remove backdrop and dialog container if (backdrop.parentNode) { @@ -2216,7 +2198,8 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; return; } - const displayModeField = form.querySelector('.fldDisplayMode'); + // .fldDisplayMode or #display-settings-layout-description + const displayModeField = form.querySelector('.fldDisplayMode') || form.querySelector('#display-settings-layout-description'); if (!displayModeField) { WARN('Display mode field not found'); return; @@ -2846,20 +2829,13 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; return; } - // Use requestAnimationFrame to avoid blocking - requestAnimationFrame(() => { + requestAnimationFrame(async () => { removeAllSkinCSS(); // Unload skin-specific optional includes for the current skin (if switching) if (currentSkinName) { unloadOptionalIncludesForSkin(currentSkinName); } - }); - - // Performance optimization: Load new CSS first, then remove old CSS - // This prevents the double-reflow/recalc that causes UI freezing - - // Step 1: Load the new CSS first (gets cached and starts loading) - requestAnimationFrame(async () => { + const cssUrls = await getSkinUrlsForCurrentVersion(skin); if (cssUrls && cssUrls.length > 0) { @@ -2873,6 +2849,9 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; // Load optional includes for this skin loadOptionalIncludes(skin); + + window.cardBuilder?.invalidateCardRadiusCache?.(); + requestAnimationFrame(() => window.cardBuilder?.ensureCardRadiusCssVar?.()); }); } diff --git a/scripts/subtitleSearch.js b/scripts/subtitleSearch.js index 890da95..fa29c90 100644 --- a/scripts/subtitleSearch.js +++ b/scripts/subtitleSearch.js @@ -331,14 +331,13 @@ throw new Error('ApiClient not available for authorization'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${language}`, { method: 'GET', headers: { 'Accept': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -418,14 +417,13 @@ throw new Error('ApiClient not available'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}`, { method: 'GET', headers: { 'Accept': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -505,14 +503,13 @@ throw new Error('ApiClient not available'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}`, { method: 'GET', headers: { 'Accept': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -569,7 +566,6 @@ throw new Error('ApiClient not available'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const deviceId = ApiClient.deviceId(); @@ -588,7 +584,7 @@ method: 'GET', headers: { 'Accept': 'application/json', - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -625,7 +621,6 @@ throw new Error('ApiClient not available'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const userId = ApiClient.getCurrentUserId(); @@ -638,7 +633,7 @@ const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Command`, { method: 'POST', headers: { - 'X-Emby-Token': token, + 'Authorization': window.apiHelper.getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -758,7 +753,6 @@ throw new Error('No play state found in session'); } - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); // Get current item ID @@ -788,7 +782,7 @@ const commandResponse = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { method: 'POST', headers: { - 'X-Emby-Token': token + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -897,13 +891,12 @@ // Get existing subtitle streams before download const existingStreams = await getExistingSubtitleStreams(itemId); - const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${subId}`, { method: 'POST', headers: { - 'Authorization': `MediaBrowser Token="${token}"` + 'Authorization': window.apiHelper.getAuthHeader() } }); diff --git a/scripts/third party/jquery.flurry.min.js b/scripts/third party/jquery.flurry.min.js deleted file mode 100644 index c59f386..0000000 --- a/scripts/third party/jquery.flurry.min.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Flurry jQuery Plugin - * - * Flurry is an easy-to-use animated snow plugin for jQuery. It takes advantage - * of CSS transforms, CSS transitions and requestAnimationFrame to provide - * smooth animation for modern browsers. Props to Jonathan Nicol @f6design - * for boilerplate code - * (see http://jonathannicol.com/blog/2012/05/06/a-jquery-plugin-boilerplate/) - * - * @link https://github.com/joshmcrty/Flurry - * @version 1.2.0 - * @author Josh McCarty - * @copyright 2016 Josh McCarty - * @license https://github.com/joshmcrty/Flurry/blob/master/LICENSE GPLv2 - */ -!function(n,e){!function(){for(var n=0,e=["ms","moz","webkit","o"],a=0;a");f.html(o).css({color:a.blur&&l<(a.large+a.small)/2?"transparent":d,"text-shadow":a.blur&&l<(a.large+a.small)/2?"0 0 1px "+d:"none",display:"inline-block","line-height":1,margin:0,padding:"2px","pointer-events":"none","font-size":l+"px",opacity:a.startTransparency,position:"absolute",top:"-"+1.2*a.large+"px",transform:"translateX("+s+"px) translateY(0px) rotateZ("+a.startRotation+"deg)",transition:"transform "+u/1e3+"s linear, opacity "+u/1e3+"s "+a.opacityEasing,"z-index":a.zIndex}),a.onFlake!==e&&a.onFlake.call(f[0]),f.appendTo(i),!function(){var n=(document.body||document.documentElement).style;return n.transition!==e||n.WebkitTransition!==e||n.MozTransition!==e||n.MsTransition!==e||n.OTransition!==e}?f.animate(h,u,"linear",function(){n(this).remove()}):(f.on("transitionend.flurry",function(e){n(e.target).remove()}),window.requestAnimationFrame(function(){f.css(h)}))}function r(t,r){var o=this,s=t,c=n(t);function l(n){r[n]!==e&&r[n].call(s)}return r=n.extend({height:c.height()>200?200:c.height(),useRelative:!c.is("body")},n.fn[a].defaults,r),n.each(r,function(n,e){parseInt(e)&&(r[n]=parseInt(e))}),function(){!0===r.useRelative&&"static"===c.css("position")&&c.css({position:"relative"});var e,a,t,s,u=n(document.createElement("div")).addClass("flurry-container").css({margin:0,padding:0,position:"absolute",top:0,right:0,left:0,height:r.height,overflow:r.overflow,"pointer-events":"none"}).prependTo(c),m=u.width();n(window).resize(function(){m=u.width()}),o.flakeInterval=(e=function(){i(r,u,m)},a=r.frequency,t=(new Date).getTime(),(s=new Object).value=window.requestAnimationFrame(function n(){(new Date).getTime()-t>=a&&(e.call(),t=(new Date).getTime()),s.value=window.requestAnimationFrame(n)}),s),l("onInit")}(),{option:function(n,e){if(!e)return r[n];r[n]=parseInt(e)||e},destroy:function(){c.each(function(){var e,t=n(this);e=o.flakeInterval,window.cancelAnimationFrame?window.cancelAnimationFrame(e.value):window.webkitCancelAnimationFrame?window.webkitCancelAnimationFrame(e.value):window.webkitCancelRequestAnimationFrame?window.webkitCancelRequestAnimationFrame(e.value):window.mozCancelRequestAnimationFrame?window.mozCancelRequestAnimationFrame(e.value):window.oCancelRequestAnimationFrame?window.oCancelRequestAnimationFrame(e.value):window.msCancelRequestAnimationFrame?window.msCancelRequestAnimationFrame(e.value):clearInterval(e),t.find(".flurry-container").remove(),l("onDestroy"),t.removeData("plugin_"+a)})}}}n.fn[a]=function(t){if("string"==typeof arguments[0]){var i,o=arguments[0],s=Array.prototype.slice.call(arguments,1);return this.each(function(){if(!n.data(this,"plugin_"+a)||"function"!=typeof n.data(this,"plugin_"+a)[o])throw new Error("Method "+o+" does not exist on jQuery."+a);i=n.data(this,"plugin_"+a)[o].apply(this,s)}),i!==e?i:this}if("object"==typeof t||!t)return this.each(function(){n.data(this,"plugin_"+a)||n.data(this,"plugin_"+a,new r(this,t))})},n.fn[a].defaults={onInit:e,onDestroy:e,onFlake:e,character:["❄"],color:"white",frequency:100,speed:3e3,small:8,large:28,wind:40,windVariance:20,rotation:90,rotationVariance:180,startRotation:0,startOpacity:1,endOpacity:0,opacityEasing:"cubic-bezier(1,.3,.6,.74)",blur:!1,overflow:"hidden",zIndex:9999}}(jQuery); \ No newline at end of file diff --git a/scripts/thirdparty/pickr/LICENSE b/scripts/thirdparty/pickr/LICENSE new file mode 100644 index 0000000..5b2447f --- /dev/null +++ b/scripts/thirdparty/pickr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 - present Simon Reinisch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/thirdparty/pickr/pickr-nano.min.css b/scripts/thirdparty/pickr/pickr-nano.min.css new file mode 100644 index 0000000..31ed391 --- /dev/null +++ b/scripts/thirdparty/pickr/pickr-nano.min.css @@ -0,0 +1,2 @@ +/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */ +.pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;border-radius:.15em;background:url("data:image/svg+xml;utf8, ") no-repeat center;background-size:0;transition:all .3s}.pickr .pcr-button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pickr .pcr-button::before{z-index:initial}.pickr .pcr-button::after{position:absolute;content:"";top:0;left:0;height:100%;width:100%;transition:background .3s;background:var(--pcr-color);border-radius:.15em}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear::before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pickr *,.pcr-app *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr input:focus,.pickr input.pcr-active,.pickr button:focus,.pickr button.pcr-active,.pcr-app input:focus,.pcr-app input.pcr-active,.pcr-app button:focus,.pcr-app button.pcr-active{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-palette,.pickr .pcr-slider,.pcr-app .pcr-palette,.pcr-app .pcr-slider{transition:box-shadow .3s}.pickr .pcr-palette:focus,.pickr .pcr-slider:focus,.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports(display: grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit, 1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:rgba(0,0,0,0);z-index:1}.pcr-app .pcr-swatches>button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button::after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -0.2em 0 -0.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(0.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff;width:auto}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff}.pcr-app .pcr-interaction .pcr-save:hover,.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover{filter:brightness(0.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-cancel{background:#f44250}.pcr-app .pcr-interaction .pcr-clear:focus,.pcr-app .pcr-interaction .pcr-cancel:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-palette,.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity{position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-palette:active,.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=nano]{width:14.25em;max-width:95vw}.pcr-app[data-theme=nano] .pcr-swatches{margin-top:.6em;padding:0 .6em}.pcr-app[data-theme=nano] .pcr-interaction{padding:0 .6em .6em .6em}.pcr-app[data-theme=nano] .pcr-selection{display:grid;grid-gap:.6em;grid-template-columns:1fr 4fr;grid-template-rows:5fr auto auto;align-items:center;height:10.5em;width:100%;align-self:flex-start}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview{grid-area:2/1/4/1;height:100%;width:100%;display:flex;flex-direction:row;justify-content:center;margin-left:.6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-last-color{display:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color{position:relative;background:var(--pcr-color);width:2em;height:2em;border-radius:50em;overflow:hidden}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{grid-area:1/1/2/3;width:100%;height:100%;z-index:1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser{grid-area:2/2/2/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{grid-area:3/2/3/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{height:.5em;margin:0 .6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;transform:translateY(-50%)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{flex-grow:1;border-radius:50em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(to right, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%))}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(to right, transparent, black),url("data:image/svg+xml;utf8, ");background-size:100%,.25em} diff --git a/scripts/thirdparty/pickr/pickr.min.js b/scripts/thirdparty/pickr/pickr.min.js new file mode 100644 index 0000000..6b99ff4 --- /dev/null +++ b/scripts/thirdparty/pickr/pickr.min.js @@ -0,0 +1,2 @@ +/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pickr=e():t.Pickr=e()}(self,(()=>(()=>{"use strict";var t={d:(e,o)=>{for(var n in o)t.o(o,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:o[n]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};t.d(e,{default:()=>E});var o={};function n(t,e,o,n,i={}){e instanceof HTMLCollection||e instanceof NodeList?e=Array.from(e):Array.isArray(e)||(e=[e]),Array.isArray(o)||(o=[o]);for(const s of e)for(const e of o)s[t](e,n,{capture:!1,...i});return Array.prototype.slice.call(arguments,1)}t.r(o),t.d(o,{adjustableInputNumbers:()=>p,createElementFromString:()=>r,createFromTemplate:()=>a,eventPath:()=>l,off:()=>s,on:()=>i,resolveElement:()=>c});const i=n.bind(null,"addEventListener"),s=n.bind(null,"removeEventListener");function r(t){const e=document.createElement("div");return e.innerHTML=t.trim(),e.firstElementChild}function a(t){const e=(t,e)=>{const o=t.getAttribute(e);return t.removeAttribute(e),o},o=(t,n={})=>{const i=e(t,":obj"),s=e(t,":ref"),r=i?n[i]={}:n;s&&(n[s]=t);for(const n of Array.from(t.children)){const t=e(n,":arr"),i=o(n,t?{}:r);t&&(r[t]||(r[t]=[])).push(Object.keys(i).length?i:n)}return n};return o(r(t))}function l(t){let e=t.path||t.composedPath&&t.composedPath();if(e)return e;let o=t.target.parentElement;for(e=[t.target,o];o=o.parentElement;)e.push(o);return e.push(document,window),e}function c(t){return t instanceof Element?t:"string"==typeof t?t.split(/>>/g).reduce(((t,e,o,n)=>(t=t.querySelector(e),ot)){function o(o){const n=[.001,.01,.1][Number(o.shiftKey||2*o.ctrlKey)]*(o.deltaY<0?1:-1);let i=0,s=t.selectionStart;t.value=t.value.replace(/[\d.]+/g,((t,o)=>o<=s&&o+t.length>=s?(s=o,e(Number(t),n,i)):(i++,t))),t.focus(),t.setSelectionRange(s,s),o.preventDefault(),t.dispatchEvent(new Event("input"))}i(t,"focus",(()=>i(window,"wheel",o,{passive:!1}))),i(t,"blur",(()=>s(window,"wheel",o)))}const{min:u,max:h,floor:d,round:m}=Math;function f(t,e,o){e/=100,o/=100;const n=d(t=t/360*6),i=t-n,s=o*(1-e),r=o*(1-i*e),a=o*(1-(1-i)*e),l=n%6;return[255*[o,r,s,s,a,o][l],255*[a,o,o,r,s,s][l],255*[s,s,a,o,o,r][l]]}function v(t,e,o){const n=(2-(e/=100))*(o/=100)/2;return 0!==n&&(e=1===n?0:n<.5?e*o/(2*n):e*o/(2-2*n)),[t,100*e,100*n]}function b(t,e,o){const n=u(t/=255,e/=255,o/=255),i=h(t,e,o),s=i-n;let r,a;if(0===s)r=a=0;else{a=s/i;const n=((i-t)/6+s/2)/s,l=((i-e)/6+s/2)/s,c=((i-o)/6+s/2)/s;t===i?r=c-l:e===i?r=1/3+n-c:o===i&&(r=2/3+l-n),r<0?r+=1:r>1&&(r-=1)}return[360*r,100*a,100*i]}function y(t,e,o,n){e/=100,o/=100;return[...b(255*(1-u(1,(t/=100)*(1-(n/=100))+n)),255*(1-u(1,e*(1-n)+n)),255*(1-u(1,o*(1-n)+n)))]}function g(t,e,o){e/=100;const n=2*(e*=(o/=100)<.5?o:1-o)/(o+e)*100,i=100*(o+e);return[t,isNaN(n)?0:n,i]}function _(t){return b(...t.match(/.{2}/g).map((t=>parseInt(t,16))))}function w(t){t=t.match(/^[a-zA-Z]+$/)?function(t){if("black"===t.toLowerCase())return"#000";const e=document.createElement("canvas").getContext("2d");return e.fillStyle=t,"#000"===e.fillStyle?null:e.fillStyle}(t):t;const e={cmyk:/^cmyk\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)/i,rgba:/^rgba?\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D*?(([\d.]+)(%?)|$)/i,hsla:/^hsla?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hsva:/^hsva?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hexa:/^#?(([\dA-Fa-f]{3,4})|([\dA-Fa-f]{6})|([\dA-Fa-f]{8}))$/i},o=t=>t.map((t=>/^(|\d+)\.\d+|\d+$/.test(t)?Number(t):void 0));let n;t:for(const i in e)if(n=e[i].exec(t))switch(i){case"cmyk":{const[,t,e,s,r]=o(n);if(t>100||e>100||s>100||r>100)break t;return{values:y(t,e,s,r),type:i}}case"rgba":{let[,t,,e,,s,,,r]=o(n);if(t="%"===n[2]?t/100*255:t,e="%"===n[4]?e/100*255:e,s="%"===n[6]?s/100*255:s,r="%"===n[9]?r/100:r,t>255||e>255||s>255||r<0||r>1)break t;return{values:[...b(t,e,s),r],a:r,type:i}}case"hexa":{let[,t]=n;4!==t.length&&3!==t.length||(t=t.split("").map((t=>t+t)).join(""));const e=t.substring(0,6);let o=t.substring(6);return o=o?parseInt(o,16)/255:void 0,{values:[..._(e),o],a:o,type:i}}case"hsla":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[...g(t,e,s),r],a:r,type:i}}case"hsva":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[t,e,s,r],a:r,type:i}}}return{values:null,type:null}}function A(t=0,e=0,o=0,n=1){const i=(t,e)=>(o=-1)=>e(~o?t.map((t=>Number(t.toFixed(o)))):t),s={h:t,s:e,v:o,a:n,toHSVA(){const t=[s.h,s.s,s.v,s.a];return t.toString=i(t,(t=>`hsva(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toHSLA(){const t=[...v(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`hsla(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toRGBA(){const t=[...f(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`rgba(${t[0]}, ${t[1]}, ${t[2]}, ${s.a})`)),t},toCMYK(){const t=function(t,e,o){const n=f(t,e,o),i=n[0]/255,s=n[1]/255,r=n[2]/255,a=u(1-i,1-s,1-r);return[100*(1===a?0:(1-i-a)/(1-a)),100*(1===a?0:(1-s-a)/(1-a)),100*(1===a?0:(1-r-a)/(1-a)),100*a]}(s.h,s.s,s.v);return t.toString=i(t,(t=>`cmyk(${t[0]}%, ${t[1]}%, ${t[2]}%, ${t[3]}%)`)),t},toHEXA(){const t=function(t,e,o){return f(t,e,o).map((t=>m(t).toString(16).padStart(2,"0")))}(s.h,s.s,s.v),e=s.a>=1?"":Number((255*s.a).toFixed(0)).toString(16).toUpperCase().padStart(2,"0");return e&&t.push(e),t.toString=()=>`#${t.join("").toUpperCase()}`,t},clone:()=>A(s.h,s.s,s.v,s.a)};return s}const $=t=>Math.max(Math.min(t,1),0);function C(t){const e={options:Object.assign({lock:null,onchange:()=>0,onstop:()=>0},t),_keyboard(t){const{options:o}=e,{type:n,key:i}=t;if(document.activeElement===o.wrapper){const{lock:o}=e.options,s="ArrowUp"===i,r="ArrowRight"===i,a="ArrowDown"===i,l="ArrowLeft"===i;if("keydown"===n&&(s||r||a||l)){let n=0,i=0;"v"===o?n=s||r?1:-1:"h"===o?n=s||r?-1:1:(i=s?-1:a?1:0,n=l?-1:r?1:0),e.update($(e.cache.x+.01*n),$(e.cache.y+.01*i)),t.preventDefault()}else i.startsWith("Arrow")&&(e.options.onstop(),t.preventDefault())}},_tapstart(t){i(document,["mouseup","touchend","touchcancel"],e._tapstop),i(document,["mousemove","touchmove"],e._tapmove),t.cancelable&&t.preventDefault(),e._tapmove(t)},_tapmove(t){const{options:o,cache:n}=e,{lock:i,element:s,wrapper:r}=o,a=r.getBoundingClientRect();let l=0,c=0;if(t){const e=t&&t.touches&&t.touches[0];l=t?(e||t).clientX:0,c=t?(e||t).clientY:0,la.left+a.width&&(l=a.left+a.width),ca.top+a.height&&(c=a.top+a.height),l-=a.left,c-=a.top}else n&&(l=n.x*a.width,c=n.y*a.height);"h"!==i&&(s.style.left=`calc(${l/a.width*100}% - ${s.offsetWidth/2}px)`),"v"!==i&&(s.style.top=`calc(${c/a.height*100}% - ${s.offsetHeight/2}px)`),e.cache={x:l/a.width,y:c/a.height};const p=$(l/a.width),u=$(c/a.height);switch(i){case"v":return o.onchange(p);case"h":return o.onchange(u);default:return o.onchange(p,u)}},_tapstop(){e.options.onstop(),s(document,["mouseup","touchend","touchcancel"],e._tapstop),s(document,["mousemove","touchmove"],e._tapmove)},trigger(){e._tapmove()},update(t=0,o=0){const{left:n,top:i,width:s,height:r}=e.options.wrapper.getBoundingClientRect();"h"===e.options.lock&&(o=t),e._tapmove({clientX:n+s*t,clientY:i+r*o})},destroy(){const{options:t,_tapstart:o,_keyboard:n}=e;s(document,["keydown","keyup"],n),s([t.wrapper,t.element],"mousedown",o),s([t.wrapper,t.element],"touchstart",o,{passive:!1})}},{options:o,_tapstart:n,_keyboard:r}=e;return i([o.wrapper,o.element],"mousedown",n),i([o.wrapper,o.element],"touchstart",n,{passive:!1}),i(document,["keydown","keyup"],r),e}function k(t={}){t=Object.assign({onchange:()=>0,className:"",elements:[]},t);const e=i(t.elements,"click",(e=>{t.elements.forEach((o=>o.classList[e.target===o?"add":"remove"](t.className))),t.onchange(e),e.stopPropagation()}));return{destroy:()=>s(...e)}}const S={variantFlipOrder:{start:"sme",middle:"mse",end:"ems"},positionFlipOrder:{top:"tbrl",right:"rltb",bottom:"btrl",left:"lrbt"},position:"bottom",margin:8,padding:0},O=(t,e,o)=>{const n="object"!=typeof t||t instanceof HTMLElement?{reference:t,popper:e,...o}:t;return{update(t=n){const{reference:e,popper:o}=Object.assign(n,t);if(!o||!e)throw new Error("Popper- or reference-element missing.");return((t,e,o)=>{const{container:n,arrow:i,margin:s,padding:r,position:a,variantFlipOrder:l,positionFlipOrder:c}={container:document.documentElement.getBoundingClientRect(),...S,...o},{left:p,top:u}=e.style;e.style.left="0",e.style.top="0";const h=t.getBoundingClientRect(),d=e.getBoundingClientRect(),m={t:h.top-d.height-s,b:h.bottom+s,r:h.right+s,l:h.left-d.width-s},f={vs:h.left,vm:h.left+h.width/2-d.width/2,ve:h.left+h.width-d.width,hs:h.top,hm:h.bottom-h.height/2-d.height/2,he:h.bottom-d.height},[v,b="middle"]=a.split("-"),y=c[v],g=l[b],{top:_,left:w,bottom:A,right:$}=n;for(const t of y){const o="t"===t||"b"===t;let n=m[t];const[s,a]=o?["top","left"]:["left","top"],[l,c]=o?[d.height,d.width]:[d.width,d.height],[p,u]=o?[A,$]:[$,A],[v,b]=o?[_,w]:[w,_];if(!(np))for(const p of g){let m=f[(o?"v":"h")+p];if(!(mu)){if(m-=d[a],n-=d[s],e.style[a]=`${m}px`,e.style[s]=`${n}px`,i){const e=o?h.width/2:h.height/2,r=c/2,u=e>r,d=m+{s:u?r:e,m:r,e:u?r:c-e}[p],f=n+{t:l,b:0,r:0,l}[t];i.style[a]=`${d}px`,i.style[s]=`${f}px`}return t+p}}}return e.style.left=p,e.style.top=u,null})(e,o,n)}}};class E{static utils=o;static version="1.9.1";static I18N_DEFAULTS={"ui:dialog":"color picker dialog","btn:toggle":"toggle color picker dialog","btn:swatch":"color swatch","btn:last-color":"use previous color","btn:save":"Save","btn:cancel":"Cancel","btn:clear":"Clear","aria:btn:save":"save and close","aria:btn:cancel":"cancel and close","aria:btn:clear":"clear and close","aria:input":"color input field","aria:palette":"color selection area","aria:hue":"hue selection slider","aria:opacity":"selection slider"};static DEFAULT_OPTIONS={appClass:null,theme:"classic",useAsButton:!1,padding:8,disabled:!1,comparison:!0,closeOnScroll:!1,outputPrecision:0,lockOpacity:!1,autoReposition:!0,container:"body",components:{interaction:{}},i18n:{},swatches:null,inline:!1,sliders:null,default:"#42445a",defaultRepresentation:null,position:"bottom-middle",adjustableNumbers:!0,showAlways:!1,closeWithKey:"Escape"};_initializingActive=!0;_recalc=!0;_nanopop=null;_root=null;_color=A();_lastColor=A();_swatchColors=[];_setupAnimationFrame=null;_eventListener={init:[],save:[],hide:[],show:[],clear:[],change:[],changestop:[],cancel:[],swatchselect:[]};constructor(t){this.options=t=Object.assign({...E.DEFAULT_OPTIONS},t);const{swatches:e,components:o,theme:n,sliders:i,lockOpacity:s,padding:r}=t;["nano","monolith"].includes(n)&&!i&&(t.sliders="h"),o.interaction||(o.interaction={});const{preview:a,opacity:l,hue:c,palette:p}=o;o.opacity=!s&&l,o.palette=p||a||l||c,this._preBuild(),this._buildComponents(),this._bindEvents(),this._finalBuild(),e&&e.length&&e.forEach((t=>this.addSwatch(t)));const{button:u,app:h}=this._root;this._nanopop=O(u,h,{margin:r}),u.setAttribute("role","button"),u.setAttribute("aria-label",this._t("btn:toggle"));const d=this;this._setupAnimationFrame=requestAnimationFrame((function e(){if(!h.offsetWidth)return requestAnimationFrame(e);d.setColor(t.default),d._rePositioningPicker(),t.defaultRepresentation&&(d._representation=t.defaultRepresentation,d.setColorRepresentation(d._representation)),t.showAlways&&d.show(),d._initializingActive=!1,d._emit("init")}))}static create=t=>new E(t);_preBuild(){const{options:t}=this;for(const e of["el","container"])t[e]=c(t[e]);this._root=(t=>{const{components:e,useAsButton:o,inline:n,appClass:i,theme:s,lockOpacity:r}=t.options,l=t=>t?"":'style="display:none" hidden',c=e=>t._t(e),p=a(`\n
\n\n ${o?"":''}\n\n
\n
\n
\n \n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n\n
\n\n
\n \n\n \n \n \n \n \n\n \n \n \n
\n
\n
\n `),u=p.interaction;return u.options.find((t=>!t.hidden&&!t.classList.add("active"))),u.type=()=>u.options.find((t=>t.classList.contains("active"))),p})(this),t.useAsButton&&(this._root.button=t.el),t.container.appendChild(this._root.root)}_finalBuild(){const t=this.options,e=this._root;if(t.container.removeChild(e.root),t.inline){const o=t.el.parentElement;t.el.nextSibling?o.insertBefore(e.app,t.el.nextSibling):o.appendChild(e.app)}else t.container.appendChild(e.app);t.useAsButton?t.inline&&t.el.remove():t.el.parentNode.replaceChild(e.root,t.el),t.disabled&&this.disable(),t.comparison||(e.button.style.transition="none",t.useAsButton||(e.preview.lastColor.style.transition="none")),this.hide()}_buildComponents(){const t=this,e=this.options.components,o=(t.options.sliders||"v").repeat(2),[n,i]=o.match(/^[vh]+$/g)?o:[],s=()=>this._color||(this._color=this._lastColor.clone()),r={palette:C({element:t._root.palette.picker,wrapper:t._root.palette.palette,onstop:()=>t._emit("changestop","slider",t),onchange(o,n){if(!e.palette)return;const i=s(),{_root:r,options:a}=t,{lastColor:l,currentColor:c}=r.preview;t._recalc&&(i.s=100*o,i.v=100-100*n,i.v<0&&(i.v=0),t._updateOutput("slider"));const p=i.toRGBA().toString(0);this.element.style.background=p,this.wrapper.style.background=`\n linear-gradient(to top, rgba(0, 0, 0, ${i.a}), transparent),\n linear-gradient(to left, hsla(${i.h}, 100%, 50%, ${i.a}), rgba(255, 255, 255, ${i.a}))\n `,a.comparison?a.useAsButton||t._lastColor||l.style.setProperty("--pcr-color",p):(r.button.style.setProperty("--pcr-color",p),r.button.classList.remove("clear"));const u=i.toHEXA().toString();for(const{el:e,color:o}of t._swatchColors)e.classList[u===o.toHEXA().toString()?"add":"remove"]("pcr-active");c.style.setProperty("--pcr-color",p)}}),hue:C({lock:"v"===i?"h":"v",element:t._root.hue.picker,wrapper:t._root.hue.slider,onstop:()=>t._emit("changestop","slider",t),onchange(o){if(!e.hue||!e.palette)return;const n=s();t._recalc&&(n.h=360*o),this.element.style.backgroundColor=`hsl(${n.h}, 100%, 50%)`,r.palette.trigger()}}),opacity:C({lock:"v"===n?"h":"v",element:t._root.opacity.picker,wrapper:t._root.opacity.slider,onstop:()=>t._emit("changestop","slider",t),onchange(o){if(!e.opacity||!e.palette)return;const n=s();t._recalc&&(n.a=Math.round(100*o)/100),this.element.style.background=`rgba(0, 0, 0, ${n.a})`,r.palette.trigger()}}),selectable:k({elements:t._root.interaction.options,className:"active",onchange(e){t._representation=e.target.getAttribute("data-type").toUpperCase(),t._recalc&&t._updateOutput("swatch")}})};this._components=r}_bindEvents(){const{_root:t,options:e}=this,o=[i(t.interaction.clear,"click",(()=>this._clearColor())),i([t.interaction.cancel,t.preview.lastColor],"click",(()=>{this.setHSVA(...(this._lastColor||this._color).toHSVA(),!0),this._emit("cancel")})),i(t.interaction.save,"click",(()=>{!this.applyColor()&&!e.showAlways&&this.hide()})),i(t.interaction.result,["keyup","input"],(t=>{this.setColor(t.target.value,!0)&&!this._initializingActive&&(this._emit("change",this._color,"input",this),this._emit("changestop","input",this)),t.stopImmediatePropagation()})),i(t.interaction.result,["focus","blur"],(t=>{this._recalc="blur"===t.type,this._recalc&&this._updateOutput(null)})),i([t.palette.palette,t.palette.picker,t.hue.slider,t.hue.picker,t.opacity.slider,t.opacity.picker],["mousedown","touchstart"],(()=>this._recalc=!0),{passive:!0})];if(!e.showAlways){const n=e.closeWithKey;o.push(i(t.button,"click",(()=>this.isOpen()?this.hide():this.show())),i(document,"keyup",(t=>this.isOpen()&&(t.key===n||t.code===n)&&this.hide())),i(document,["touchstart","mousedown"],(e=>{this.isOpen()&&!l(e).some((e=>e===t.app||e===t.button))&&this.hide()}),{capture:!0}))}if(e.adjustableNumbers){const e={rgba:[255,255,255,1],hsva:[360,100,100,1],hsla:[360,100,100,1],cmyk:[100,100,100,100]};p(t.interaction.result,((t,o,n)=>{const i=e[this.getColorRepresentation().toLowerCase()];if(i){const e=i[n],s=t+(e>=100?1e3*o:o);return s<=0?0:Number((s{n.isOpen()&&(e.closeOnScroll&&n.hide(),null===t?(t=setTimeout((()=>t=null),100),requestAnimationFrame((function e(){n._rePositioningPicker(),null!==t&&requestAnimationFrame(e)}))):(clearTimeout(t),t=setTimeout((()=>t=null),100)))}),{capture:!0}))}this._eventBindings=o}_rePositioningPicker(){const{options:t}=this;if(!t.inline){if(!this._nanopop.update({container:document.body.getBoundingClientRect(),position:t.position})){const t=this._root.app,e=t.getBoundingClientRect();t.style.top=(window.innerHeight-e.height)/2+"px",t.style.left=(window.innerWidth-e.width)/2+"px"}}}_updateOutput(t){const{_root:e,_color:o,options:n}=this;if(e.interaction.type()){const t=`to${e.interaction.type().getAttribute("data-type")}`;e.interaction.result.value="function"==typeof o[t]?o[t]().toString(n.outputPrecision):""}!this._initializingActive&&this._recalc&&this._emit("change",o,t,this)}_clearColor(t=!1){const{_root:e,options:o}=this;o.useAsButton||e.button.style.setProperty("--pcr-color","rgba(0, 0, 0, 0.15)"),e.button.classList.add("clear"),o.showAlways||this.hide(),this._lastColor=null,this._initializingActive||t||(this._emit("save",null),this._emit("clear"))}_parseLocalColor(t){const{values:e,type:o,a:n}=w(t),{lockOpacity:i}=this.options,s=void 0!==n&&1!==n;return e&&3===e.length&&(e[3]=void 0),{values:!e||i&&s?null:e,type:o}}_t(t){return this.options.i18n[t]||E.I18N_DEFAULTS[t]}_emit(t,...e){this._eventListener[t].forEach((t=>t(...e,this)))}on(t,e){return this._eventListener[t].push(e),this}off(t,e){const o=this._eventListener[t]||[],n=o.indexOf(e);return~n&&o.splice(n,1),this}addSwatch(t){const{values:e}=this._parseLocalColor(t);if(e){const{_swatchColors:t,_root:o}=this,n=A(...e),s=r(``; + badge.innerHTML = `${displayText}`; return badge; }, @@ -127,6 +126,7 @@ sectionIndex, container, label = type, + description = '', currentValuesArray = [], availableTypes = null, includeItemTypes = null, @@ -163,9 +163,9 @@ const displayText = (type === 'Collection' || type === 'Playlist') && typeof val === 'object' ? val.name : val; const value = (type === 'Collection' || type === 'Playlist') && typeof val === 'object' ? val.id : val; return ` - + ${displayText} - + `; }).join(''); @@ -173,37 +173,31 @@ const typeKey = type === 'IncludeItemTypes' ? 'includeItemTypes' : type.toLowerCase(); const placeholderText = type === 'IncludeItemTypes' ? 'Type to add item type...' : `Type to add ${type.toLowerCase()}...`; - // Determine if refresh button should be shown (for cached data types) const showRefresh = (type === 'Tag' || type === 'Genre' || type === 'Collection' || type === 'Playlist'); - - // Determine if badges should appear below input (for IncludeItemTypes, Genres, Tags) - // Check for both singular and plural forms since type may be normalized - const badgesBelowInput = (type === 'IncludeItemTypes' || type === 'Genres' || type === 'Tags' || type === 'Genre' || type === 'Tag'); - + html = ` -
-
-
-
${label}
-
- ${showRefresh ? `` : ''} - -
+
+
+
+
${label}
+ ${description ? `
${description}
` : ''}
-
-
- - -
-
- ${badgesHtml} -
+
+ ${showRefresh ? `` : ''} +
- -
+
+
+ + +
+
+
+
${badgesHtml}
+
`; @@ -826,41 +820,6 @@ `; } - /** - * Build toggle slider HTML (simplified version for user editor) - */ - function buildToggleSliderHTML(checked, sectionId, onToggle) { - const isEnabled = checked !== false; - return ` - - `; - } - /** * Build section row HTML for order editor */ @@ -889,7 +848,11 @@ return `
- ${buildToggleSliderHTML(isEnabled, sectionId)} + ${buildToggleSlider('', isEnabled, '', { + wrapInLabel: false, + cssClass: 'toggle-slider section-toggle-switch', + dataAttributes: { 'section-id': sectionId } + })}
${sectionName.replace(/"/g, '"')} @@ -1432,17 +1395,28 @@ const currentState = sectionsContainer.getAttribute(attrName); const newState = currentState === 'false' ? 'true' : 'false'; const newStateBool = newState === 'true'; - - // Update container attribute + + // Keep at least one section type visible — do not turn off the last active type + if (!newStateBool && currentState === 'true') { + let enabledTypeCount = 0; + for (const attr of sectionsContainer.attributes) { + if (attr.name.startsWith('data-section-type-') && attr.value === 'true') { + enabledTypeCount++; + } + } + if (enabledTypeCount <= 1) { + return; + } + } + sectionsContainer.setAttribute(attrName, newState); - - // Update button appearance + if (newStateBool) { toggleBtn.classList.add('button-submit'); } else { toggleBtn.classList.remove('button-submit'); } - + // Save the new state to localStorage const savedToggleStates = getSectionTypeToggleStates(); savedToggleStates[sectionTypeLower] = newStateBool; @@ -1553,12 +1527,329 @@ } } + /** + * Escape HTML special characters for safe attribute/text insertion. + */ + function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + /** + * Build Jellyfin select/dropdown HTML + */ + function buildSelect(id, options, selectedValue, label) { + const optionsHTML = options.map(opt => { + const value = typeof opt === 'string' ? opt : opt.value; + const optLabel = typeof opt === 'string' ? opt : opt.label; + const selected = value === selectedValue ? 'selected' : ''; + return ``; + }).join(''); + + return ` +
+ + +
+ `; + } + + /** + * Build Jellyfin text input HTML + * @param {Record} [attrs] Extra HTML attributes (e.g. { min: 0, step: 1 }) + */ + function buildTextInput(id, value, label, type = 'text', placeholder = '', attrs = {}) { + const extraAttrs = Object.entries(attrs || {}) + .filter(([, v]) => v != null && v !== false) + .map(([k, v]) => (v === true + ? ` ${k}` + : ` ${k}="${String(v).replace(/&/g, '&').replace(/"/g, '"')}"`)) + .join(''); + return ` +
+ + +
+ `; + } + + /** + * Build checkbox control HTML (Jellyfin emby-checkbox markup). + * @param {string} [id] - Optional input id (omit/empty for class-based inputs) + * @param {boolean} checked + * @param {string} label + * @param {object} [options] + * @param {string} [options.className] - Extra classes on the input + * @param {object} [options.dataAttributes] - data-* attrs, e.g. { id: 'x' } → data-id="x" + * @param {boolean} [options.wrapInSection] - Wrap in .sectioncheckbox + */ + function buildCheckbox(id, checked, label, options = {}) { + const { + className = '', + dataAttributes = {}, + wrapInSection = false + } = options; + + const safeLabel = escapeHtml(label); + const idAttr = id ? ` id="${escapeHtml(id)}"` : ''; + const extraClass = className ? ` ${escapeHtml(className)}` : ''; + const dataAttrs = Object.entries(dataAttributes) + .map(([key, value]) => ` data-${escapeHtml(key)}="${escapeHtml(String(value))}"`) + .join(''); + + const control = ` + + `; + + if (wrapInSection) { + return `
${control}
`; + } + return control; + } + + /** + * Build toggle card with label, description, and pill switch (via buildToggleSlider). + */ + function buildToggleCard(id, checked, label, description, options = {}) { + const isEnabled = checked !== false; + const { + hintKey, + checkboxClass = '', + checkboxDataAttributes = {} + } = options; + const hintAttr = hintKey ? ` data-hsae-hint="${escapeHtml(hintKey)}"` : ''; + const checkboxClassAttr = checkboxClass ? ` class="${escapeHtml(checkboxClass)}"` : ''; + const checkboxDataAttrs = Object.entries(checkboxDataAttributes) + .map(([key, value]) => ` data-${escapeHtml(key)}="${escapeHtml(String(value))}"`) + .join(' '); + const toggleBtn = buildToggleSlider(id, checked, '', { + includeHiddenCheckbox: false, + wrapInLabel: false, + cssClass: 'kefin-toggle-switch', + knob: 'css', + dataAttributes: checkboxDataAttributes + }); + return ` +
+ +
+
${escapeHtml(label)}
+ ${description ? `
${escapeHtml(description)}
` : ''} +
+ ${toggleBtn} +
+ `; + } + + /** + * Update minimal pill toggle switch UI (no ON/OFF text). + */ + function updateToggleSwitchUI(btn, checked) { + if (!btn) return; + const isEnabled = checked === true; + btn.dataset.enabled = isEnabled; + btn.setAttribute('aria-pressed', String(isEnabled)); + const checkboxId = btn.dataset.checkboxId; + if (checkboxId) { + const checkbox = document.getElementById(checkboxId); + if (checkbox) checkbox.checked = isEnabled; + } + } + + /** + * Wire kefin-toggle-switch buttons under root (idempotent). + * @param {ParentNode} root + */ + function bindToggleCards(root) { + if (!root || typeof root.querySelectorAll !== 'function') 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'; + updateToggleSwitchUI(btn, next); + }); + }); + } + + /** + * Build enabled toggle buttons (ENABLED/DISABLED) + */ + function buildEnabledToggleButtons(id, enabled) { + const isEnabled = enabled !== false; + return ` +
+ + +
+ `; + } + + /** + * Build toggle slider (compact thumb switch, no ON/OFF text). + * @param {string} id + * @param {boolean} checked + * @param {string} label + * @param {Object} options + * @param {boolean} [options.includeHiddenCheckbox] + * @param {Object} [options.dataAttributes] + * @param {string} [options.cssClass='toggle-slider'] + * @param {boolean} [options.wrapInLabel=true] + * @param {'element'|'css'} [options.knob] - element = inline thumb span; css = ::after (e.g. kefin-toggle-switch) + * @param {string} [options.checkboxClass] + */ + function buildToggleSlider(id, checked, label, options = {}) { + const { + includeHiddenCheckbox = false, + dataAttributes = {}, + cssClass = 'toggle-slider', + wrapInLabel = true, + checkboxClass = '', + knob + } = options; + + const isEnabled = checked !== false; + const useCssKnob = knob === 'css' || (knob == null && String(cssClass).includes('kefin-toggle-switch')); + const dataAttrsString = Object.entries(dataAttributes) + .map(([key, value]) => `data-${escapeHtml(key)}="${escapeHtml(String(value))}"`) + .join(' '); + const checkboxClassAttr = checkboxClass ? ` class="${escapeHtml(checkboxClass)}"` : ''; + + const inlineStyle = useCssKnob ? '' : ` style=" + position: relative; + width: 52px; + height: 28px; + border-radius: 14px; + border: none; + background: ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'}; + cursor: pointer; + transition: background-color 0.3s ease; + flex-shrink: 0; + padding: 0; + display: inline-flex; + align-items: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + "`; + + const knobHtml = useCssKnob ? '' : ` + `; + + const toggleButton = ` + + `; + + const hiddenCheckbox = includeHiddenCheckbox && id ? ` + + ` : ''; + + if (wrapInLabel) { + return ` + + `; + } + return hiddenCheckbox + toggleButton; + } + + /** + * Update toggle slider UI programmatically + */ + function updateToggleSliderUI(toggleButton, isEnabled) { + if (!toggleButton) return; + + const enabled = !!isEnabled; + toggleButton.dataset.enabled = enabled; + toggleButton.setAttribute('aria-pressed', String(enabled)); + + if (toggleButton.style && toggleButton.getAttribute('style')) { + toggleButton.style.background = enabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'; + } + + const knob = toggleButton.querySelector('.toggle-slider-thumb'); + if (knob) { + knob.style.left = enabled ? 'calc(100% - 25px)' : '3px'; + } + + const checkboxId = toggleButton.dataset.checkboxId; + if (checkboxId) { + const checkbox = document.getElementById(checkboxId); + if (checkbox) checkbox.checked = enabled; + } + } + // Expose to global scope window.KefinTweaksUI = { createAutoCompleteInput, BadgeSystem, renderHomeSectionsOrderEditor, - setupOrderEditorListeners + setupOrderEditorListeners, + escapeHtml, + buildSelect, + buildTextInput, + buildCheckbox, + buildToggleCard, + buildToggleSlider, + buildEnabledToggleButtons, + updateToggleSwitchUI, + updateToggleSliderUI, + bindToggleCards }; LOG('UI utilities loaded'); diff --git a/scripts/userHelper.js b/scripts/userHelper.js index c0981ee..5fc828f 100644 --- a/scripts/userHelper.js +++ b/scripts/userHelper.js @@ -23,16 +23,33 @@ } } - // Run this on a separate thread to avoid blocking the main thread with requestIdleCallback - async function waitForLogin() { + // Poll until ApiClient reports logged in. Uses one Promise so callers always settle. + // requestIdleCallback (with setTimeout fallback) avoids busy-looping the main thread. + async function waitForLogin(maxWaitMs = 60000) { + const startTime = Date.now(); + const schedule = (fn) => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(fn); + } else { + setTimeout(fn, 0); + } + }; + return new Promise((resolve) => { - requestIdleCallback(() => { + const tick = () => { if (window.ApiClient && window.ApiClient._loggedIn) { + LOG('waitForLogin: logged in'); resolve(true); - } else { - setTimeout(() => waitForLogin(), 100); + return; } - }); + if (Date.now() - startTime >= maxWaitMs) { + WARN('waitForLogin: timed out after', maxWaitMs, 'ms'); + resolve(false); + return; + } + setTimeout(() => schedule(tick), 100); + }; + schedule(tick); }); } @@ -63,7 +80,7 @@ LOG('Fetching user display preferences'); const response = await fetch(`${ApiClient.serverAddress()}/DisplayPreferences/usersettings?userId=${ApiClient.getCurrentUserId()}&client=emby`, { headers: { - 'X-Emby-Token': ApiClient.accessToken() + 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -110,19 +127,162 @@ updateDisplayPreferences: async function(prefs) { ensureApiClient(); const userId = ApiClient.getCurrentUserId(); + return await this.updateDisplayPreferencesForUser(userId, prefs, { updateCache: true }); + }, + + /** + * Fetch display preferences for an arbitrary user (admin). + * @param {string} userId + * @returns {Promise} + */ + getUserDisplayPreferencesForUser: async function(userId) { + ensureApiClient(); + if (!userId) throw new Error('userId is required'); + const response = await fetch( + `${ApiClient.serverAddress()}/DisplayPreferences/usersettings?userId=${encodeURIComponent(userId)}&client=emby`, + { headers: { 'Authorization': window.apiHelper.getAuthHeader() } } + ); + if (!response.ok) { + throw new Error(`Failed to fetch display preferences for ${userId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Persist display preferences for an arbitrary user (admin). + * @param {string} userId + * @param {object} prefs + * @param {{ updateCache?: boolean }} [options] + * @returns {Promise} + */ + updateDisplayPreferencesForUser: async function(userId, prefs, options = {}) { + ensureApiClient(); + if (!userId) throw new Error('userId is required'); const serverAddress = ApiClient.serverAddress(); - const accessToken = ApiClient.accessToken(); - const url = `${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`; + const url = `${serverAddress}/DisplayPreferences/usersettings?userId=${encodeURIComponent(userId)}&client=emby`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Emby-Token': accessToken + 'Authorization': window.apiHelper.getAuthHeader() }, body: JSON.stringify(prefs) }); + if (response.ok && options.updateCache && userId === ApiClient.getCurrentUserId()) { + __userDisplayPreferences = prefs; + if (window.LocalStorageCache) { + try { + const cache = new window.LocalStorageCache(); + cache.set('userDisplayPreferences', prefs, userId, 24 * 60 * 60 * 1000); + } catch (_) { /* ignore */ } + } + } return response.ok; }, + + /** + * Parse CustomPrefs.kefinTweaks (JSON string or object) into a plain object. + * @param {object|string|null} customPrefsOrPrefs - CustomPrefs object, full display prefs, or raw kefinTweaks value + * @returns {object} + */ + parseKefinTweaks: function(customPrefsOrPrefs) { + if (!customPrefsOrPrefs) return {}; + let raw = customPrefsOrPrefs; + if (customPrefsOrPrefs.CustomPrefs) { + raw = customPrefsOrPrefs.CustomPrefs.kefinTweaks; + } else if (customPrefsOrPrefs.kefinTweaks !== undefined) { + raw = customPrefsOrPrefs.kefinTweaks; + } + if (!raw) return {}; + if (typeof raw === 'object') return { ...raw }; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (e) { + WARN('Failed to parse CustomPrefs.kefinTweaks:', e); + return {}; + } + }, + + /** + * Load a single feature blob from CustomPrefs.kefinTweaks.. + * @param {string} featureKey + * @param {*} [defaultValue=null] + * @returns {Promise<*>} + */ + getKefinTweaksFeature: async function(featureKey, defaultValue = null) { + if (!featureKey) return defaultValue; + try { + const { promise } = await this.getUserDisplayPreferences(); + const prefs = await promise; + const kefin = this.parseKefinTweaks(prefs); + return Object.prototype.hasOwnProperty.call(kefin, featureKey) + ? kefin[featureKey] + : defaultValue; + } catch (e) { + WARN(`getKefinTweaksFeature(${featureKey}) failed:`, e); + return defaultValue; + } + }, + + /** + * Replace CustomPrefs.kefinTweaks. while preserving sibling keys. + * @param {string} featureKey + * @param {*} value + * @returns {Promise} + */ + setKefinTweaksFeature: async function(featureKey, value) { + if (!featureKey) return false; + try { + const { promise } = await this.getUserDisplayPreferences(); + const prefs = await promise; + if (!prefs) return false; + if (!prefs.CustomPrefs) prefs.CustomPrefs = {}; + const kefin = this.parseKefinTweaks(prefs); + kefin[featureKey] = value; + prefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + return await this.updateDisplayPreferences(prefs); + } catch (e) { + ERR(`setKefinTweaksFeature(${featureKey}) failed:`, e); + return false; + } + }, + + /** + * Update CustomPrefs.kefinTweaks. via object merge or updater function. + * @param {string} featureKey + * @param {object|Function} patchOrFn - Partial object to merge, or (current) => nextValue + * @returns {Promise} + */ + updateKefinTweaksFeature: async function(featureKey, patchOrFn) { + if (!featureKey) return false; + try { + const { promise } = await this.getUserDisplayPreferences(); + const prefs = await promise; + if (!prefs) return false; + if (!prefs.CustomPrefs) prefs.CustomPrefs = {}; + const kefin = this.parseKefinTweaks(prefs); + const current = Object.prototype.hasOwnProperty.call(kefin, featureKey) + ? kefin[featureKey] + : null; + let next; + if (typeof patchOrFn === 'function') { + next = patchOrFn(current); + } else if (patchOrFn && typeof patchOrFn === 'object' && !Array.isArray(patchOrFn) + && current && typeof current === 'object' && !Array.isArray(current)) { + next = { ...current, ...patchOrFn }; + } else { + next = patchOrFn; + } + kefin[featureKey] = next; + prefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + return await this.updateDisplayPreferences(prefs); + } catch (e) { + ERR(`updateKefinTweaksFeature(${featureKey}) failed:`, e); + return false; + } + }, + waitForLogin: waitForLogin }; diff --git a/scripts/userManager.css b/scripts/userManager.css new file mode 100644 index 0000000..f5443a8 --- /dev/null +++ b/scripts/userManager.css @@ -0,0 +1,120 @@ +/* User Manager — template UI on dashboard user pages */ + +#newUserPage:not([data-user-template="None"]) .folderAccessContainer { + display: none !important; +} + +#newUserPage[data-user-template="None"] .um-kefin-save, +#newUserPage:not([data-user-template]) .um-kefin-save { + display: none !important; +} + +#newUserPage:not([data-user-template="None"]) .newUserProfileForm .emby-button.button-submit:not(.um-kefin-save) { + display: none !important; +} + +.um-template-field { + margin: 1em 0; +} + +.um-template-field-row { + display: flex; + align-items: flex-end; + gap: 0.75em; + flex-wrap: wrap; +} + +.um-template-field-row .selectContainer { + flex: 1 1 220px; + margin: 0; +} + +.um-bulk-root { + display: flex; + flex-direction: column; + gap: 1em; + padding: 0.25em 0; +} + +.um-bulk-user-list { + max-height: 50vh; + overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 0.5em; +} + +.um-bulk-user-item { + display: flex; + align-items: center; + gap: 0.75em; + padding: 0.45em 0.35em; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.um-bulk-user-item:last-child { + border-bottom: none; +} + +.um-bulk-toolbar { + display: grid; + gap: 0.5em; + grid-template-columns: 1fr 1fr; + align-items: center; +} + +.um-bulk-status { + font-size: 0.9em; + opacity: 0.85; + min-height: 1.2em; +} + +.um-bulk-fab { + margin-left: 0.25em; +} + +.um-config-root .um-template-list { + display: flex; + flex-direction: column; + gap: 0.5em; + margin-top: 1em; +} + +.um-config-root .um-template-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + padding: 0.75em 1em; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + background: rgba(255, 255, 255, 0.02); + cursor: pointer; +} + +.um-config-root .um-template-row:hover { + background: rgba(255, 255, 255, 0.05); +} + +.um-editor-root .um-section { + margin: 1.25em 0; + padding-bottom: 1em; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.um-editor-root .um-section-title { + margin: 0 0 0.75em; + font-size: 1.1em; + font-weight: 500; +} + +.um-editor-root .checkboxList { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.um-bulk-root .selectContainer, +.um-bulk-toolbar button { + margin: 0; +} diff --git a/scripts/userManager.js b/scripts/userManager.js new file mode 100644 index 0000000..a278deb --- /dev/null +++ b/scripts/userManager.js @@ -0,0 +1,568 @@ +// KefinTweaks User Manager — dashboard hooks for user templates + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks UserManager]', ...args); + const ERR = (...args) => console.error('[KefinTweaks UserManager]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks UserManager]', ...args); + + const BULK_MODAL_ID = 'kefin-usermanager-bulk'; + const NONE = 'None'; + const MARKER_ADD = 'data-um-add-injected'; + const MARKER_BULK = 'data-um-bulk-injected'; + + function clonePolicy(policy) { + if (window.KefinUserManagerConfig?.clonePolicy) { + return window.KefinUserManagerConfig.clonePolicy(policy); + } + return JSON.parse(JSON.stringify(policy ?? {})); + } + + function loadConfig() { + if (window.KefinUserManagerConfig?.loadConfig) { + return window.KefinUserManagerConfig.loadConfig(); + } + const raw = window.KefinTweaksConfig?.userManager || {}; + return { + defaultTemplateName: raw.defaultTemplateName || '', + templates: Array.isArray(raw.templates) ? raw.templates : [] + }; + } + + function getAuthHeader() { + return window.apiHelper?.getAuthHeader?.() || ''; + } + + async function apiJson(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + Authorization: getAuthHeader(), + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}) + } + }); + if (!response.ok) { + let detail = `${response.status}`; + try { + const body = await response.json(); + detail = body?.message || body?.Message || detail; + } catch (_) { + try { + const text = await response.text(); + if (text) detail = text; + } catch (__) { /* ignore */ } + } + throw new Error(detail); + } + if (response.status === 204) return null; + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch (_) { + return text; + } + } + + async function createUser(name, password) { + const url = ApiClient.getUrl('Users/New'); + return apiJson(url, { + method: 'POST', + body: JSON.stringify({ Name: name, Password: password }) + }); + } + + async function applyPolicy(userId, policy) { + const url = ApiClient.getUrl(`Users/${userId}/Policy`); + return apiJson(url, { + method: 'POST', + body: JSON.stringify(policy) + }); + } + + async function fetchUsers() { + if (typeof ApiClient?.getUsers === 'function') { + const users = await ApiClient.getUsers(); + return Array.isArray(users) ? users : []; + } + const users = await apiJson(ApiClient.getUrl('Users'), { method: 'GET' }); + return Array.isArray(users) ? users : []; + } + + function toast(msg) { + window.KefinTweaksToaster?.toast?.(msg); + } + + function isAddUserPage(hash) { + const h = hash || window.location.hash || ''; + return /\/dashboard\/users\/usernew\b/i.test(h) || /\/dashboard\/users\/add\b/i.test(h); + } + + function isUsersListPage(hash) { + const h = hash || window.location.hash || ''; + if (isAddUserPage(h)) return false; + if (/\/dashboard\/users\/user\b/i.test(h)) return false; + return /\/dashboard\/users\/?(\?|$|#|$)/i.test(h) || /#\/dashboard\/users\/?$/i.test(h); + } + + function getNewUserPage() { + return document.querySelector('#newUserPage') + || document.querySelector('.newUserPage') + || document.querySelector('[data-role="page"].newUserPage'); + } + + function waitForElement(getter, timeoutMs = 8000, intervalMs = 100) { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + const el = getter(); + if (el) { + resolve(el); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(null); + return; + } + setTimeout(tick, intervalMs); + }; + tick(); + }); + } + + function getTemplateOptionsHtml(config, includeNone) { + const names = (config.templates || []).map((t) => t.name); + const options = []; + if (includeNone) { + options.push(``); + } + names.forEach((name) => { + options.push(``); + }); + return options.join(''); + } + + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function escapeAttr(str) { + return escapeHtml(str).replace(/'/g, '''); + } + + function findNativeSaveButton(page) { + return page.querySelector('.newUserProfileForm .emby-button.button-submit:not(.um-kefin-save)') + || page.querySelector('form .emby-button.button-submit:not(.um-kefin-save)') + || page.querySelector('.button-submit:not(.um-kefin-save)'); + } + + function updateSaveVisibility(page, templateName) { + const useTemplate = templateName && templateName !== NONE; + page.setAttribute('data-user-template', useTemplate ? templateName : NONE); + + const nativeSave = findNativeSaveButton(page); + const kefinSave = page.querySelector('.um-kefin-save'); + if (nativeSave) { + nativeSave.style.display = useTemplate ? 'none' : ''; + } + if (kefinSave) { + kefinSave.style.display = useTemplate ? '' : 'none'; + } + } + + async function handleKefinSave(page) { + const select = page.querySelector('#um-template-select'); + const templateName = select?.value || NONE; + if (templateName === NONE) return; + + const config = loadConfig(); + const template = config.templates.find((t) => t.name === templateName); + if (!template?.policy) { + toast('Selected template was not found'); + return; + } + + const nameInput = page.querySelector('#txtUsername') + || page.querySelector('input[type="text"]'); + const passwordInput = page.querySelector('#txtPassword') + || page.querySelector('input[type="password"]'); + const name = (nameInput?.value || '').trim(); + const password = passwordInput?.value || ''; + + if (!name) { + toast('Username is required'); + nameInput?.focus(); + return; + } + + const kefinSave = page.querySelector('.um-kefin-save'); + if (kefinSave) kefinSave.disabled = true; + + try { + const created = await createUser(name, password); + const userId = created?.Id || created?.id; + if (!userId) throw new Error('User created but no Id returned'); + + await applyPolicy(userId, clonePolicy(template.policy)); + toast(`User "${name}" created with template "${templateName}"`); + window.location.hash = '#/dashboard/users'; + } catch (e) { + ERR('Failed to create user with template', e); + toast(`Failed to create user: ${e.message || e}`); + } finally { + if (kefinSave) kefinSave.disabled = false; + } + } + + async function injectAddUserPage() { + const page = await waitForElement(getNewUserPage); + if (!page) { + WARN('New user page not found'); + return; + } + if (page.getAttribute(MARKER_ADD) === '1') { + // Refresh template options in case config changed + refreshAddUserTemplateSelect(page); + return; + } + + const passwordField = await waitForElement(() => + page.querySelector('#txtPassword') + || page.querySelector('input[type="password"]') + ); + if (!passwordField) { + WARN('Password field not found on new user page'); + return; + } + + const config = loadConfig(); + const defaultName = config.defaultTemplateName && config.templates.some((t) => t.name === config.defaultTemplateName) + ? config.defaultTemplateName + : NONE; + + const templatesContainer = document.createElement('div'); + templatesContainer.className = 'um-template-field inputContainer'; + templatesContainer.innerHTML = ` +
+
+ + +
+
+ `; + + const editTemplatesContainer = document.createElement('div'); + editTemplatesContainer.className = 'um-template-field inputContainer'; + editTemplatesContainer.innerHTML = ` +
+ +
+ `; + + const passwordContainer = passwordField.closest('.inputContainer') || passwordField.parentElement; + if (passwordContainer?.parentElement) { + passwordContainer.parentElement.insertBefore(templatesContainer, passwordContainer.nextSibling); + passwordContainer.parentElement.insertBefore(editTemplatesContainer, templatesContainer.nextSibling); + } else { + passwordField.insertAdjacentElement('afterend', templatesContainer); + passwordField.insertAdjacentElement('afterend', editTemplatesContainer); + } + + const select = templatesContainer.querySelector('#um-template-select'); + if (select) { + select.value = defaultName; + if (![...select.options].some((o) => o.value === defaultName)) { + select.value = NONE; + } + } + + editTemplatesContainer.querySelector('.um-edit-templates-btn')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (window.KefinUserManagerConfig?.openConfigModal) { + window.KefinUserManagerConfig.openConfigModal(); + } else if (window.KefinTweaksFeatureConfigs?.userManager?.openConfigModal) { + window.KefinTweaksFeatureConfigs.userManager.openConfigModal(); + } else { + toast('User Manager configuration is not available'); + } + }); + + select?.addEventListener('change', () => { + updateSaveVisibility(page, select.value || NONE); + }); + + // Kefin Save button next to native submit + const nativeSave = await waitForElement(() => findNativeSaveButton(page), 5000); + const kefinSave = document.createElement('button'); + kefinSave.type = 'button'; + kefinSave.className = 'raised button-submit block emby-button um-kefin-save'; + kefinSave.textContent = 'Save'; + kefinSave.style.display = 'none'; + kefinSave.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + handleKefinSave(page); + }); + + if (nativeSave?.parentElement) { + nativeSave.parentElement.insertBefore(kefinSave, nativeSave.nextSibling); + } else { + const form = page.querySelector('form') || page; + form.appendChild(kefinSave); + } + + page.setAttribute(MARKER_ADD, '1'); + updateSaveVisibility(page, select?.value || NONE); + LOG('Injected template controls on add-user page'); + } + + function refreshAddUserTemplateSelect(page) { + const select = page.querySelector('#um-template-select'); + if (!select) return; + const prev = select.value; + const config = loadConfig(); + select.innerHTML = getTemplateOptionsHtml(config, true); + if ([...select.options].some((o) => o.value === prev)) { + select.value = prev; + } else if (config.defaultTemplateName && [...select.options].some((o) => o.value === config.defaultTemplateName)) { + select.value = config.defaultTemplateName; + } else { + select.value = NONE; + } + updateSaveVisibility(page, select.value || NONE); + } + + async function openBulkEditor() { + if (!window.ModalSystem?.create) { + toast('Modal system not available'); + return; + } + + const config = loadConfig(); + if (!config.templates.length) { + toast('Create a user template first in Features → User Manager'); + return; + } + + let users = []; + try { + users = await fetchUsers(); + } catch (e) { + ERR('Failed to load users', e); + toast(`Failed to load users: ${e.message || e}`); + return; + } + + users = users + .filter((u) => u?.Id && u.Name) + .sort((a, b) => String(a.Name).localeCompare(String(b.Name), undefined, { sensitivity: 'base' })); + + const defaultTemplate = config.defaultTemplateName && config.templates.some((t) => t.name === config.defaultTemplateName) + ? config.defaultTemplateName + : config.templates[0].name; + + const content = document.createElement('div'); + content.className = 'um-bulk-root'; + content.innerHTML = ` +
+ + +
+
+ + +
+
+ ${users.map((u) => ` + + `).join('') || '
No users found
'} +
+
+ `; + + const templateSelect = content.querySelector('#um-bulk-template'); + if (templateSelect) templateSelect.value = defaultTemplate; + + content.querySelector('#um-bulk-select-all')?.addEventListener('click', () => { + content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { cb.checked = true; }); + syncApplyEnabled(); + }); + content.querySelector('#um-bulk-select-none')?.addEventListener('click', () => { + content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { cb.checked = false; }); + syncApplyEnabled(); + }); + content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { + cb.addEventListener('change', syncApplyEnabled); + }); + + const footer = document.createElement('div'); + footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;'; + footer.innerHTML = ` + + + `; + + function syncApplyEnabled() { + const any = !!content.querySelector('.um-bulk-user-check:checked'); + const applyBtn = footer.querySelector('#um-bulk-apply'); + if (applyBtn) applyBtn.disabled = !any; + } + + footer.querySelector('#um-bulk-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(BULK_MODAL_ID); + }); + + footer.querySelector('#um-bulk-apply')?.addEventListener('click', async () => { + const applyBtn = footer.querySelector('#um-bulk-apply'); + const statusEl = content.querySelector('#um-bulk-status'); + const templateName = templateSelect?.value; + const template = loadConfig().templates.find((t) => t.name === templateName); + if (!template?.policy) { + toast('Selected template was not found'); + return; + } + + const selected = [...content.querySelectorAll('.um-bulk-user-check:checked')].map((cb) => ({ + id: cb.value, + name: cb.dataset.name || cb.value + })); + if (!selected.length) return; + + if (applyBtn) applyBtn.disabled = true; + let ok = 0; + let fail = 0; + for (let i = 0; i < selected.length; i++) { + const user = selected[i]; + if (statusEl) { + statusEl.textContent = `Applying (${i + 1}/${selected.length}): ${user.name}`; + } + try { + await applyPolicy(user.id, clonePolicy(template.policy)); + ok++; + } catch (e) { + fail++; + ERR(`Failed policy apply for ${user.name}`, e); + } + } + + const summary = fail + ? `Applied template to ${ok} user(s); ${fail} failed` + : `Applied template to ${ok} user(s)`; + if (statusEl) statusEl.textContent = summary; + toast(summary); + if (applyBtn) applyBtn.disabled = false; + if (!fail) { + window.ModalSystem.close(BULK_MODAL_ID); + } + }); + + const modal = window.ModalSystem.create({ + id: BULK_MODAL_ID, + title: 'Bulk edit user accounts', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true + }); + if (modal?.dialog) { + modal.dialog.style.maxWidth = '720px'; + modal.dialog.style.width = '90vw'; + } + syncApplyEnabled(); + } + + async function injectUsersListPage() { + const addBtn = await waitForElement(() => + document.querySelector('#btnAddUser') + || document.querySelector('button[title*="Add user" i]') + || document.querySelector('.btnAddUser') + ); + if (!addBtn) { + WARN('Add user button not found on users list'); + return; + } + + const parent = addBtn.parentElement || addBtn.closest('.fabSection') || addBtn.closest('.sectionTitleContainer'); + if (!parent) return; + if (parent.querySelector('.um-bulk-fab') || parent.getAttribute(MARKER_BULK) === '1') return; + + const fab = document.createElement('button'); + fab.type = 'button'; + fab.className = 'fab btnFloating um-bulk-fab sectionTitleButton'; + fab.title = 'Bulk edit user accounts'; + fab.setAttribute('is', 'emby-button'); + fab.innerHTML = ''; + fab.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openBulkEditor(); + }); + + // Place next to add-user fab + if (addBtn.nextSibling) { + parent.insertBefore(fab, addBtn.nextSibling); + } else { + parent.appendChild(fab); + } + parent.setAttribute(MARKER_BULK, '1'); + LOG('Injected bulk-edit fab on users list'); + } + + async function handleView(hash) { + try { + const admin = !!(await window.apiHelper?.isAdmin?.()); + if (!admin) return; + + if (isAddUserPage(hash)) { + await injectAddUserPage(); + } else if (isUsersListPage(hash)) { + await injectUsersListPage(); + } + } catch (e) { + ERR('onViewPage handler error', e); + } + } + + function register() { + if (!window.KefinTweaksUtils?.onViewPage) { + WARN('onViewPage unavailable, retrying...'); + setTimeout(register, 500); + return; + } + + window.KefinTweaksUtils.onViewPage((view, element, hash) => { + // Defer slightly so Jellyfin finishes painting the form + setTimeout(() => handleView(hash || window.location.hash), 250); + }, { + pages: ['dashboard/users'] + }); + + // Immediate check for already-open page + setTimeout(() => handleView(window.location.hash), 400); + LOG('User Manager handlers registered'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', register); + } else { + register(); + } +})(); diff --git a/scripts/utils.js b/scripts/utils.js index 78c2b6a..645a46c 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -307,52 +307,1551 @@ initialize(); } - /** - * Add a custom menu link to the custom menu options - * @param {string} name - Display name for the menu item - * @param {string} icon - Material icon name - * @param {string} url - URL to navigate to - * @param {boolean} openInNewTab - Whether to open in new tab (default: false) - * @param {string} containerSelector - CSS selector for the container to add the link to (default: '.customMenuOptions') - * @returns {Promise} - True if successfully added, false otherwise - */ - function addCustomMenuLink(name, icon, url, openInNewTab = false, containerSelector = '.customMenuOptions') { - return new Promise((resolve) => { - // Check if menu container already exists - const existingContainer = document.querySelector(containerSelector); - if (existingContainer) { - const success = addLinkToContainer(existingContainer, name, icon, url, openInNewTab); - resolve(success); + // ----- Custom menu links (placements: side / top main|right / userMenu) ----- + const customMenuLinkRegistry = []; + let customMenuLinkObserver = null; + const FAVORITES_HREF = '#/home?tab=1'; + const DEFAULT_CUSTOM_MENU_SELECTOR = '.customMenuOptions'; + const ADMIN_MENU_SELECTOR = '.adminMenuOptions'; + const USER_MENU_SELECTOR = '.userMenuOptions'; + + function findByDataAttr(root, attr, value) { + if (!root) return null; + return Array.from(root.querySelectorAll(`[${attr}]`)).find( + (el) => el.getAttribute(attr) === value + ) || null; + } + + function customMenuLinkKey(name, url, action) { + const n = String(name || '').trim(); + const u = String(url || '').trim(); + if (u) return `${n}||${u}`; + const a = String(action || '').trim(); + return `${n}||action:${a}`; + } + + function entryKey(entry) { + return customMenuLinkKey(entry?.name, entry?.url, entry?.action); + } + + function normalizeTopNavigation(value) { + const v = String(value || 'none').toLowerCase().trim(); + if (v === 'main' || v === 'right') return v; + return 'none'; + } + + function parseVersionParts(version) { + return String(version || '') + .split(/[.+-]/) + .filter(Boolean) + .map((p) => { + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 0; + }); + } + + function isJellyfinWebAtLeast12() { + const version = window.ApiClient?._appVersion; + if (version) { + const parts = parseVersionParts(version); + const major = parts[0] ?? 0; + return major >= 12; + } + return !!( + document.querySelector('.MuiDrawer-paper') + || document.getElementById('app-user-menu') + || document.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`) + ); + } + + function resolveWindowAction(path) { + const parts = String(path || '').split('.').filter(Boolean); + let cur = window; + for (const part of parts) { + if (cur == null) return null; + cur = cur[part]; + } + return typeof cur === 'function' ? cur : null; + } + + function getCustomMenuLinkHref(entry) { + if (entry?.action && String(entry.action).trim()) return '#'; + return String(entry?.url || '').trim() || '#'; + } + + function bindCustomMenuLinkActivation(el, entry) { + if (!el || el.__kefinCmlBound) return; + const actionPath = entry?.action && String(entry.action).trim(); + if (!actionPath) return; + + el.__kefinCmlBound = true; + el.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const fn = resolveWindowAction(actionPath); + if (!fn) { + WARN(`Custom menu link action not found or not a function: ${actionPath}`); return; } + try { + fn.call(null, e); + } catch (err) { + ERR(`Error running custom menu link action ${actionPath}:`, err); + } + }); + } - LOG(`Custom menu container not found, waiting for it to appear for link: ${name}`); + function parseCustomMenuLinkOrder(value) { + if (value == null || value === '') return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; + } - // Use MutationObserver to watch for the menu container - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - mutation.addedNodes.forEach(node => { - if (node.nodeType === Node.ELEMENT_NODE) { - // Check if the added node is the menu or contains it - if (node.matches?.(containerSelector) || node.querySelector?.(containerSelector)) { - const container = node.matches?.(containerSelector) ? node : node.querySelector(containerSelector); - LOG(`Custom menu container detected, adding link: ${name}`); - observer.disconnect(); - - const success = addLinkToContainer(container, name, icon, url, openInNewTab); - resolve(success); - return; - } - } - }); + function parseCustomMenuLinkOptions(fifthArg) { + if (fifthArg && typeof fifthArg === 'object' && !Array.isArray(fifthArg)) { + const action = fifthArg.action != null ? String(fifthArg.action).trim() : ''; + let containerSelector = DEFAULT_CUSTOM_MENU_SELECTOR; + + if (fifthArg.containerSelector) { + containerSelector = fifthArg.containerSelector; + } else if (fifthArg.isAdminLink) { + containerSelector = ADMIN_MENU_SELECTOR; + } else if (fifthArg.isUserLink) { + containerSelector = USER_MENU_SELECTOR; + } + + return { + containerSelector: containerSelector, + collapsed: !!fifthArg.collapsed, + sideMenu: fifthArg.sideMenu !== false, + topNavigation: normalizeTopNavigation(fifthArg.topNavigation), + userMenu: !!fifthArg.userMenu, + isAdminLink: !!fifthArg.isAdminLink, + isUserLink: !!fifthArg.isUserLink, + action: action || '', + order: parseCustomMenuLinkOrder(fifthArg.order) + }; + } + + return { + containerSelector: typeof fifthArg === 'string' ? fifthArg : DEFAULT_CUSTOM_MENU_SELECTOR, + collapsed: false, + sideMenu: true, + topNavigation: 'none', + userMenu: false, + isAdminLink: false, + isUserLink: false, + action: '', + order: undefined + }; + } + + async function isCurrentUserAdmin() { + try { + if (window.apiHelper && typeof window.apiHelper.isAdmin === 'function') { + return !!(await window.apiHelper.isAdmin()); + } + if (window.ApiClient && typeof window.ApiClient.getCurrentUser === 'function') { + const user = await window.ApiClient.getCurrentUser(); + return !!(user && user.Policy && user.Policy.IsAdministrator === true); + } + } catch (err) { + WARN('Could not determine admin status for custom menu link:', err); + } + return false; + } + + function upsertCustomMenuLinkRegistry(entry) { + const key = entryKey(entry); + const idx = customMenuLinkRegistry.findIndex(e => entryKey(e) === key); + if (idx >= 0) { + customMenuLinkRegistry[idx] = entry; + } else { + customMenuLinkRegistry.push(entry); + } + } + + function findFavoritesDrawerLi() { + const drawer = document.querySelector('.MuiDrawer-paper'); + if (!drawer) return null; + const fav = drawer.querySelector(`a[href="${FAVORITES_HREF}"]`); + if (!fav) return null; + return fav.closest('li') || fav.parentElement; + } + + function findTopNavStack() { + const fav = document.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`); + if (!fav) return null; + return fav.closest('.MuiStack-root'); + } + + function applyOpenInNewTab(anchor, openInNewTab) { + if (!openInNewTab) return; + anchor.setAttribute('rel', 'noopener noreferrer'); + anchor.setAttribute('target', '_blank'); + } + + function getNativeTopNavAnchor(stack) { + if (!stack) return null; + const fav = stack.querySelector(`a[href="${FAVORITES_HREF}"]`); + if (fav && !fav.hasAttribute('data-kefin-custom-menu-top-link')) return fav; + return Array.from(stack.querySelectorAll(':scope > a')).find( + (a) => !a.hasAttribute('data-kefin-custom-menu-top-link') + && !a.hasAttribute('data-kefin-custom-menu-more') + ) || null; + } + + function sanitizeClonedNavItem(root) { + if (!root) return; + const clear = (el) => { + if (!el || !el.classList) return; + el.classList.remove('Mui-selected', 'Mui-focusVisible', 'selected'); + el.removeAttribute('aria-current'); + el.removeAttribute('aria-selected'); + }; + clear(root); + root.querySelectorAll('.Mui-selected, .Mui-focusVisible, [aria-current], [aria-selected]') + .forEach(clear); + } + + function applyMaterialIcon(root, iconName) { + const icon = iconName || 'link'; + const existingMaterial = root.querySelector('.material-icons'); + if (existingMaterial) { + existingMaterial.textContent = icon; + return true; + } + + const svg = root.querySelector('svg'); + if (svg) { + const span = document.createElement('span'); + const svgClass = svg.getAttribute('class') || ''; + span.className = svgClass; + if (!span.classList.contains('material-icons')) { + span.classList.add('material-icons'); + } + span.setAttribute('aria-hidden', svg.getAttribute('aria-hidden') || 'true'); + span.textContent = icon; + svg.replaceWith(span); + return true; + } + + return false; + } + + function applyAnchorLabel(anchor, name) { + const primary = anchor.querySelector( + '.MuiListItemText-primary, .MuiTypography-root, .navMenuOptionText' + ); + if (primary) { + primary.textContent = name; + return; + } + + const textNodes = []; + const walker = document.createTreeWalker(anchor, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const parent = node.parentElement; + if (!parent) continue; + if (parent.closest('svg, .material-icons')) continue; + if (!String(node.textContent || '').trim()) continue; + textNodes.push(node); + } + if (!textNodes.length) { + anchor.appendChild(document.createTextNode(name)); + return; + } + textNodes[textNodes.length - 1].textContent = name; + for (let i = 0; i < textNodes.length - 1; i++) { + textNodes[i].textContent = ''; + } + } + + function retargetClonedAnchor(anchor, entry, dataAttr, key) { + if (!anchor || !entry) return false; + sanitizeClonedNavItem(anchor); + anchor.href = getCustomMenuLinkHref(entry); + anchor.setAttribute(dataAttr, key); + anchor.removeAttribute('target'); + anchor.removeAttribute('rel'); + if (!entry.action) { + applyOpenInNewTab(anchor, entry.openInNewTab); + } + applyMaterialIcon(anchor, entry.icon); + applyAnchorLabel(anchor, entry.name); + bindCustomMenuLinkActivation(anchor, entry); + return true; + } + + /** + * Clone Favorites drawer
  • and retarget for a custom link. + * @returns {HTMLElement|null} + */ + function createMuiDrawerListItem(entry, referenceLi) { + if (!referenceLi || !entry) return null; + const referenceAnchor = referenceLi.querySelector('a'); + if (!referenceAnchor) return null; + + const key = entryKey(entry); + const li = referenceLi.cloneNode(true); + sanitizeClonedNavItem(li); + li.setAttribute('data-kefin-custom-menu-link', key); + + const a = li.querySelector('a'); + if (!retargetClonedAnchor(a, entry, 'data-kefin-custom-menu-link', key)) { + return null; + } + return li; + } + + function injectIntoV12Drawer(entry) { + const favLi = findFavoritesDrawerLi(); + if (!favLi || !favLi.parentElement) return false; + + const key = entryKey(entry); + if (findByDataAttr(favLi.parentElement, 'data-kefin-custom-menu-link', key)) { + return true; + } + + let insertAfter = favLi; + let next = favLi.nextElementSibling; + while (next && next.querySelector?.('[data-kefin-custom-menu-link]')) { + insertAfter = next; + next = next.nextElementSibling; + } + + const li = createMuiDrawerListItem(entry, favLi); + if (!li) return false; + insertAfter.insertAdjacentElement('afterend', li); + return true; + } + + /** + * Clone a native top-nav and retarget for a custom link. + * @returns {HTMLElement|null} + */ + function createMuiTopNavButton(entry, referenceAnchor) { + if (!referenceAnchor || !entry) return null; + + const key = entryKey(entry); + const a = referenceAnchor.cloneNode(true); + if (!retargetClonedAnchor(a, entry, 'data-kefin-custom-menu-top-link', key)) { + return null; + } + a.classList.add('kefin-custom-menu-top-link'); + return a; + } + + function ensureTopNavDivider(stack) { + let divider = stack.querySelector('[data-kefin-custom-menu-top-divider]'); + if (divider) return divider; + + divider = document.createElement('div'); + divider.className = 'kefin-custom-menu-top-divider'; + divider.setAttribute('data-kefin-custom-menu-top-divider', 'true'); + divider.setAttribute('role', 'separator'); + divider.setAttribute('aria-hidden', 'true'); + + const anchors = Array.from(stack.querySelectorAll(':scope > a')).filter( + a => !a.hasAttribute('data-kefin-custom-menu-top-link') + && !a.hasAttribute('data-kefin-custom-menu-more') + ); + const lastNative = anchors[anchors.length - 1]; + if (lastNative) { + lastNative.insertAdjacentElement('afterend', divider); + } else { + stack.appendChild(divider); + } + return divider; + } + + const USER_VIEW_OVERFLOW_MENU_ID = 'user-view-overflow-menu'; + + function findNativeTopNavMoreButton(stack) { + if (!stack) return null; + const byAria = Array.from( + stack.querySelectorAll(`button[aria-controls="${USER_VIEW_OVERFLOW_MENU_ID}"]`) + ).find((btn) => !btn.hasAttribute('data-kefin-custom-menu-more')); + if (byAria) return byAria; + return Array.from(stack.querySelectorAll(':scope > button')).find( + (btn) => !btn.hasAttribute('data-kefin-custom-menu-more') + && /more/i.test((btn.textContent || '').trim()) + ) || null; + } + + function findTopNavMoreButton(stack) { + return findNativeTopNavMoreButton(stack) + || stack?.querySelector('[data-kefin-custom-menu-more]') + || null; + } + + function getUserViewOverflowMenu() { + return document.getElementById(USER_VIEW_OVERFLOW_MENU_ID); + } + + function getUserViewOverflowMenuList(menuRoot = getUserViewOverflowMenu()) { + if (!menuRoot) return null; + return menuRoot.querySelector('ul[role="menu"]'); + } + + function isUserViewOverflowMenuOpen(menuRoot = getUserViewOverflowMenu()) { + return !!(menuRoot && !menuRoot.classList.contains('MuiModal-hidden')); + } + + function closeUserViewOverflowMenu() { + const menu = getUserViewOverflowMenu(); + if (!menu) return; + menu.classList.add('MuiModal-hidden'); + const kefinMore = document.querySelector('[data-kefin-custom-menu-more]'); + if (kefinMore) kefinMore.setAttribute('aria-expanded', 'false'); + } + + function openUserViewOverflowMenu(anchorBtn) { + const menu = ensureUserViewOverflowMenu(); + if (!menu) return; + menu.classList.remove('MuiModal-hidden'); + + const paper = menu.querySelector('.MuiPopover-paper, .MuiMenu-paper'); + if (paper && anchorBtn) { + const rect = anchorBtn.getBoundingClientRect(); + paper.style.top = `${Math.round(rect.bottom + 4)}px`; + paper.style.left = `${Math.round(rect.left)}px`; + paper.style.transformOrigin = '0px 0px 0px'; + } + + if (anchorBtn?.hasAttribute('data-kefin-custom-menu-more')) { + anchorBtn.setAttribute('aria-expanded', 'true'); + } + } + + function ensureUserViewOverflowMenu() { + let menu = getUserViewOverflowMenu(); + if (menu) return menu; + + menu = document.createElement('div'); + menu.id = USER_VIEW_OVERFLOW_MENU_ID; + menu.setAttribute('role', 'presentation'); + menu.className = 'MuiPopover-root MuiMenu-root MuiModal-root MuiModal-hidden'; + menu.setAttribute('data-kefin-custom-overflow-menu', 'true'); + + const backdrop = document.createElement('div'); + backdrop.setAttribute('aria-hidden', 'true'); + backdrop.className = 'MuiBackdrop-root MuiBackdrop-invisible MuiModal-backdrop'; + + 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-rounded MuiPaper-elevation8 MuiPopover-paper MuiMenu-paper'; + paper.tabIndex = -1; + + const list = document.createElement('ul'); + list.className = 'MuiList-root MuiList-padding MuiMenu-list'; + list.setAttribute('role', 'menu'); + list.tabIndex = -1; + paper.appendChild(list); + + const sentinelEnd = document.createElement('div'); + sentinelEnd.tabIndex = 0; + sentinelEnd.setAttribute('data-testid', 'sentinelEnd'); + + menu.appendChild(backdrop); + menu.appendChild(sentinelStart); + menu.appendChild(paper); + menu.appendChild(sentinelEnd); + document.body.appendChild(menu); + + backdrop.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closeUserViewOverflowMenu(); + }); + + return menu; + } + + function bindKefinMoreButtonToggle(moreBtn) { + if (!moreBtn || moreBtn.__kefinMoreToggleBound) return; + moreBtn.__kefinMoreToggleBound = true; + + moreBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const menu = ensureUserViewOverflowMenu(); + if (isUserViewOverflowMenuOpen(menu)) { + closeUserViewOverflowMenu(); + } else { + openUserViewOverflowMenu(moreBtn); + } + }); + + if (!window.__kefinCustomMenuMoreDocClickBound) { + window.__kefinCustomMenuMoreDocClickBound = true; + document.addEventListener('click', (e) => { + const menu = getUserViewOverflowMenu(); + if (!menu || !menu.hasAttribute('data-kefin-custom-overflow-menu')) return; + if (!isUserViewOverflowMenuOpen(menu)) return; + const btn = document.querySelector('[data-kefin-custom-menu-more]'); + if (menu.contains(e.target) || (btn && btn.contains(e.target))) return; + closeUserViewOverflowMenu(); + }); + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + const menu = getUserViewOverflowMenu(); + if (!menu || !menu.hasAttribute('data-kefin-custom-overflow-menu')) return; + if (!isUserViewOverflowMenuOpen(menu)) return; + closeUserViewOverflowMenu(); + }); + } + } + + function createKefinTopNavMoreButton(stack) { + // Prefer class list from Favorites top-nav link (includes emotion css-* tokens). + const favAnchor = (stack && stack.querySelector(`a[href="${FAVORITES_HREF}"]`)) + || document.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`) + || getNativeTopNavAnchor(stack); + + const moreBtn = document.createElement('button'); + moreBtn.type = 'button'; + moreBtn.tabIndex = 0; + if (favAnchor?.className) { + moreBtn.className = favAnchor.className; + sanitizeClonedNavItem(moreBtn); + } else { + moreBtn.className = 'MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textInherit MuiButton-sizeMedium MuiButton-textSizeMedium MuiButton-colorInherit'; + } + moreBtn.setAttribute('data-kefin-custom-menu-more', 'true'); + moreBtn.setAttribute('aria-controls', USER_VIEW_OVERFLOW_MENU_ID); + moreBtn.setAttribute('aria-haspopup', 'true'); + moreBtn.setAttribute('aria-expanded', 'false'); + moreBtn.appendChild(document.createTextNode('More')); + + const endIcon = document.createElement('span'); + endIcon.className = 'MuiButton-icon MuiButton-endIcon MuiButton-iconSizeMedium'; + endIcon.innerHTML = ''; + moreBtn.appendChild(endIcon); + + const ripple = document.createElement('span'); + ripple.className = 'MuiTouchRipple-root'; + moreBtn.appendChild(ripple); + + return moreBtn; + } + + function removeLegacyKefinMorePopover() { + const legacy = document.getElementById('kefin-custom-menu-more-popover'); + if (legacy) legacy.remove(); + } + + function ensureTopNavMoreButton(stack, divider) { + removeLegacyKefinMorePopover(); + ensureCustomMenuLinkStyles(); + + const nativeMore = findNativeTopNavMoreButton(stack); + if (nativeMore) { + // Native React More owns open/close and usually mounts the menu on demand. + return nativeMore; + } + + ensureUserViewOverflowMenu(); + + let moreBtn = stack.querySelector('[data-kefin-custom-menu-more]'); + if (moreBtn) { + bindKefinMoreButtonToggle(moreBtn); + return moreBtn; + } + + moreBtn = createKefinTopNavMoreButton(stack); + bindKefinMoreButtonToggle(moreBtn); + + if (divider) { + divider.insertAdjacentElement('beforebegin', moreBtn); + } else { + stack.appendChild(moreBtn); + } + return moreBtn; + } + + function createOverflowMenuItem(entry, menuList, stack) { + const key = entryKey(entry); + const nativeItem = menuList.querySelector('a[role="menuitem"]:not([data-kefin-custom-menu-top-link])'); + if (nativeItem) { + const item = nativeItem.cloneNode(true); + if (!retargetClonedAnchor(item, entry, 'data-kefin-custom-menu-top-link', key)) { + return null; + } + item.addEventListener('click', () => { + closeUserViewOverflowMenu(); + }); + return item; + } + + const stackRef = getNativeTopNavAnchor(stack); + if (stackRef) { + const item = stackRef.cloneNode(true); + if (!retargetClonedAnchor(item, entry, 'data-kefin-custom-menu-top-link', key)) { + return null; + } + item.setAttribute('role', 'menuitem'); + item.classList.add('MuiMenuItem-root', 'MuiMenuItem-gutters'); + item.addEventListener('click', () => { + closeUserViewOverflowMenu(); + }); + return item; + } + + const item = document.createElement('a'); + item.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters'; + item.setAttribute('role', 'menuitem'); + item.tabIndex = -1; + item.href = getCustomMenuLinkHref(entry); + item.setAttribute('data-kefin-custom-menu-top-link', key); + if (!entry.action) { + applyOpenInNewTab(item, entry.openInNewTab); + } + + 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'); + iconSpan.textContent = entry.icon || 'link'; + 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 = entry.name; + text.appendChild(span); + + item.appendChild(icon); + item.appendChild(text); + bindCustomMenuLinkActivation(item, entry); + item.addEventListener('click', () => { + closeUserViewOverflowMenu(); + }); + return item; + } + + function insertByMainOrder(parent, el, order) { + const idx = Math.max(0, Math.floor(order)); + const ref = parent.children[idx]; + if (ref) parent.insertBefore(el, ref); + else parent.appendChild(el); + } + + function insertByRightOrder(parent, el, order) { + const n = Math.max(0, Math.floor(order)); + if (isModernUI()) { + if (n === 0) { + parent.appendChild(el); + return; + } + const idx = Math.max(0, parent.children.length - n); + const ref = parent.children[idx]; + if (ref) parent.insertBefore(el, ref); + else parent.insertBefore(el, parent.firstChild); + return; + } + + // Legacy headerRight: last two children are cast/user controls — keep them rightmost + const reserved = 2; + const idx = parent.children.length - reserved - n; + if (idx <= 0) { + parent.insertBefore(el, parent.firstChild); + return; + } + const ref = parent.children[idx]; + if (ref) parent.insertBefore(el, ref); + else parent.insertBefore(el, parent.firstChild); + } + + function injectIntoTopNavV12(entry) { + const stack = findTopNavStack(); + if (!stack) return false; + + const key = entryKey(entry); + const hasOrder = Number.isFinite(entry.order); + + const divider = ensureTopNavDivider(stack); + + if (entry.collapsed) { + ensureTopNavMoreButton(stack, divider); + let menu = getUserViewOverflowMenu(); + if (!menu) { + // Create shell only when we own the More button; otherwise wait for React. + if (stack.querySelector('[data-kefin-custom-menu-more]')) { + menu = ensureUserViewOverflowMenu(); + } else { + return false; + } + } + const menuList = getUserViewOverflowMenuList(menu); + if (!menuList) return false; + + if (findByDataAttr(menuList, 'data-kefin-custom-menu-top-link', key) + || findByDataAttr(menu, 'data-kefin-custom-menu-top-link', key)) { + return true; + } + + const item = createOverflowMenuItem(entry, menuList, stack); + if (!item) return false; + menuList.appendChild(item); + return true; + } + + if (findByDataAttr(stack, 'data-kefin-custom-menu-top-link', key)) { + return true; + } + + const btn = createMuiTopNavButton(entry, getNativeTopNavAnchor(stack)); + if (!btn) return false; + + if (hasOrder) { + insertByMainOrder(stack, btn, entry.order); + return true; + } + + const moreBtn = findTopNavMoreButton(stack); + if (moreBtn) { + moreBtn.insertAdjacentElement('beforebegin', btn); + } else { + let insertAfter = divider; + let next = divider.nextElementSibling; + while (next && next.hasAttribute?.('data-kefin-custom-menu-top-link')) { + insertAfter = next; + next = next.nextElementSibling; + } + insertAfter.insertAdjacentElement('afterend', btn); + } + return true; + } + + function findLegacyTabsSlider() { + return document.querySelector('.headerTabs.sectionTabs .emby-tabs-slider') + || document.querySelector('.emby-tabs-slider'); + } + + function getNativeLegacyTabButton(slider) { + if (!slider) return null; + return Array.from(slider.querySelectorAll('.emby-tab-button')).find( + (btn) => !btn.hasAttribute('data-kefin-custom-menu-top-link') + ) || null; + } + + function injectIntoTopNavLegacyTabs(entry) { + const slider = findLegacyTabsSlider(); + if (!slider) return false; + + const key = entryKey(entry); + if (findByDataAttr(slider, 'data-kefin-custom-menu-top-link', key)) { + return true; + } + + const reference = getNativeLegacyTabButton(slider); + if (!reference) return false; + + const btn = reference.cloneNode(true); + sanitizeClonedNavItem(btn); + btn.classList.remove('emby-tab-button-active'); + btn.removeAttribute('href'); + btn.setAttribute('data-kefin-custom-menu-top-link', key); + btn.classList.add('kefin-custom-menu-top-link'); + + let nextIndex = slider.children.length; + let maxIndex = -1; + for (const child of slider.children) { + const v = parseInt(child.getAttribute('data-index'), 10); + if (Number.isFinite(v) && v > maxIndex) maxIndex = v; + } + if (maxIndex >= 0) nextIndex = maxIndex + 1; + btn.setAttribute('data-index', String(nextIndex)); + + const foreground = btn.querySelector('.emby-button-foreground') || btn; + if (foreground === btn) { + btn.textContent = entry.name; + } else { + foreground.textContent = entry.name; + } + + if (btn.tagName === 'A') { + btn.href = getCustomMenuLinkHref(entry); + if (!entry.action) { + applyOpenInNewTab(btn, entry.openInNewTab); + } + bindCustomMenuLinkActivation(btn, entry); + } else { + bindCustomMenuLinkActivation(btn, entry); + if (!entry.action) { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const href = String(entry.url || '').trim(); + if (!href) return; + if (entry.openInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer'); + } else if (href.startsWith('#')) { + window.location.hash = href.slice(1); + } else { + window.location.href = href; + } + }); + } + } + + if (Number.isFinite(entry.order)) { + insertByMainOrder(slider, btn, entry.order); + } else { + slider.appendChild(btn); + } + return true; + } + + function isModernUI() { + return document.querySelector('.MuiBox-root') !== null; + } + + function injectIntoTopNavMain(entry) { + if (isModernUI()) { + return injectIntoTopNavV12(entry); + } + return injectIntoTopNavLegacyTabs(entry); + } + + function injectIntoTopNavRightLegacy(entry) { + const headerRight = document.querySelector('.headerRight'); + if (!headerRight) return false; + + const key = entryKey(entry); + if (findByDataAttr(headerRight, 'data-kefin-custom-menu-top-right', key)) { + return true; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('is', 'paper-icon-button-light'); + button.className = 'headerButton headerButtonRight paper-icon-button-light kefin-custom-menu-top-right'; + button.title = entry.name; + button.setAttribute('aria-label', entry.name); + button.setAttribute('data-kefin-custom-menu-top-right', key); + + const iconSpan = document.createElement('span'); + iconSpan.className = `material-icons ${entry.icon || 'link'}`; + iconSpan.setAttribute('aria-hidden', 'true'); + button.appendChild(iconSpan); + + bindCustomMenuLinkActivation(button, entry); + if (!entry.action) { + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const href = String(entry.url || '').trim(); + if (!href) return; + if (entry.openInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer'); + } else if (href.startsWith('#')) { + window.location.hash = href.slice(1); + } else { + window.location.href = href; } }); + } - observer.observe(document.body, { - childList: true, - subtree: true + const searchButton = headerRight.querySelector('.headerSearchButton'); + if (Number.isFinite(entry.order)) { + insertByRightOrder(headerRight, button, entry.order); + } else if (searchButton) { + headerRight.insertBefore(button, searchButton); + } else { + headerRight.appendChild(button); + } + return true; + } + + function injectIntoTopNavRightV12(entry) { + const search = document.querySelector('.headerRight a[href*="#/search"]') + || document.querySelector('.MuiToolbar-root a[href*="#/search"]') + || document.querySelector('a[href*="#/search"]'); + if (!search || !search.parentElement) return false; + + const parent = search.parentElement; + const key = entryKey(entry); + if (findByDataAttr(parent, 'data-kefin-custom-menu-top-right', key) + || findByDataAttr(document, 'data-kefin-custom-menu-top-right', key)) { + return true; + } + + let el; + if (search.tagName === 'A' || search.querySelector?.('a')) { + const ref = search.tagName === 'A' ? search : search.querySelector('a'); + el = ref.cloneNode(true); + sanitizeClonedNavItem(el); + el.href = getCustomMenuLinkHref(entry); + el.setAttribute('data-kefin-custom-menu-top-right', key); + el.classList.add('kefin-custom-menu-top-right'); + el.title = entry.name; + el.setAttribute('aria-label', entry.name); + el.removeAttribute('target'); + el.removeAttribute('rel'); + if (!entry.action) { + applyOpenInNewTab(el, entry.openInNewTab); + } + applyMaterialIcon(el, entry.icon || 'link'); + bindCustomMenuLinkActivation(el, entry); + } else { + el = document.createElement('button'); + el.type = 'button'; + el.className = search.className || ''; + el.classList.add('kefin-custom-menu-top-right'); + el.title = entry.name; + el.setAttribute('aria-label', entry.name); + el.setAttribute('data-kefin-custom-menu-top-right', key); + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-icons'; + iconSpan.setAttribute('aria-hidden', 'true'); + iconSpan.textContent = entry.icon || 'link'; + el.appendChild(iconSpan); + bindCustomMenuLinkActivation(el, entry); + if (!entry.action) { + el.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const href = String(entry.url || '').trim(); + if (!href) return; + if (entry.openInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer'); + } else if (href.startsWith('#')) { + window.location.hash = href.slice(1); + } else { + window.location.href = href; + } + }); + } + } + + if (Number.isFinite(entry.order)) { + insertByRightOrder(parent, el, entry.order); + } else { + // Default: as first child of the parent element + parent.insertBefore(el, parent.firstChild); + } + return true; + } + + function injectIntoTopNavRight(entry) { + if (isModernUI()) { + return injectIntoTopNavRightV12(entry); + } + return injectIntoTopNavRightLegacy(entry); + } + + 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 + })); + } + + function getNativeUserMenuAnchor(menuList) { + if (!menuList) return null; + return Array.from(menuList.querySelectorAll('a[role="menuitem"]')).find( + (a) => !a.hasAttribute('data-kefin-custom-user-menu-link') + && !a.hasAttribute('data-kefintweaks-user-menu-config-button') + ) || null; + } + + function createAppUserMenuLink(entry, referenceAnchor) { + if (!entry || !referenceAnchor) return null; + + const key = entryKey(entry); + const link = referenceAnchor.cloneNode(true); + if (!retargetClonedAnchor(link, entry, 'data-kefin-custom-user-menu-link', key)) { + return null; + } + + link.addEventListener('click', () => { + closeAppUserMenu(); + }); + return link; + } + + function getUserMenuDividers(menuList) { + if (!menuList) return []; + return Array.from(menuList.children).filter( + (el) => el.matches && (el.matches('hr') || el.classList.contains('MuiDivider-root')) + ); + } + + function injectIntoUserMenuV12(entry) { + const appUserMenu = document.getElementById('app-user-menu'); + if (!appUserMenu) return false; + + const menuList = appUserMenu.querySelector('ul[role="menu"]'); + if (!menuList) return false; + + const key = entryKey(entry); + const existing = findByDataAttr(menuList, 'data-kefin-custom-user-menu-link', key); + if (existing) { + // Upgrade hand-built items that lack MUI emotion classes + if (/\bcss-/.test(existing.className)) { + return true; + } + existing.remove(); + } + + const dividers = getUserMenuDividers(menuList); + let insertBefore = null; + let insertAfter = null; + if (entry.isAdminLink) { + // Admin section: before the second divider (fallback: last divider / Dashboard-adjacent) + insertBefore = dividers[1] || dividers[dividers.length - 1] || null; + if (!insertBefore) { + const dashboardLink = menuList.querySelector('a[href="#/dashboard"]') + || menuList.querySelector('a[href*="#/dashboard"]'); + if (!dashboardLink) return false; + let divider = dashboardLink.nextElementSibling; + while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) { + divider = divider.nextElementSibling; + } + insertBefore = divider; + } + } else if (entry.isUserLink) { + // User section: after the last divider + insertAfter = dividers[dividers.length - 1] || null; + if (!insertAfter) { + const signOutButton = menuList.querySelector('a[href="#/signout"]') + || menuList.querySelector('a[href*="#/signout"]'); + if (!signOutButton) return false; + insertAfter = signOutButton.previousElementSibling; + } + } else { + // User section: before the first divider + insertBefore = dividers[0] || null; + } + if (!insertBefore && !insertAfter) return false; + + const reference = getNativeUserMenuAnchor(menuList) + || menuList.querySelector('a[role="menuitem"]'); + const link = createAppUserMenuLink(entry, reference); + if (!link) return false; + + if (insertAfter) { + insertAfter.after(link); + } else { + menuList.insertBefore(link, insertBefore); + } + return true; + } + + function findPreferencesAdminSection() { + return document.querySelector('.userPreferencesPage:not(.hide) .adminSection'); + } + + function findPreferencesDefaultSection() { + const page = document.querySelector('.userPreferencesPage:not(.hide)') + || document.querySelector('.userPreferencesPage'); + if (!page) return null; + const withUsername = Array.from(page.querySelectorAll('.verticalSection')).find( + (section) => section.querySelector('.headerUsername') + ); + return withUsername || null; + } + + function findPreferencesUserSection() { + return document.querySelector('.userPreferencesPage:not(.hide) .userSection'); + } + + function createPreferencesUserMenuLink(entry) { + const key = entryKey(entry); + const link = document.createElement('a'); + link.setAttribute('data-ripple', 'false'); + link.href = getCustomMenuLinkHref(entry); + link.style.cssText = 'display:block;padding:0;margin:0'; + link.className = 'listItem-border emby-button'; + link.setAttribute('data-kefin-custom-user-menu-link', key); + + if (!entry.action) { + applyOpenInNewTab(link, entry.openInNewTab); + } + + const iconName = entry.icon || 'link'; + link.innerHTML = ` +
    + +
    +
    +
    +
    + `; + const titleEl = link.querySelector('.listItemBodyText'); + if (titleEl) titleEl.textContent = entry.name; + + bindCustomMenuLinkActivation(link, entry); + return link; + } + + function injectIntoUserMenuLegacy(entry) { + const section = entry.isUserLink ? findPreferencesUserSection() : entry.isAdminLink ? findPreferencesAdminSection() : findPreferencesDefaultSection(); + if (!section) return false; + + const key = entryKey(entry); + if (findByDataAttr(section, 'data-kefin-custom-user-menu-link', key)) { + return true; + } + + const link = createPreferencesUserMenuLink(entry); + const header = section.querySelector('.headerUsername'); + if (header) { + header.after(link); + } else { + section.appendChild(link); + } + return true; + } + + function injectIntoUserMenu(entry) { + if (isModernUI()) { + return injectIntoUserMenuV12(entry); + } + return injectIntoUserMenuLegacy(entry); + } + + function injectOneCustomMenuLink(entry) { + let anyOk = false; + + if (entry.sideMenu !== false) { + const drawerOk = injectIntoV12Drawer(entry); + const legacyContainer = document.querySelector(entry.containerSelector || DEFAULT_CUSTOM_MENU_SELECTOR); + const legacyOk = legacyContainer ? addLinkToContainer(legacyContainer, entry) : false; + anyOk = drawerOk || legacyOk || anyOk; + } + + if (entry.topNavigation === 'main') { + anyOk = injectIntoTopNavMain(entry) || anyOk; + } else if (entry.topNavigation === 'right') { + anyOk = injectIntoTopNavRight(entry) || anyOk; + } + + if (entry.userMenu) { + anyOk = injectIntoUserMenu(entry) || anyOk; + } + + return anyOk; + } + + function reapplyAllCustomMenuLinks() { + customMenuLinkRegistry.forEach((entry) => { + try { + injectOneCustomMenuLink(entry); + } catch (err) { + ERR('Error re-applying custom menu link:', entry?.name, err); + } + }); + } + + function ensureCustomMenuLinkObserver() { + if (customMenuLinkObserver || typeof MutationObserver === 'undefined' || !document.body) { + return; + } + + let scheduled = false; + customMenuLinkObserver = new MutationObserver(() => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + reapplyAllCustomMenuLinks(); }); }); + + customMenuLinkObserver.observe(document.body, { + childList: true, + subtree: true + }); + } + + /** + * Inject top-nav custom menu link styles once. + */ + function ensureCustomMenuLinkStyles() { + if (!document.getElementById('kefin-custom-menu-links-styles')) { + const style = document.createElement('style'); + style.id = 'kefin-custom-menu-links-styles'; + style.textContent = ` +.kefin-custom-menu-top-divider { + width: 1px; + align-self: stretch; + margin: 0.5em 0.35em; + flex-shrink: 0; + background: currentColor; + opacity: 0.25; + height: 30px; + align-self: center; +} +.kefin-custom-menu-top-divider:not(:has(+ *)) { + display: none; +} +.kefin-custom-menu-top-link { + text-transform: none; + white-space: nowrap; +} +.userPreferencesPage .readOnlyContent > * { + display: flex; + flex-direction: column; +} +.userPreferencesPage .readOnlyContent > *:not(.userSection) > :not([data-kefin-custom-user-menu-link]) { + order: -1; +} +`; + (document.head || document.documentElement).appendChild(style); + } + + // Explicit More-button styles (native emotion classes unavailable when More is absent) + if (!document.getElementById('kefin-custom-menu-more-styles')) { + const moreStyle = document.createElement('style'); + moreStyle.id = 'kefin-custom-menu-more-styles'; + moreStyle.textContent = ` +button[data-kefin-custom-menu-more].MuiButtonBase-root { + display: inline-flex; + -moz-box-align: center; + align-items: center; + -moz-box-pack: center; + justify-content: center; + position: relative; + box-sizing: border-box; + outline: 0; + margin: 0; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + text-decoration: none; + font-family: "Noto Sans", sans-serif; + font-weight: 500; + font-size: 0.875rem; + line-height: 1.75; + text-transform: none; + min-width: 64px; + border: 0; + border-radius: var(--jf-shape-borderRadius); + padding: 6px 8px; + background-color: var(--variant-textBg); + color: inherit; + --variant-containedBg: var(--jf-palette-Button-inheritContainedBg); + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), border-color 250ms cubic-bezier(0.4, 0, 0.2, 1); +} +button[data-kefin-custom-menu-more] .MuiButton-icon { + display: inherit; + margin-right: -4px; + margin-left: 8px; +} +button[data-kefin-custom-menu-more] .MuiTouchRipple-root { + overflow: hidden; + pointer-events: none; + position: absolute; + z-index: 0; + inset: 0; + border-radius: inherit; +} +button[data-kefin-custom-menu-more] .MuiSvgIcon-root { + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + flex-shrink: 0; + transition: fill 300ms cubic-bezier(0.4, 0, 0.2, 1); + fill: currentcolor; + font-size: 20px; +} +`; + (document.head || document.documentElement).appendChild(moreStyle); + } + + // Kefin-owned overflow menu styles (only when we created #user-view-overflow-menu) + if (!document.getElementById('kefin-custom-overflow-menu-styles')) { + const overflowStyle = document.createElement('style'); + overflowStyle.id = 'kefin-custom-overflow-menu-styles'; + overflowStyle.textContent = ` +#user-view-overflow-menu[data-kefin-custom-overflow-menu] { + position: fixed; + z-index: var(--jf-zIndex-modal); + inset: 0; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu].MuiModal-hidden { + visibility: hidden; + pointer-events: none; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiBackdrop-root { + position: fixed; + display: flex; + -moz-box-align: center; + align-items: center; + -moz-box-pack: center; + justify-content: center; + inset: 0; + background-color: transparent; + z-index: -1; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiPaper-root { + background-color: var(--jf-palette-background-paper); + color: var(--jf-palette-text-primary); + border-radius: 4px; + box-shadow: var(--Paper-shadow); + background-image: var(--Paper-overlay); + position: absolute; + overflow: hidden auto; + min-width: 16px; + min-height: 16px; + max-width: calc(100% - 32px); + outline: 0; + max-height: calc(100% - 96px); + --Paper-shadow: var(--jf-shadows-8); + --Paper-overlay: var(--jf-overlays-8); + opacity: 0; + transform: scale(0.75, 0.5625); + transition: opacity 299ms cubic-bezier(0.4, 0, 0.2, 1), transform 199ms cubic-bezier(0.4, 0, 0.2, 1) 100ms; + transform-origin: 0 0 0; + visibility: hidden; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu]:not(.MuiModal-hidden) .MuiPaper-root { + opacity: 1; + visibility: visible; + transform: none; + transition: opacity 299ms cubic-bezier(0.4, 0, 0.2, 1), transform 199ms cubic-bezier(0.4, 0, 0.2, 1); +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiList-root { + list-style: none; + margin: 0; + padding: 8px 0; + position: relative; + outline: 0; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiButtonBase-root { + background-color: transparent; + outline: 0; + border: 0; + margin: 0; + border-radius: 0; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + color: inherit; + font-family: "Noto Sans", sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.5; + display: flex; + -moz-box-pack: start; + justify-content: flex-start; + -moz-box-align: center; + align-items: center; + position: relative; + text-decoration: none; + min-height: 48px; + padding: 6px 16px; + box-sizing: border-box; + white-space: nowrap; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiListItemIcon-root { + color: var(--jf-palette-action-active); + flex-shrink: 0; + display: inline-flex; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiSvgIcon-root, +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .material-icons.MuiSvgIcon-root { + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + flex-shrink: 0; + transition: fill 300ms cubic-bezier(0.4, 0, 0.2, 1); + fill: currentcolor; + font-size: 1.5rem; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiTypography-root { + margin: 0; + font-family: "Noto Sans", sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.5; +} +#user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiTouchRipple-root { + overflow: hidden; + pointer-events: none; + position: absolute; + z-index: 0; + inset: 0; + border-radius: inherit; +} +`; + (document.head || document.documentElement).appendChild(overflowStyle); + } + + removeLegacyKefinMorePopover(); + } + + /** + * Add a custom menu link with per-link placements. + * @param {string} name - Display name for the menu item + * @param {string} icon - Material icon name + * @param {string} [url] - URL to navigate to (optional when options.action is set) + * @param {boolean} openInNewTab - Whether to open in new tab (default: false) + * @param {string|Object} [containerSelectorOrOptions] - Legacy selector string, or options: + * { containerSelector, collapsed, sideMenu, topNavigation, userMenu, isAdminLink, isUserLink, action, order } + * @returns {Promise} + */ + async function addCustomMenuLink(name, icon, url, openInNewTab = false, containerSelectorOrOptions = DEFAULT_CUSTOM_MENU_SELECTOR) { + ensureCustomMenuLinkStyles(); + const options = parseCustomMenuLinkOptions(containerSelectorOrOptions); + const trimmedUrl = url != null ? String(url).trim() : ''; + const action = options.action || ''; + + if (!name || (!trimmedUrl && !action)) { + WARN('addCustomMenuLink requires name and either url or action'); + return false; + } + + if (options.isAdminLink) { + const admin = await isCurrentUserAdmin(); + if (!admin) { + LOG(`Skipping admin-only custom menu link (not admin): ${name}`); + return false; + } + } + + let containerSelector = DEFAULT_CUSTOM_MENU_SELECTOR; + if (options.containerSelector) { + containerSelector = options.containerSelector; + } else if (options.isAdminLink) { + containerSelector = ADMIN_MENU_SELECTOR; + } else if (options.isUserLink) { + containerSelector = USER_MENU_SELECTOR; + } + + const entry = { + name, + icon: icon || 'link', + url: trimmedUrl, + action, + openInNewTab: !!openInNewTab, + collapsed: !!options.collapsed, + sideMenu: options.sideMenu !== false, + topNavigation: options.topNavigation || 'none', + userMenu: !!options.userMenu, + isAdminLink: !!options.isAdminLink, + isUserLink: !!options.isUserLink, + order: options.order, + containerSelector: containerSelector + }; + + // Explicit legacy selector (e.g. Configure → .adminMenuOptions): side-only path + if (typeof containerSelectorOrOptions === 'string' + && containerSelectorOrOptions !== DEFAULT_CUSTOM_MENU_SELECTOR) { + entry.sideMenu = true; + entry.topNavigation = 'none'; + entry.userMenu = false; + } + + upsertCustomMenuLinkRegistry(entry); + ensureCustomMenuLinkObserver(); + + return new Promise((resolve) => { + const success = injectOneCustomMenuLink(entry); + if (success) { + resolve(true); + return; + } + }); + } + + /** + * Add the link to the existing container + * @param {Element} container - The custom menu options container + * @param {Object} entry - Link entry + * @returns {boolean} - True if successfully added + */ + function addLinkToContainer(container, entry) { + try { + const name = entry.name; + const icon = entry.icon || 'link'; + const key = entryKey(entry); + + if (findByDataAttr(container, 'data-kefin-custom-menu-link', key)) { + return true; + } + + const existingByText = Array.from(container.querySelectorAll('.navMenuOptionText')) + .find(el => el.textContent === name); + if (existingByText) { + return true; + } + + const link = document.createElement('a'); + link.className = 'emby-button navMenuOption lnkMediaFolder'; + link.dataset.name = String(name).toLowerCase().replace(/\s+/g, '-'); + + const href = getCustomMenuLinkHref(entry); + link.href = href; + + link.setAttribute('data-kefin-custom-menu-link', key); + + if (!entry.action) { + applyOpenInNewTab(link, entry.openInNewTab); + if (isNativeJellyfinMenuHref(href)) { + link.setAttribute('is', 'emby-linkbutton'); + } + } + + const iconSpan = document.createElement('span'); + iconSpan.className = `material-icons navMenuOptionIcon ${icon}`; + iconSpan.setAttribute('aria-hidden', 'true'); + + const textSpan = document.createElement('span'); + textSpan.className = 'navMenuOptionText'; + textSpan.textContent = name; + + link.appendChild(iconSpan); + link.appendChild(textSpan); + bindCustomMenuLinkActivation(link, entry); + container.appendChild(link); + + LOG(`Successfully added custom menu link: ${name}`); + return true; + } catch (err) { + ERR(`Error adding custom menu link ${entry?.name}:`, err); + return false; + } + } + + /** + * Waits for ApiClient to be available + * @param {number} maxWaitMs - Maximum time to wait in milliseconds (default: 10000 = 10 seconds) + * @param {number} checkInterval - Interval between checks in milliseconds (default: 100) + * @returns {Promise} - ApiClient object or null if timeout + */ + async function waitForApiClient(maxWaitMs = 5000, checkInterval = 100) { + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + await new Promise(resolve => setTimeout(resolve, checkInterval)); + if (window.ApiClient) { + return window.ApiClient; + } + } + return null; } /** @@ -387,6 +1886,135 @@ return false; } + // Page-lifetime cache: normalized name/guid key → plugin Id + const pluginIdCache = new Map(); + const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector']; + + function normalizePluginKey(value) { + return String(value || '').trim().toLowerCase(); + } + + function looksLikeGuid(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim()); + } + + /** + * Clear cached plugin id(s). Omit argument to clear all. + * @param {string|string[]} [nameOrGuid] + */ + function clearPluginIdCache(nameOrGuid) { + if (nameOrGuid == null) { + pluginIdCache.clear(); + return; + } + + const keys = Array.isArray(nameOrGuid) ? nameOrGuid : [nameOrGuid]; + const idsToClear = new Set(); + + for (const key of keys) { + const normalized = normalizePluginKey(key); + const cachedId = pluginIdCache.get(normalized); + pluginIdCache.delete(normalized); + if (cachedId) idsToClear.add(cachedId); + if (looksLikeGuid(key)) idsToClear.add(String(key).trim()); + } + + if (idsToClear.size === 0) return; + + for (const [k, v] of [...pluginIdCache.entries()]) { + if (idsToClear.has(v) || idsToClear.has(k)) { + pluginIdCache.delete(k); + } + } + for (const id of idsToClear) { + pluginIdCache.delete(normalizePluginKey(id)); + } + } + + /** + * Resolve a Jellyfin plugin Id by name or GUID (cached for the page lifetime). + * @param {string|string[]} nameOrGuid - Plugin name, GUID, or alias list tried in order + * @returns {Promise} + */ + async function resolvePluginId(nameOrGuid) { + const aliases = (Array.isArray(nameOrGuid) ? nameOrGuid : [nameOrGuid]) + .filter(a => a != null && String(a).trim() !== ''); + + if (aliases.length === 0) { + return null; + } + + for (const alias of aliases) { + const cached = pluginIdCache.get(normalizePluginKey(alias)); + if (cached) return cached; + } + + const api = window.apiHelper; + if (!api || typeof api.getPlugins !== 'function') { + throw new Error('apiHelper.getPlugins is not available'); + } + + const pluginsData = await api.getPlugins(); + const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); + + for (const alias of aliases) { + const normalized = normalizePluginKey(alias); + const isGuid = looksLikeGuid(alias); + const plugin = pluginsList.find(p => { + if (isGuid) return normalizePluginKey(p.Id) === normalized; + return normalizePluginKey(p.Name) === normalized; + }); + + if (plugin && plugin.Id) { + pluginIdCache.set(normalizePluginKey(plugin.Id), plugin.Id); + for (const a of aliases) { + pluginIdCache.set(normalizePluginKey(a), plugin.Id); + } + return plugin.Id; + } + } + + return null; + } + + async function fetchPluginConfigurationResponse(pluginId) { + const server = ApiClient._serverAddress; + return fetch(`${server}/Plugins/${pluginId}/Configuration`, { + headers: { 'Authorization': getAuthHeader() } + }); + } + + /** + * GET /Plugins/{id}/Configuration for a plugin resolved by name or GUID. + * On 404, clears cached id(s), re-resolves once, and retries once. + * @param {string|string[]} nameOrGuid + * @returns {Promise} + */ + async function getPluginConfiguration(nameOrGuid) { + let pluginId = await resolvePluginId(nameOrGuid); + if (!pluginId) { + throw new Error('Plugin not found'); + } + + let response = await fetchPluginConfigurationResponse(pluginId); + + if (response.status === 404) { + clearPluginIdCache(nameOrGuid); + clearPluginIdCache(pluginId); + pluginId = await resolvePluginId(nameOrGuid); + if (!pluginId) { + throw new Error('Plugin not found after cache clear'); + } + response = await fetchPluginConfigurationResponse(pluginId); + } + + if (!response.ok) { + throw new Error(`Failed to get plugin config: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + /** * Saves the current KefinTweaksConfig back to JS Injector plugin * @param {Object} config - Optional config object to save. If not provided, uses window.KefinTweaksConfig @@ -403,8 +2031,8 @@ if (!window.ApiClient) { if (shouldWaitForLogin) { LOG('ApiClient not available, waiting for initialization...'); - const loggedIn = await waitForLogin(loginWaitTime); - if (!loggedIn) { + const apiClient = await waitForApiClient(loginWaitTime); + if (!apiClient) { WARN('ApiClient not available after waiting, cannot save config'); return false; } @@ -436,30 +2064,16 @@ if (!configToSave) { throw new Error('No config provided and window.KefinTweaksConfig is not available'); } - - const pluginsData = await apiHelper.getPlugins(); - const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); - - const plugin = pluginsList.find(p => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector'); - if (!plugin) { + + const pluginId = await resolvePluginId(JS_INJECTOR_ALIASES); + if (!pluginId) { WARN('JavaScript Injector plugin not found, cannot save config'); return false; } - - const pluginId = plugin.Id; + + const injectorConfig = await getPluginConfiguration(pluginId); const server = ApiClient._serverAddress; - - // Get current injector config const configUrl = `${server}/Plugins/${pluginId}/Configuration`; - const configResponse = await fetch(configUrl, { - headers: { 'Authorization': getAuthHeader() } - }); - - if (!configResponse.ok) { - throw new Error(`Failed to get plugin config: ${configResponse.statusText}`); - } - - const injectorConfig = await configResponse.json(); // Ensure CustomJavaScripts array exists if (!injectorConfig.CustomJavaScripts) { @@ -513,63 +2127,6 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; } } - /** - * Add the link to the existing container - * @param {Element} container - The custom menu options container - * @param {string} name - Display name for the menu item - * @param {string} icon - Material icon name - * @param {string} url - URL to navigate to - * @param {boolean} openInNewTab - Whether to open in new tab - * @returns {boolean} - True if successfully added - */ - function addLinkToContainer(container, name, icon, url, openInNewTab) { - try { - // Check if link already exists to prevent duplicates - const existingLinks = container.querySelectorAll('a[href="' + url + '"]'); - const existingByText = Array.from(container.querySelectorAll('.navMenuOptionText')).find(el => el.textContent === name); - - if (existingLinks.length > 0 || existingByText) { - LOG(`Custom menu link already exists, skipping: ${name}`); - return true; - } - - // Create the menu link element - const link = document.createElement('a'); - link.setAttribute('is', 'emby-linkbutton'); - link.className = 'emby-button navMenuOption lnkMediaFolder'; - link.dataset.name = name.toLowerCase().replace(' ', '-'); - link.href = url; - - if (openInNewTab) { - link.setAttribute('rel', 'noopener noreferrer'); - link.setAttribute('target', '_blank'); - } - - // Create the icon span - const iconSpan = document.createElement('span'); - iconSpan.className = `material-icons navMenuOptionIcon ${icon}`; - iconSpan.setAttribute('aria-hidden', 'true'); - - // Create the text span - const textSpan = document.createElement('span'); - textSpan.className = 'navMenuOptionText'; - textSpan.textContent = name; - - // Append icon and text to link - link.appendChild(iconSpan); - link.appendChild(textSpan); - - // Append link to container - container.appendChild(link); - - LOG(`Successfully added custom menu link: ${name}`); - return true; - } catch (err) { - ERR(`Error adding custom menu link ${name}:`, err); - return false; - } - } - let _watchlistTabIndex = null; async function fetchWatchlistTabIndex() { @@ -579,7 +2136,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; method: "GET", headers: { "Content-Type": "application/json", - "X-Emby-Token": ApiClient._serverInfo.AccessToken || ApiClient.accessToken(), + "Authorization": getAuthHeader(), }, }); const data = await response.json(); @@ -591,7 +2148,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; }); return tabIndex; } catch (err) { - ERR('Failed to fetch watchlist tab index:', err); + WARN('CustomTabs plugin not found.'); return null; } } @@ -623,6 +2180,386 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; return _watchlistTabIndex; } + // ----- Custom pages (hash routes → .customPage in skinBody) ----- + const DISALLOWED_CUSTOM_PAGE_SEGMENTS = new Set([ + 'home', 'shows', 'movies', 'list', 'playlists', 'collections', 'books', 'livetv', 'music' + ]); + /** @type {Map} */ + const customPageRegistry = new Map(); + let customPageViewHandlerUnregister = null; + let customPageHashChangeBound = false; + /** @type {string|null} */ + let activeCustomPageTitle = null; + let customPageTitleGuardObserver = null; + let customPageTitleGuardApplying = false; + + function normalizeCustomPageHref(href) { + let raw = String(href || '').trim(); + if (!raw) return null; + if (!raw.startsWith('#')) raw = `#${raw}`; + if (!raw.startsWith('#/')) raw = `#/${raw.slice(1).replace(/^\//, '')}`; + // Path only (no query) + const path = raw.split('?')[0]; + // Collapse trailing slash except "#/" + const normalized = path.length > 2 && path.endsWith('/') ? path.slice(0, -1) : path; + return normalized; + } + + function getCustomPagePathFromHash(hash = window.location.hash) { + const h = String(hash || '').split('?')[0]; + if (!h.startsWith('#/')) return null; + const normalized = h.length > 2 && h.endsWith('/') ? h.slice(0, -1) : h; + return normalized; + } + + function getCustomPageSegment(hrefPath) { + const match = String(hrefPath || '').match(/^#\/([^\/\?]+)/); + return match ? match[1].toLowerCase() : null; + } + + function isNativeJellyfinMenuHref(url) { + const path = normalizeCustomPageHref(url); + let segment = getCustomPageSegment(path); + if (!segment) return false; + if (segment.endsWith('.html')) segment = segment.slice(0, -5); + return DISALLOWED_CUSTOM_PAGE_SEGMENTS.has(segment); + } + + function slugifyCustomPageTitle(title) { + const slug = String(title || 'page') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug || 'page'; + } + + /** Idempotent fallback if plugin did not early-inject #kefin-custom-page-styles. */ + function ensureCustomPageStyles() { + 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 { + display: none; +} + +#reactRoot:not(:has(.skinBody .customPage:not(.hide))) .pageTitle { + display: none !important; +} + +#reactRoot .skinBody:not(:has(.customPage:not(.hide))) #fallbackPage > * { + display: none; +} + +#reactRoot:not(:has(.skinBody .customPage:not(.hide))) #fallbackPage::after { + content:''; + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #4ecdc4; + border-radius: 50%; + animation: spin 1s linear infinite; + position: relative; + left: 50%; + transform: translateX(-50%); + top: 1em; +} + +`; + (document.head || document.documentElement).appendChild(style); + } + + function findCustomPageHost() { + return document.querySelector('.skinBody:not(.mainAnimatedPages):has(.page:not(.hide))') + || document.querySelector('.skinBody:not(.mainAnimatedPages)'); + } + + function applyCustomPageTitles(title) { + const t = String(title || '').trim(); + if (!t) return; + customPageTitleGuardApplying = true; + try { + try { + document.title = t; + } catch (_) { /* ignore */ } + document.querySelectorAll('h3.pageTitle').forEach((el) => { + if (el.textContent !== t) el.textContent = t; + }); + } finally { + customPageTitleGuardApplying = false; + } + } + + function customPageTitlesNeedSync(expected) { + if (!expected) return false; + if (document.title !== expected) return true; + const pageTitles = document.querySelectorAll('h3.pageTitle'); + for (const el of pageTitles) { + if (el.textContent !== expected) return true; + } + return false; + } + + function stopCustomPageTitleGuard() { + if (customPageTitleGuardObserver) { + customPageTitleGuardObserver.disconnect(); + customPageTitleGuardObserver = null; + } + activeCustomPageTitle = null; + } + + function ensureCustomPageTitleGuard(title) { + const t = String(title || '').trim(); + if (!t) { + stopCustomPageTitleGuard(); + return; + } + activeCustomPageTitle = t; + applyCustomPageTitles(t); + + if (customPageTitleGuardObserver) return; + + const syncIfNeeded = () => { + if (customPageTitleGuardApplying || !activeCustomPageTitle) return; + if (customPageTitlesNeedSync(activeCustomPageTitle)) { + applyCustomPageTitles(activeCustomPageTitle); + } + }; + + customPageTitleGuardObserver = new MutationObserver(() => { + syncIfNeeded(); + }); + + const titleEl = document.querySelector('head > title') || (() => { + const el = document.createElement('title'); + (document.head || document.documentElement).appendChild(el); + return el; + })(); + customPageTitleGuardObserver.observe(titleEl, { + childList: true, + characterData: true, + subtree: true + }); + + // childList only: Jellyfin typically replaces pageTitle text via textContent (childList) + const root = document.getElementById('reactRoot') || document.body || document.documentElement; + if (root) { + customPageTitleGuardObserver.observe(root, { + childList: true, + subtree: true + }); + } + + document.querySelectorAll('h3.pageTitle').forEach((el) => { + customPageTitleGuardObserver.observe(el, { + childList: true, + characterData: true, + subtree: true + }); + }); + } + + function reviveScriptsInRoot(root) { + if (!root || root.getAttribute('data-kefin-scripts-revived') === 'true') return; + const scripts = Array.from(root.querySelectorAll('script')); + scripts.forEach((oldScript) => { + const neo = document.createElement('script'); + Array.from(oldScript.attributes).forEach((attr) => { + neo.setAttribute(attr.name, attr.value); + }); + if (!oldScript.src) { + neo.textContent = oldScript.textContent; + } + oldScript.parentNode.replaceChild(neo, oldScript); + }); + root.setAttribute('data-kefin-scripts-revived', 'true'); + } + + function ensureCustomPageElement(entry) { + if (entry.pageEl && document.body.contains(entry.pageEl)) { + return entry.pageEl; + } + + const existing = document.getElementById(`customPage-${entry.safeTitle}`); + if (existing) { + entry.pageEl = existing; + return existing; + } + + const host = findCustomPageHost(); + if (!host) return null; + + const page = document.createElement('div'); + page.id = `customPage-${entry.safeTitle}`; + page.setAttribute('data-role', 'page'); + page.className = 'page customPage mainAnimatedPage libraryPage hide'; + page.setAttribute('data-title', entry.title); + page.setAttribute('data-backbutton', 'true'); + page.setAttribute('data-menubutton', 'false'); + page.setAttribute('data-kefin-custom-page', entry.hrefPath); + + const padded = document.createElement('div'); + padded.className = 'padded-left padded-right'; + padded.innerHTML = entry.contentHtml || ''; + page.appendChild(padded); + + host.appendChild(page); + // innerHTML does not execute scripts — re-create them in order once + reviveScriptsInRoot(padded); + entry.pageEl = page; + return page; + } + + function showCustomPageEntry(entry) { + ensureCustomPageStyles(); + const page = ensureCustomPageElement(entry); + if (!page) return false; + page.classList.remove('hide'); + page.setAttribute('data-title', entry.title); + ensureCustomPageTitleGuard(entry.title); + return true; + } + + function hideCustomPageEntry(entry) { + let page = entry.pageEl; + if (!page || !document.body.contains(page)) { + page = document.getElementById(`customPage-${entry.safeTitle}`); + } + if (!page) { + page = Array.from(document.querySelectorAll('[data-kefin-custom-page]')).find( + (el) => el.getAttribute('data-kefin-custom-page') === entry.hrefPath + ) || null; + } + if (!page) return; + page.classList.add('hide'); + entry.pageEl = page; + } + + function syncAllCustomPages() { + const currentPath = getCustomPagePathFromHash(); + let matched = null; + customPageRegistry.forEach((entry) => { + if (currentPath && currentPath === entry.hrefPath) { + matched = entry; + showCustomPageEntry(entry); + } else { + hideCustomPageEntry(entry); + } + }); + if (!matched) { + stopCustomPageTitleGuard(); + } + } + + function ensureCustomPageViewHandler() { + if (customPageViewHandlerUnregister) return; + // onViewShow misses custom→custom hash changes (no real Jellyfin view); + // hashchange covers those; both are fine when they double-fire. + const unregView = onViewPage(() => { + syncAllCustomPages(); + }, { pages: [] }); + if (!customPageHashChangeBound) { + window.addEventListener('hashchange', syncAllCustomPages); + customPageHashChangeBound = true; + } + customPageViewHandlerUnregister = () => { + if (typeof unregView === 'function') unregView(); + if (customPageHashChangeBound) { + window.removeEventListener('hashchange', syncAllCustomPages); + customPageHashChangeBound = false; + } + }; + } + + /** + * Register a custom hash-route page rendered into .skinBody. + * @param {string} href - e.g. "#/watchlist" (exact path match; query ignored) + * @param {string} title - document / page title + * @param {string} contentHtml - HTML string for the padded content area + * @returns {Function|null} unregister function, or null if rejected + */ + function addCustomPage(href, title, contentHtml) { + const hrefPath = normalizeCustomPageHref(href); + if (!hrefPath) { + WARN('addCustomPage: invalid href'); + return null; + } + + const segment = getCustomPageSegment(hrefPath); + if (!segment || DISALLOWED_CUSTOM_PAGE_SEGMENTS.has(segment)) { + WARN(`addCustomPage: disallowed route "${hrefPath}"`); + return null; + } + + if (contentHtml == null || typeof contentHtml !== 'string') { + WARN('addCustomPage: contentHtml must be a string'); + return null; + } + + const pageTitle = String(title || segment).trim() || segment; + const safeTitle = slugifyCustomPageTitle(pageTitle); + + const existing = customPageRegistry.get(hrefPath); + if (existing) { + existing.title = pageTitle; + existing.contentHtml = contentHtml; + existing.safeTitle = safeTitle; + // Do not rewrite innerHTML on update if page already mounted (JS may have rendered into it) + if (existing.pageEl) { + existing.pageEl.setAttribute('data-title', pageTitle); + } + ensureCustomPageViewHandler(); + syncAllCustomPages(); + return () => { + const entry = customPageRegistry.get(hrefPath); + if (!entry) return; + hideCustomPageEntry(entry); + customPageRegistry.delete(hrefPath); + if (customPageRegistry.size === 0) { + stopCustomPageTitleGuard(); + if (customPageViewHandlerUnregister) { + customPageViewHandlerUnregister(); + customPageViewHandlerUnregister = null; + } + } else { + syncAllCustomPages(); + } + }; + } + + const entry = { + hrefPath, + title: pageTitle, + contentHtml, + pageEl: null, + safeTitle + }; + customPageRegistry.set(hrefPath, entry); + ensureCustomPageStyles(); + ensureCustomPageViewHandler(); + syncAllCustomPages(); + + LOG(`Registered custom page: ${hrefPath} ("${pageTitle}")`); + + return () => { + const current = customPageRegistry.get(hrefPath); + if (!current) return; + hideCustomPageEntry(current); + customPageRegistry.delete(hrefPath); + if (customPageRegistry.size === 0) { + stopCustomPageTitleGuard(); + if (customPageViewHandlerUnregister) { + customPageViewHandlerUnregister(); + customPageViewHandlerUnregister = null; + } + } else { + syncAllCustomPages(); + } + }; + } + // Expose utilities to global scope window.KefinTweaksUtils = { onViewPage, @@ -631,8 +2568,15 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; getHandlerCount, clearHandlers, addCustomMenuLink, + ensureCustomMenuLinkStyles, + addCustomPage, saveConfigToJavaScriptInjector, - getWatchlistTabIndex + resolvePluginId, + getPluginConfiguration, + clearPluginIdCache, + getWatchlistTabIndex, + waitForApiClient, + waitForLogin }; LOG('Initialized successfully'); diff --git a/scripts/versionPreferences.js b/scripts/versionPreferences.js new file mode 100644 index 0000000..4a13d00 --- /dev/null +++ b/scripts/versionPreferences.js @@ -0,0 +1,351 @@ +// KefinTweaks Item Version Preferences +// Reorders and sanitizes media source options on item details pages + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks VersionPreferences]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks VersionPreferences]', ...args); + + 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]; + + const SELECTOR = '.itemDetailPage:not(.hide) .selectSourceContainer select.detailTrackSelect'; + const CONTAINER_SELECTOR = '.itemDetailPage:not(.hide) .selectSourceContainer'; + const ORIGINAL_ATTR = 'data-kefin-original-label'; + const SELECTED_INDEX_ATTR = 'data-selected-index'; + const CHANGE_BOUND_ATTR = 'data-kefin-vp-change-bound'; + + function isNoneSentinel(term) { + return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase(); + } + + /** + * Keep exactly one "None" sentinel (canonical casing). Missing → append at end. + */ + 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 + }; + } + + function getPreferences() { + 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 + }; + } + + function indexOfNone(terms) { + const idx = (terms || []).findIndex(isNoneSentinel); + return idx >= 0 ? idx : (terms || []).length; + } + + /** + * Highest-priority (earliest array index) case-insensitive substring match. + * Skips the "None" sentinel — it is not a text match. + * @returns {{ term: string, index: number } | null} + */ + function findBestTerm(label, terms) { + if (!label || !Array.isArray(terms) || !terms.length) return null; + const haystack = String(label).toLowerCase(); + for (let i = 0; i < terms.length; i++) { + const term = terms[i]; + if (typeof term !== 'string' || !term.trim()) continue; + if (isNoneSentinel(term)) continue; + if (haystack.includes(term.toLowerCase())) { + return { term, index: i }; + } + } + return null; + } + + function getOriginalLabel(option) { + if (!option) return ''; + if (option.hasAttribute(ORIGINAL_ATTR)) { + return option.getAttribute(ORIGINAL_ATTR) || ''; + } + const label = option.textContent || ''; + option.setAttribute(ORIGINAL_ATTR, label); + return label; + } + + function sanitizeLabel(editionTerm, versionTerm, showVersionBeforeEdition = false) { + if (editionTerm && versionTerm) { + return showVersionBeforeEdition + ? `${versionTerm} - ${editionTerm}` + : `${editionTerm} - ${versionTerm}`; + } + if (editionTerm) return editionTerm; + if (versionTerm) return versionTerm; + return null; + } + + function desiredLabel(desc) { + if (desc.hasMatch) { + const { showVersionBeforeEdition } = getPreferences(); + return sanitizeLabel(desc.editionTerm, desc.versionTerm, showVersionBeforeEdition) ?? desc.originalLabel; + } + return desc.originalLabel; + } + + function rankOptions(options, prefs) { + const noneEditionIndex = indexOfNone(prefs.editions); + const noneVersionIndex = indexOfNone(prefs.versions); + + const descriptors = Array.from(options).map((option, originalIndex) => { + const originalLabel = getOriginalLabel(option); + const editionMatch = findBestTerm(originalLabel, prefs.editions); + const versionMatch = findBestTerm(originalLabel, prefs.versions); + const hasMatch = !!(editionMatch || versionMatch); + return { + option, + originalIndex, + originalLabel, + hasMatch, + editionIndex: editionMatch ? editionMatch.index : noneEditionIndex, + versionIndex: versionMatch ? versionMatch.index : noneVersionIndex, + editionTerm: editionMatch?.term || null, + versionTerm: versionMatch?.term || null + }; + }); + + descriptors.sort((a, b) => { + if (a.editionIndex !== b.editionIndex) return a.editionIndex - b.editionIndex; + if (a.versionIndex !== b.versionIndex) return a.versionIndex - b.versionIndex; + return a.originalIndex - b.originalIndex; + }); + + return descriptors; + } + + function isSelectInExpectedState(select, ranked) { + const options = Array.from(select.options); + if (options.length !== ranked.length) return false; + for (let i = 0; i < ranked.length; i++) { + if (options[i] !== ranked[i].option) return false; + if (options[i].textContent !== desiredLabel(ranked[i])) return false; + } + return true; + } + + function parseSelectedIndexAttr(select) { + if (!select?.hasAttribute(SELECTED_INDEX_ATTR)) return null; + const raw = select.getAttribute(SELECTED_INDEX_ATTR); + const idx = parseInt(raw, 10); + return Number.isFinite(idx) ? idx : null; + } + + function bindSelectedIndexTracker(select) { + if (!select || select.hasAttribute(CHANGE_BOUND_ATTR)) return; + select.setAttribute(CHANGE_BOUND_ATTR, 'true'); + select.addEventListener('change', () => { + select.setAttribute(SELECTED_INDEX_ATTR, String(select.selectedIndex)); + }); + } + + function applyToSelect(select) { + if (!select || !select.options?.length) return false; + + bindSelectedIndexTracker(select); + + const prefs = getPreferences(); + const previousValue = select.value; + const savedIndex = parseSelectedIndexAttr(select); + const ranked = rankOptions(select.options, prefs); + + if (isSelectInExpectedState(select, ranked)) { + return false; + } + + isApplying = true; + try { + ranked.forEach((desc, i) => { + const label = desiredLabel(desc); + if (desc.option.textContent !== label) { + desc.option.textContent = label; + } + if (select.options[i] !== desc.option) { + select.appendChild(desc.option); + } + }); + + const maxIndex = select.options.length - 1; + let targetIndex = 0; + if (savedIndex != null && savedIndex >= 0 && savedIndex <= maxIndex) { + targetIndex = savedIndex; + } + + const targetValue = select.options[targetIndex]?.value; + if (targetValue != null && targetValue !== '') { + if (select.selectedIndex !== targetIndex) { + select.selectedIndex = targetIndex; + } + if (select.value !== targetValue) { + select.value = targetValue; + } + if (targetValue !== previousValue) { + select.dispatchEvent(new Event('change', { bubbles: true })); + } + } + + select.setAttribute(SELECTED_INDEX_ATTR, String(select.selectedIndex)); + + LOG('Applied version preferences to detailTrackSelect', { + count: ranked.length, + selectedIndex: select.selectedIndex, + selected: select.value, + restored: savedIndex != null + }); + return true; + } finally { + isApplying = false; + } + } + + function findSourceSelect() { + return document.querySelector(SELECTOR); + } + + let observer = null; + let applyScheduled = false; + let isApplying = false; + + function disconnectObserver() { + if (observer) { + observer.disconnect(); + observer = null; + } + applyScheduled = false; + } + + function tryApply() { + if (isApplying) return false; + const select = findSourceSelect(); + if (!select || !select.options?.length) return false; + return applyToSelect(select); + } + + function scheduleTryApply() { + if (isApplying || applyScheduled) return; + applyScheduled = true; + requestAnimationFrame(() => { + applyScheduled = false; + if (!observer) return; + tryApply(); + }); + } + + function isDetailsHash(hash) { + const h = hash || window.location.hash || ''; + return h.includes('details.html') || h.includes('/details'); + } + + function isRelevantMutation(mutations) { + for (const mutation of mutations) { + if (mutation.type !== 'childList') continue; + + const target = mutation.target; + if (target?.matches?.('select.detailTrackSelect')) return true; + if (target?.tagName === 'OPTION' && !target.hasAttribute(ORIGINAL_ATTR)) return true; + + for (const node of mutation.addedNodes) { + if (node.nodeType !== 1) continue; + if (node.matches?.('select.detailTrackSelect')) return true; + if (node.matches?.('option') && !node.hasAttribute(ORIGINAL_ATTR)) return true; + if (node.querySelector?.('select.detailTrackSelect, option')) return true; + } + } + return false; + } + + function observeForSelect() { + disconnectObserver(); + + tryApply(); + + const container = document.querySelector(CONTAINER_SELECTOR); + if (!container) { + return; + } + + observer = new MutationObserver((mutations) => { + if (isApplying) return; + if (!isRelevantMutation(mutations)) return; + scheduleTryApply(); + }); + observer.observe(container, { childList: true, subtree: true }); + } + + function onDetailsPage() { + observeForSelect(); + } + + function init() { + if (!window.KefinTweaksUtils?.onViewPage) { + WARN('KefinTweaksUtils.onViewPage unavailable'); + return; + } + + window.KefinTweaksUtils.onViewPage(() => { + try { + onDetailsPage(); + } catch (err) { + WARN('Failed to apply version preferences', err); + } + }, { pages: ['details'] }); + + window.KefinTweaksUtils.onViewPage((_view, _element, hash) => { + if (!isDetailsHash(hash)) { + disconnectObserver(); + } + }); + + LOG('Initialized'); + } + + window.KefinVersionPreferences = { + NONE_SENTINEL, + getDefaults, + getPreferences, + ensureNoneSentinel, + isNoneSentinel, + findBestTerm, + isSelectInExpectedState, + applyToSelect + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/scripts/watchTogether.js b/scripts/watchTogether.js new file mode 100644 index 0000000..d4788e3 --- /dev/null +++ b/scripts/watchTogether.js @@ -0,0 +1,1401 @@ +/** + * KefinTweaks Watch Together + * Account-scoped roster in CustomPrefs.kefinTweaks.watchTogether; + * device-local enabledUserIds + lastSelectionAt; sync primary playstate to enabled users. + */ +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks WatchTogether]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks WatchTogether]', ...args); + const ERR = (...args) => console.error('[KefinTweaks WatchTogether]', ...args); + + const DEVICE_STORAGE_PREFIX = 'kefinTweaks_watchTogether'; + const FEATURE_KEY = 'watchTogether'; + const MODAL_ID = 'kefin-watch-together-modal'; + const ADD_USER_MODAL_ID = 'kefin-watch-together-add-modal'; + const WATCH_TOGETHER_MODAL_ID = 'kefin-watch-together-prompt'; + const REMOVE_CONFIRM_MODAL_ID = 'kefin-watch-together-remove-confirm'; + const SYNC_DEBOUNCE_MS = 250; + const DEFAULT_SESSION_TIMEOUT_MINUTES = 30; + + let syncStarted = false; + let activeModal = null; + let addUserModal = null; + let watchTogetherModal = null; + /** Refreshes the open Watch Together picker in place (avatars + pending selection). */ + let refreshWatchTogetherPicker = null; + const syncDebounceTimers = new Map(); + + /** Cached CustomPrefs roster: { users: [{ userId, name, accessToken }] } */ + let groupCache = { users: [] }; + let groupHydrated = false; + + let inContinuousVideoSession = false; + let isAddingOsdButton = false; + + function getUi() { + return window.KefinTweaksUI || {}; + } + + function injectStyles() { + if (document.getElementById('kefin-watch-together-styles')) return; + const style = document.createElement('style'); + style.id = 'kefin-watch-together-styles'; + style.textContent = ` + .headerUserButton[data-watch-together] .material-icons.person::before, + [data-watch-together].material-icons.person::before { + content: "\\e7ef"; + } + .mus-watch-together-avatars { + display: flex; + flex-wrap: wrap; + gap: 1em; + justify-content: center; + padding-top: 1em; + } + .mus-watch-together-avatar-wrap { + position: relative; + } + .mus-watch-together-avatar { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4em; + cursor: pointer; + background: none; + border: none; + padding: 0; + color: inherit; + } + .mus-watch-together-avatar .mus-avatar-circle { + width: 96px; + height: 96px; + border-radius: 50%; + overflow: hidden; + border: 3px solid transparent; + box-sizing: border-box; + background: rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease; + opacity: 0.45; + } + .mus-watch-together-avatar.is-enabled .mus-avatar-circle { + border-color: rgba(0, 164, 220, 0.95); + box-shadow: 0 0 0 2px rgba(0, 164, 220, 0.35); + opacity: 1; + } + .mus-watch-together-avatar .mus-avatar-circle img { + width: 100%; + height: 100%; + object-fit: cover; + } + .mus-watch-together-avatar .mus-avatar-fallback { + font-size: 28px; + opacity: 0.85; + } + .mus-watch-together-avatar .mus-avatar-name { + font-size: 0.75em; + text-align: center; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mus-avatar-remove { + position: absolute; + top: -8px; + right: -8px; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.75); + color: #fff; + z-index: 2; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + .mus-watch-together-avatar-wrap:hover .mus-avatar-remove { + opacity: 1; + pointer-events: auto; + } + .mus-avatar-remove .material-icons { + font-size: 14px; + line-height: 1; + } + .mus-wt-footer-actions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5em; + width: 100%; + justify-content: flex-end; + align-items: center; + } + .mus-wt-footer-actions .emby-button { + margin: 0; + } + .material-icons.group_off::before { + content: "\\e747"; + } + #videoOsdPage:not(.hide) button.btnWatchTogether { + margin: 0; + } + `; + document.head.appendChild(style); + } + + function escapeHtml(str) { + if (typeof getUi().escapeHtml === 'function') return getUi().escapeHtml(str); + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function getPrimaryUserId() { + return (typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId) + ? ApiClient.getCurrentUserId() + : null; + } + + function getPrimaryUserName() { + if (typeof ApiClient !== 'undefined') { + if (ApiClient._currentUser?.Name) return ApiClient._currentUser.Name; + const id = getPrimaryUserId(); + if (id) return id; + } + return 'User'; + } + + function getDeviceStorageKey() { + const serverId = (typeof ApiClient !== 'undefined' && ApiClient.serverId) + ? (ApiClient.serverId() || '') + : ''; + const primaryUserId = getPrimaryUserId() || ''; + if (!serverId || !primaryUserId) return null; + return `${DEVICE_STORAGE_PREFIX}:${serverId}:${primaryUserId}`; + } + + function readDeviceState() { + const key = getDeviceStorageKey(); + if (!key) return { enabledUserIds: [], lastSelectionAt: 0 }; + try { + const raw = localStorage.getItem(key); + if (!raw) return { enabledUserIds: [], lastSelectionAt: 0 }; + const parsed = JSON.parse(raw); + const enabledUserIds = Array.isArray(parsed?.enabledUserIds) + ? parsed.enabledUserIds.filter(Boolean) + : []; + const lastSelectionAt = Number(parsed?.lastSelectionAt) || 0; + return { enabledUserIds, lastSelectionAt }; + } catch (e) { + WARN('Failed to read device watch-together state:', e); + return { enabledUserIds: [], lastSelectionAt: 0 }; + } + } + + function writeDeviceState(state) { + const key = getDeviceStorageKey(); + if (!key) return false; + try { + const enabledUserIds = Array.isArray(state?.enabledUserIds) + ? [...new Set(state.enabledUserIds.filter(Boolean))] + : []; + const lastSelectionAt = Number(state?.lastSelectionAt) || 0; + localStorage.setItem(key, JSON.stringify({ enabledUserIds, lastSelectionAt })); + updateHeaderWatchTogetherIndicator(); + updateSyncListenerState(); + return true; + } catch (e) { + ERR('Failed to write device watch-together state:', e); + return false; + } + } + + function stampLastSelectionAt(enabledUserIds) { + const current = readDeviceState(); + return writeDeviceState({ + enabledUserIds: enabledUserIds != null ? enabledUserIds : current.enabledUserIds, + lastSelectionAt: Date.now() + }); + } + + function normalizeWatchTogether(raw) { + const users = Array.isArray(raw?.users) ? raw.users : []; + return { + users: users + .filter((u) => u && u.userId && (u.accessToken || u.viaAdmin === true)) + .map((u) => { + const entry = { + userId: u.userId, + name: u.name || '' + }; + if (u.viaAdmin === true) { + entry.viaAdmin = true; + } else if (u.accessToken) { + entry.accessToken = u.accessToken; + } + return entry; + }) + }; + } + + async function hydrateGroupCache() { + try { + const raw = await window.userHelper?.getKefinTweaksFeature?.(FEATURE_KEY, { users: [] }); + groupCache = normalizeWatchTogether(raw); + groupHydrated = true; + return groupCache; + } catch (e) { + WARN('Failed to hydrate watchTogether group:', e); + groupCache = { users: [] }; + groupHydrated = true; + return groupCache; + } + } + + async function persistGroup(nextGroup) { + const normalized = normalizeWatchTogether(nextGroup); + const ok = await window.userHelper?.setKefinTweaksFeature?.(FEATURE_KEY, normalized); + if (ok) { + groupCache = normalized; + groupHydrated = true; + updateHeaderWatchTogetherIndicator(); + updateSyncListenerState(); + } + return ok; + } + + function getSessionUsers() { + return (groupCache.users || []).slice(); + } + + function getEnabledSessionUsers() { + const primaryId = getPrimaryUserId(); + const { enabledUserIds } = readDeviceState(); + const enabledSet = new Set(enabledUserIds); + return getSessionUsers().filter( + (u) => enabledSet.has(u.userId) + && u.userId + && u.userId !== primaryId + && (u.accessToken || u.viaAdmin === true) + ); + } + + async function upsertSessionUser(entry) { + const users = getSessionUsers(); + const idx = users.findIndex((u) => u.userId === entry.userId); + const next = { + userId: entry.userId, + name: entry.name || '' + }; + if (entry.viaAdmin === true) { + next.viaAdmin = true; + } else if (entry.accessToken) { + next.accessToken = entry.accessToken; + } else { + ERR('upsertSessionUser requires accessToken or viaAdmin'); + return false; + } + if (idx >= 0) users[idx] = next; + else users.push(next); + const ok = await persistGroup({ users }); + if (ok) { + const device = readDeviceState(); + if (!device.enabledUserIds.includes(entry.userId)) { + writeDeviceState({ + enabledUserIds: [...device.enabledUserIds, entry.userId], + lastSelectionAt: device.lastSelectionAt + }); + } + } + return ok; + } + + function setUserEnabled(userId, enabled) { + const device = readDeviceState(); + const set = new Set(device.enabledUserIds); + if (enabled) set.add(userId); + else set.delete(userId); + return writeDeviceState({ + enabledUserIds: [...set], + lastSelectionAt: device.lastSelectionAt + }); + } + + async function removeSessionUser(userId) { + const users = getSessionUsers().filter((u) => u.userId !== userId); + const ok = await persistGroup({ users }); + const device = readDeviceState(); + writeDeviceState({ + enabledUserIds: device.enabledUserIds.filter((id) => id !== userId), + lastSelectionAt: device.lastSelectionAt + }); + return ok; + } + + function getSessionTimeoutMs() { + const minutes = window.KefinTweaksConfig?.watchTogether?.sessionTimeout; + const n = Number(minutes); + const use = Number.isFinite(n) && n > 0 ? n : DEFAULT_SESSION_TIMEOUT_MINUTES; + return use * 60 * 1000; + } + + function getHeaderUserButtonTitle() { + const enabledUsers = getEnabledSessionUsers(); + if (!enabledUsers.length) return null; + const others = enabledUsers.map((u) => u.name || u.userId).join(', '); + return `${getPrimaryUserName()} and ${others}`; + } + + function updateHeaderWatchTogetherIndicator() { + const hasMulti = getEnabledSessionUsers().length > 0; + const multiTitle = hasMulti ? getHeaderUserButtonTitle() : null; + + document.querySelectorAll('.headerUserButton').forEach((btn) => { + if (hasMulti) { + if (!btn.hasAttribute('data-wt-original-title')) { + const existing = btn.getAttribute('title'); + if (existing) btn.setAttribute('data-wt-original-title', existing); + } + if (multiTitle) btn.setAttribute('title', multiTitle); + btn.setAttribute('data-watch-together', 'true'); + btn.querySelectorAll('.material-icons.person').forEach((icon) => { + icon.setAttribute('data-watch-together', 'true'); + }); + } else { + if (btn.hasAttribute('data-wt-original-title')) { + const original = btn.getAttribute('data-wt-original-title'); + if (original) btn.setAttribute('title', original); + else btn.removeAttribute('title'); + btn.removeAttribute('data-wt-original-title'); + } + btn.removeAttribute('data-watch-together'); + btn.querySelectorAll('[data-watch-together]').forEach((el) => { + el.removeAttribute('data-watch-together'); + }); + } + }); + } + + function authHeaderForToken(token) { + if (window.apiHelper?.getAuthHeaderForToken) { + return window.apiHelper.getAuthHeaderForToken(token); + } + return ''; + } + + /** Primary admin token for viaAdmin members; stored token for password-linked members. */ + function authHeaderForSessionUser(user) { + if (user?.viaAdmin === true || !user?.accessToken) { + if (window.apiHelper?.getAuthHeader) { + return window.apiHelper.getAuthHeader(); + } + return authHeaderForToken( + typeof ApiClient !== 'undefined' && ApiClient.accessToken + ? ApiClient.accessToken() + : '' + ); + } + return authHeaderForToken(user.accessToken); + } + + async function authenticateByName(username, password) { + const serverUrl = ApiClient.serverAddress(); + if (!serverUrl) throw new Error('Server address unavailable'); + + const response = await fetch(`${serverUrl}/Users/AuthenticateByName`, { + method: 'POST', + headers: { + Authorization: authHeaderForToken(''), + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ + Username: username, + Pw: password + }) + }); + + if (!response.ok) { + let detail = `${response.status}`; + try { + const errBody = await response.json(); + if (errBody?.message || errBody?.Message) { + detail = errBody.message || errBody.Message; + } + } catch (_) { /* ignore */ } + throw new Error(detail); + } + + return response.json(); + } + + async function logoutSessionToken(accessToken) { + if (!accessToken) return; + try { + const serverUrl = ApiClient.serverAddress(); + if (!serverUrl) return; + await fetch(`${serverUrl}/Sessions/Logout`, { + method: 'POST', + headers: { + Authorization: authHeaderForToken(accessToken), + 'Content-Type': 'application/json' + } + }); + } catch (e) { + WARN('Best-effort logout failed:', e); + } + } + + function getSessionUserIds() { + return new Set(getSessionUsers().map((u) => u.userId)); + } + + function isPrimaryUserDataEvent(msg) { + const eventUserId = msg?.Data?.UserId; + const primaryId = getPrimaryUserId(); + if (!primaryId || !eventUserId) return false; + if (eventUserId !== primaryId) return false; + return !getSessionUserIds().has(eventUserId); + } + + async function markItemPlayed(user, itemId) { + const serverUrl = ApiClient.serverAddress(); + const url = `${serverUrl}/Users/${encodeURIComponent(user.userId)}/PlayedItems/${encodeURIComponent(itemId)}`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: authHeaderForSessionUser(user), + 'Content-Type': 'application/json' + } + }); + if (response.status === 401 || response.status === 403) { + setUserEnabled(user.userId, false); + WARN(`Disabled session user ${user.name || user.userId} after ${response.status} on PlayedItems`); + return false; + } + if (!response.ok) { + throw new Error(`PlayedItems ${response.status}`); + } + return true; + } + + async function mirrorUserData(user, itemId, userData) { + const serverUrl = ApiClient.serverAddress(); + const url = `${serverUrl}/Users/${encodeURIComponent(user.userId)}/Items/${encodeURIComponent(itemId)}/UserData`; + const body = { + PlaybackPositionTicks: userData.PlaybackPositionTicks ?? 0, + PlayedPercentage: userData.PlayedPercentage, + PlayCount: userData.PlayCount, + IsFavorite: userData.IsFavorite, + LastPlayedDate: userData.LastPlayedDate + }; + if (userData.Played === true) { + body.Played = true; + } + Object.keys(body).forEach((k) => { + if (body[k] === undefined) delete body[k]; + }); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: authHeaderForSessionUser(user), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + if (response.status === 401 || response.status === 403) { + setUserEnabled(user.userId, false); + WARN(`Disabled session user ${user.name || user.userId} after ${response.status} on UserData`); + return false; + } + if (!response.ok) { + throw new Error(`UserData ${response.status}`); + } + return true; + } + + async function syncUserDataToSessionUsers(userData) { + if (!userData?.ItemId) return; + const itemId = userData.ItemId; + const targets = getEnabledSessionUsers(); + if (!targets.length) return; + + const hasProgress = typeof userData.PlaybackPositionTicks === 'number' + && userData.PlaybackPositionTicks > 0; + const shouldPushUserData = hasProgress + || userData.PlaybackPositionTicks === 0 + || userData.PlayedPercentage != null; + const shouldMarkPlayed = userData.Played === true; + + if (!shouldMarkPlayed && !shouldPushUserData) return; + + await Promise.all(targets.map(async (user) => { + try { + if (shouldMarkPlayed) { + await markItemPlayed(user, itemId); + } + if (shouldPushUserData) { + await mirrorUserData(user, itemId, userData); + } + } catch (e) { + WARN(`Sync failed for ${user.name || user.userId}:`, e); + } + })); + } + + function scheduleSyncUserData(userData) { + if (!userData?.ItemId) return; + const itemId = userData.ItemId; + clearTimeout(syncDebounceTimers.get(itemId)); + syncDebounceTimers.set(itemId, setTimeout(() => { + syncDebounceTimers.delete(itemId); + syncUserDataToSessionUsers(userData).catch((e) => WARN('Debounced sync error:', e)); + }, SYNC_DEBOUNCE_MS)); + } + + function handleUserDataChangedMessage(msg) { + if (!isPrimaryUserDataEvent(msg)) return; + + const list = msg?.Data?.UserDataList; + if (!Array.isArray(list) || !list.length) return; + if (!getEnabledSessionUsers().length) return; + list.forEach((entry) => scheduleSyncUserData(entry)); + } + + function stopSync() { + if (!syncStarted) return; + if (window.websocketHelper?.unlisten) { + window.websocketHelper.unlisten('UserDataChanged', handleUserDataChangedMessage); + } + syncStarted = false; + syncDebounceTimers.forEach((timer) => clearTimeout(timer)); + syncDebounceTimers.clear(); + LOG('Playstate sync stopped (no enabled session users)'); + } + + function startSync() { + if (syncStarted) return; + if (!getEnabledSessionUsers().length) return; + if (!window.websocketHelper?.listen) { + WARN('websocketHelper not available; retrying sync start'); + setTimeout(updateSyncListenerState, 1000); + return; + } + window.websocketHelper.listen('UserDataChanged', handleUserDataChangedMessage); + syncStarted = true; + LOG('Playstate sync listening for UserDataChanged'); + } + + function updateSyncListenerState() { + if (getEnabledSessionUsers().length > 0) { + startSync(); + } else { + stopSync(); + } + } + + function getUserAvatarUrl(userId) { + const server = ApiClient?.serverAddress?.() || ''; + if (!server || !userId) return ''; + return `${server}/Users/${encodeURIComponent(userId)}/Images/Primary`; + } + + /** + * Shared Watch Together account picker UI (playback prompt + manage modal). + * @param {Array} users + * @param {string[]} enabledUserIds + * @param {{ showRemove?: boolean }} [options] + */ + function buildAvatarToggleHtml(users, enabledUserIds, options = {}) { + const { showRemove = false } = options; + const enabledSet = new Set(enabledUserIds); + if (!users.length) { + return `
    No accounts in your Watch Together group yet.
    `; + } + const items = users.map((user) => { + const enabled = enabledSet.has(user.userId); + const name = escapeHtml(user.name || user.userId); + const src = escapeHtml(getUserAvatarUrl(user.userId)); + const removeBtn = showRemove + ? `` + : ''; + return ` +
    + ${removeBtn} + +
    + `; + }).join(''); + return `
    ${items}
    `; + } + + function buildWatchTogetherPickerBodyHtml(users, enabledUserIds, options = {}) { + const { showRemove = false, hint = 'Tap accounts to include them in watch-state sync for this session.' } = options; + return ` +
    +
    + ${escapeHtml(hint)} +
    + + ${buildAvatarToggleHtml(users, enabledUserIds, { showRemove })} +
    + `; + } + + /** + * @param {HTMLElement} root + * @param {string[]} pendingEnabledIds - mutated in place for pending selection + * @param {{ applyImmediately?: boolean, onRemove?: Function }} [options] + */ + function bindAvatarToggleHandlers(root, pendingEnabledIds, options = {}) { + const { applyImmediately = false, onRemove = null } = options; + + root.querySelectorAll('.mus-watch-together-avatar').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const userId = btn.getAttribute('data-user-id'); + if (!userId) return; + const idx = pendingEnabledIds.indexOf(userId); + if (idx >= 0) pendingEnabledIds.splice(idx, 1); + else pendingEnabledIds.push(userId); + const enabled = pendingEnabledIds.includes(userId); + btn.classList.toggle('is-enabled', enabled); + btn.setAttribute('aria-pressed', String(enabled)); + if (applyImmediately) { + setUserEnabled(userId, enabled); + stampLastSelectionAt([...pendingEnabledIds]); + } + }); + }); + + root.querySelectorAll('.mus-avatar-remove').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const userId = btn.getAttribute('data-user-id'); + if (!userId) return; + const user = getSessionUsers().find((u) => u.userId === userId); + const label = user?.name || userId; + const readdHint = user?.viaAdmin + ? 'You can re-add them from the user list.' + : 'They will need to be authenticated again to re-add.'; + const confirmed = await confirmRemoveWatchTogetherUser(label, readdHint); + if (!confirmed) return; + const token = user?.accessToken; + await removeSessionUser(userId); + if (token) await logoutSessionToken(token); + window.KefinTweaksToaster?.toast?.(`Removed ${label} from Watch Together group`); + const idx = pendingEnabledIds.indexOf(userId); + if (idx >= 0) pendingEnabledIds.splice(idx, 1); + if (typeof onRemove === 'function') { + onRemove(userId); + } else { + btn.closest('.mus-watch-together-avatar-wrap')?.remove(); + if (!root.querySelector('.mus-watch-together-avatar-wrap')) { + const host = root.querySelector('.mus-watch-together-avatars'); + if (host) { + host.outerHTML = buildAvatarToggleHtml([], [], { showRemove: true }); + } + } + } + }); + }); + } + + /** + * Jellyfin-style confirm for removing a Watch Together member. + * @returns {Promise} + */ + function confirmRemoveWatchTogetherUser(label, readdHint) { + return new Promise((resolve) => { + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + + if (!window.ModalSystem?.create) { + settle(window.confirm(`Remove ${label} from Watch Together? ${readdHint}`)); + return; + } + + if (window.ModalSystem.isOpen?.(REMOVE_CONFIRM_MODAL_ID)) { + window.ModalSystem.close(REMOVE_CONFIRM_MODAL_ID); + } + + const modal = window.ModalSystem.create({ + id: REMOVE_CONFIRM_MODAL_ID, + title: 'Remove User', + content: ` +
    + Remove ${escapeHtml(label)} from Watch Together? +
    +
    + ${escapeHtml(readdHint)} +
    + `, + footer: ` + + `, + // Avoid Escape also closing the parent Watch Together modal (both listen on document). + closeOnBackdrop: true, + closeOnEscape: false, + showCloseButton: true, + onOpen: (modalInstance) => { + if (modalInstance?.dialog) { + modalInstance.dialog.style.maxWidth = '420px'; + modalInstance.dialog.style.width = '90vw'; + } + modalInstance.dialogFooter?.querySelector('#mus-remove-cancel')?.addEventListener('click', (e) => { + e.preventDefault(); + settle(false); + modalInstance.close(); + }); + modalInstance.dialogFooter?.querySelector('#mus-remove-confirm')?.addEventListener('click', (e) => { + e.preventDefault(); + settle(true); + modalInstance.close(); + }); + } + }); + if (modal) { + modal.onClose = () => settle(false); + } + }); + } + + function openWatchTogetherPrompt(options = {}) { + const { + onComplete, + title = 'Watch Together', + showRemove = false, + applyImmediately = false, + hint = 'Watch state will sync to the selected accounts in your group as you watch new content.' + } = options; + + if (!window.ModalSystem?.create) { + onComplete?.(); + return; + } + if (watchTogetherModal?.isOpen) { + watchTogetherModal.close(); + } + + const users = getSessionUsers(); + // Manage / prefs entry can open with an empty group so users can add accounts. + if (!users.length && !showRemove) { + onComplete?.(); + return; + } + + const pendingEnabledIds = readDeviceState().enabledUserIds + .filter((id) => users.some((u) => u.userId === id)); + + const contentHtml = buildWatchTogetherPickerBodyHtml(users, pendingEnabledIds, { + showRemove, + hint + }); + + const showAddUser = options.showAddUser !== false; + const modalId = showRemove ? MODAL_ID : WATCH_TOGETHER_MODAL_ID; + + watchTogetherModal = window.ModalSystem.create({ + id: modalId, + title, + content: contentHtml, + footer: showAddUser + ? ` + + ` + : null, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + watchTogetherModal = modalInstance; + if (showRemove) activeModal = modalInstance; + if (modalInstance?.dialog) { + modalInstance.dialog.style.maxWidth = '480px'; + modalInstance.dialog.style.width = '92vw'; + } + const root = modalInstance.dialogContent; + + const rebind = () => { + const nextUsers = getSessionUsers(); + const deviceEnabled = new Set(readDeviceState().enabledUserIds); + // Drop removed users from pending selection + for (let i = pendingEnabledIds.length - 1; i >= 0; i--) { + if (!nextUsers.some((u) => u.userId === pendingEnabledIds[i])) { + pendingEnabledIds.splice(i, 1); + } + } + // Pick up newly added / device-enabled users (e.g. after Add User) + nextUsers.forEach((u) => { + if (deviceEnabled.has(u.userId) && !pendingEnabledIds.includes(u.userId)) { + pendingEnabledIds.push(u.userId); + } + }); + const body = root.querySelector('.mus-watch-together-prompt'); + if (!body) return; + body.outerHTML = buildWatchTogetherPickerBodyHtml(nextUsers, pendingEnabledIds, { + showRemove, + hint + }); + bindAvatarToggleHandlers(root, pendingEnabledIds, { + applyImmediately, + onRemove: () => rebind() + }); + }; + + refreshWatchTogetherPicker = rebind; + + bindAvatarToggleHandlers(root, pendingEnabledIds, { + applyImmediately, + onRemove: () => rebind() + }); + + modalInstance.dialogFooter?.querySelector('#mus-wt-add-user')?.addEventListener('click', (e) => { + e.preventDefault(); + openAddUserModal(); + }); + + modalInstance.dialogFooter?.querySelector('#mus-wt-ok')?.addEventListener('click', (e) => { + e.preventDefault(); + watchTogetherModal.close(); + }); + } + }); + if (watchTogetherModal) { + watchTogetherModal.onClose = () => { + // Dismiss (X / backdrop / Escape) applies the current selection + session stamp. + stampLastSelectionAt([...pendingEnabledIds]); + watchTogetherModal = null; + refreshWatchTogetherPicker = null; + if (showRemove) activeModal = null; + onComplete?.(); + }; + } + } + + async function fetchCandidateUsersForAdmin() { + const primaryId = getPrimaryUserId(); + const existing = getSessionUserIds(); + let users = []; + try { + if (typeof ApiClient?.getUsers === 'function') { + users = await ApiClient.getUsers(); + } else { + const serverUrl = ApiClient.serverAddress(); + const response = await fetch(`${serverUrl}/Users`, { + method: 'GET', + headers: { + Authorization: window.apiHelper?.getAuthHeader?.() || authHeaderForToken(ApiClient.accessToken?.() || ''), + Accept: 'application/json' + } + }); + if (!response.ok) throw new Error(`Users ${response.status}`); + users = await response.json(); + } + } catch (e) { + ERR('Failed to load users for admin Watch Together add:', e); + throw e; + } + if (!Array.isArray(users)) users = []; + return users + .filter((u) => u?.Id && u.Id !== primaryId && !existing.has(u.Id)) + .map((u) => ({ userId: u.Id, name: u.Name || u.Id })) + .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' })); + } + + function buildAddUserModalContentHtml() { + const { buildTextInput } = getUi(); + const usernameField = typeof buildTextInput === 'function' + ? buildTextInput('mus-add-username', '', 'Username', 'text') + : ''; + const passwordField = typeof buildTextInput === 'function' + ? buildTextInput('mus-add-password', '', 'Password', 'password') + : ''; + + return ` +
    + ${usernameField} + ${passwordField} + +
    + + +
    +
    + `; + } + + function buildAdminAddUserModalContentHtml() { + return ` +
    +
    + +
    + +
    + + +
    +
    + `; + } + + function populateAdminUserSelect(root, candidates) { + const select = root?.querySelector('#mus-add-user-select'); + const addBtn = root?.querySelector('#mus-add-admin-add'); + if (!select) return; + select.innerHTML = ''; + if (!candidates.length) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'No users available to add'; + select.appendChild(opt); + select.disabled = true; + if (addBtn) addBtn.disabled = true; + return; + } + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = 'Select a user…'; + select.appendChild(placeholder); + candidates.forEach((u) => { + const opt = document.createElement('option'); + opt.value = u.userId; + opt.textContent = u.name; + opt.setAttribute('data-name', u.name); + select.appendChild(opt); + }); + select.disabled = false; + if (addBtn) addBtn.disabled = false; + } + + async function submitAdminAddUser(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + const select = root.querySelector('#mus-add-user-select'); + const errorEl = root.querySelector('#mus-add-user-error'); + const addBtn = root.querySelector('#mus-add-admin-add'); + const userId = select?.value || ''; + const name = select?.selectedOptions?.[0]?.getAttribute('data-name') + || select?.selectedOptions?.[0]?.textContent + || userId; + + if (errorEl) { + errorEl.style.display = 'none'; + errorEl.textContent = ''; + } + if (!userId) { + if (errorEl) { + errorEl.textContent = 'Select a user to add.'; + errorEl.style.display = 'block'; + } + return; + } + if (userId === getPrimaryUserId()) { + if (errorEl) { + errorEl.textContent = 'Cannot add the currently signed-in user to the session'; + errorEl.style.display = 'block'; + } + return; + } + + if (addBtn) addBtn.disabled = true; + try { + await upsertSessionUser({ userId, name, viaAdmin: true }); + modalInstance.close(); + if (typeof refreshWatchTogetherPicker === 'function' && watchTogetherModal?.isOpen) { + refreshWatchTogetherPicker(); + } + window.KefinTweaksToaster?.toast?.(`Added ${name} to Watch Together group`); + } catch (err) { + if (errorEl) { + errorEl.textContent = err?.message || 'Failed to add user'; + errorEl.style.display = 'block'; + } + ERR('Admin add user failed:', err); + if (addBtn) addBtn.disabled = false; + } + } + + async function submitAddUserAuth(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + const username = root.querySelector('#mus-add-username')?.value?.trim() || ''; + const password = root.querySelector('#mus-add-password')?.value ?? ''; + const errorEl = root.querySelector('#mus-add-user-error'); + const authBtn = root.querySelector('#mus-add-authenticate'); + + if (errorEl) { + errorEl.style.display = 'none'; + errorEl.textContent = ''; + } + if (!username) { + if (errorEl) { + errorEl.textContent = 'Username is required.'; + errorEl.style.display = 'block'; + } + return; + } + + if (authBtn) authBtn.disabled = true; + try { + const result = await authenticateByName(username, password); + const userId = result?.User?.Id || result?.UserId; + const name = result?.User?.Name || username; + const accessToken = result?.AccessToken || result?.accessToken; + if (!userId || !accessToken) { + throw new Error('Authentication response missing user or token'); + } + if (userId === getPrimaryUserId()) { + throw new Error('Cannot add the currently signed-in user to the session'); + } + await upsertSessionUser({ userId, name, accessToken }); + const pwEl = root.querySelector('#mus-add-password'); + const userEl = root.querySelector('#mus-add-username'); + if (pwEl) pwEl.value = ''; + if (userEl) userEl.value = ''; + modalInstance.close(); + if (typeof refreshWatchTogetherPicker === 'function' && watchTogetherModal?.isOpen) { + refreshWatchTogetherPicker(); + } + window.KefinTweaksToaster?.toast?.(`Added ${name} to Watch Together group`); + } catch (err) { + if (errorEl) { + errorEl.textContent = err?.message || 'Authentication failed'; + errorEl.style.display = 'block'; + } + ERR('Add user failed:', err); + } finally { + if (authBtn) authBtn.disabled = false; + } + } + + function bindAddUserModalHandlers(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + root.querySelector('#mus-add-cancel')?.addEventListener('click', (e) => { + e.preventDefault(); + modalInstance.close(); + }); + + root.querySelector('#mus-add-authenticate')?.addEventListener('click', (e) => { + e.preventDefault(); + submitAddUserAuth(modalInstance); + }); + + const handleEnter = (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + submitAddUserAuth(modalInstance); + }; + root.querySelector('#mus-add-username')?.addEventListener('keydown', handleEnter); + root.querySelector('#mus-add-password')?.addEventListener('keydown', handleEnter); + + setTimeout(() => root.querySelector('#mus-add-username')?.focus(), 50); + } + + function bindAdminAddUserModalHandlers(modalInstance) { + const root = modalInstance?.dialogContent; + if (!root) return; + + root.querySelector('#mus-add-cancel')?.addEventListener('click', (e) => { + e.preventDefault(); + modalInstance.close(); + }); + + root.querySelector('#mus-add-admin-add')?.addEventListener('click', (e) => { + e.preventDefault(); + submitAdminAddUser(modalInstance); + }); + + root.querySelector('#mus-add-user-select')?.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + submitAdminAddUser(modalInstance); + }); + } + + async function openAddUserModal() { + if (!window.ModalSystem?.create) { + alert('Modal system not available.'); + return; + } + if (addUserModal?.isOpen) { + addUserModal.close(); + } + + let isAdminUser = false; + try { + isAdminUser = !!(await window.apiHelper?.isAdmin?.()); + } catch (_) { + isAdminUser = false; + } + + addUserModal = window.ModalSystem.create({ + id: ADD_USER_MODAL_ID, + title: null, + content: isAdminUser ? buildAdminAddUserModalContentHtml() : buildAddUserModalContentHtml(), + footer: null, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: async (modalInstance) => { + addUserModal = modalInstance; + if (modalInstance?.dialog) { + modalInstance.dialog.style.maxWidth = '400px'; + modalInstance.dialog.style.width = '90vw'; + } + if (isAdminUser) { + bindAdminAddUserModalHandlers(modalInstance); + const errorEl = modalInstance.dialogContent?.querySelector('#mus-add-user-error'); + try { + const candidates = await fetchCandidateUsersForAdmin(); + populateAdminUserSelect(modalInstance.dialogContent, candidates); + setTimeout(() => modalInstance.dialogContent?.querySelector('#mus-add-user-select')?.focus(), 50); + } catch (err) { + populateAdminUserSelect(modalInstance.dialogContent, []); + if (errorEl) { + errorEl.textContent = err?.message || 'Failed to load users'; + errorEl.style.display = 'block'; + } + } + } else { + bindAddUserModalHandlers(modalInstance); + } + } + }); + if (addUserModal) { + addUserModal.onClose = () => { + addUserModal = null; + }; + } + } + + function openManageModal() { + openWatchTogetherPrompt({ + title: 'Watch Together', + showRemove: true, + applyImmediately: true + }); + } + + function removeLegacyPreferencesLinks() { + document.querySelectorAll('[data-kefin-watch-state-sync], [data-kefin-add-user-session]').forEach((el) => { + el.remove(); + }); + } + + async function registerUserMenuLink(attempt = 0) { + removeLegacyPreferencesLinks(); + + if (!window.KefinTweaksUtils?.addCustomMenuLink) { + if (attempt < 20) { + setTimeout(() => registerUserMenuLink(attempt + 1), 500); + } else { + WARN('addCustomMenuLink not available; Watch Together user menu link not registered'); + } + return; + } + + try { + await window.KefinTweaksUtils.addCustomMenuLink( + 'Watch Together', + 'group', + '#', + false, + { + userMenu: true, + sideMenu: false, + isUserLink: true, + action: 'KefinWatchTogether.openManageModal' + } + ); + LOG('Registered Watch Together user menu link'); + } catch (e) { + ERR('Failed to register Watch Together user menu link:', e); + } + } + + function shouldPromptWatchTogether() { + if (!getSessionUsers().length) return false; + if (inContinuousVideoSession) return false; + const { lastSelectionAt } = readDeviceState(); + if (lastSelectionAt && (Date.now() - lastSelectionAt) < getSessionTimeoutMs()) { + return false; + } + return true; + } + + function handleVideoPageEnter() { + addWatchTogetherOsdButton(); + + if (!shouldPromptWatchTogether()) { + updateSyncListenerState(); + return; + } + + openWatchTogetherPrompt({ + onComplete: () => { + updateSyncListenerState(); + addWatchTogetherOsdButton(); + } + }); + + inContinuousVideoSession = true; + } + + function handleVideoPageLeave() { + inContinuousVideoSession = false; + } + + function watchTogetherOsdButtonExists() { + return !!document.querySelector('#videoOsdPage:not(.hide) button.btnWatchTogether'); + } + + function createWatchTogetherOsdButton() { + const button = document.createElement('button'); + button.setAttribute('is', 'paper-icon-button-light'); + button.className = 'btnWatchTogether autoSize paper-icon-button-light'; + button.title = 'Watch Together'; + + const icon = document.createElement('span'); + icon.className = 'xlargePaperIconButton material-icons group'; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openManageModal(); + }); + return button; + } + + function addWatchTogetherOsdButton() { + if (isAddingOsdButton) return; + if (watchTogetherOsdButtonExists()) return; + if (!getSessionUsers().length && !getEnabledSessionUsers().length) { + // Still show button if group empty so users can manage from OSD after adding via prefs; + // only skip when feature has no group AND we're not on video — actually always show if hydrated group could be empty. + } + isAddingOsdButton = true; + try { + const buttonsContainer = document.querySelector('#videoOsdPage:not(.hide) .osdControls > .buttons'); + if (!buttonsContainer) return; + const ratingButton = buttonsContainer.querySelector('.btnUserRating'); + if (!ratingButton) return; + const btn = createWatchTogetherOsdButton(); + buttonsContainer.insertBefore(btn, ratingButton); + } finally { + isAddingOsdButton = false; + } + } + + function registerVideoPageHandler() { + if (!window.KefinTweaksUtils?.onViewPage) { + setTimeout(registerVideoPageHandler, 1000); + return; + } + + window.KefinTweaksUtils.onViewPage((view, element, hash, itemPromise, previousHash) => { + const onVideo = hash && (hash.includes('#/video') || hash.includes('/video')); + const previousOnVideo = previousHash && (previousHash.includes('#/video') || previousHash.includes('/video')); + + if (onVideo && !previousOnVideo) { + setTimeout(handleVideoPageEnter, 400); + setTimeout(addWatchTogetherOsdButton, 900); + } else if (inContinuousVideoSession) { + handleVideoPageLeave(); + } + }, { + pages: ['video'] + }); + + if (window.location.hash.includes('#/video') || window.location.hash.includes('/video')) { + setTimeout(handleVideoPageEnter, 500); + } + + LOG('Video onViewPage handler registered'); + } + + async function initialize() { + try { + LOG('initialize: start', { hash: window.location.hash }); + if (window.userHelper?.waitForLogin) { + LOG('initialize: waiting for login'); + await window.userHelper.waitForLogin(); + LOG('initialize: login ready'); + } else { + LOG('initialize: userHelper.waitForLogin unavailable, continuing'); + } + injectStyles(); + LOG('initialize: hydrating group cache'); + await hydrateGroupCache(); + LOG('initialize: group hydrated', { + groupHydrated, + userCount: groupCache?.users?.length ?? 0 + }); + updateHeaderWatchTogetherIndicator(); + updateSyncListenerState(); + LOG('initialize: registering video page handler + user menu link'); + registerVideoPageHandler(); + LOG('Initialized'); + } catch (e) { + ERR('Initialization failed:', e); + } + } + + window.KefinWatchTogether = { + openManageModal, + openWatchTogetherPrompt, + getSessionUsers, + getEnabledSessionUsers, + updateSyncListenerState, + hydrateGroupCache + }; + + initialize().then(() => { + registerUserMenuLink(); + }); +})(); diff --git a/scripts/watchlist.css b/scripts/watchlist.css index c647303..7670106 100644 --- a/scripts/watchlist.css +++ b/scripts/watchlist.css @@ -1,1959 +1,1992 @@ -.watchlist-icon[data-active='true'] .detailButton-icon:before { - font-variation-settings: 'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 20; -} -.custom-scroller-container h2.sectionTitle.sectionTitle-cards { - align-items: center; +.watchlist-icon[data-active="true"] .detailButton-icon:before { + font-variation-settings: + "FILL" 1, + "wght" 400, + "GRAD" 0, + "opsz" 20; } /* Watchlist Tab Styling */ .sections.watchlist { - padding-bottom: 20px; - margin-top: 20px; + padding-bottom: 20px; + margin-top: 20px; } .sections.watchlist .watchlist-tabs { - display: flex; - gap: 8px; - padding: 0 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - justify-content: center; + display: flex; + gap: 8px; + padding: 0 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + justify-content: center; } .sections.watchlist .watchlist-tabs button { - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.7); - padding: 12px 16px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - border-bottom: 2px solid transparent; - transition: all 0.2s ease; - position: relative; - border-radius: 10px 10px 0 0; - font-family: inherit; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + padding: 12px 16px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; + position: relative; + border-radius: 10px 10px 0 0; + font-family: inherit; } .sections.watchlist .watchlist-tabs button:hover { - color: rgba(200, 200, 200, 0.9); - color: rgba(200, 200, 200, 0.9); - background: rgba(255, 255, 255, 0.05); + color: rgba(200, 200, 200, 0.9); + color: rgba(200, 200, 200, 0.9); + background: rgba(255, 255, 255, 0.05); } .sections.watchlist .watchlist-tabs button.active { - color: rgba(200, 200, 200, 1); - border-bottom-color: #ffffff; - background: rgba(200, 200, 200, 0.1); - color: rgba(200, 200, 200, 1); - border-bottom-color: #ffffff; - background: rgba(200, 200, 200, 0.1); + color: rgba(200, 200, 200, 1); + border-bottom-color: #ffffff; + background: rgba(200, 200, 200, 0.1); + color: rgba(200, 200, 200, 1); + border-bottom-color: #ffffff; + background: rgba(200, 200, 200, 0.1); } /* Tab content styling - hide content by default, show only active */ .sections.watchlist div[data-tab]:not(.watchlist-tabs) { - display: none; - position: relative; + display: none; + position: relative; } .sections.watchlist div[data-tab]:not(.watchlist-tabs).active { - display: block; + display: block; } .watchlist > div:not([data-tab="watchlist"]) > div { - max-width: 1200px; - margin: 0 auto; + max-width: 1200px; + margin: 0 auto; } /* Progress Tab Styling */ .progress-series { - padding: 0 16px; + padding: 0 16px; } .progress-card { - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - margin-bottom: 16px; - overflow: hidden; - transition: background-color 0.2s ease; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; + transition: background-color 0.2s ease; } .progress-card:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.08); } .progress-card-completed { - border-left: 4px solid #4caf50; - background: rgba(76, 175, 80, 0.05); + border-left: 4px solid #4caf50; + background: rgba(76, 175, 80, 0.05); } .progress-card-completed:hover { - background: rgba(76, 175, 80, 0.08); + background: rgba(76, 175, 80, 0.08); } .progress-card-content { - display: flex; - padding: 16px; - gap: 16px; + display: flex; + padding: 16px; + gap: 16px; } .progress-poster { - flex-shrink: 0; - width: 140px; - height: 200px; - border-radius: 6px; - overflow: hidden; - background: rgba(255, 255, 255, 0.1); + flex-shrink: 0; + width: 140px; + height: 200px; + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.1); } .progress-poster .poster-link { - display: block; - width: 100%; - height: 100%; - text-decoration: none; - transition: opacity 0.2s ease; + display: block; + width: 100%; + height: 100%; + text-decoration: none; + transition: opacity 0.2s ease; } .progress-poster .poster-link:hover { - opacity: 0.8; + opacity: 0.8; } .progress-poster img { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .progress-details { - flex: 1; - display: flex; - flex-direction: column; - gap: 12px; + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; } .progress-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; } -.progress-tab-header, .movie-history-header, .watchlist-header { - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 10px 0 10px 10px; - max-width: 1200px; - margin: 0 auto; +.progress-tab-header, +.movie-history-header, +.watchlist-header { + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 0 10px 10px; + max-width: 1200px; + margin: 0 auto; } .progress-title { - font-size: 24px; - font-weight: 600; - color: #fff; - margin: 0; - flex: 1; - display: flex; - align-items: center; - gap: 12px; + font-size: 24px; + font-weight: 600; + color: #fff; + margin: 0; + flex: 1; + display: flex; + align-items: center; + gap: 12px; } .progress-title .title-link { - color: #fff; - text-decoration: none; - transition: color 0.2s ease; + color: #fff; + text-decoration: none; + transition: color 0.2s ease; } .progress-title .title-link:hover { - color: #00a4dc; - text-decoration: underline; + color: #00a4dc; + text-decoration: underline; } .completion-badge { - background: #4caf50; - color: #fff; - font-size: 12px; - font-weight: 500; - padding: 4px 8px; - border-radius: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; + background: #4caf50; + color: #fff; + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; } .progress-navigation { - display: flex; - gap: 8px; - align-items: center; + display: flex; + gap: 8px; + align-items: center; } .nav-button { - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.6); - cursor: pointer; - padding: 8px; - border-radius: 4px; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; } .nav-button:hover { - color: rgba(255, 255, 255, 0.9); - background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); } .nav-button .material-icons { - font-size: 20px; + font-size: 20px; } .progress-percentage { - font-size: 24px; - font-weight: 600; - color: #00a4dc; - min-width: 60px; - text-align: right; + font-size: 24px; + font-weight: 600; + color: #00a4dc; + min-width: 60px; + text-align: right; } .progress-bar-container { - margin: 8px 0; + margin: 8px 0; } .progress-series .progress-bar { - width: 100%; - height: 8px; - background: rgba(255, 255, 255, 0.2); - border-radius: 4px; - overflow: hidden; + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; + overflow: hidden; } .progress-bar-fill { - height: 100%; - background: linear-gradient(90deg, #00a4dc 0%, #0078d4 100%); - border-radius: 4px; - transition: width 0.3s ease; + height: 100%; + background: linear-gradient(90deg, #00a4dc 0%, #0078d4 100%); + border-radius: 4px; + transition: width 0.3s ease; } .progress-bar-completed .progress-bar-fill { - background: linear-gradient(90deg, #4caf50 0%, #2e7d32 100%); + background: linear-gradient(90deg, #4caf50 0%, #2e7d32 100%); } -.progress-stats { - color: rgba(255, 255, 255, 0.9); - font-size: 14px; - line-height: 1.5; - display: flex; - align-items: center; - gap: 8px; +.progress-stats { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + line-height: 1.5; + display: flex; + align-items: center; + gap: 8px; } -.progress-header-stats-container, .movie-history-stats-container, .watchlist-header-stats-container { - display: flex; - gap: 10px; +.progress-header-stats-container, +.movie-history-stats-container, +.watchlist-header-stats-container { + display: flex; + gap: 10px; } -.progress-header-stats, .watchlist-header-stats, .movie-history-stats { - font-size: 14px; - color: rgba(255, 255, 255, 0.7); - background: rgba(255, 255, 255, 0.1); - padding: 12px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.15); - display: flex; - align-items: center; +.progress-header-stats, +.watchlist-header-stats, +.movie-history-stats { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.1); + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.15); + display: flex; + align-items: center; } .watchlist-header-right { - display: flex; - align-items: center; - gap: 10px; + display: flex; + align-items: center; + gap: 10px; } .layout-toggle-btn { - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - padding: 8px; - border-radius: 4px; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; } .layout-toggle-btn:hover { - color: rgba(255, 255, 255, 0.9); - background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); } .layout-toggle-btn .material-icons { - font-size: 20px; + font-size: 20px; } .progress-stats-separator { - color: rgba(255, 255, 255, 0.5); - font-weight: 300; + color: rgba(255, 255, 255, 0.5); + font-weight: 300; } .progress-stats strong { - color: #fff; - font-weight: 600; + color: #fff; + font-weight: 600; } .progress-last-watched { - color: rgba(255, 255, 255, 0.7); - font-size: 14px; - line-height: 1.4; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + line-height: 1.4; } .progress-last-watched strong { - color: rgba(255, 255, 255, 0.9); - font-weight: 500; + color: rgba(255, 255, 255, 0.9); + font-weight: 500; } .progress-actions { - display: flex; - gap: 16px; - margin-top: 8px; + display: flex; + gap: 16px; + margin-top: 8px; } .action-link { - background: none; - border: none; - color: #00a4dc; - cursor: pointer; - font-size: 14px; - font-weight: 500; - padding: 0; - text-decoration: none; - transition: color 0.2s ease; + background: none; + border: none; + color: #00a4dc; + cursor: pointer; + font-size: 14px; + font-weight: 500; + padding: 0; + text-decoration: none; + transition: color 0.2s ease; } .action-link:hover { - color: #0078d4; - text-decoration: underline; + color: #0078d4; + text-decoration: underline; } /* Empty State Messages */ .watchlist-empty-message, .progress-empty-message, .movie-history-empty-message { - text-align: center; - padding: 60px 20px; - color: rgba(255, 255, 255, 0.7); + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.7); } .empty-message-icon { - margin-bottom: 20px; - opacity: 0.6; + margin-bottom: 20px; + opacity: 0.6; } .empty-message-icon .material-icons { - font-size: 64px; - color: rgba(255, 255, 255, 0.4); + font-size: 64px; + color: rgba(255, 255, 255, 0.4); } .empty-message-title { - color: #fff; - font-size: 20px; - font-weight: 500; - margin: 0 0 12px 0; + color: #fff; + font-size: 20px; + font-weight: 500; + margin: 0 0 12px 0; } .empty-message-subtitle { - margin: 0; - font-size: 16px; - line-height: 1.5; - color: rgba(255, 255, 255, 0.6); + margin: 0; + font-size: 16px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.6); } /* Pagination Controls */ .pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 16px; - margin-top: 20px; - border-top: 1px solid rgba(255, 255, 255, 0.1); - grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 16px; + margin-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + grid-column: 1 / -1; } .pagination-top { - margin-top: 0; - margin-bottom: 10px; - padding-top: 0; - border-top: none; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 0; + margin-bottom: 10px; + padding-top: 0; + border-top: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .pagination-bottom { - margin-top: 10px; - margin-bottom: 0; + margin-top: 10px; + margin-bottom: 0; } .pagination-info { - color: rgba(255, 255, 255, 0.7); - font-size: 14px; - font-weight: 500; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + font-weight: 500; } .pagination-controls { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .pagination-btn { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.8); - padding: 8px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - min-width: 40px; - height: 40px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.8); + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 40px; } .pagination-btn:hover:not(.disabled) { - background: rgba(255, 255, 255, 0.2); - color: #fff; - border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.2); + color: #fff; + border-color: rgba(255, 255, 255, 0.3); } .pagination-btn.active { - background: #00a4dc; - color: #fff; - border-color: #00a4dc; + background: #00a4dc; + color: #fff; + border-color: #00a4dc; } .pagination-btn.disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .pagination-pages { - display: flex; - align-items: center; - gap: 4px; - margin: 0 8px; + display: flex; + align-items: center; + gap: 4px; + margin: 0 8px; } .pagination-ellipsis { - color: rgba(255, 255, 255, 0.5); - padding: 0 8px; - font-size: 14px; + color: rgba(255, 255, 255, 0.5); + padding: 0 8px; + font-size: 14px; } .pagination-btn .material-icons { - font-size: 18px; + font-size: 18px; } /* Responsive Design */ @media (max-width: 768px) { - .progress-card-content { - flex-direction: column; - gap: 12px; - } - - .progress-poster { - width: 250px; - height: 46.25px; - align-self: center; - max-width: 95%; - } + .progress-card-content { + flex-direction: column; + gap: 12px; + } - .progress-poster img { - object-fit: contain; - } + .progress-poster { + width: 250px; + height: 46.25px; + align-self: center; + max-width: 95%; + } - .progress-tab-header { - align-items: center; - display: flex; - flex-direction: column; - padding: 20px; - } - - .progress-header { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .progress-title { - font-size: 20px; - } - - .progress-percentage { - font-size: 20px; - text-align: left; - } - - .progress-navigation { - align-self: flex-end; - } - - .progress-pagination { - flex-direction: column; - gap: 16px; - text-align: center; - } - - .pagination-controls { - flex-wrap: wrap; - justify-content: center; - } - - .pagination-pages { - margin: 0 4px; - } + .progress-poster img { + object-fit: contain; + } - .pagination-pages>*:not(.pagination-btn.active) { - display: none; - } + .progress-tab-header { + align-items: center; + display: flex; + flex-direction: column; + padding: 20px; + } - .tab-header { - display: flex; - flex-direction: column; - padding: 10px; - } + .progress-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } - .progress-header-stats-container, .movie-history-stats-container, .watchlist-header-stats-container { - flex-wrap: wrap; - justify-content: center; - } + .progress-title { + font-size: 20px; + } - .tab-header h2 { - padding: 0; - margin: 0; - } + .progress-percentage { + font-size: 20px; + text-align: left; + } + + .progress-navigation { + align-self: flex-end; + } + + .progress-pagination { + flex-direction: column; + gap: 16px; + text-align: center; + } + + .pagination-controls { + flex-wrap: wrap; + justify-content: center; + } + + .pagination-pages { + margin: 0 4px; + } + + .pagination-pages > *:not(.pagination-btn.active) { + display: none; + } + + .tab-header { + display: flex; + flex-direction: column; + padding: 10px; + } + + .progress-header-stats-container, + .movie-history-stats-container, + .watchlist-header-stats-container { + flex-wrap: wrap; + justify-content: center; + } + + .tab-header h2 { + padding: 0; + margin: 0; + } } /* Progress Statistics Container */ .progress-stats-container { - margin: 20px auto !important; - padding: 0 20px; + margin: 20px auto !important; + padding: 0 20px; } .progress-stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - margin-bottom: 20px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 20px; } .stat-card { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 12px; - padding: 20px; - display: flex; - align-items: center; - gap: 15px; - transition: all 0.3s ease; - backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 20px; + display: flex; + align-items: center; + gap: 15px; + transition: all 0.3s ease; + backdrop-filter: blur(10px); } .stat-card:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.25); - transform: translateY(-2px); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-2px); } .stat-icon { - display: flex; - align-items: center; - justify-content: center; - width: 50px; - height: 50px; - background: rgba(255, 255, 255, 0.1); - border-radius: 50%; - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + flex-shrink: 0; } .stat-icon .material-icons { - font-size: 24px; - color: rgba(255, 255, 255, 0.8); + font-size: 24px; + color: rgba(255, 255, 255, 0.8); } .stat-content { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .stat-value { - font-size: 28px; - font-weight: 700; - color: #fff; - line-height: 1; - margin-bottom: 4px; + font-size: 28px; + font-weight: 700; + color: #fff; + line-height: 1; + margin-bottom: 4px; } .stat-label { - font-size: 14px; - color: rgba(255, 255, 255, 0.7); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; } .top-shows-container { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 12px; - padding: 20px; - backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + padding: 20px; + backdrop-filter: blur(10px); } .top-shows-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 15px; - font-size: 16px; - font-weight: 600; - color: #fff; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 15px; + font-size: 16px; + font-weight: 600; + color: #fff; } .top-shows-header .material-icons { - font-size: 20px; - color: #ffd700; + font-size: 20px; + color: #ffd700; } .top-shows-list { - display: flex; - flex-direction: column; - gap: 10px; + display: flex; + flex-direction: column; + gap: 10px; } .top-show-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 15px; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.08); - transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 15px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + transition: all 0.2s ease; } .top-show-item:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); } .top-show-item:first-child { - background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05)); - border-color: rgba(255, 215, 0, 0.3); + background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05)); + border-color: rgba(255, 215, 0, 0.3); } .top-show-item:first-child .show-rank { - color: #ffd700; - font-weight: 700; + color: #ffd700; + font-weight: 700; } .top-show-name { - flex: 1; - font-size: 14px; - font-weight: 500; - color: #fff; - margin-right: 10px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + flex: 1; + font-size: 14px; + font-weight: 500; + color: #fff; + margin-right: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .top-show-episodes { - font-size: 12px; - color: rgba(255, 255, 255, 0.6); - font-weight: 500; - background: rgba(255, 255, 255, 0.1); - padding: 4px 8px; - border-radius: 12px; - white-space: nowrap; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + font-weight: 500; + background: rgba(255, 255, 255, 0.1); + padding: 4px 8px; + border-radius: 12px; + white-space: nowrap; } .show-rank { - font-size: 16px; - font-weight: 600; - color: rgba(255, 255, 255, 0.8); - margin-right: 12px; - min-width: 20px; - text-align: center; + font-size: 16px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + margin-right: 12px; + min-width: 20px; + text-align: center; } /* Responsive Design for Stats */ @media (max-width: 768px) { - .progress-stats-grid { - grid-template-columns: 1fr; - gap: 12px; - } - - .stat-card { - padding: 15px; - gap: 12px; - } - - .stat-icon { - width: 40px; - height: 40px; - } - - .stat-icon .material-icons { - font-size: 20px; - } - - .stat-value { - font-size: 24px; - } - - .stat-label { - font-size: 12px; - } - - .top-shows-container { - padding: 15px; - } - - .top-show-item { - padding: 10px 12px; - } - - .top-show-name { - font-size: 13px; - } - - .top-show-episodes { - font-size: 11px; - padding: 3px 6px; - } + .progress-stats-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .stat-card { + padding: 15px; + gap: 12px; + } + + .stat-icon { + width: 40px; + height: 40px; + } + + .stat-icon .material-icons { + font-size: 20px; + } + + .stat-value { + font-size: 24px; + } + + .stat-label { + font-size: 12px; + } + + .top-shows-container { + padding: 15px; + } + + .top-show-item { + padding: 10px 12px; + } + + .top-show-name { + font-size: 13px; + } + + .top-show-episodes { + font-size: 11px; + padding: 3px 6px; + } } /* Movie History Styles */ .movie-history-container { - padding: 0 20px; - margin-bottom: 0 auto; - max-width: 1200px; + padding: 0 20px; + margin-bottom: 0 auto; + max-width: 1200px; } - /* Paginated Container */ .paginated-container { - padding: 0 20px; - margin: 0 auto; - max-width: 1200px; + padding: 0 20px; + margin: 0 auto; + max-width: 1200px; } /* Pagination positioning */ .pagination-top { - margin-bottom: 20px; + margin-bottom: 20px; } .pagination-bottom { - margin-top: 20px; + margin-top: 20px; } -.pagination-controls>button:first-child, .pagination-controls>button:last-child { - display: none; +.pagination-controls > button:first-child, +.pagination-controls > button:last-child { + display: none; } .movie-history-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; - margin-bottom: 30px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 30px; } .movie-card { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 12px; - overflow: hidden; - transition: all 0.3s ease; - backdrop-filter: blur(10px); - position: relative; - display: grid; - grid-template-rows: auto 1fr; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; + backdrop-filter: blur(10px); + position: relative; + display: grid; + grid-template-rows: auto 1fr; } .movie-card:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.25); - transform: translateY(-4px); - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-4px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); } .movie-poster { - position: relative; - width: 100%; - aspect-ratio: 16 / 9; - overflow: hidden; - transition: transform 0.3s ease; + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + transition: transform 0.3s ease; } .movie-poster img { - width: 100%; - height: 100%; - object-fit: cover; + width: 100%; + height: 100%; + object-fit: cover; } .movie-poster-placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.6); + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); } .movie-poster-placeholder .material-icons { - font-size: 48px; + font-size: 48px; } .movie-card:hover .movie-poster { - transform: scale(1.05); + transform: scale(1.05); } .movie-poster-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%); - display: flex; - align-items: flex-end; - justify-content: space-between; - padding: 15px; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%); + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 15px; } .movie-watched-badge { - background: rgba(76, 175, 80, 0.9); - color: white; - padding: 4px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - display: flex; - align-items: center; - gap: 4px; + background: rgba(76, 175, 80, 0.9); + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; } .movie-watched-badge .material-icons { - font-size: 14px; + font-size: 14px; } /* Movie poster overlay favorite button */ .movie-poster-overlay .cardOverlayButton { - position: absolute; - top: 15px; - right: 15px; - width: 40px; - height: 40px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.3); - color: rgba(255, 255, 255, 0.8); - display: flex; - align-items: center; - justify-content: center; - transition: all 0.3s ease; - backdrop-filter: blur(10px); - z-index: 10; + position: absolute; + top: 15px; + right: 15px; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + backdrop-filter: blur(10px); + z-index: 10; } .movie-poster-overlay .cardOverlayButton:hover { - background: rgba(0, 0, 0, 0.8); - border-color: rgba(255, 255, 255, 0.5); - color: #fff; - transform: scale(1.1); + background: rgba(0, 0, 0, 0.8); + border-color: rgba(255, 255, 255, 0.5); + color: #fff; + transform: scale(1.1); } .movie-poster-overlay .cardOverlayButton.favorited { - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.3); - color: #ff0000; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.3); + color: #ff0000; } .movie-poster-overlay .cardOverlayButton.favorited:hover { - background: rgba(0, 0, 0, 0.9); - transform: scale(1.1); + background: rgba(0, 0, 0, 0.9); + transform: scale(1.1); } .movie-poster-overlay .cardOverlayButton .material-icons { - font-size: 20px; + font-size: 20px; } -.movie-poster-overlay:hover .cardOverlayButton, .cardOverlayButton.favorited { - opacity: 1; +.movie-poster-overlay:hover .cardOverlayButton, +.cardOverlayButton.favorited { + opacity: 1; } .movie-details { - padding: 20px; - display: grid; - grid-template-rows: auto auto auto 1fr; + padding: 20px; + display: grid; + grid-template-rows: auto auto auto 1fr; } .movie-details a:not(:hover) { - text-decoration: none; + text-decoration: none; } .movie-title { - font-size: 18px; - font-weight: 600; - color: #fff; - margin: 0 0 8px 0; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - justify-self: start; + font-size: 18px; + font-weight: 600; + color: #fff; + margin: 0 0 8px 0; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + justify-self: start; } .movie-meta { - display: flex; - gap: 6px; - margin-bottom: 12px; + display: flex; + gap: 6px; + margin-bottom: 12px; } .movie-year { - font-size: 14px; - color: rgba(255, 255, 255, 0.7); - font-weight: 500; + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; } .movie-runtime { - font-size: 13px; - color: rgba(255, 255, 255, 0.6); - display: flex; - align-items: center; - gap: 4px; + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + gap: 4px; } .movie-runtime .material-icons { - font-size: 14px; + font-size: 14px; } .movie-watched-date { - font-size: 12px; - color: rgba(255, 255, 255, 0.5); - font-style: italic; - margin-top: auto; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + font-style: italic; + margin-top: auto; } .movie-actions { - display: flex; - gap: 8px; - margin-top: 15px; - align-self: end; - justify-content: center; + display: flex; + gap: 8px; + margin-top: 15px; + align-self: end; + justify-content: center; } .movie-action-btn { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #fff; - padding: 8px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - text-decoration: none; - text-align: center; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 4px; - cursor: pointer; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + text-decoration: none; + text-align: center; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; } .movie-action-btn:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.4); - text-decoration: none; - color: #fff; + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); + text-decoration: none; + color: #fff; } .movie-action-btn .material-icons { - font-size: 14px; + font-size: 14px; } .movie-action-btn.favorited { - background: rgba(255, 193, 7, 0.2); - border-color: rgba(255, 193, 7, 0.4); - color: #ffc107; + background: rgba(255, 193, 7, 0.2); + border-color: rgba(255, 193, 7, 0.4); + color: #ffc107; } .movie-action-btn.favorited:hover { - background: rgba(255, 193, 7, 0.3); - border-color: rgba(255, 193, 7, 0.6); - color: #ffc107; + background: rgba(255, 193, 7, 0.3); + border-color: rgba(255, 193, 7, 0.6); + color: #ffc107; } /* Responsive Design for Movie History */ @media (max-width: 768px) { - .movie-history-grid { - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 15px; - } + .movie-history-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 15px; + } - .movie-poster { - aspect-ratio: 16 / 9; - } - - .movie-details { - padding: 15px; - } - - .movie-title { - font-size: 16px; - } - - .movie-poster-overlay .cardOverlayButton { - width: 36px; - height: 36px; - top: 12px; - right: 12px; - } - - .movie-poster-overlay .cardOverlayButton .material-icons { - font-size: 18px; - } + .movie-poster { + aspect-ratio: 16 / 9; + } + + .movie-details { + padding: 15px; + } + + .movie-title { + font-size: 16px; + } + + .movie-poster-overlay .cardOverlayButton { + width: 36px; + height: 36px; + top: 12px; + right: 12px; + } + + .movie-poster-overlay .cardOverlayButton .material-icons { + font-size: 18px; + } } /* Search Container */ .search-container { - margin: 20px 0 !important; - padding: 0 20px; - justify-self: center; + margin: 20px auto !important; + padding: 0 20px; + justify-self: center; } .search-input-wrapper { - position: relative; - max-width: 400px; - margin: 0 auto; + position: relative; + max-width: 400px; + margin: 0 auto; } .search-input-wrapper .search-icon { - position: absolute; - left: -26px; - top: 50%; - transform: translateY(-50%); - color: rgba(255, 255, 255, 0.6); - font-size: 20px; - pointer-events: none; + position: absolute; + left: -26px; + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.6); + font-size: 20px; + pointer-events: none; } .search-input { - width: 100%; - height: 40px; - padding: 0 40px 0 40px; - transform: translateX(-40px); - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 20px; - color: #fff; - font-size: 14px; - outline: none; - transition: all 0.3s ease; + width: 100%; + height: 40px; + padding: 0 40px 0 40px; + transform: translateX(-40px); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 20px; + color: #fff; + font-size: 14px; + outline: none; + transition: all 0.3s ease; } .search-input::placeholder { - color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.5); } .search-input:focus { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.4); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); } .search-clear-btn { - position: absolute; - right: -32px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: rgba(255, 255, 255, 0.6); - cursor: pointer; - padding: 4px; - border-radius: 50%; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; + position: absolute; + right: -32px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + padding: 4px; + border-radius: 50%; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; } .search-clear-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); } .search-clear-btn .material-icons { - font-size: 18px; + font-size: 18px; } /* Unwatched Episodes Container */ .unwatched-episodes-container { - margin-top: 15px; - padding: 15px; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.1); - animation: slideDown 0.3s ease-out; + margin-top: 15px; + padding: 15px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + animation: slideDown 0.3s ease-out; } @keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .unwatched-episodes-list { - max-height: 300px; - overflow-y: auto; - overflow-x: hidden; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; } .unwatched-episode-item { - display: flex; - align-items: center; - padding: 8px 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - transition: background-color 0.2s ease, opacity 0.3s ease; + display: flex; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: + background-color 0.2s ease, + opacity 0.3s ease; } .unwatched-episode-item:last-child { - border-bottom: none; + border-bottom: none; } .unwatched-episode-item:hover { - background: rgba(255, 255, 255, 0.05); - border-radius: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; } .episode-image { - flex-shrink: 0; - margin-right: 12px; - max-width: 60px; + flex-shrink: 0; + margin-right: 12px; + max-width: 60px; } .episode-image img { - width: 60px; - height: 40px; - object-fit: cover; - border-radius: 4px; + width: 60px; + height: 40px; + object-fit: cover; + border-radius: 4px; } .episode-placeholder { - width: 60px; - height: 40px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.6); + width: 60px; + height: 40px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); } .episode-placeholder .material-icons { - font-size: 20px; - margin-bottom: 2px; + font-size: 20px; + margin-bottom: 2px; } .episode-placeholder .episode-number { - font-size: 10px; - font-weight: 500; - color: rgba(255, 255, 255, 0.8); + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); } .episode-details { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } /* Sort Button */ .sort-button { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - color: white; - cursor: pointer; - transition: all 0.2s ease; - font-size: 14px; - margin-left: 8px; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.2s ease; + font-size: 14px; + margin-left: 8px; } .sort-button:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); } .sort-button .material-icons { - font-size: 18px; + font-size: 18px; } .sort-button .sort-label { - font-weight: 500; + font-weight: 500; } /* Episode Watched Toggle Button */ .episode-watched-toggle { - background: none; - border: 2px solid rgba(255, 255, 255, 0.3); - border-radius: 50%; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - margin-right: 12px; - transition: all 0.2s ease; - flex-shrink: 0; + background: none; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-right: 12px; + transition: all 0.2s ease; + flex-shrink: 0; } .episode-watched-toggle:hover { - border-color: rgba(255, 255, 255, 0.6); - background: rgba(255, 255, 255, 0.1); - transform: scale(1.05); + border-color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.1); + transform: scale(1.05); } .episode-watched-toggle:active { - transform: scale(0.95); + transform: scale(0.95); } .episode-watched-toggle .material-icons { - font-size: 18px; - color: rgba(255, 255, 255, 0.7); - transition: color 0.2s ease; + font-size: 18px; + color: rgba(255, 255, 255, 0.7); + transition: color 0.2s ease; } .episode-watched-toggle:hover .material-icons { - color: rgba(255, 255, 255, 0.9); + color: rgba(255, 255, 255, 0.9); } .episode-title { - margin-bottom: 4px; + margin-bottom: 4px; } .episode-link { - color: #fff; - text-decoration: none; - font-weight: 500; - font-size: 14px; - transition: color 0.2s ease; + color: #fff; + text-decoration: none; + font-weight: 500; + font-size: 14px; + transition: color 0.2s ease; } .episode-link:hover { - color: #4fc3f7; - text-decoration: underline; + color: #4fc3f7; + text-decoration: underline; } .episode-date { - color: rgba(255, 255, 255, 0.6); - font-size: 12px; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; } .unwatched-loading, .unwatched-empty, .unwatched-error { - text-align: center; - padding: 20px; - color: rgba(255, 255, 255, 0.7); - font-style: italic; + text-align: center; + padding: 20px; + color: rgba(255, 255, 255, 0.7); + font-style: italic; } .unwatched-error { - color: #ff6b6b; + color: #ff6b6b; } /* Statistics tab loading states */ -div[data-tab="statistics"]>* { - display: none; +div[data-tab="statistics"] > * { + display: none; } -div[data-tab="statistics"][data-ready="true"]>*:not(.loading-container) { - display: block; +div[data-tab="statistics"][data-ready="true"] > *:not(.loading-container) { + display: block; } -div[data-tab="statistics"][data-ready="false"]>.loading-container { - display: block; +div[data-tab="statistics"][data-ready="false"] > .loading-container { + display: block; } div[data-tab="watchlist"] .loading-message { - padding-top: 85px; + padding-top: 85px; } - .progress-series .progress-bar { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(1px, 1fr)); - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; - padding: 1px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(1px, 1fr)); + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 1px; } - .progress-chunk:first-child { - border-radius: 2px 0 0 2px; + border-radius: 2px 0 0 2px; } .progress-chunk:last-child { - border-radius: 0 2px 2px 0; + border-radius: 0 2px 2px 0; } .progress-chunk { - height: 8px; - transition: all 0.2s ease; - position: relative; + height: 8px; + transition: all 0.2s ease; + position: relative; } .progress-chunk.watched { - background: #00a4dc; + background: #00a4dc; } .progress-chunk.unwatched { - background: rgba(255, 255, 255, 0.1); - cursor: pointer; + background: rgba(255, 255, 255, 0.1); + cursor: pointer; } .progress-chunk.unwatched:hover { - background: rgba(255, 255, 255, 0.3); - transform: scaleY(1.5); - z-index: 1; - position: relative; + background: rgba(255, 255, 255, 0.3); + transform: scaleY(1.5); + z-index: 1; + position: relative; } .progress-chunk.watched:hover { - background: #0078d4; - transform: scaleY(1.2); - z-index: 1; - position: relative; + background: #0078d4; + transform: scaleY(1.2); + z-index: 1; + position: relative; } .progress-bar-completed .progress-chunk.watched { - background: #4caf50; + background: #4caf50; } .progress-bar-completed .progress-chunk.watched:hover { - background: #2e7d32; + background: #2e7d32; } /* Responsive adjustments */ @media (max-width: 768px) { - .progress-chunk { - height: 6px; - } + .progress-chunk { + height: 6px; + } } /* Show All Button */ .show-all-btn { - display: flex; - align-items: center; - gap: 4px; - padding: 6px 12px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - color: white; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - margin-left: auto; + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + margin-left: auto; } .show-all-btn:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); } .show-all-btn .material-icons { - font-size: 16px; + font-size: 16px; } /* Top Shows Header */ .top-shows-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; } /* All Shows Modal */ .all-shows-modal-content { - max-width: 600px; - max-height: 80vh; - overflow: hidden; - display: flex; - flex-direction: column; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; } .all-shows-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .close-btn { - background: none; - border: none; - color: white; - cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: background-color 0.2s ease; + background: none; + border: none; + color: white; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; } .close-btn:hover { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.1); } .close-btn .material-icons { - font-size: 20px; + font-size: 20px; } .all-shows-list { - max-height: 60vh; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 8px; - padding-right: 5px; + max-height: 60vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 5px; } .all-show-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background: rgba(255, 255, 255, 0.05); - border-radius: 6px; - border: 1px solid rgba(255, 255, 255, 0.1); - transition: background-color 0.2s ease; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: background-color 0.2s ease; } .all-show-item:hover { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.1); } .show-rank { - font-weight: 600; - font-size: 14px; - color: #00a4dc; - min-width: 24px; - text-align: center; + font-weight: 600; + font-size: 14px; + color: #00a4dc; + min-width: 24px; + text-align: center; } .show-info { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .show-name { - font-weight: 500; - font-size: 14px; - color: white; - margin-bottom: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-weight: 500; + font-size: 14px; + color: white; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .show-episodes { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); + font-size: 12px; + color: rgba(255, 255, 255, 0.7); } .no-shows-message { - text-align: center; - color: rgba(255, 255, 255, 0.7); - font-style: italic; - padding: 40px 20px; + text-align: center; + color: rgba(255, 255, 255, 0.7); + font-style: italic; + padding: 40px 20px; } /* Episode Confirmation Modal */ .episode-confirmation-content { - max-width: 400px; - min-width: 350px; + max-width: 400px; + min-width: 350px; } .confirmation-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .confirmation-header h3 { - margin: 0; - color: white; - font-size: 18px; - font-weight: 600; + margin: 0; + color: white; + font-size: 18px; + font-weight: 600; } .confirmation-body { - margin-bottom: 24px; + margin-bottom: 24px; } .episode-info { - background: rgba(255, 255, 255, 0.05); - border-radius: 6px; - padding: 12px; - margin-bottom: 16px; - border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); } .episode-title { - font-weight: 500; - font-size: 14px; - color: white; - margin-bottom: 4px; + font-weight: 500; + font-size: 14px; + color: white; + margin-bottom: 4px; } .episode-details { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); + font-size: 12px; + color: rgba(255, 255, 255, 0.7); } .confirmation-message { - color: rgba(255, 255, 255, 0.9); - font-size: 14px; - line-height: 1.4; + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + line-height: 1.4; } .confirmation-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - flex-wrap: wrap; + display: flex; + gap: 12px; + justify-content: flex-end; + flex-wrap: wrap; } .confirm-btn, .cancel-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 10px 16px; - border-radius: 4px; - border: none; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + border-radius: 4px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; } .confirm-btn { - background: #4caf50; - color: white; + background: #4caf50; + color: white; } .confirm-btn:hover { - background: #45a049; - transform: translateY(-1px); + background: #45a049; + transform: translateY(-1px); } .cancel-btn { - background: rgba(255, 255, 255, 0.1); - color: white; - border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); } .cancel-btn:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); } .confirm-btn .material-icons, .cancel-btn .material-icons { - font-size: 18px; + font-size: 18px; } .view-episode-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 10px 16px; - border-radius: 4px; - border: none; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - background: rgba(0, 164, 220, 0.1); - color: #00a4dc; - border: 1px solid rgba(0, 164, 220, 0.3); + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + border-radius: 4px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: rgba(0, 164, 220, 0.1); + color: #00a4dc; + border: 1px solid rgba(0, 164, 220, 0.3); } .view-episode-btn:hover { - background: rgba(0, 164, 220, 0.2); - border-color: rgba(0, 164, 220, 0.5); - color: #00a4dc; - transform: translateY(-1px); + background: rgba(0, 164, 220, 0.2); + border-color: rgba(0, 164, 220, 0.5); + color: #00a4dc; + transform: translateY(-1px); } .view-episode-btn .material-icons { - font-size: 18px; + font-size: 18px; } /* Mark All Episodes Confirmation Modal */ .series-info { - background: rgba(255, 255, 255, 0.05); - border-radius: 6px; - padding: 12px; - margin-bottom: 16px; - border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); } .series-title { - font-weight: 500; - font-size: 14px; - color: white; - margin-bottom: 4px; + font-weight: 500; + font-size: 14px; + color: white; + margin-bottom: 4px; } .series-details { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); + font-size: 12px; + color: rgba(255, 255, 255, 0.7); } .episode-count-info .episode-count { - font-weight: 600; - font-size: 16px; - color: #ffc107; + font-weight: 600; + font-size: 16px; + color: #ffc107; } .episode-count-info { - background: rgba(255, 193, 7, 0.1); - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: 6px; - padding: 12px; - margin-bottom: 16px; - text-align: center; - display: flex; - gap: 10px; - justify-content: center; - color: rgba(255, 255, 255, 0.9); - font-size: 14px; + background: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.3); + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; + text-align: center; + display: flex; + gap: 10px; + justify-content: center; + color: rgba(255, 255, 255, 0.9); + font-size: 14px; } /* Mobile responsive for Show All */ @media (max-width: 768px) { - .all-shows-modal-content { - max-width: calc(100vw - 40px); - max-height: 90vh; - } - - .show-all-btn { - padding: 4px 8px; - font-size: 11px; - } - - .show-all-btn .material-icons { - font-size: 14px; - } - - .episode-confirmation-content { - max-width: calc(100vw - 40px); - min-width: auto; - } - - .confirmation-actions { - flex-direction: column; - } - - .confirm-btn, - .cancel-btn { - width: 100%; - justify-content: center; - } + .all-shows-modal-content { + max-width: calc(100vw - 40px); + max-height: 90vh; + } + + .show-all-btn { + padding: 4px 8px; + font-size: 11px; + } + + .show-all-btn .material-icons { + font-size: 14px; + } + + .episode-confirmation-content { + max-width: calc(100vw - 40px); + min-width: auto; + } + + .confirmation-actions { + flex-direction: column; + } + + .confirm-btn, + .cancel-btn { + width: 100%; + justify-content: center; + } } .mdl-radio { - box-sizing: border-box; - display: inline-block; - line-height: 24px; - margin: 0; - position: relative + box-sizing: border-box; + display: inline-block; + line-height: 24px; + margin: 0; + position: relative; } .radio-label-block { - -webkit-align-items: center; - align-items: center; - display: -webkit-flex; - display: flex; - margin-bottom: .5em; - margin-top: .5em + -webkit-align-items: center; + align-items: center; + display: -webkit-flex; + display: flex; + margin-bottom: 0.5em; + margin-top: 0.5em; } .mdl-radio__button { - -webkit-appearance: none; - appearance: none; - border: none; - height: 1px; - line-height: 24px; - margin: 0; - opacity: 0; - padding: 0; - position: absolute; - width: 1px + -webkit-appearance: none; + appearance: none; + border: none; + height: 1px; + line-height: 24px; + margin: 0; + opacity: 0; + padding: 0; + position: absolute; + width: 1px; } .mdl-radio__circles { - border-radius: 50%; - cursor: pointer; - height: 1.08em; - position: relative; - width: 1.08em + border-radius: 50%; + cursor: pointer; + height: 1.08em; + position: relative; + width: 1.08em; } -[dir=ltr] .mdl-radio__circles { - margin-right: .54em +[dir="ltr"] .mdl-radio__circles { + margin-right: 0.54em; } -[dir=rtl] .mdl-radio__circles { - margin-left: .54em +[dir="rtl"] .mdl-radio__circles { + margin-left: 0.54em; } .mdl-radio__circles svg { - height: 100%; - overflow: visible; - position: absolute; - top: 0; - width: 100%; - z-index: 1 + height: 100%; + overflow: visible; + position: absolute; + top: 0; + width: 100%; + z-index: 1; } -[dir=ltr] .mdl-radio__circles svg { - left: 0 +[dir="ltr"] .mdl-radio__circles svg { + left: 0; } -[dir=rtl] .mdl-radio__circles svg { - right: 0 +[dir="rtl"] .mdl-radio__circles svg { + right: 0; } -.mdl-radio__button:disabled+.mdl-radio__circles { - cursor: auto +.mdl-radio__button:disabled + .mdl-radio__circles { + cursor: auto; } -.mdl-radio__button:disabled+.mdl-radio__circles .mdl-radio__outer-circle { - color: rgba(0,0,0,.26) +.mdl-radio__button:disabled + .mdl-radio__circles .mdl-radio__outer-circle { + color: rgba(0, 0, 0, 0.26); } -.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__outer-circle { - color: #00a4dc +.mdl-radio.show-focus .mdl-radio__button:focus + .mdl-radio__circles .mdl-radio__outer-circle { + color: #00a4dc; } .mdl-radio__inner-circle { - -webkit-transform: scale(0); - transform: scale(0); - -webkit-transform-origin: 50% 50%; - transform-origin: 50% 50%; - transition-duration: .2s; - transition-property: -webkit-transform; - transition-property: transform; - transition-property: transform,-webkit-transform + -webkit-transform: scale(0); + transform: scale(0); + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + transition-duration: 0.2s; + transition-property: -webkit-transform; + transition-property: transform; + transition-property: + transform, + -webkit-transform; } -.mdl-radio__button:checked+.mdl-radio__circles .mdl-radio__inner-circle { - -webkit-transform: scale(1); - transform: scale(1) +.mdl-radio__button:checked + .mdl-radio__circles .mdl-radio__inner-circle { + -webkit-transform: scale(1); + transform: scale(1); } -.mdl-radio__button:disabled+.mdl-radio__circles .mdl-radio__inner-circle { - color: rgba(0,0,0,.26) +.mdl-radio__button:disabled + .mdl-radio__circles .mdl-radio__inner-circle { + color: rgba(0, 0, 0, 0.26); } -.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__inner-circle { - color: #00a4dc +.mdl-radio.show-focus .mdl-radio__button:focus + .mdl-radio__circles .mdl-radio__inner-circle { + color: #00a4dc; } .mdl-radio__focus-circle { - background: #00a4dc; - border-radius: 50%; - box-sizing: border-box; - height: 100%; - left: 0; - margin: 0; - opacity: .26; - position: absolute; - top: 0; - -webkit-transform: scale(0); - transform: scale(0); - transition-duration: .2s; - transition-property: -webkit-transform; - transition-property: transform; - transition-property: transform,-webkit-transform; - width: 100% + background: #00a4dc; + border-radius: 50%; + box-sizing: border-box; + height: 100%; + left: 0; + margin: 0; + opacity: 0.26; + position: absolute; + top: 0; + -webkit-transform: scale(0); + transform: scale(0); + transition-duration: 0.2s; + transition-property: -webkit-transform; + transition-property: transform; + transition-property: + transform, + -webkit-transform; + width: 100%; } -.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__focus-circle { - -webkit-transform: scale(1.75); - transform: scale(1.75) +.mdl-radio.show-focus .mdl-radio__button:focus + .mdl-radio__circles .mdl-radio__focus-circle { + -webkit-transform: scale(1.75); + transform: scale(1.75); } .mdl-radio__label { - cursor: pointer + cursor: pointer; } -.mdl-radio__button:disabled+.mdl-radio__label { - color: rgba(0,0,0,.26); - cursor: auto +.mdl-radio__button:disabled + .mdl-radio__label { + color: rgba(0, 0, 0, 0.26); + cursor: auto; } -.watchlist-button[data-active="true"], .watchlist-icon[data-active="true"] { - color: rgb(34, 197, 94) !important; +.watchlist-button[data-active="true"], +.watchlist-icon[data-active="true"] { + color: rgb(34, 197, 94) !important; } -.watchlist-button[data-active="true"] span, .watchlist-icon[data-active="true"] span { - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48; +.watchlist-button[data-active="true"] span, +.watchlist-icon[data-active="true"] span { + font-variation-settings: + "FILL" 1, + "wght" 400, + "GRAD" 0, + "opsz" 48; } .slide .watchlist-button::before { - content: 'bookmark_outline'; - font-family: 'Material Icons'; - font-weight: 600; - display: inline-block; - font-size: 22px; - vertical-align: middle; - font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48; + content: "bookmark_outline"; + font-family: "Material Icons"; + font-weight: 600; + display: inline-block; + font-size: 22px; + vertical-align: middle; + font-variation-settings: + "FILL" 0, + "wght" 400, + "GRAD" 0, + "opsz" 48; } -.slide .watchlist-button { - border-radius: 50%; - height: 50px; - width: 50px; - border: none; - cursor: pointer; - transition: color 0.2s; - -webkit-tap-highlight-color: #fff0; +.slide .watchlist-button { + border-radius: 50%; + height: 50px; + width: 50px; + border: none; + cursor: pointer; + transition: color 0.2s; + -webkit-tap-highlight-color: #fff0; } -.card:has(button[data-played="true"]) .watchlist-button, +.card:has(button[data-played="true"]) .watchlist-button, .card:not(:has(button[data-played])) .watchlist-button, -.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon, +.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon, .mainDetailButtons:not(:has(button[data-played])) .watchlist-icon, .mainDetailButtons:not(:has(button[data-played])) .btnRecommend { - display: none; + display: none; } /* Import/Export Modal Styles */ .import-export-modal-content { - min-width: 500px; - max-width: 800px; + min-width: 500px; + max-width: 800px; } .import-export-tabs { - display: flex; - gap: 8px; - margin-bottom: 20px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + gap: 8px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .import-export-tab { - flex: 1; - padding: 12px; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; + flex: 1; + padding: 12px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; } .import-export-tab:hover { - color: rgba(255, 255, 255, 0.9); - background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.05); } .import-export-tab.active { - color: #fff; - border-bottom-color: #00a4dc; + color: #fff; + border-bottom-color: #00a4dc; } .import-export-tab-content { - min-height: 400px; + min-height: 400px; } /* Responsive Design for Import/Export Modal */ @media (max-width: 768px) { - .import-export-modal-content { - min-width: calc(100vw - 40px); - max-width: calc(100vw - 40px); - } - - .import-export-tab-content { - min-height: 300px; - } + .import-export-modal-content { + min-width: calc(100vw - 40px); + max-width: calc(100vw - 40px); + } + + .import-export-tab-content { + min-height: 300px; + } } /* List View Styling */ -[data-layout='List'] .cardScalable { - display: none; +[data-layout="List"] .cardScalable { + display: none; } -[data-layout='List'] .itemsContainer { - display: flex; - flex-direction: column; +[data-layout="List"] .itemsContainer { + display: flex; + flex-direction: column; } -[data-layout='List'] .verticalSection.emby-scroller-container { - max-width: 1200px; - margin: 0 auto; +[data-layout="List"] .verticalSection.emby-scroller-container { + max-width: 1200px; + margin: 0 auto; } -[data-layout='List'] .cardBox { - display: flex; - width: 100%; +[data-layout="List"] .cardBox { + display: flex; + width: 100%; } -[data-layout='List'] .card { - width: 100% !important; - cursor: initial; +[data-layout="List"] .card { + width: 100% !important; + cursor: initial; } -[data-layout='List'] .cardText-first { - flex: 1; - text-align: left !important; +[data-layout="List"] .cardText-first { + flex: 1; + text-align: left !important; } -[data-layout='List'] .cardText-secondary { - font-size: 100%; - padding-top: .24em; +[data-layout="List"] .cardText-secondary { + font-size: 100%; + padding-top: 0.24em; } .import-export-tab-content { - display: flex; - flex-direction: column; - gap: 10px; -} \ No newline at end of file + display: flex; + flex-direction: column; + gap: 10px; +} + +.navMenuOption[data-name="watchlist"] { + order: -1; +} + +.userSection > *:not(.emby-button), +.userSection .emby-button[data-kefin-custom-user-menu-link*="Watchlist"] { + order: -1; +} diff --git a/scripts/watchlist.js b/scripts/watchlist.js index 8574e69..210b3b1 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -189,13 +189,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: movieCache.allDataLoaded = false; movieCache.totalPages = 0; tabStates.history.isDataFetched = false; - } else if (cacheName === 'watchlist') { - // Clear all watchlist sections - const sections = ['movies', 'series', 'seasons', 'episodes']; - sections.forEach(section => { - localStorageCache.clear(`watchlist_${section}`); - watchlistCache[section].data = []; - }); + } else if (cacheName === 'watchlist') { + await invalidateAllWatchlistQueries(); + resetWatchlistItemsByType(); } // Re-fetch data @@ -204,7 +200,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } else if (cacheName === 'movies') { await initHistoryTab(); } else if (cacheName === 'watchlist') { - await initWatchlistTab(); + await initWatchlistTab(true); } // Show success feedback with checkmark @@ -961,9 +957,19 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); } - // Centralized selector function for visible library page + // Centralized selector function for visible library / custom page function getVisibleLibraryPage() { - return document.querySelector('.homePage:not(.hide)'); + return document.querySelector('.libraryPage:not(.hide), .customPage:not(.hide), .homePage:not(.hide)'); + } + + // Safe element getter that targets the visible library page + function getElementByIdSafe(elementId) { + const libraryPage = getVisibleLibraryPage(); + if (libraryPage) { + const scoped = libraryPage.querySelector(`#${CSS.escape ? CSS.escape(elementId) : elementId}`); + if (scoped) return scoped; + } + return document.getElementById(elementId); } // Tab state tracking to avoid unnecessary re-rendering @@ -1261,13 +1267,13 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: try { const episodeUrl = `${serverUrl}/Items/${episodeId}?UserId=${userId}&Fields=ParentId`; - const episodeRes = await fetch(episodeUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); + const episodeRes = await fetch(episodeUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const episodeData = await episodeRes.json(); // Get the series ID from the episode's parent (season) parent if (episodeData.ParentId) { const seasonUrl = `${serverUrl}/Items/${episodeData.ParentId}?UserId=${userId}&Fields=ParentId`; - const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); + const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const seasonData = await seasonRes.json(); return seasonData.ParentId || null; @@ -1349,7 +1355,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: try { // Fetch the series item const seriesUrl = `${serverUrl}/Items/${seriesId}?UserId=${userId}&Fields=UserData,RecursiveItemCount&EnableImageTypes=Primary,Banner`; - const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); + const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const series = await seriesRes.json(); if (!series || !series.Id) { @@ -2137,42 +2143,43 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: LOG(`Hash updated: pageTab=${pageTab}, page=${page}, search="${search}", movieSearch="${movieSearch}"`, newUrl); } - // Hash change listener for browser navigation + // Hash change listener for browser navigation / in-page tab URL sync function setupHashChangeListener() { - window.addEventListener('hashchange', () => { - if (!window.location.hash.includes('#/home')) { + window.addEventListener('hashchange', async () => { + const path = String(window.location.hash || '').split('?')[0]; + if (path !== '#/watchlist') { return; } - LOG('Hash changed, checking watchlist tab'); + LOG('Hash changed on #/watchlist, syncing watchlist state'); + await renderWatchlist(); + const params = getUrlParams(); - - // Check if we're on any watchlist tab and render accordingly - if (params.pageTab === 'progress' || params.pageTab === 'history' || params.pageTab === 'watchlist' || params.pageTab === 'statistics') { - // Find the watchlist section and switch to the appropriate tab + const pageTab = params.pageTab || 'watchlist'; + + // Switch sub-tab UI from URL (pageTab query) + if (pageTab === 'progress' || pageTab === 'history' || pageTab === 'watchlist' || pageTab === 'statistics') { const watchlistSection = getWatchlistSection(); if (watchlistSection) { - // Update tab UI const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button'); buttons.forEach(button => button.classList.remove('active')); - + const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)'); tabContents.forEach(content => content.style.display = 'none'); - - const activeButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${params.pageTab}"]`); - const activeContent = watchlistSection.querySelector(`div[data-tab="${params.pageTab}"]:not(.watchlist-tabs)`); - + + const activeButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${pageTab}"]`); + const activeContent = watchlistSection.querySelector(`div[data-tab="${pageTab}"]:not(.watchlist-tabs)`); + if (activeButton) activeButton.classList.add('active'); if (activeContent) activeContent.style.display = 'block'; - - // Render content based on active tab - if (params.pageTab === 'progress') { + + if (pageTab === 'progress') { renderProgressContent(); - } else if (params.pageTab === 'watchlist') { + } else if (pageTab === 'watchlist') { renderWatchlistContent(); - } else if (params.pageTab === 'history') { + } else if (pageTab === 'history') { renderHistoryContent(); - } else if (params.pageTab === 'statistics') { + } else if (pageTab === 'statistics') { renderStatisticsContent(); } } @@ -2264,54 +2271,12 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } } - // Initialize a specific watchlist section + // Initialize a specific watchlist section (legacy no-op; progressive path owns section loading) async function initWatchlistSection(section, type) { - LOG(`Initializing ${section} section`); - - // Check localStorage cache first - const userId = window.ApiClient.getCurrentUserId(); - const cachedData = localStorageCache.get(`watchlist_${section}`); - if (cachedData && false) { - LOG(`Using localStorage cache for watchlist ${section}`); - watchlistCache[section].data = cachedData; - return; - } - - try { - const items = watchlistCache[section].data; - - // Sort items by release date descending (newest first) - const sortedItems = items.sort((a, b) => { - const dateA = new Date(a.PremiereDate || a.ProductionYear || 0); - const dateB = new Date(b.PremiereDate || b.ProductionYear || 0); - return dateB - dateA; // Descending order (newest first) - }); - - // Store in cache - //watchlistCache[section].data = sortedItems; - - // Store in localStorage for next time (optimized) - const optimizedData = optimizeWatchlistDataForStorage(sortedItems); - localStorageCache.set(`watchlist_${section}`, optimizedData, ApiClient._currentUser.Id, WATCHLIST_CACHE_TTL); - LOG(`Stored optimized watchlist ${section} data in localStorage`); - - // Sync watched status for this section (remove played items) - const playedItems = sortedItems.filter(item => item.UserData && item.UserData.Played); - if (playedItems.length > 0) { - LOG(`Found ${playedItems.length} played items in ${section} section, removing from watchlist`); - for (const item of playedItems) { - await removeItemFromWatchlist(item.Id, item.Type); - } - } - - LOG(`${section} section initialization complete: ${sortedItems.length} items`); - } catch (err) { - ERR(`Error initializing ${section} section:`, err); - watchlistCache[section].data = []; - } + LOG(`initWatchlistSection skipped for ${section}/${type} (progressive render)`); } - async function initWatchlistTab() { + async function initWatchlistTab(forceRefresh = false) { if (tabStates.watchlist.isFetching) { LOG('Watchlist tab is fetching'); return; @@ -2321,48 +2286,21 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: tabStates.watchlist.isFetching = true; tabStates.watchlist.isDataFetched = false; - renderWatchlistContent(); - try { - // Fetch watchlist data - const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode,BoxSet,Playlist,Video' }); - localStorageCache.set('watchlist_movies', watchlistData.Items.filter(item => item.Type === 'Movie')); - localStorageCache.set('watchlist_series', watchlistData.Items.filter(item => item.Type === 'Series')); - localStorageCache.set('watchlist_seasons', watchlistData.Items.filter(item => item.Type === 'Season')); - localStorageCache.set('watchlist_episodes', watchlistData.Items.filter(item => item.Type === 'Episode')); - localStorageCache.set('watchlist_boxsets', watchlistData.Items.filter(item => item.Type === 'BoxSet')); - localStorageCache.set('watchlist_playlists', watchlistData.Items.filter(item => item.Type === 'Playlist')); - localStorageCache.set('watchlist_homevideos', watchlistData.Items.filter(item => item.Type === 'Video')); - watchlistCache.movies.data = watchlistData.Items.filter(item => item.Type === 'Movie'); - watchlistCache.series.data = watchlistData.Items.filter(item => item.Type === 'Series'); - watchlistCache.seasons.data = watchlistData.Items.filter(item => item.Type === 'Season'); - watchlistCache.episodes.data = watchlistData.Items.filter(item => item.Type === 'Episode'); - watchlistCache.boxsets.data = watchlistData.Items.filter(item => item.Type === 'BoxSet'); - watchlistCache.playlists.data = watchlistData.Items.filter(item => item.Type === 'Playlist'); - watchlistCache.homevideos.data = watchlistData.Items.filter(item => item.Type === 'Video'); - - // Initialize all watchlist sections in parallel - await Promise.all([ - initWatchlistSection('movies', 'Movie'), - initWatchlistSection('series', 'Series'), - initWatchlistSection('seasons', 'Season'), - initWatchlistSection('episodes', 'Episode'), - initWatchlistSection('boxsets', 'BoxSet'), - initWatchlistSection('playlists', 'Playlist'), - initWatchlistSection('homevideos', 'Video') - ]); - + if (forceRefresh) { + await invalidateAllWatchlistQueries(); + } + await renderWatchlistProgressive(forceRefresh); tabStates.watchlist.isDataFetched = true; - tabStates.watchlist.isFetching = false; // Sync watched status to remove played items await syncWatchedStatusToWatchlist(); - renderWatchlistContent(); - LOG('Watchlist tab initialization complete'); } catch (err) { ERR('Error initializing watchlist tab:', err); + tabStates.watchlist.isDataFetched = false; + } finally { tabStates.watchlist.isFetching = false; } } @@ -2499,12 +2437,14 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); - // Fetch series from API directly - const seriesUrl = `${serverUrl}/Items?IncludeItemTypes=Series&UserId=${userId}&Recursive=true&Fields=UserData,RecursiveItemCount&EnableImageTypes=Primary,Banner`; - + // Fetch series via apiHelper try { - const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); - const seriesData = await seriesRes.json(); + const seriesData = await window.apiHelper.getItems({ + IncludeItemTypes: 'Series', + Recursive: true, + Fields: 'UserData,RecursiveItemCount', + EnableImageTypes: 'Primary,Banner' + }, false); const series = seriesData.Items || []; if (series.length === 0) { @@ -2594,7 +2534,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Get all episodes including missing ones to get accurate TotalRecordCount const episodesUrl = `${serverUrl}/Shows/${series.Id}/Episodes?UserId=${userId}&Fields=UserData&EnableImageTypes=Primary`; - const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); + const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": window.apiHelper.getAuthHeader() } }); const episodesData = await episodesRes.json(); let episodes = episodesData.Items || []; @@ -2734,7 +2674,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const toggleRes = await fetch(toggleUrl, { method: method, headers: { - "Authorization": `MediaBrowser Token="${token}"` + "Authorization": window.apiHelper.getAuthHeader() } }); @@ -3057,17 +2997,192 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const progressPagination = createPaginationSystem(progressCache); const moviePagination = createPaginationSystem(movieCache); - // Watchlist cache for different sections - const watchlistCache = { - movies: { data: [] }, - series: { data: [] }, - seasons: { data: [] }, - episodes: { data: [] }, - boxsets: { data: [] }, - playlists: { data: [] }, - homevideos: { data: [] } + // Lightweight in-memory index of last progressive watchlist paint (for stats / membership checks) + const watchlistItemsByType = { + Movie: [], + Series: [], + Season: [], + Episode: [], + BoxSet: [], + Playlist: [], + Video: [] }; + const WATCHLIST_SECTION_TYPES = [ + { type: 'Movie', key: 'movies', label: 'Movies' }, + { type: 'Series', key: 'series', label: 'Shows' }, + { type: 'Season', key: 'seasons', label: 'Seasons' }, + { type: 'Episode', key: 'episodes', label: 'Episodes' }, + { type: 'BoxSet', key: 'boxsets', label: 'Collections' }, + { type: 'Playlist', key: 'playlists', label: 'Playlists' }, + { type: 'Video', key: 'homevideos', label: 'Home Videos' } + ]; + + let watchlistRenderGeneration = 0; + + function resetWatchlistItemsByType() { + WATCHLIST_SECTION_TYPES.forEach(({ type }) => { + watchlistItemsByType[type] = []; + }); + } + + function getWatchlistProgressiveHost() { + const watchlistSection = getWatchlistSection(); + if (!watchlistSection) return null; + let host = watchlistSection.querySelector('.watchlist-progressive-results'); + if (!host) { + const tab = watchlistSection.querySelector('div[data-tab="watchlist"]'); + if (!tab) return null; + host = document.createElement('div'); + host.className = 'watchlist-progressive-results'; + const emptyMsg = tab.querySelector('.watchlist-empty-message'); + if (emptyMsg) tab.insertBefore(host, emptyMsg); + else tab.appendChild(host); + } + return host; + } + + function postProcessWatchlistItems(data) { + const items = Array.isArray(data) + ? data.slice() + : (Array.isArray(data?.Items) ? data.Items.slice() : []); + return items.sort((a, b) => { + const dateA = new Date(a.PremiereDate || a.ProductionYear || 0); + const dateB = new Date(b.PremiereDate || b.ProductionYear || 0); + return dateB - dateA; + }); + } + + async function buildWatchlistProgressiveSection(itemType, label, order, forceRefresh = false) { + const apiHelper = window.apiHelper; + if (!apiHelper?.getWatchlistQuery) { + throw new Error('apiHelper.getWatchlistQuery unavailable'); + } + + const queryOptions = { + IncludeItemTypes: itemType, + Fields: 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData' + }; + const queryResult = await apiHelper.getWatchlistQuery(queryOptions, { + ttl: WATCHLIST_CACHE_TTL, + forceRefresh + }); + + const sectionConfig = { + id: `watchlist-${itemType}`, + name: label, + enabled: true, + order, + ttl: WATCHLIST_CACHE_TTL, + userConfigurable: false, + queries: [{ + queryOptions: { + Filters: 'Likes', + IncludeItemTypes: itemType, + Recursive: true, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + ...queryOptions + } + }] + }; + + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + const raw = typeof queryResult.ensureData === 'function' + ? queryResult.ensureData() + : queryResult.dataPromise; + mappedDataPromise = Promise.resolve(raw) + .then((data) => postProcessWatchlistItems(data)) + .catch((err) => { + ERR('watchlist ensureData failed for', itemType, err); + return []; + }); + } + return mappedDataPromise; + }; + + const result = { + data: postProcessWatchlistItems(queryResult.data), + isStale: queryResult.isStale === true, + isStalePromise: queryResult.isStalePromise, + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + return { config: sectionConfig, result, itemType }; + } + + async function invalidateAllWatchlistQueries() { + const apiHelper = window.apiHelper; + if (!apiHelper?.invalidateWatchlistQueries) return; + await Promise.all( + WATCHLIST_SECTION_TYPES.map(({ type }) => + apiHelper.invalidateWatchlistQueries({ IncludeItemTypes: type }) + ) + ); + } + + async function renderWatchlistProgressive(forceRefresh = false) { + const host = getWatchlistProgressiveHost(); + if (!host) { + ERR('Watchlist progressive host not found'); + return; + } + if (!window.cardBuilder?.renderProgressiveSections) { + ERR('cardBuilder.renderProgressiveSections unavailable'); + return; + } + + const generation = ++watchlistRenderGeneration; + hideEmptyWatchlistMessage(); + + const sectionPromises = WATCHLIST_SECTION_TYPES.map(({ type, label }, index) => + buildWatchlistProgressiveSection(type, label, index, forceRefresh).catch((err) => { + ERR('Failed to build watchlist section for', type, err); + return null; + }) + ); + + const sections = await Promise.all(sectionPromises); + if (generation !== watchlistRenderGeneration) { + LOG('Skipping stale watchlist render', { generation, watchlistRenderGeneration }); + return; + } + + const validSections = sections.filter(Boolean); + host.innerHTML = ''; + await window.cardBuilder.renderProgressiveSections( + host, + validSections.map((section) => Promise.resolve(section)), + { showStaleDataBeforeRefresh: true } + ); + + resetWatchlistItemsByType(); + await Promise.all(validSections.map(async (section) => { + const items = await section.result.ensureData(); + const list = Array.isArray(items) ? items : (items?.Items || []); + watchlistItemsByType[section.itemType] = list; + })); + + if (generation !== watchlistRenderGeneration) return; + + updateWatchlistHeaderStats(); + const totalItems = WATCHLIST_SECTION_TYPES.reduce( + (sum, { type }) => sum + (watchlistItemsByType[type]?.length || 0), + 0 + ); + if (totalItems === 0) showEmptyWatchlistMessage(); + else hideEmptyWatchlistMessage(); + } + // Function to render the complete watchlist HTML structure function renderWatchlistHtml() { const watchlistSection = getWatchlistSection(); @@ -3124,13 +3239,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: -
    -
    -
    -
    -
    -
    -
    +