Pray for me

This commit is contained in:
HighImKevin
2026-09-10 07:25:06 -04:00
parent bd45be2fd6
commit 1dbb3b8518
98 changed files with 47471 additions and 10558 deletions
+233 -187
View File
@@ -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.
<hr style="background: #b4c5fb !important;">
## Uninstallation
@@ -178,8 +181,6 @@ These tasks only run for administrator users and happen automatically in the bac
<br/>
<hr style="background: #b4c5fb !important;">
## 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.
<hr style="background: #b4c5fb !important;">
- **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.
<hr style="background: #b4c5fb !important;">
## 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)
<hr style="background: #b4c5fb !important;">
<hr style="background: #b4c5fb !important;">
### 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!
<div align="center">
<img src="pages/images/watchlist.png" alt="watchlist" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
### **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.
<div align="center">
<img src="pages/images/progress.png" alt="progress" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
### **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.
<div align="center">
<img src="pages/images/history.png" alt="history" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
### **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.
<div align="center">
<img src="pages/images/statistics.png" alt="statistics" style="max-width: 100%; height: auto;"/>
</div>
### **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.
<table align="center" style="width: 100%;">
@@ -279,54 +287,55 @@ Export your Watchlist to JSON which can later be imported using the Watchlist Im
<td style="width: 50%;"><img src="pages/images/watchlist-import.png" width="1000"/></td>
</tr>
</table>
<br/>
<br/>
- **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
<hr style="background: #b4c5fb !important;">
Cached watchlist updates when toggling watchlist status from card overlays
<hr style="background: #b4c5fb !important;">
#### **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.)
<div align="center">
<img src="pages/images/seasonalsection.png" alt="homescreen" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
- **Watchlist Integration**:
Dedicated watchlist section on home screen
Dedicated watchlist section on home screen
<div align="center">
<img src="pages/images/homescreen.png" alt="homescreen" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
#### **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
<div align="center">
<img src="pages/images/search.png" alt="search" style="max-width: 100%; height: auto;"/>
@@ -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
<div align="center">
<img src="pages/images/watchlistoverlay.png" alt="watchlistoverlay" style="max-width: 100%; height: auto;"/>
@@ -346,7 +356,6 @@ Watchlist toggle button added to all item card overlays
<br/>
<hr style="background: #b4c5fb !important;">
#### **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
<td style="width: 65%;"><img src="pages/images/appearanceusersettings.png" width="1000"/></td>
</tr>
</table>
<br/>
<br/>
- **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.
<hr style="background: #b4c5fb !important;">
<hr style="background: #b4c5fb !important;">
### UX Improvements
#### **Subtitle Search**:
Search and download subtitles directly from the video OSD
#### **Subtitle Search**:
Search and download subtitles directly from the video OSD
<div align="center">
<img src="pages/images/subtitlesearch.png" alt="subtitlesearch" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
#### **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
<div align="center">
<img src="pages/images/continuewatching.png" alt="continuewatching" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
#### **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.
<hr style="background: #b4c5fb !important;">
### 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
<div align="center">
<img src="pages/images/breadcrumbs.png" alt="breadcrumbs" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
#### **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
<div align="center">
<img src="pages/images/custommenu.png" alt="custommenu" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
#### **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.
<div align="center">
<img src="https://i.imgur.com/ttP9cGT.png" alt="series-episodes" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<br/>
#### **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.
<div align="center">
<img src="pages/images/itemdetailscollections.png" alt="itemdetailscollections" style="max-width: 100%; height: auto;"/>
@@ -436,69 +455,70 @@ This shows an "Included In" section on the Item Details page which displays any
<br/>
<hr style="background: #b4c5fb !important;">
### 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
<hr style="background: #b4c5fb !important;">
Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-updoot) backend script
<hr style="background: #b4c5fb !important;">
## 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` |
<br>
### 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.
<hr style="background: #b4c5fb !important;">
## 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)
<hr style="background: #b4c5fb !important;">
<hr style="background: #b4c5fb !important;">
## 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
<hr style="background: #b4c5fb !important;">
- This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser
<hr style="background: #b4c5fb !important;">
## 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.
<hr style="background: #b4c5fb !important;">
## 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:
<div align="center">
<img src="pages\images\faq\pending.png" alt="jsinjector publicjs" style="max-width: 100%; height: auto;"/>
</div> <br/>
</div> <br/>
> ### 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
<div align="center">
<img src="pages\images\faq\publicjs.png" alt="jsinjector publicjs" style="max-width: 100%; height: auto;"/>
</div>
- Open DevTools (right-click page > inspect)
- Click Sources tab
- Verify that `JavaScriptInjector\public.js` is present:
<div align="center">
<img src="pages\images\faq\publicjs.png" alt="jsinjector publicjs" style="max-width: 100%; height: auto;"/>
</div>
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
<div align="center">
<img src="pages\images\faq\disablecache.png" alt="jsinjector publicjs" style="max-width: 100%; height: auto;"/>
</div>
## 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
<hr style="background: #b4c5fb !important;">
This README and the inline comments in kefinTweaks.js and the other scripts is the only real documentation available at this time
<hr style="background: #b4c5fb !important;">
## Roadmap
### Planned Features
- **Jellyfin 10.11 Support**
- **Jellyfin 10.11 Support**
- **Additional options for User configuration in the Jellyfin UI**
<hr style="background: #b4c5fb !important;">
### 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.
+59 -31
View File
@@ -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 @@
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Custom Menu Links JSON</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Add custom menu links that will appear in the custom menu. Use Material icon names for icons.</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Add custom menu links with per-link placements. Require <code>name</code> and either <code>url</code> or <code>action</code> (a dotted path on <code>window</code>, e.g. <code>MyPlugin.openDialog</code>). Defaults: <code>sideMenu</code> true, <code>topNavigation</code> "none", <code>userMenu</code> false. Use <code>collapsed</code> with <code>topNavigation: "main"</code> on v12 to place under More. Optional <code>order</code> overrides top-nav position: for <code>main</code>, left-to-right child index (<code>0</code> = first); for <code>right</code>, right-to-left (<code>0</code> = last child).</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">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 <a href="https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links">here</a>.</div>
<textarea id="customMenuLinksJson" class="fld emby-textarea" rows="12" placeholder='[{"name":"Link Name","icon":"link","url":"#/..."}]' style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;">${JSON.stringify(config, null, 2)}</textarea>
<textarea id="customMenuLinksJson" class="fld emby-textarea" rows="12" placeholder='[{"name":"Link Name","icon":"link","url":"#/..."}]' style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;">${JSON.stringify(config.customMenuLinks, null, 2)}</textarea>
<details style="margin-top: 0.75em;">
<summary class="listItemBodyText secondary" style="font-size: 0.9em; color: #4a9eff; cursor: pointer;">View Example Format</summary>
<pre style="background: rgba(0,0,0,0.3); padding: 1em; border-radius: 4px; margin-top: 0.5em; overflow-x: auto; font-size: 0.85em; line-height: 1.6;">[
@@ -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"
}
]</pre>
</details>
@@ -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 @@
<button class="emby-button raised block button-submit" id="save-custommenulinks-config-btn">Save</button>
`;
// 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');
})();
+286 -46
View File
@@ -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';
}
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -84,9 +84,9 @@
LOG('Migration completed successfully');
} catch (error) {
ERR('Migration failed:', error);
} finally {
return newHomeScreenConfig;
}
return false;
}
return true;
}
/**
@@ -404,6 +404,7 @@
enabled: libConfig.enabled !== false,
order: libConfig.order || 61,
cardFormat: libConfig.cardFormat || 'Poster',
jellyfinId: 'latestmedia',
queries: [{
path: '/Items/Latest',
queryOptions: {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,439 @@
// Shared Custom Items editor (wizard + advanced section editor)
(function() {
'use strict';
const CUSTOM_IMAGE_FIELDS = [
{ key: 'posterUrl', label: 'Poster URL', required: true },
{ key: 'thumbUrl', label: 'Thumb URL' },
{ key: 'backdropUrl', label: 'Backdrop URL' },
{ key: 'squareUrl', label: 'Square URL' },
{ key: 'bannerUrl', label: 'Banner URL' },
{ key: 'logoUrl', label: 'Logo URL' }
];
const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0];
const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1);
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderMaterialIcon(name, className = '') {
return `<span class="material-icons${className ? ' ' + className : ''}" aria-hidden="true">${escapeHtml(name)}</span>`;
}
function getOptions(options = {}) {
return {
idPrefix: options.idPrefix || 'hsse-custom',
actionAttr: options.actionAttr || 'data-hsse-action',
actions: {
openEditor: 'wizard-open-custom-item-editor',
closeEditor: 'wizard-close-custom-item-editor',
addItem: 'wizard-add-custom-item',
removeItem: 'wizard-remove-custom-item',
toggleImages: 'wizard-toggle-custom-images',
...(options.actions || {})
},
intro: options.intro !== false
};
}
function actionAttr(options, action) {
return `${options.actionAttr}="${escapeHtml(options.actions[action])}"`;
}
function fieldId(options, index, key) {
return `${options.idPrefix}-${index}-${key}`;
}
function createBlankCustomItem() {
return {
name: '',
cardFooter: '',
cardUrl: '',
posterUrl: '',
thumbUrl: '',
backdropUrl: '',
squareUrl: '',
bannerUrl: '',
logoUrl: ''
};
}
function staticItemToDraft(item) {
if (!item) return createBlankCustomItem();
const draft = createBlankCustomItem();
draft.name = item.Name || item.name || '';
draft.cardFooter = item.cardFooter || '';
draft.cardUrl = item.cardUrl || '';
draft.posterUrl = item.posterUrl || item.imageUrl || '';
draft.thumbUrl = item.thumbUrl || '';
draft.backdropUrl = item.backdropUrl || '';
draft.squareUrl = item.squareUrl || '';
draft.bannerUrl = item.bannerUrl || '';
draft.logoUrl = item.logoUrl || '';
return draft;
}
function staticItemsToDrafts(items) {
const list = (items || []).map(staticItemToDraft).filter(Boolean);
return list.length ? list : [createBlankCustomItem()];
}
function ensureCustomItemsInitialized(state) {
if (!state.customItems?.length) {
state.customItems = [createBlankCustomItem()];
}
}
function hasAdditionalCustomImages(item) {
return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim());
}
function isCustomItemAdditionalExpanded(state, index, item) {
const explicit = state.customItemAdditionalExpanded?.[index];
if (explicit === true) return true;
if (explicit === false) return false;
return hasAdditionalCustomImages(item);
}
function reindexCustomItemAdditionalExpanded(state, removedIndex) {
if (!state.customItemAdditionalExpanded) return;
const next = {};
Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => {
const i = parseInt(key, 10);
if (Number.isNaN(i) || value === undefined) return;
if (i < removedIndex) next[i] = value;
else if (i > removedIndex) next[i - 1] = value;
});
state.customItemAdditionalExpanded = next;
}
function hasValidCustomItems(items) {
return (items || []).some(item => item.name?.trim() && item.posterUrl?.trim());
}
function hasCustomItemContent(item) {
if (!item) return false;
return [
item.name, item.cardFooter, item.cardUrl, item.posterUrl,
item.thumbUrl, item.backdropUrl, item.squareUrl, item.bannerUrl, item.logoUrl
].some(value => value?.trim());
}
function buildStaticItemFromDraft(draft) {
const name = draft.name?.trim();
const posterUrl = draft.posterUrl?.trim();
if (!name || !posterUrl) return null;
const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl'];
const provided = {};
imageKeys.forEach(key => {
const value = draft[key]?.trim();
if (value) provided[key] = value;
});
const item = { Name: name, Type: 'Folder' };
const cardUrl = draft.cardUrl?.trim();
if (cardUrl) item.cardUrl = cardUrl;
const cardFooter = draft.cardFooter?.trim();
if (cardFooter) item.cardFooter = cardFooter;
const keys = Object.keys(provided);
if (keys.length === 1 && provided.posterUrl) {
item.imageUrl = provided.posterUrl;
} else {
Object.assign(item, provided);
}
return item;
}
function collectCustomItemFromForm(root, state, index, options) {
if (!root || !state.customItems?.[index]) return;
const opts = getOptions(options);
const item = state.customItems[index];
const nameEl = root.querySelector(`#${fieldId(opts, index, 'name')}`);
const cardFooterEl = root.querySelector(`#${fieldId(opts, index, 'cardFooter')}`);
const cardUrlEl = root.querySelector(`#${fieldId(opts, index, 'cardUrl')}`);
if (nameEl) item.name = nameEl.value;
if (cardFooterEl) item.cardFooter = cardFooterEl.value;
if (cardUrlEl) item.cardUrl = cardUrlEl.value;
CUSTOM_IMAGE_FIELDS.forEach(field => {
const el = root.querySelector(`#${fieldId(opts, index, field.key)}`);
if (el) item[field.key] = el.value;
});
}
function collectCustomItemsFromForm(root, state, options) {
if (!root || !state.customItems) return;
const editorIndex = state.customItemEditorIndex;
if (editorIndex != null && !Number.isNaN(editorIndex)) {
collectCustomItemFromForm(root, state, editorIndex, options);
}
}
function updateCustomImagePreview(imgEl, url) {
if (!imgEl) return;
const trimmed = (url || '').trim();
if (!trimmed) {
imgEl.removeAttribute('src');
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'empty';
return;
}
imgEl.style.display = '';
imgEl.dataset.previewState = 'loading';
imgEl.onerror = () => {
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'error';
};
imgEl.onload = () => {
imgEl.style.display = '';
imgEl.dataset.previewState = 'loaded';
};
imgEl.src = trimmed;
}
function refreshPreviews(root, state, options) {
if (!root || !state.customItems) return;
state.customItems.forEach((item, index) => {
const tileImg = root.querySelector(`img[data-tile-index="${index}"]`);
updateCustomImagePreview(tileImg, item.posterUrl);
});
root.querySelectorAll('[data-hsse-custom-preview], [data-hsae-custom-preview]').forEach(input => {
const img = root.querySelector(`img[data-preview-for="${input.id}"]`);
updateCustomImagePreview(img, input.value);
});
}
function buildCustomImageFieldHTML(index, field, value, options, fieldOptions = {}) {
const opts = getOptions(options);
const { isPoster = false } = fieldOptions;
const requiredMark = field.required ? ' <span class="hsse-required-mark">*</span>' : '';
const inputId = fieldId(opts, index, field.key);
const previewAttr = isPoster ? ' data-hsse-custom-preview="true" data-hsae-custom-preview="true"' : '';
const posterHint = isPoster
? `<p class="listItemBodyText secondary hsse-field-hint hsse-custom-image-field-hint">You can place images in your &quot;Jellyfin\\Server\\jellyfin-web&quot; folder and use the &quot;/web/image_name.png&quot; path to access them.</p>`
: '';
return `
<div class="hsse-field hsse-custom-image-field${isPoster ? '' : ' hsse-custom-image-field-compact'}">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${inputId}">${escapeHtml(field.label)}${requiredMark}</label>
<input type="url" id="${inputId}" class="fld emby-input hsse-custom-image-input" value="${escapeHtml(value)}" placeholder="https://..." aria-label="${escapeHtml(field.label)}"${previewAttr}>
${posterHint}
</div>
`;
}
function renderCustomItemEditorHTML(index, item, state, options) {
const opts = getOptions(options);
const additionalExpanded = isCustomItemAdditionalExpanded(state, index, item);
const posterInputId = fieldId(opts, index, 'posterUrl');
return `
<div class="hsse-custom-item-main">
<div class="hsse-custom-item-fields">
<div class="hsse-field hsse-custom-field">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${fieldId(opts, index, 'name')}">Name <span class="hsse-required-mark">*</span></label>
<input type="text" id="${fieldId(opts, index, 'name')}" class="fld emby-input" value="${escapeHtml(item.name)}" placeholder="Item name" aria-label="Item name">
</div>
<div class="hsse-field hsse-custom-field">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${fieldId(opts, index, 'cardFooter')}">Card Footer</label>
<input type="text" id="${fieldId(opts, index, 'cardFooter')}" class="fld emby-input" value="${escapeHtml(item.cardFooter || '')}" placeholder="Optional footer text" aria-label="Card footer">
</div>
<div class="hsse-field hsse-custom-field">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${fieldId(opts, index, 'cardUrl')}">Link URL</label>
<input type="url" id="${fieldId(opts, index, 'cardUrl')}" class="fld emby-input" value="${escapeHtml(item.cardUrl)}" placeholder="#/movies.html or https://..." aria-label="Link URL">
</div>
${buildCustomImageFieldHTML(index, CUSTOM_POSTER_FIELD, item.posterUrl || '', opts, { isPoster: true })}
</div>
<div class="hsse-custom-item-preview-col" aria-hidden="true">
<div class="hsse-image-preview hsse-item-main-preview">
<img class="hsse-image-preview-img" alt="" data-preview-for="${posterInputId}">
</div>
</div>
</div>
<div class="hsse-custom-image-toggle-wrap">
<button type="button" class="emby-button raised hsse-custom-image-toggle" ${actionAttr(opts, 'toggleImages')} data-item-index="${index}" aria-expanded="${additionalExpanded ? 'true' : 'false'}">
${additionalExpanded ? 'Hide Additional Image Types' : 'Configure Additional Image Types'}
</button>
</div>
<div class="hsse-field-grid hsse-custom-image-grid hsse-custom-image-additional${additionalExpanded ? '' : ' hsse-collapsed'}"${additionalExpanded ? '' : ' hidden'}>
${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '', opts)).join('')}
</div>
`;
}
function renderCustomItemTileHTML(index, item, canRemove, options) {
const opts = getOptions(options);
const label = item.name?.trim() || `Item ${index + 1}`;
return `
<div class="hsse-custom-item-tile" data-custom-item-index="${index}">
<button type="button" class="hsse-custom-item-tile-image hsse-image-preview hsse-item-tile-preview" ${actionAttr(opts, 'openEditor')} data-item-index="${index}" aria-label="Edit ${escapeHtml(label)}">
<img class="hsse-image-preview-img" alt="" data-tile-index="${index}">
<span class="hsse-custom-item-edit-badge">${renderMaterialIcon('edit')}</span>
</button>
<div class="listItemBodyText hsse-custom-item-tile-label">${escapeHtml(label)}</div>
<button type="button" class="emby-button raised hsse-custom-item-trash" ${actionAttr(opts, 'removeItem')} data-item-index="${index}"${canRemove ? '' : ' disabled'} aria-label="Remove ${escapeHtml(label)}">
${renderMaterialIcon('delete', 'hsse-custom-item-trash-icon')}
</button>
</div>
`;
}
function renderCustomItemEditorPanelHTML(state, options) {
const opts = getOptions(options);
const editorIndex = state.customItemEditorIndex;
if (editorIndex == null || Number.isNaN(editorIndex)) return '';
const item = state.customItems?.[editorIndex];
if (!item) return '';
const title = item.name?.trim()
? `Edit: ${item.name.trim()}`
: `Edit Item ${editorIndex + 1}`;
return `
<div class="hsse-custom-item-editor-overlay">
<button type="button" class="hsse-custom-item-editor-backdrop" ${actionAttr(opts, 'closeEditor')} aria-label="Close editor"></button>
<div class="hsse-custom-item-editor-panel dialog" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
<div class="hsse-custom-item-editor-header">
<div class="listItemBodyText hsse-custom-item-editor-title">${escapeHtml(title)}</div>
</div>
<div class="hsse-custom-item-editor-body">
${renderCustomItemEditorHTML(editorIndex, item, state, opts)}
</div>
<div class="hsse-custom-item-editor-footer">
<button type="button" class="emby-button raised" ${actionAttr(opts, 'closeEditor')} data-discard="true">Cancel</button>
<button type="button" class="emby-button raised button-submit" ${actionAttr(opts, 'closeEditor')}>Done</button>
</div>
</div>
</div>
`;
}
function renderCustomItemsHTML(state, options) {
const opts = getOptions(options);
ensureCustomItemsInitialized(state);
const items = state.customItems;
const canRemove = items.length > 1;
return `
<div class="hsse-custom-items-stage">
${opts.intro ? `
<div class="hsse-section-block hsse-custom-items-intro">
<div class="listItemBodyText hsse-section-label">Custom Items</div>
<p class="listItemBodyText secondary hsse-field-hint">Name and poster required. Link and other images optional.</p>
</div>` : ''}
<div class="hsse-custom-items-grid">
${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts)).join('')}
<button type="button" class="hsse-custom-item-add-tile" ${actionAttr(opts, 'addItem')} aria-label="Add item">
${renderMaterialIcon('add', 'hsse-custom-item-add-icon')}
</button>
</div>
${renderCustomItemEditorPanelHTML(state, opts)}
</div>
`;
}
function handleAction(action, el, root, state, options, onRefresh) {
const opts = getOptions(options);
const actions = opts.actions;
switch (action) {
case actions.openEditor: {
const openIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(openIndex)) break;
if (state.customItemEditorIndex != null && state.customItemEditorIndex !== openIndex) {
collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts);
}
state.customItemEditorIndex = openIndex;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.closeEditor: {
const discard = el.dataset.discard === 'true';
if (!discard && state.customItemEditorIndex != null) {
collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts);
}
state.customItemEditorIndex = null;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.addItem: {
collectCustomItemsFromForm(root, state, opts);
state.customItems.push(createBlankCustomItem());
state.customItemEditorIndex = state.customItems.length - 1;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.removeItem: {
const removeIndex = parseInt(el.dataset.itemIndex, 10);
if (state.customItems.length <= 1 || Number.isNaN(removeIndex)) break;
if (state.customItemEditorIndex === removeIndex) {
collectCustomItemFromForm(root, state, removeIndex, opts);
} else {
collectCustomItemsFromForm(root, state, opts);
}
const itemToRemove = state.customItems[removeIndex];
if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) {
break;
}
state.customItems.splice(removeIndex, 1);
reindexCustomItemAdditionalExpanded(state, removeIndex);
if (state.customItemEditorIndex === removeIndex) {
state.customItemEditorIndex = null;
} else if (state.customItemEditorIndex > removeIndex) {
state.customItemEditorIndex -= 1;
}
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.toggleImages: {
const toggleIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(toggleIndex)) break;
collectCustomItemFromForm(root, state, toggleIndex, opts);
if (!state.customItemAdditionalExpanded) state.customItemAdditionalExpanded = {};
const item = state.customItems[toggleIndex];
const currentlyExpanded = isCustomItemAdditionalExpanded(state, toggleIndex, item);
state.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
default:
break;
}
}
window.KefinHomeScreenCustomItemsEditor = {
CUSTOM_IMAGE_FIELDS,
createBlankCustomItem,
staticItemToDraft,
staticItemsToDrafts,
ensureCustomItemsInitialized,
hasValidCustomItems,
hasCustomItemContent,
buildStaticItemFromDraft,
collectCustomItemsFromForm,
collectCustomItemFromForm,
renderCustomItemsHTML,
refreshPreviews,
handleAction,
reindexCustomItemAdditionalExpanded,
getCustomItemsOptions: (overrides = {}) => getOptions({
idPrefix: 'hsae-custom',
actionAttr: 'data-hsae-action',
actions: {
openEditor: 'hsae-open-custom-item-editor',
closeEditor: 'hsae-close-custom-item-editor',
addItem: 'hsae-add-custom-item',
removeItem: 'hsae-remove-custom-item',
toggleImages: 'hsae-toggle-custom-images'
},
...overrides
})
};
})();
+165
View File
@@ -0,0 +1,165 @@
// KefinTweaks Home Screen Section Editor Profiles
(function() {
'use strict';
const EDITOR_PROFILES = {
full: {
queryFields: 'all',
advancedFields: 'all',
lockVisibility: false,
allowGroupChange: true,
allowItemSourceChange: true
},
minimal: {
queryFields: ['limit'],
advancedFields: ['cache'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false,
hideCustomItems: true
},
postProcessing: {
queryFields: ['limit'],
advancedFields: ['cache', 'sectionSort', 'minimumItems', 'itemLimit', 'limitBeforeSort'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false,
hideCustomItems: true
},
normal: {
queryFields: ['limit', 'sortBy', 'sortOrder'],
advancedFields: ['cache', 'sectionSort', 'itemLimit', 'minimumItems', 'flattenSeries', 'limitBeforeSort'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false
},
dateCutoffs: {
queryFields: ['limit', 'sortBy', 'sortOrder'],
advancedFields: ['cache', 'sectionSort', 'itemLimit', 'minimumItems', 'flattenSeries', 'limitBeforeSort', 'minAge', 'maxAge'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false
}
};
const SECTION_PROFILE_BY_ID = {
'my-media': 'minimal',
'my-media-small': 'minimal',
'continueWatching': 'minimal',
'nextUp': 'minimal',
'continueWatchingAndNextUp': 'minimal',
'continueListening': 'minimal',
'continueReading': 'minimal',
'upcoming': 'minimal',
'thematic-story-origins': 'minimal',
'thematic-core-concepts': 'minimal',
'thematic-crime-stories': 'minimal',
'thematic-character-perspective': 'minimal',
'thematic-settings': 'minimal',
'thematic-tone-style': 'minimal',
'thematic-themes-topics': 'minimal',
'popularTVStudios': 'postProcessing',
'popularMovieStudios': 'postProcessing',
'popularStudios': 'postProcessing',
'recentlyReleased.movies': 'dateCutoffs',
'recentlyReleased.episodes': 'dateCutoffs',
'liveTv-recordings': 'normal',
'liveTv-timers': 'normal',
'liveTv-browse': 'normal',
'liveTv-on-now': 'normal',
'liveTv-programs': 'normal',
'liveTv-channels': 'normal',
'liveTv-series-on-now': 'normal',
'liveTv-movies-on-now': 'normal',
'liveTv-sports-on-now': 'normal',
'liveTv-news-on-now': 'normal',
'liveTv-kids-on-now': 'normal',
'imdbTop250': 'normal',
'watchAgain': 'normal',
'watchlist': 'normal',
'resumePlaylists': 'normal',
'completeCollections': 'normal',
'playlists': 'normal',
'collections': 'normal',
'seasonal.halloween.halloween-tag': 'normal',
'seasonal.halloween.halloween-horror': 'normal',
'seasonal.halloween.halloween-thriller': 'normal',
'seasonal.christmas.christmas-movies': 'normal',
'seasonal.christmas.christmas-episodes': 'normal',
'seasonal.new-years.new-years-movies': 'normal',
'seasonal.new-years.new-years-episodes': 'normal',
'seasonal.valentines.valentines-movies': 'normal',
'seasonal.valentines.valentines-episodes': 'normal',
'seasonal.thanksgiving.thanksgiving-movies': 'normal',
'seasonal.thanksgiving.thanksgiving-episodes': 'normal',
'genreMovies': 'normal',
'spotlightGenre': 'normal',
'directedByTopDirector': 'normal',
'writtenByTopWriter': 'normal',
'starringTopActor': 'normal',
'starringActorRecentlyWatched': 'normal',
'directedByDirectorRecentlyWatched': 'normal',
'writtenByWriterRecentlyWatched': 'normal',
'becauseYouWatched': 'normal',
'becauseYouRecentlyWatched': 'normal',
'becauseYouLiked': 'normal',
'studioShows': 'normal',
'spotlightNetwork': 'normal'
};
const SECTION_PROFILE_BY_PREFIX = [
{ prefix: 'custom-', profile: 'full' },
{ prefix: 'recently-added-', profile: 'minimal' },
{ prefix: 'popular-genres-', profile: 'minimal' }
];
function isKnownDefaultSection(sectionId) {
if (!sectionId || sectionId.startsWith('custom-')) return false;
if (SECTION_PROFILE_BY_ID[sectionId]) return true;
return SECTION_PROFILE_BY_PREFIX.some((rule) => sectionId.startsWith(rule.prefix));
}
function resolveEditorProfile(section, context = {}) {
const id = section?.id;
if (!id) return 'full';
if (SECTION_PROFILE_BY_ID[id]) {
return SECTION_PROFILE_BY_ID[id];
}
const prefixRule = SECTION_PROFILE_BY_PREFIX.find((rule) => id.startsWith(rule.prefix));
if (prefixRule) {
return prefixRule.profile;
}
if (id === 'collections' && context.groupType === 'DISCOVERY_SECTION_GROUPS') {
return 'normal';
}
if (context.isCustomSection) {
return 'full';
}
console.warn('[KefinHomeScreenEditorProfiles] No profile for section:', id);
return 'normal';
}
function getEditorProfileDefinition(profileKey) {
return EDITOR_PROFILES[profileKey] || EDITOR_PROFILES.full;
}
window.KefinHomeScreenEditorProfiles = {
EDITOR_PROFILES,
SECTION_PROFILE_BY_ID,
SECTION_PROFILE_BY_PREFIX,
isKnownDefaultSection,
resolveEditorProfile,
getEditorProfileDefinition
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+982
View File
@@ -0,0 +1,982 @@
// Home Screen Section Benchmarks — timed query overview for admin config
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinHomeScreenBenchmark]', ...args);
const STORAGE_KEY = 'kefin.homeScreen.benchmark.selectedIds';
const DEFAULT_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16;
const CATEGORIES = [
{ id: 'default', label: 'Default', groupsKey: 'HOME_SECTION_GROUPS' },
{ id: 'custom', label: 'Custom', groupsKey: 'CUSTOM_SECTION_GROUPS' },
{ id: 'discovery', label: 'Discovery', groupsKey: 'DISCOVERY_SECTION_GROUPS' },
{ id: 'seasonal', label: 'Seasonal', groupsKey: 'SEASONAL_SECTION_GROUPS' }
];
const MS_BANDS = [
{ max: 500, className: 'kefin-bench-ms-fast' },
{ max: 1000, className: 'kefin-bench-ms-mid' },
{ max: 2000, className: 'kefin-bench-ms-slow' },
{ max: Infinity, className: 'kefin-bench-ms-worst' }
];
/** @type {{ aborted: boolean, controller: AbortController|null, onSkipRemaining?: Function } | null} */
let activeRun = null;
let navCancelBound = false;
/**
* Survives re-renders of the tab so results are not lost when the user
* switches tabs or reopens the config modal.
* @type {{ entries: Array, results: Map<string, Object> } | null}
*/
let lastRun = null;
let lastViewMode = 'picker';
function hasStoredResults() {
return !!lastRun?.entries?.length;
}
function flattenGroups(groups) {
if (!Array.isArray(groups)) return [];
const out = [];
groups.forEach((group) => {
if (group && Array.isArray(group.sections)) {
out.push(...group.sections);
}
});
return out;
}
function getSectionsForCategories(config, enabledCategoryIds) {
const enabled = new Set(enabledCategoryIds);
const sections = [];
CATEGORIES.forEach((cat) => {
if (!enabled.has(cat.id)) return;
const groups = config?.[cat.groupsKey] || [];
flattenGroups(groups).forEach((section) => {
if (!section?.id) return;
sections.push({
section,
category: cat.label,
categoryId: cat.id
});
});
});
return sections;
}
function loadStoredSelectedIds() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : null;
} catch (e) {
return null;
}
}
function saveStoredSelectedIds(ids) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} catch (e) {
LOG('Could not persist selection', e);
}
}
function msBandClass(ms) {
if (ms == null || Number.isNaN(ms)) return '';
for (const band of MS_BANDS) {
if (ms < band.max) return band.className;
}
return MS_BANDS[MS_BANDS.length - 1].className;
}
function withBenchmarkQueryOptions(query) {
const queryOptions = { ...(query?.queryOptions || {}) };
if (queryOptions.Limit == null || queryOptions.Limit === '') {
queryOptions.Limit = DEFAULT_QUERY_LIMIT;
}
return { ...query, queryOptions };
}
function uniqueItemsById(items) {
const seen = new Set();
const out = [];
(items || []).forEach((item) => {
const id = item?.Id ?? item?.id;
if (id != null) {
if (seen.has(id)) return;
seen.add(id);
}
out.push(item);
});
return out;
}
function analyzeItemFields(items) {
let hasMediaStreams = false;
let hasPeople = false;
let hasStudios = false;
(items || []).forEach((item) => {
if (item?.MediaStreams?.length) hasMediaStreams = true;
if (item?.People?.length) hasPeople = true;
if (item?.Studios?.length) hasStudios = true;
});
return { hasMediaStreams, hasPeople, hasStudios };
}
function jsonByteSize(value) {
try {
const json = typeof value === 'string' ? value : JSON.stringify(value);
return new TextEncoder().encode(json || '').byteLength;
} catch (e) {
return 0;
}
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function getAuthHeaders() {
if (window.apiHelper?.getAuthHeader) {
return {
Authorization: window.apiHelper.getAuthHeader(),
Accept: 'application/json'
};
}
return { Accept: 'application/json' };
}
async function timedFetchJson(url, signal) {
const start = performance.now();
const response = await fetch(url, { headers: getAuthHeaders(), signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
const data = JSON.parse(text);
return { data, ms: performance.now() - start, responseBytes: jsonByteSize(text) };
}
async function timedDataSource(dataSource, options) {
const start = performance.now();
const result = await window.apiHelper.fetchFromDataSource(dataSource, options || {}, false);
const ms = performance.now() - start;
const items = result?.Items || result || [];
return {
items: Array.isArray(items) ? items : [],
ms,
responseBytes: jsonByteSize(result)
};
}
async function fetchQueryItems(query, section, signal) {
const userId = window.ApiClient?.getCurrentUserId?.();
const serverUrl = window.ApiClient?.serverAddress?.();
if (!userId || !serverUrl) {
throw new Error('ApiClient not ready');
}
const previewQuery = withBenchmarkQueryOptions(query);
const useSpotlight =
section.renderMode === 'Spotlight' || section.spotlight === true;
if (previewQuery.dataSource) {
return await timedDataSource(previewQuery.dataSource, previewQuery.queryOptions);
}
if (!window.apiHelper?.buildQueryFromSection) {
throw new Error('apiHelper.buildQueryFromSection not available');
}
const built = window.apiHelper.buildQueryFromSection(
previewQuery,
userId,
serverUrl,
useSpotlight
);
if (built && typeof built === 'object' && built.dataSource) {
return await timedDataSource(built.dataSource, built.options);
}
if (!built || typeof built !== 'string') {
throw new Error('Could not build query URL');
}
const { data, ms, responseBytes } = await timedFetchJson(built, signal);
const items = data?.Items || (Array.isArray(data) ? data : []);
return { items, ms, responseBytes };
}
/**
* Discovery sections ship as templates without queries: their source (genre, person,
* studio, collection, ...) is picked at render time. Reuse the home screen resolver so
* the benchmark measures the same queries the home screen would actually issue.
*/
async function resolveSectionQueries(entry) {
const section = entry.section;
const isTemplate = !section.queries?.length && !section.items?.length;
if (!isTemplate || !section.type) return { section, error: null };
const resolve = window.homeScreen3?.resolveDiscoverySection;
if (!resolve) {
return { section, error: 'Discovery resolver unavailable' };
}
try {
const resolved = await resolve(section, {
pairSpotlight: false,
resolveViewMore: false
});
if (!resolved?.queries?.length) {
return { section, error: `Could not resolve ${section.type} source` };
}
return { section: resolved, error: null };
} catch (e) {
return { section, error: `Source resolve failed: ${e?.message || e}` };
}
}
async function benchmarkSection(section, signal) {
if (section.items?.length) {
return {
totalMs: 0,
items: section.items,
responseBytes: 0,
queryMetrics: [],
error: null,
skippedQueries: true
};
}
const queries = section.queries || [];
if (!queries.length) {
return {
totalMs: 0,
items: [],
responseBytes: 0,
queryMetrics: [],
error: 'No queries',
skippedQueries: true
};
}
let totalMs = 0;
let responseBytes = 0;
const allItems = [];
const queryMetrics = [];
for (const query of queries) {
if (signal?.aborted) {
return {
totalMs,
items: allItems,
responseBytes,
queryMetrics,
error: 'Cancelled',
cancelled: true
};
}
const result = await fetchQueryItems(query, section, signal);
const { items, ms } = result;
const queryBytes = result.responseBytes || 0;
totalMs += ms;
responseBytes += queryBytes;
allItems.push(...items);
queryMetrics.push({ ms, itemCount: items.length, responseBytes: queryBytes });
}
return {
totalMs,
items: uniqueItemsById(allItems),
responseBytes,
queryMetrics,
error: null
};
}
function chipHtml(on, label) {
const cls = on ? 'kefin-bench-chip-on' : 'kefin-bench-chip-off';
const text = on ? 'On' : 'Off';
return `<span class="kefin-bench-chip ${cls}" title="${label}">${label}: ${text}</span>`;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function ensureBenchmarkStyles() {
if (document.getElementById('kefin-bench-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-bench-styles';
style.textContent = `
.kefin-bench-wrap { font-size: 0.95em; }
.kefin-bench-cats {
display: flex;
flex-wrap: wrap;
gap: 1em;
margin: 1.25em 0;
}
.kefin-bench-cat-btn {
padding: 0.75em 1.75em;
font-size: 1em;
line-height: 1.2;
border-radius: 999px;
border: 2px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.5);
cursor: pointer;
font-weight: 600;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.kefin-bench-cat-btn:hover:not(:disabled) {
color: rgba(255,255,255,0.85);
border-color: rgba(255,255,255,0.4);
}
.kefin-bench-cat-btn.is-active {
border-color: var(--theme-primary-color, #00a4dc);
color: #fff;
background: var(--theme-primary-color, #00a4dc);
}
.kefin-bench-cat-btn.is-active:hover:not(:disabled) {
border-color: var(--theme-primary-color, #00a4dc);
color: #fff;
filter: brightness(1.1);
}
.kefin-bench-cat-btn:disabled { opacity: 0.5; cursor: default; }
.kefin-bench-actions {
display: flex; gap: 0.75em; align-items: center; flex-wrap: wrap; margin-top: 1em;
}
.kefin-bench-wrap [hidden] { display: none !important; }
.kefin-bench-panel { min-height: 120px; }
.kefin-bench-picker-toolbar {
display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.65em; flex-wrap: wrap;
}
.kefin-bench-picker-count {
font-size: 0.85em; color: rgba(255,255,255,0.55); margin-left: 0.25em;
}
#kefin-bench-picker-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.4em;
max-height: 460px;
overflow-y: auto;
padding: 0.5em;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
background: rgba(0,0,0,0.15);
}
@media (max-width: 1100px) {
#kefin-bench-picker-grid { grid-template-columns: 1fr 1fr 1fr; }
}
@media (max-width: 800px) {
#kefin-bench-picker-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 520px) {
#kefin-bench-picker-grid { grid-template-columns: 1fr; }
}
.kefin-bench-picker-cell {
height: 24px;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.5em;
padding: 0 0.4em;
border-radius: 4px;
min-width: 0;
cursor: pointer;
}
.kefin-bench-picker-cell:hover { background: rgba(255,255,255,0.07); }
.kefin-bench-picker-cell input { flex-shrink: 0; margin: 0; }
.kefin-bench-picker-label {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 0.4em;
overflow: hidden;
}
.kefin-bench-picker-name {
font-size: 1em;
color: rgba(255,255,255,0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kefin-bench-picker-badge {
flex-shrink: 0;
font-size: 0.65em;
padding: 0.1em 0.4em;
border-radius: 3px;
background: rgba(0,164,220,0.2);
color: rgba(255,255,255,0.7);
}
.kefin-bench-table-wrap { overflow-x: auto; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; }
.kefin-bench-summary-wrap {
overflow-x: auto;
margin-bottom: 1em;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
}
.kefin-bench-summary-table td {
font-size: 1.1em;
font-weight: 600;
text-align: center;
}
.kefin-bench-summary-table th { text-align: center; white-space: nowrap; }
.kefin-bench-summary-table td[title] { cursor: help; }
.kefin-bench-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
/* Fixed layout keeps column widths stable as rows fill in */
table-layout: fixed;
}
.kefin-bench-table:not(.kefin-bench-summary-table) { min-width: 780px; }
.kefin-bench-table th, .kefin-bench-table td {
padding: 0.55em 0.65em; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.08);
overflow: hidden; text-overflow: ellipsis;
}
.kefin-bench-table tbody tr { height: 2.9em; }
.kefin-bench-col-name, .kefin-bench-col-plain { white-space: nowrap; }
.kefin-bench-table th { font-weight: 600; color: rgba(255,255,255,0.85); background: rgba(0,0,0,0.2); }
.kefin-bench-table tr.kefin-bench-row-running td { color: rgba(255,255,255,0.6); }
.kefin-bench-ms-fast { background: rgba(47,158,68,0.35); }
.kefin-bench-ms-mid { background: rgba(255,212,59,0.25); }
.kefin-bench-ms-slow { background: rgba(253,126,20,0.3); }
.kefin-bench-ms-worst { background: rgba(224,49,49,0.35); }
.kefin-bench-chips { display: flex; flex-wrap: nowrap; gap: 0.35em; }
.kefin-bench-chip {
font-size: 0.75em; padding: 0.15em 0.45em; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.2);
white-space: nowrap;
}
.kefin-bench-chip-on { background: rgba(47,158,68,0.25); }
.kefin-bench-chip-off { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); }
`;
document.head.appendChild(style);
}
function buildPickerGridHtml(sections, checkedSet) {
if (!sections.length) {
return '<p class="listItemBodyText secondary" style="padding:1em;text-align:center;">No sections in the selected categories.</p>';
}
return sections
.map((entry) => {
const id = entry.section.id;
const checked = checkedSet ? checkedSet.has(id) : true;
const name = escapeHtml(entry.section.name || id);
const cat = escapeHtml(entry.category);
return `
<label class="kefin-bench-picker-cell">
<input type="checkbox" class="kefin-bench-pick" value="${escapeHtml(id)}" ${checked ? 'checked' : ''} />
<span class="kefin-bench-picker-label">
<span class="kefin-bench-picker-name">${name}</span>
<span class="kefin-bench-picker-badge">${cat}</span>
</span>
</label>
`;
})
.join('');
}
function buildTableShell() {
return `
<div class="kefin-bench-summary-wrap">
<table class="kefin-bench-table kefin-bench-summary-table">
<colgroup>
<col style="width:16.66%" />
<col style="width:16.66%" />
<col style="width:16.66%" />
<col style="width:16.66%" />
<col style="width:16.68%" />
<col style="width:16.68%" />
</colgroup>
<thead>
<tr>
<th>&lt;500ms</th>
<th>500–1000ms</th>
<th>1000–2000ms</th>
<th>2000ms+</th>
<th>&gt;100 items</th>
<th>&gt;5 MB</th>
</tr>
</thead>
<tbody>
<tr class="kefin-bench-summary-row">
<td class="kefin-bench-ms-fast" data-summary="fast">0</td>
<td class="kefin-bench-ms-mid" data-summary="mid">0</td>
<td class="kefin-bench-ms-slow" data-summary="slow">0</td>
<td class="kefin-bench-ms-worst" data-summary="worst">0</td>
<td data-summary="large-items">0</td>
<td data-summary="large-response">0</td>
</tr>
</tbody>
</table>
</div>
<div class="kefin-bench-table-wrap">
<table class="kefin-bench-table">
<colgroup>
<col style="width:24%" />
<col style="width:10%" />
<col style="width:9%" />
<col style="width:12%" />
<col style="width:7%" />
<col style="width:24%" />
<col style="width:14%" />
</colgroup>
<thead>
<tr>
<th>Section</th>
<th>Category</th>
<th>Time (ms)</th>
<th>Response size</th>
<th>Items</th>
<th>Fields</th>
<th>Status</th>
</tr>
</thead>
<tbody class="kefin-bench-tbody"></tbody>
</table>
</div>
`;
}
function rowHtml(entry, result) {
const name = result?.resolvedName || entry.section.name || entry.section.id;
const status = result?.status || 'pending';
const ms = result?.totalMs;
const msClass = status === 'done' ? msBandClass(ms) : '';
const msText =
status === 'running' ? '…' :
status === 'skipped' ? '—' :
status === 'error' ? '—' :
status === 'done' ? Math.round(ms) : '—';
const items = result?.itemCount ?? '—';
const responseSize = status === 'done' ? formatBytes(result.responseBytes) : '—';
const fields =
status === 'done'
? `<div class="kefin-bench-chips">
${chipHtml(result.hasMediaStreams, 'MediaStreams')}
${chipHtml(result.hasPeople, 'People')}
${chipHtml(result.hasStudios, 'Studios')}
</div>`
: '—';
const statusText =
status === 'running' ? 'Running…' :
status === 'skipped' ? 'Skipped' :
status === 'error' ? (result.error || 'Error') :
status === 'done' ? (result.skippedQueries ? 'Static / no query' : 'OK') : 'Pending';
return `
<tr class="kefin-bench-row kefin-bench-row-${status}" data-section-id="${entry.section.id}">
<td class="kefin-bench-col-name" title="${escapeHtml(name)}">${escapeHtml(name)}</td>
<td class="kefin-bench-col-plain">${escapeHtml(entry.category)}</td>
<td class="kefin-bench-col-plain ${msClass}">${msText}</td>
<td class="kefin-bench-col-plain">${responseSize}</td>
<td class="kefin-bench-col-plain">${items}</td>
<td>${fields}</td>
<td class="kefin-bench-col-plain" title="${escapeHtml(statusText)}">${escapeHtml(statusText)}</td>
</tr>
`;
}
function cancelActiveRun(markSkipped) {
if (!activeRun) return;
activeRun.aborted = true;
if (activeRun.controller) {
try {
activeRun.controller.abort();
} catch (e) { /* ignore */ }
}
if (markSkipped && activeRun.onSkipRemaining) {
activeRun.onSkipRemaining();
}
activeRun = null;
}
function bindNavCancel() {
if (navCancelBound) return;
navCancelBound = true;
const onNavigate = () => {
if (!activeRun) return;
if (window.location.hash === activeRun.startHash) return;
cancelActiveRun(true);
};
window.addEventListener('hashchange', onNavigate);
if (window.KefinTweaksUtils?.onViewPage) {
window.KefinTweaksUtils.onViewPage(() => onNavigate());
}
}
function updateSummaryCells(scopeEl, entries, results) {
const buckets = {
fast: new Map(),
mid: new Map(),
slow: new Map(),
worst: new Map(),
'large-items': new Map(),
'large-response': new Map()
};
entries.forEach((entry) => {
const result = results.get(entry.section.id);
if (!result?.queryMetrics?.length) return;
const name = result.resolvedName || entry.section.name || entry.section.id;
const tally = (key) => {
buckets[key].set(name, (buckets[key].get(name) || 0) + 1);
};
result.queryMetrics.forEach((query) => {
if (query.ms < 500) tally('fast');
else if (query.ms < 1000) tally('mid');
else if (query.ms < 2000) tally('slow');
else tally('worst');
if (query.itemCount > 100) tally('large-items');
if (query.responseBytes > 5 * 1024 * 1024) tally('large-response');
});
});
const summary = scopeEl?.querySelector('.kefin-bench-summary-row');
if (!summary) return;
Object.keys(buckets).forEach((key) => {
const cell = summary.querySelector(`[data-summary="${key}"]`);
if (!cell) return;
const tallied = [...buckets[key].entries()];
const total = tallied.reduce((sum, [, count]) => sum + count, 0);
cell.textContent = total;
if (total) {
// One line per section, with a query count when a section contributes more than once
cell.title = tallied
.map(([name, count]) => (count > 1 ? `${name} (${count} queries)` : name))
.join('\n');
} else {
cell.removeAttribute('title');
}
});
}
function renderResultsTable(resultsEl, entries, results) {
resultsEl.innerHTML = buildTableShell();
const tbody = resultsEl.querySelector('.kefin-bench-tbody');
tbody.innerHTML = entries
.map((entry) => rowHtml(entry, results.get(entry.section.id) || { status: 'pending' }))
.join('');
updateSummaryCells(resultsEl, entries, results);
return tbody;
}
async function runBenchmarks(selectedEntries, tbody, ui, results) {
const controller = new AbortController();
const signal = controller.signal;
const updateSummary = () => {
updateSummaryCells(tbody.closest('#kefin-bench-results'), selectedEntries, results);
};
const updateRow = (entry, result) => {
results.set(entry.section.id, result);
const tr = tbody.querySelector(`tr[data-section-id="${entry.section.id}"]`);
if (tr) {
tr.outerHTML = rowHtml(entry, result);
}
updateSummary();
};
const skipRemaining = () => {
selectedEntries.forEach((entry) => {
if (results.get(entry.section.id)?.status === 'running' ||
!results.has(entry.section.id)) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
}
});
ui.setRunning(false);
};
activeRun = {
aborted: false,
controller,
startHash: window.location.hash,
onSkipRemaining: skipRemaining
};
ui.setRunning(true);
for (const entry of selectedEntries) {
if (activeRun?.aborted || signal.aborted) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
continue;
}
updateRow(entry, { status: 'running' });
try {
const resolved = await resolveSectionQueries(entry);
if (resolved.error) {
updateRow(entry, {
status: 'error',
error: resolved.error,
totalMs: null,
itemCount: '—'
});
continue;
}
const bench = await benchmarkSection(resolved.section, signal);
if (activeRun?.aborted || signal.aborted || bench.cancelled) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
continue;
}
const fields = analyzeItemFields(bench.items);
updateRow(entry, {
status: bench.error && !bench.skippedQueries ? 'error' : 'done',
resolvedName: resolved.section !== entry.section ? resolved.section.name : null,
totalMs: bench.totalMs,
itemCount: bench.items.length,
hasMediaStreams: fields.hasMediaStreams,
hasPeople: fields.hasPeople,
hasStudios: fields.hasStudios,
responseBytes: bench.responseBytes,
queryMetrics: bench.queryMetrics,
skippedQueries: bench.skippedQueries,
error: bench.error
});
} catch (e) {
if (signal.aborted || activeRun?.aborted) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
} else {
updateRow(entry, {
status: 'error',
error: e?.message || String(e),
totalMs: null,
itemCount: '—'
});
}
}
await new Promise((r) => setTimeout(r, 0));
}
activeRun = null;
ui.setRunning(false);
}
/**
* Render benchmark UI into host element.
* @param {HTMLElement} hostEl
* @param {{ getConfig?: () => Object }} options
*/
function renderHomeScreenBenchmarkUI(hostEl, options = {}) {
if (!hostEl) return;
cancelActiveRun(true);
const getConfig = options.getConfig || (() => window.KefinHomeScreen?.getConfig?.() || {});
ensureBenchmarkStyles();
hostEl.innerHTML = `
<div class="kefin-bench-wrap">
<div class="listItemBodyText" style="font-weight:600; font-size:1.1em; margin-bottom:0.35em;">
Home Screen Section Benchmarks
</div>
<div class="listItemBodyText secondary" style="margin-bottom:0.5em;">
Test your home sections for slow queries or otherwise unintended results.
Timings reflect server round-trip per query (not full card render).
</div>
<div class="kefin-bench-cats" id="kefin-bench-cats"></div>
<div id="kefin-bench-panel" class="kefin-bench-panel"></div>
<div class="kefin-bench-actions">
<button type="button" class="emby-button raised button-submit kefin-bench-run-btn" id="kefin-bench-run">Run Benchmarks</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-stop" hidden>Stop</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-view-results" hidden>View Results</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-edit" hidden>Edit Selection</button>
</div>
<div id="kefin-bench-results" style="margin-top:1.25em;"></div>
</div>
`;
const catsEl = hostEl.querySelector('#kefin-bench-cats');
const runBtn = hostEl.querySelector('#kefin-bench-run');
const stopBtn = hostEl.querySelector('#kefin-bench-stop');
const viewResultsBtn = hostEl.querySelector('#kefin-bench-view-results');
const editBtn = hostEl.querySelector('#kefin-bench-edit');
const panelEl = hostEl.querySelector('#kefin-bench-panel');
const resultsEl = hostEl.querySelector('#kefin-bench-results');
const enabledCats = new Set(CATEGORIES.map((c) => c.id));
function getCurrentSections() {
const config = typeof getConfig === 'function' ? getConfig() : {};
return getSectionsForCategories(config, [...enabledCats]);
}
function getCheckedIdsFromPanel() {
return Array.from(panelEl.querySelectorAll('.kefin-bench-pick:checked'))
.map((cb) => cb.value);
}
function updatePickerCount() {
const countEl = panelEl.querySelector('.kefin-bench-picker-count');
if (!countEl) return;
const total = panelEl.querySelectorAll('.kefin-bench-pick').length;
const checked = panelEl.querySelectorAll('.kefin-bench-pick:checked').length;
countEl.textContent = `${checked} of ${total} selected`;
}
function bindPickerToolbar() {
panelEl.querySelector('#kefin-bench-run-top')
?.addEventListener('click', () => startRun());
const selectAllBtn = panelEl.querySelector('#kefin-bench-select-all');
const deselectAllBtn = panelEl.querySelector('#kefin-bench-deselect-all');
selectAllBtn?.addEventListener('click', () => {
panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = true; });
updatePickerCount();
});
deselectAllBtn?.addEventListener('click', () => {
panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = false; });
updatePickerCount();
});
panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => {
cb.addEventListener('change', () => updatePickerCount());
});
}
function showPickerPanel(preserveCheckedIds) {
const sections = getCurrentSections();
const stored = loadStoredSelectedIds();
const storedSet = stored ? new Set(stored) : null;
const checkedSet = preserveCheckedIds instanceof Set ? preserveCheckedIds : storedSet;
panelEl.innerHTML = `
<div class="kefin-bench-picker-toolbar">
<button type="button" class="emby-button raised button-submit kefin-bench-run-btn" id="kefin-bench-run-top">Run Benchmarks</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-select-all">Select all</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-deselect-all">Deselect all</button>
<span class="kefin-bench-picker-count"></span>
</div>
<div class="kefin-bench-picker-grid" id="kefin-bench-picker-grid">
${buildPickerGridHtml(sections, checkedSet)}
</div>
`;
bindPickerToolbar();
updatePickerCount();
}
function showPicker() {
cancelActiveRun(true);
lastViewMode = 'picker';
resultsEl.innerHTML = '';
catsEl.hidden = false;
panelEl.hidden = false;
editBtn.hidden = true;
viewResultsBtn.hidden = !hasStoredResults();
ui.setRunning(false);
}
function showResults(entries, results) {
lastViewMode = 'results';
catsEl.hidden = true;
panelEl.hidden = true;
editBtn.hidden = false;
viewResultsBtn.hidden = true;
return renderResultsTable(resultsEl, entries, results);
}
const ui = {
setRunning(running) {
stopBtn.hidden = !running;
hostEl.querySelectorAll('.kefin-bench-run-btn').forEach((btn) => {
btn.disabled = running;
});
}
};
function startRun() {
if (activeRun) return;
const sections = getCurrentSections();
if (!sections.length) {
window.KefinTweaksToaster?.toast?.('No sections in the selected categories.');
return;
}
const selectedIds = getCheckedIdsFromPanel();
if (!selectedIds.length) {
window.KefinTweaksToaster?.toast?.('Select at least one section to benchmark.');
return;
}
saveStoredSelectedIds(selectedIds);
const idSet = new Set(selectedIds);
const selected = sections.filter((e) => idSet.has(e.section.id));
if (!selected.length) return;
lastRun = { entries: selected, results: new Map() };
const tbody = showResults(lastRun.entries, lastRun.results);
runBenchmarks(lastRun.entries, tbody, ui, lastRun.results);
}
CATEGORIES.forEach((cat) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kefin-bench-cat-btn is-active';
btn.dataset.cat = cat.id;
btn.setAttribute('aria-pressed', 'true');
btn.textContent = cat.label;
btn.addEventListener('click', () => {
if (activeRun) return;
const prevChecked = new Set(getCheckedIdsFromPanel());
if (enabledCats.has(cat.id)) {
enabledCats.delete(cat.id);
btn.classList.remove('is-active');
btn.setAttribute('aria-pressed', 'false');
} else {
enabledCats.add(cat.id);
btn.classList.add('is-active');
btn.setAttribute('aria-pressed', 'true');
}
showPickerPanel(prevChecked);
});
catsEl.appendChild(btn);
});
stopBtn.addEventListener('click', () => cancelActiveRun(true));
editBtn.addEventListener('click', () => showPicker());
runBtn.addEventListener('click', () => startRun());
viewResultsBtn.addEventListener('click', () => {
if (!hasStoredResults()) return;
showResults(lastRun.entries, lastRun.results);
});
showPickerPanel();
if (lastViewMode === 'results' && hasStoredResults()) {
showResults(lastRun.entries, lastRun.results);
} else {
showPicker();
}
}
window.KefinHomeScreenBenchmark = {
renderHomeScreenBenchmarkUI,
cancelActiveRun
};
bindNavCancel();
LOG('loaded');
})();
+7 -5
View File
@@ -62,8 +62,8 @@
<div style="max-width: 800px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Enable Jellyseerr</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Enable Jellyseerr integration for request functionality</div>
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Enable Seerr results from JellyfinEnhanced</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="search_enableJellyseerr" ${config.enableJellyseerr === true ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
@@ -135,9 +135,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('Search configuration modal opened');
} catch (error) {
@@ -136,9 +136,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('Series Episodes configuration modal opened');
} catch (error) {
+5 -3
View File
@@ -136,9 +136,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('Series Info configuration modal opened');
} catch (error) {
+5 -3
View File
@@ -628,9 +628,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('Skin Manager configuration modal opened');
} catch (error) {
@@ -59,6 +59,13 @@
*/
function buildConfigHTML(config) {
const previewOnHover = config.PreviewOnHover === true;
const useVideoStream = config.UseVideoStream !== false;
let fallbackAfterMs = 500;
if (Number.isFinite(config.FallbackAfterMs * 1) && config.FallbackAfterMs >= 0) {
fallbackAfterMs = config.FallbackAfterMs * 1;
} else if (Number.isFinite(config.FallbackAfterSeconds * 1) && config.FallbackAfterSeconds >= 0) {
fallbackAfterMs = config.FallbackAfterSeconds * 1000;
}
const hoverDelay = Number.isFinite(config.HOVER_PREVIEW_TIMEOUT_MS * 1)
? config.HOVER_PREVIEW_TIMEOUT_MS * 1
: (Number.isFinite(config.HOVER_TIMEOUT * 1) ? config.HOVER_TIMEOUT * 1 : 800);
@@ -85,6 +92,38 @@
</div>
</div>
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Use Video Stream Preview</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
When enabled, hover preview plays a muted video stream (<code>/Videos/{id}/stream</code>)
instead of cycling trickplay images as the card background.
</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="thumbnailScrubber_UseVideoStream" ${useVideoStream ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Fallback After (ms)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
When Use Video Stream is on, how long to wait for the direct stream to start playing
before falling back to trickplay images.
Set to 0 to disable the timeout (play errors still fall back to trickplay).
</div>
<input type="number"
id="thumbnailScrubber_FallbackAfterMs"
class="fld emby-input"
min="0"
step="50"
value="${fallbackAfterMs}"
style="width: 120px;">
</div>
</div>
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Delay Before Previewing On Hover (ms)</div>
@@ -144,9 +183,11 @@
*/
function collectConfig() {
const previewOnHover = document.getElementById('thumbnailScrubber_PreviewOnHover')?.checked === true;
const useVideoStream = document.getElementById('thumbnailScrubber_UseVideoStream')?.checked === true;
const hoverDelayInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS');
const scrubDelayInput = document.getElementById('thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS');
const hoverSpeedInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_FRAME_MS');
const fallbackAfterInput = document.getElementById('thumbnailScrubber_FallbackAfterMs');
let hoverDelay = hoverDelayInput ? parseInt(hoverDelayInput.value, 10) : 800;
if (!Number.isFinite(hoverDelay) || hoverDelay < 0) {
@@ -163,8 +204,15 @@
hoverSpeed = 0;
}
let fallbackAfterMs = fallbackAfterInput ? parseInt(fallbackAfterInput.value, 10) : 500;
if (!Number.isFinite(fallbackAfterMs) || fallbackAfterMs < 0) {
fallbackAfterMs = 500;
}
return {
PreviewOnHover: previewOnHover,
UseVideoStream: useVideoStream,
FallbackAfterMs: fallbackAfterMs,
// Delay before previewing on hover
HOVER_PREVIEW_TIMEOUT_MS: hoverDelay,
HOVER_TIMEOUT: hoverDelay, // legacy key
@@ -226,9 +274,11 @@
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '800px';
modalInstance.dialog.style.height = 'auto';
if (window.innerWidth >= 900) {
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '800px';
modalInstance.dialog.style.height = 'auto';
}
LOG('Thumbnail Scrubber configuration modal opened');
} catch (error) {
+882
View File
@@ -0,0 +1,882 @@
// KefinTweaks User Manager Configuration UI — User Templates
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks UserManager Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks UserManager Config]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks UserManager Config]', ...args);
const CONFIG_MODAL_ID = 'kefin-usermanager-config';
const EDITOR_MODAL_ID = 'kefin-usermanager-template-editor';
const TAG_MODAL_ID = 'kefin-usermanager-tag-modal';
const SCHEDULE_MODAL_ID = 'kefin-usermanager-schedule-modal';
const DEFAULT_AUTH = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider';
const DEFAULT_PASSWORD_RESET = 'Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider';
const UNRATED_TYPES = [
{ type: 'Book', label: 'Books' },
{ type: 'ChannelContent', label: 'Channels' },
{ type: 'LiveTvChannel', label: 'Live TV' },
{ type: 'Movie', label: 'Movies' },
{ type: 'Music', label: 'Music' },
{ type: 'Trailer', label: 'Trailers' },
{ type: 'Series', label: 'Shows' }
];
const DAYS = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
'Everyday', 'Weekday', 'Weekend'
];
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function clone(obj) {
return JSON.parse(JSON.stringify(obj ?? {}));
}
function getSeedPolicy() {
return {
IsAdministrator: false,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
BlockedTags: [],
AllowedTags: [],
EnableUserPreferenceAccess: true,
AccessSchedules: [],
BlockUnratedItems: [],
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: true,
EnableRemoteAccess: true,
EnableLiveTvManagement: true,
EnableLiveTvAccess: true,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDeletionFromFolders: [],
EnableContentDownloading: true,
EnableSyncTranscoding: true,
EnableMediaConversion: true,
EnabledDevices: [],
EnableAllDevices: true,
EnabledChannels: [],
EnableAllChannels: true,
EnabledFolders: [],
EnableAllFolders: true,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: -1,
MaxActiveSessions: 0,
EnablePublicSharing: true,
BlockedMediaFolders: [],
BlockedChannels: [],
RemoteClientBitrateLimit: 0,
AuthenticationProviderId: DEFAULT_AUTH,
PasswordResetProviderId: DEFAULT_PASSWORD_RESET,
SyncPlayAccess: 'CreateAndJoinGroups'
};
}
function normalizeConfig(raw) {
const cfg = raw && typeof raw === 'object' ? raw : {};
const templates = Array.isArray(cfg.templates)
? cfg.templates.filter((t) => t && typeof t.name === 'string' && t.name.trim())
: [];
let defaultTemplateName = typeof cfg.defaultTemplateName === 'string' ? cfg.defaultTemplateName : '';
if (defaultTemplateName && !templates.some((t) => t.name === defaultTemplateName)) {
defaultTemplateName = '';
}
return { defaultTemplateName, templates };
}
function loadConfig() {
return normalizeConfig(window.KefinTweaksConfig?.userManager);
}
async function saveConfig(next) {
try {
if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {};
window.KefinTweaksConfig.userManager = normalizeConfig(next);
if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved');
return true;
}
if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
} catch (e) {
ERR('Error saving config:', e);
return false;
}
}
async function fetchMediaFolders() {
try {
const url = ApiClient.getUrl('Library/MediaFolders');
const data = await ApiClient.getJSON(url);
return Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
} catch (e) {
WARN('Failed to load media folders', e);
return [];
}
}
function buildCheckbox(id, checked, label, options) {
return window.KefinTweaksUI?.buildCheckbox?.(id, checked, label, options) || '';
}
function hourOptions(selected) {
const opts = [];
for (let h = 0; h <= 24; h += 0.5) {
const hours = Math.floor(h) % 12 || 12;
const mins = h % 1 ? '30' : '00';
const ampm = h < 12 || h === 24 ? 'AM' : 'PM';
const label = h === 24 ? '12:00 AM' : `${hours}:${mins} ${ampm}`;
opts.push(`<option value="${h}" ${Number(selected) === h ? 'selected' : ''}>${label}</option>`);
}
return opts.join('');
}
function formatScheduleTime(h) {
const n = Number(h);
if (!Number.isFinite(n)) return '';
if (n === 24) return '12:00 AM';
const hours = Math.floor(n) % 12 || 12;
const mins = n % 1 ? '30' : '00';
const ampm = n < 12 ? 'AM' : 'PM';
return `${hours}:${mins} ${ampm}`;
}
function bitrateToMbps(bps) {
const n = Number(bps) || 0;
if (n <= 0) return '';
return String(Math.round((n / 1000000) * 100) / 100);
}
function mbpsToBitrate(mbps) {
const n = parseFloat(mbps);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.round(n * 1000000);
}
function buildTagListHTML(tags, listClass) {
if (!tags?.length) {
return `<div class="listItemBodyText secondary ${listClass}-empty">No tags</div>`;
}
return tags.map((tag, i) => `
<div class="listItem paperList" style="margin-bottom:0.35em;">
<div class="listItemBody"><h3 class="listItemBodyText">${escapeHtml(tag)}</h3></div>
<button type="button" class="emby-button raised um-delete-tag" data-list="${listClass}" data-index="${i}" title="Delete">
<span class="material-icons" style="font-size:1.1em;">delete</span>
</button>
</div>`).join('');
}
function buildScheduleListHTML(schedules) {
if (!schedules?.length) {
return `<div class="listItemBodyText secondary um-schedules-empty">No access schedules</div>`;
}
return schedules.map((s, i) => `
<div class="listItem paperList um-schedule-row" style="margin-bottom:0.35em;"
data-day="${escapeHtml(s.DayOfWeek)}" data-start="${s.StartHour}" data-end="${s.EndHour}">
<div class="listItemBody two-line">
<h3 class="listItemBodyText">${escapeHtml(s.DayOfWeek)}</h3>
<div class="listItemBodyText secondary">${formatScheduleTime(s.StartHour)} - ${formatScheduleTime(s.EndHour)}</div>
</div>
<button type="button" class="emby-button raised um-delete-schedule" data-index="${i}" title="Delete">
<span class="material-icons" style="font-size:1.1em;">delete</span>
</button>
</div>`).join('');
}
function buildEditorHTML(template, folders) {
const policy = { ...getSeedPolicy(), ...(template?.policy || {}) };
const name = template?.name || '';
const enabledFolders = new Set(policy.EnabledFolders || []);
const deleteFolders = new Set(policy.EnableContentDeletionFromFolders || []);
const blockUnrated = new Set(policy.BlockUnratedItems || []);
const allowedTags = policy.AllowedTags || [];
const blockedTags = policy.BlockedTags || [];
const schedules = policy.AccessSchedules || [];
const folderChecks = folders.map((f) => {
const id = f.Id || f.id;
const label = f.Name || f.name || id;
return buildCheckbox('', enabledFolders.has(id), label, {
className: 'um-folder',
wrapInSection: true,
dataAttributes: { id }
});
}).join('');
const deleteFolderChecks = folders.map((f) => {
const id = f.Id || f.id;
const label = f.Name || f.name || id;
return buildCheckbox('', deleteFolders.has(id), label, {
className: 'um-delete-folder',
wrapInSection: true,
dataAttributes: { id }
});
}).join('');
const unratedChecks = UNRATED_TYPES.map((u) =>
buildCheckbox('', blockUnrated.has(u.type), u.label, {
className: 'um-unrated',
wrapInSection: true,
dataAttributes: { itemtype: u.type }
})
).join('');
return `
<div class="um-template-editor" style="max-width:100%;">
<div class="inputContainer">
<label class="inputLabel" for="um-template-name">Template name</label>
<input id="um-template-name" class="emby-input" type="text" required value="${escapeHtml(name)}" placeholder="e.g. Standard">
</div>
<div class="verticalSection">
<h2 class="paperListLabel">Permissions</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableRemoteAccess, 'Allow remote connections to this server', { className: 'um-chk-remote', wrapInSection: true })}
${buildCheckbox('', !!policy.IsAdministrator, 'Allow this user to manage the server', { className: 'um-chk-admin', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableCollectionManagement, 'Allow this user to manage collections', { className: 'um-chk-collections', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableSubtitleManagement, 'Allow this user to edit subtitles', { className: 'um-chk-subtitles', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableLyricManagement, 'Allow this user to manage lyrics', { className: 'um-chk-lyrics', wrapInSection: true })}
</div>
</div>
<div class="verticalSection">
<h2 class="paperListLabel">Feature access</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableLiveTvAccess, 'Allow Live TV access', { className: 'um-chk-livetv', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableLiveTvManagement, 'Allow Live TV recording management', { className: 'um-chk-livetv-mgmt', wrapInSection: true })}
</div>
</div>
<div class="verticalSection">
<h2 class="paperListLabel">Media playback</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableMediaPlayback, 'Allow media playback', { className: 'um-chk-playback', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableAudioPlaybackTranscoding, 'Allow audio playback that requires transcoding', { className: 'um-chk-audio-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableVideoPlaybackTranscoding, 'Allow video playback that requires transcoding', { className: 'um-chk-video-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnablePlaybackRemuxing, 'Allow video playback that requires conversion without re-encoding', { className: 'um-chk-remux', wrapInSection: true })}
${buildCheckbox('', !!policy.ForceRemoteSourceTranscoding, 'Force transcoding of remote media sources such as Live TV', { className: 'um-chk-force-remote-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableSyncTranscoding, 'Allow sync transcoding', { className: 'um-chk-sync-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableMediaConversion, 'Allow media conversion', { className: 'um-chk-conversion', wrapInSection: true })}
</div>
<div class="inputContainer" style="margin-top:0.75em;">
<label class="inputLabel" for="um-bitrate">Internet streaming bitrate limit (Mbps)</label>
<input id="um-bitrate" class="emby-input" type="number" min="0" step="0.25" value="${escapeHtml(bitrateToMbps(policy.RemoteClientBitrateLimit))}">
<div class="fieldDescription">Leave empty or 0 for no per-user limit.</div>
</div>
<div class="selectContainer" style="margin-top:0.75em;">
<label class="selectLabel" for="um-syncplay">SyncPlay access</label>
<select id="um-syncplay" class="emby-select-withcolor emby-select">
<option value="CreateAndJoinGroups" ${policy.SyncPlayAccess === 'CreateAndJoinGroups' ? 'selected' : ''}>Allow user to create and join groups</option>
<option value="JoinGroups" ${policy.SyncPlayAccess === 'JoinGroups' ? 'selected' : ''}>Allow user to join groups</option>
<option value="None" ${policy.SyncPlayAccess === 'None' ? 'selected' : ''}>Disabled for this user</option>
</select>
</div>
</div>
<div class="verticalSection">
<h2 class="checkboxListLabel" style="margin-bottom:1em;">Allow media deletion from</h2>
<div class="checkboxList paperList checkboxList-paperList">
${buildCheckbox('', !!policy.EnableContentDeletion, 'All libraries', { className: 'um-chk-delete-all', wrapInSection: true })}
<div class="um-delete-folders checkboxList" style="${policy.EnableContentDeletion ? 'display:none;' : ''}">
${deleteFolderChecks || '<div class="fieldDescription">No libraries found</div>'}
</div>
</div>
</div>
<div class="verticalSection">
<h2 class="checkboxListLabel">Remote Control</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableRemoteControlOfOtherUsers, 'Allow remote control of other users', { className: 'um-chk-remote-others', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableSharedDeviceControl, 'Allow remote control of shared devices', { className: 'um-chk-remote-shared', wrapInSection: true })}
</div>
</div>
<div class="verticalSection">
<h2 class="checkboxListLabel">Other</h2>
${buildCheckbox('', !!policy.EnableContentDownloading, 'Allow media downloads', { className: 'um-chk-download', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableUserPreferenceAccess, 'Allow user preference access', { className: 'um-chk-prefs', wrapInSection: true })}
${buildCheckbox('', !!policy.EnablePublicSharing, 'Allow public sharing', { className: 'um-chk-public-share', wrapInSection: true })}
${buildCheckbox('', !!policy.IsDisabled, 'Disable this user', { className: 'um-chk-disabled', wrapInSection: true })}
${buildCheckbox('', !!policy.IsHidden, 'Hide this user from login screens', { className: 'um-chk-hidden', wrapInSection: true })}
<div class="inputContainer" style="margin-top:0.75em;">
<label class="inputLabel" for="um-lockout">Failed login tries before user is locked out</label>
<input id="um-lockout" class="emby-input" type="number" min="-1" step="1" value="${escapeHtml(policy.LoginAttemptsBeforeLockout ?? -1)}">
<div class="fieldDescription">-1 disables lockout. 0 inherits server default.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="um-max-sessions">Maximum number of simultaneous user sessions</label>
<input id="um-max-sessions" class="emby-input" type="number" min="0" step="1" value="${escapeHtml(policy.MaxActiveSessions ?? 0)}">
<div class="fieldDescription">0 disables the limit.</div>
</div>
</div>
<div class="verticalSection folderAccessContainer">
<h2>Library Access</h2>
${buildCheckbox('', !!policy.EnableAllFolders, 'Enable access to all libraries', { className: 'um-chk-all-folders', wrapInSection: true })}
<div class="um-folder-list" style="${policy.EnableAllFolders ? 'display:none;' : ''}">
<h3 class="checkboxListLabel">Libraries</h3>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${folderChecks || '<div class="fieldDescription">No libraries found</div>'}
</div>
</div>
</div>
<div class="verticalSection">
<h3 class="checkboxListLabel">Block items with no or unrecognized rating information</h3>
<div class="checkboxList paperList" style="padding:0.5em 1em;">${unratedChecks}</div>
</div>
<div class="verticalSection" style="margin-bottom:1.5em;">
<div class="sectionTitleContainer flex align-items-center" style="display:flex;align-items:center;gap:0.5em;">
<h2 class="sectionTitle" style="margin:0;">Allow items with tags</h2>
<button type="button" class="emby-button raised um-add-allowed-tag" title="Add">
<span class="material-icons" style="font-size:1.1em;">add</span>
</button>
</div>
<div class="fieldDescription">Only show media with at least one of the specified tags.</div>
<div class="um-allowed-tags" style="margin-top:0.5em;">${buildTagListHTML(allowedTags, 'allowed')}</div>
</div>
<div class="verticalSection" style="margin-bottom:1.5em;">
<div class="sectionTitleContainer flex align-items-center" style="display:flex;align-items:center;gap:0.5em;">
<h2 class="sectionTitle" style="margin:0;">Block items with tags</h2>
<button type="button" class="emby-button raised um-add-blocked-tag" title="Add">
<span class="material-icons" style="font-size:1.1em;">add</span>
</button>
</div>
<div class="fieldDescription">Hide media with at least one of the specified tags.</div>
<div class="um-blocked-tags" style="margin-top:0.5em;">${buildTagListHTML(blockedTags, 'blocked')}</div>
</div>
<div class="verticalSection" style="margin-bottom:1.5em;">
<div class="sectionTitleContainer flex align-items-center" style="display:flex;align-items:center;gap:0.5em;">
<h2 class="sectionTitle" style="margin:0;">Access Schedule</h2>
<button type="button" class="emby-button raised um-add-schedule" title="Add">
<span class="material-icons" style="font-size:1.1em;">add</span>
</button>
</div>
<p class="fieldDescription">Limit access to certain hours.</p>
<div class="um-schedules">${buildScheduleListHTML(schedules)}</div>
</div>
</div>
`;
}
function collectCheckedIds(root, selector) {
return Array.from(root.querySelectorAll(`${selector}:checked`))
.map((el) => el.getAttribute('data-id'))
.filter(Boolean);
}
function collectPolicyFromEditor(root, state) {
const enableAllFolders = !!root.querySelector('.um-chk-all-folders')?.checked;
const enableContentDeletion = !!root.querySelector('.um-chk-delete-all')?.checked;
return {
...getSeedPolicy(),
IsAdministrator: !!root.querySelector('.um-chk-admin')?.checked,
IsHidden: !!root.querySelector('.um-chk-hidden')?.checked,
EnableCollectionManagement: !!root.querySelector('.um-chk-collections')?.checked,
EnableSubtitleManagement: !!root.querySelector('.um-chk-subtitles')?.checked,
EnableLyricManagement: !!root.querySelector('.um-chk-lyrics')?.checked,
IsDisabled: !!root.querySelector('.um-chk-disabled')?.checked,
BlockedTags: state.blockedTags.slice(),
AllowedTags: state.allowedTags.slice(),
EnableUserPreferenceAccess: !!root.querySelector('.um-chk-prefs')?.checked,
AccessSchedules: state.schedules.map((s) => ({ ...s })),
BlockUnratedItems: Array.from(root.querySelectorAll('.um-unrated:checked'))
.map((el) => el.getAttribute('data-itemtype'))
.filter(Boolean),
EnableRemoteControlOfOtherUsers: !!root.querySelector('.um-chk-remote-others')?.checked,
EnableSharedDeviceControl: !!root.querySelector('.um-chk-remote-shared')?.checked,
EnableRemoteAccess: !!root.querySelector('.um-chk-remote')?.checked,
EnableLiveTvManagement: !!root.querySelector('.um-chk-livetv-mgmt')?.checked,
EnableLiveTvAccess: !!root.querySelector('.um-chk-livetv')?.checked,
EnableMediaPlayback: !!root.querySelector('.um-chk-playback')?.checked,
EnableAudioPlaybackTranscoding: !!root.querySelector('.um-chk-audio-tc')?.checked,
EnableVideoPlaybackTranscoding: !!root.querySelector('.um-chk-video-tc')?.checked,
EnablePlaybackRemuxing: !!root.querySelector('.um-chk-remux')?.checked,
ForceRemoteSourceTranscoding: !!root.querySelector('.um-chk-force-remote-tc')?.checked,
EnableContentDeletion: enableContentDeletion,
EnableContentDeletionFromFolders: enableContentDeletion
? []
: collectCheckedIds(root, '.um-delete-folder'),
EnableContentDownloading: !!root.querySelector('.um-chk-download')?.checked,
EnableSyncTranscoding: !!root.querySelector('.um-chk-sync-tc')?.checked,
EnableMediaConversion: !!root.querySelector('.um-chk-conversion')?.checked,
EnabledDevices: [],
EnableAllDevices: true,
EnabledChannels: [],
EnableAllChannels: true,
EnabledFolders: enableAllFolders ? [] : collectCheckedIds(root, '.um-folder'),
EnableAllFolders: enableAllFolders,
LoginAttemptsBeforeLockout: parseInt(root.querySelector('#um-lockout')?.value ?? '-1', 10),
MaxActiveSessions: parseInt(root.querySelector('#um-max-sessions')?.value ?? '0', 10),
EnablePublicSharing: !!root.querySelector('.um-chk-public-share')?.checked,
RemoteClientBitrateLimit: mbpsToBitrate(root.querySelector('#um-bitrate')?.value),
AuthenticationProviderId: DEFAULT_AUTH,
PasswordResetProviderId: DEFAULT_PASSWORD_RESET,
SyncPlayAccess: root.querySelector('#um-syncplay')?.value || 'CreateAndJoinGroups'
};
}
function promptText(title, placeholder) {
return new Promise((resolve) => {
const content = document.createElement('div');
content.innerHTML = `
<div class="inputContainer">
<label class="inputLabel" for="um-tag-input">${escapeHtml(title)}</label>
<input id="um-tag-input" class="emby-input" type="text" placeholder="${escapeHtml(placeholder || '')}">
</div>`;
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-tag-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-tag-ok">Add</button>`;
const modal = window.ModalSystem.create({
id: TAG_MODAL_ID,
title,
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (m) => {
const input = m.dialogContent.querySelector('#um-tag-input');
const finish = (val) => {
window.ModalSystem.close(TAG_MODAL_ID);
resolve(val);
};
m.dialogFooter.querySelector('#um-tag-cancel')?.addEventListener('click', () => finish(null));
m.dialogFooter.querySelector('#um-tag-ok')?.addEventListener('click', () => {
finish(input?.value?.trim() || null);
});
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finish(input.value.trim() || null);
}
});
setTimeout(() => input?.focus(), 50);
},
onClose: () => resolve(null)
});
if (modal?.dialog) {
modal.dialog.style.maxWidth = '420px';
modal.dialog.style.width = '90vw';
}
});
}
function promptSchedule() {
return new Promise((resolve) => {
const dayOpts = DAYS.map((d) => `<option value="${d}">${d === 'Everyday' ? 'Every day' : d === 'Weekday' ? 'Weekdays' : d === 'Weekend' ? 'Weekends' : d}</option>`).join('');
const content = document.createElement('div');
content.innerHTML = `
<div class="selectContainer">
<label class="selectLabel" for="um-sched-day">Day of week</label>
<select id="um-sched-day" class="emby-select-withcolor emby-select">${dayOpts}</select>
</div>
<div class="selectContainer">
<label class="selectLabel" for="um-sched-start">Start time</label>
<select id="um-sched-start" class="emby-select-withcolor emby-select">${hourOptions(0)}</select>
</div>
<div class="selectContainer">
<label class="selectLabel" for="um-sched-end">End time</label>
<select id="um-sched-end" class="emby-select-withcolor emby-select">${hourOptions(24)}</select>
</div>`;
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-sched-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-sched-ok">Add</button>`;
let settled = false;
const finish = (val) => {
if (settled) return;
settled = true;
window.ModalSystem.close(SCHEDULE_MODAL_ID);
resolve(val);
};
const modal = window.ModalSystem.create({
id: SCHEDULE_MODAL_ID,
title: 'Access Schedule',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (m) => {
m.dialogFooter.querySelector('#um-sched-cancel')?.addEventListener('click', () => finish(null));
m.dialogFooter.querySelector('#um-sched-ok')?.addEventListener('click', () => {
const DayOfWeek = m.dialogContent.querySelector('#um-sched-day')?.value;
const StartHour = parseFloat(m.dialogContent.querySelector('#um-sched-start')?.value);
const EndHour = parseFloat(m.dialogContent.querySelector('#um-sched-end')?.value);
if (!DayOfWeek || !Number.isFinite(StartHour) || !Number.isFinite(EndHour) || EndHour <= StartHour) {
window.KefinTweaksToaster?.toast?.('End time must be after start time');
return;
}
finish({ DayOfWeek, StartHour, EndHour });
});
},
onClose: () => finish(null)
});
if (modal?.dialog) {
modal.dialog.style.maxWidth = '420px';
modal.dialog.style.width = '90vw';
}
});
}
function refreshTagLists(root, state) {
const allowedEl = root.querySelector('.um-allowed-tags');
const blockedEl = root.querySelector('.um-blocked-tags');
if (allowedEl) allowedEl.innerHTML = buildTagListHTML(state.allowedTags, 'allowed');
if (blockedEl) blockedEl.innerHTML = buildTagListHTML(state.blockedTags, 'blocked');
}
function refreshSchedules(root, state) {
const el = root.querySelector('.um-schedules');
if (el) el.innerHTML = buildScheduleListHTML(state.schedules);
}
function setCheckboxesChecked(root, selector, checked) {
root.querySelectorAll(selector).forEach((el) => {
el.checked = checked;
if (checked) el.setAttribute('checked', '');
else el.removeAttribute('checked');
});
}
function bindEditorInteractions(root, state) {
const bindAllLibrariesToggle = (chkSel, listSel, itemSel) => {
const chk = root.querySelector(chkSel);
const list = root.querySelector(listSel);
const sync = () => {
const allChecked = !!chk?.checked;
if (list) list.style.display = allChecked ? 'none' : '';
// When leaving "all", select every library by default
if (!allChecked) setCheckboxesChecked(root, itemSel, true);
};
chk?.addEventListener('change', sync);
// Initial visibility only — do not force-select on load (preserve saved template)
if (list) list.style.display = chk?.checked ? 'none' : '';
};
bindAllLibrariesToggle('.um-chk-all-folders', '.um-folder-list', '.um-folder');
bindAllLibrariesToggle('.um-chk-delete-all', '.um-delete-folders', '.um-delete-folder');
root.addEventListener('click', async (e) => {
if (e.target.closest('.um-add-allowed-tag')) {
const tag = await promptText('Allow tag', 'Enter a tag');
if (tag && !state.allowedTags.includes(tag)) {
state.allowedTags.push(tag);
refreshTagLists(root, state);
}
return;
}
if (e.target.closest('.um-add-blocked-tag')) {
const tag = await promptText('Block tag', 'Enter a tag');
if (tag && !state.blockedTags.includes(tag)) {
state.blockedTags.push(tag);
refreshTagLists(root, state);
}
return;
}
if (e.target.closest('.um-add-schedule')) {
const schedule = await promptSchedule();
if (schedule) {
state.schedules.push(schedule);
refreshSchedules(root, state);
}
return;
}
const delTag = e.target.closest('.um-delete-tag');
if (delTag) {
const list = delTag.dataset.list;
const index = parseInt(delTag.dataset.index, 10);
if (list === 'allowed') state.allowedTags.splice(index, 1);
if (list === 'blocked') state.blockedTags.splice(index, 1);
refreshTagLists(root, state);
return;
}
const delSched = e.target.closest('.um-delete-schedule');
if (delSched) {
const index = parseInt(delSched.dataset.index, 10);
state.schedules.splice(index, 1);
refreshSchedules(root, state);
}
});
}
function buildMainConfigHTML(config) {
const options = [`<option value="">None</option>`]
.concat(config.templates.map((t) =>
`<option value="${escapeHtml(t.name)}" ${config.defaultTemplateName === t.name ? 'selected' : ''}>${escapeHtml(t.name)}</option>`
)).join('');
const list = config.templates.length
? config.templates.map((t) => `
<div class="listItem um-template-row" data-name="${escapeHtml(t.name)}"
style="border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:0.65em 0.75em;margin-bottom:0.5em;display:flex;align-items:center;gap:0.75em;cursor:pointer;">
<div class="listItemBody" style="flex:1;min-width:0;">
<div class="listItemBodyText">${escapeHtml(t.name)}</div>
<div class="listItemBodyText secondary" style="font-size:0.85em;">
Modified ${escapeHtml(t.dateModified ? new Date(t.dateModified).toLocaleString() : '—')}
</div>
</div>
<button type="button" class="emby-button raised um-edit-template" data-name="${escapeHtml(t.name)}" title="Edit">
<span class="material-icons" style="font-size:1.1em;">edit</span>
</button>
<button type="button" class="emby-button raised um-delete-template" data-name="${escapeHtml(t.name)}" title="Delete">
<span class="material-icons" style="font-size:1.1em;">delete</span>
</button>
</div>`).join('')
: `<div class="listItemBodyText secondary">No templates yet. Create one to get started.</div>`;
return `
<div class="um-config-root">
<div class="listItemBodyText secondary" style="margin-bottom:1em;">
Create reusable user policy templates and apply them to new or existing accounts.
Import / Export user data will be added here later.
</div>
<div class="selectContainer" style="margin-bottom:1em;">
<label class="selectLabel" for="um-default-template">Default template for new users</label>
<select id="um-default-template" class="emby-select-withcolor emby-select">${options}</select>
<div class="fieldDescription">Used as the initial selection on the Add User page.</div>
</div>
<div style="margin-bottom:0.75em;">
<button type="button" class="emby-button raised button-submit" id="um-create-template">Create Template</button>
</div>
<div class="um-template-list">${list}</div>
</div>`;
}
async function persistTemplates(mutator) {
const config = loadConfig();
mutator(config);
const ok = await saveConfig(config);
if (!ok) {
alert('Error saving configuration. Ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
return ok;
}
async function openTemplateEditor(existingTemplate) {
const isEdit = !!(existingTemplate && existingTemplate.name);
const originalName = isEdit ? existingTemplate.name : null;
const folders = await fetchMediaFolders();
const state = {
allowedTags: (existingTemplate?.policy?.AllowedTags || []).slice(),
blockedTags: (existingTemplate?.policy?.BlockedTags || []).slice(),
schedules: clone(existingTemplate?.policy?.AccessSchedules || [])
};
const content = document.createElement('div');
content.innerHTML = buildEditorHTML(
existingTemplate || { name: '', policy: getSeedPolicy() },
folders
);
bindEditorInteractions(content, state);
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-editor-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-editor-save">Save Template</button>`;
window.ModalSystem.create({
id: EDITOR_MODAL_ID,
title: isEdit ? `Edit Template: ${existingTemplate.name}` : 'Create Template',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
if (window.innerWidth >= 900) {
modal.dialog.style.maxWidth = '90vw';
modal.dialog.style.width = '1400px';
modal.dialog.style.height = '90vh';
}
modal.dialogFooter.querySelector('#um-editor-cancel')?.addEventListener('click', () => {
window.ModalSystem.close(EDITOR_MODAL_ID);
});
modal.dialogFooter.querySelector('#um-editor-save')?.addEventListener('click', async () => {
const root = modal.dialogContent;
const name = root.querySelector('#um-template-name')?.value?.trim();
if (!name) {
window.KefinTweaksToaster?.toast?.('Template name is required');
return;
}
const policy = collectPolicyFromEditor(root, state);
const config = loadConfig();
if (!isEdit && config.templates.some((t) => t.name === name)) {
window.KefinTweaksToaster?.toast?.('A template with that name already exists');
return;
}
if (isEdit && name !== originalName && config.templates.some((t) => t.name === name)) {
window.KefinTweaksToaster?.toast?.('A template with that name already exists');
return;
}
const next = {
name,
dateModified: new Date().toISOString(),
policy
};
const ok = await persistTemplates((cfg) => {
if (isEdit) {
const idx = cfg.templates.findIndex((t) => t.name === originalName);
if (idx >= 0) cfg.templates[idx] = next;
else cfg.templates.push(next);
if (cfg.defaultTemplateName === originalName) cfg.defaultTemplateName = name;
} else {
cfg.templates.push(next);
}
});
if (ok) {
window.KefinTweaksToaster?.toast?.('Template saved');
window.ModalSystem.close(EDITOR_MODAL_ID);
refreshMainConfigListIfOpen();
}
});
}
});
}
function refreshMainConfigListIfOpen() {
const listRoot = document.querySelector('.um-config-root');
if (!listRoot) return;
const wrap = listRoot.parentElement;
if (!wrap) return;
wrap.innerHTML = buildMainConfigHTML(loadConfig());
bindMainConfigActions(wrap);
}
function bindMainConfigActions(root) {
if (!root) return;
const scope = root;
scope.querySelector('#um-create-template')?.addEventListener('click', () => {
openTemplateEditor(null);
});
scope.querySelector('#um-default-template')?.addEventListener('change', async (e) => {
const value = e.target.value || '';
await persistTemplates((config) => {
config.defaultTemplateName = value;
});
});
scope.addEventListener('click', async (e) => {
const del = e.target.closest('.um-delete-template');
if (del) {
e.stopPropagation();
const name = del.dataset.name;
if (!name || !confirm(`Delete template "${name}"?`)) return;
const ok = await persistTemplates((config) => {
config.templates = config.templates.filter((t) => t.name !== name);
if (config.defaultTemplateName === name) config.defaultTemplateName = '';
});
if (ok) refreshMainConfigListIfOpen();
return;
}
const editBtn = e.target.closest('.um-edit-template');
const row = e.target.closest('.um-template-row');
const target = editBtn || row;
if (target) {
e.stopPropagation();
const name = target.dataset.name;
const template = loadConfig().templates.find((t) => t.name === name);
if (template) openTemplateEditor(clone(template));
}
});
}
async function openConfigModal() {
try {
const config = loadConfig();
const content = document.createElement('div');
content.innerHTML = buildMainConfigHTML(config);
bindMainConfigActions(content);
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `<button class="emby-button raised" onclick="window.ModalSystem.close('${CONFIG_MODAL_ID}')">Close</button>`;
const modal = window.ModalSystem.create({
id: CONFIG_MODAL_ID,
title: 'User Manager',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
});
if (modal?.dialog && window.innerWidth >= 900) {
modal.dialog.style.maxWidth = '90vw';
modal.dialog.style.width = '1400px';
modal.dialog.style.height = '90vh';
}
LOG('User Manager config opened');
} catch (e) {
ERR('Error opening User Manager config', e);
alert('Error opening configuration: ' + e.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.userManager = {
openConfigModal,
openTemplateEditor,
loadConfig,
getSeedPolicy,
clonePolicy: clone
};
// Shared helpers for runtime script
window.KefinUserManagerConfig = {
loadConfig,
getSeedPolicy,
clonePolicy: clone,
openTemplateEditor,
openConfigModal
};
console.log('[KefinTweaks UserManager Config] Script loaded');
})();
@@ -0,0 +1,496 @@
// KefinTweaks Item Version Preferences Configuration UI
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks VersionPreferences Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks VersionPreferences Config]', ...args);
const MODAL_ID = 'kefin-versionpreferences-config';
const NONE_SENTINEL = 'None';
const DEFAULT_VERSIONS = ['4k', '2160p', '1080p', '720p', '576p', '480p', '360p', NONE_SENTINEL];
const DEFAULT_EDITIONS = ['Theatrical', "Director's Cut", 'Extended Edition', 'Fan Edit', NONE_SENTINEL];
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function isNoneSentinel(term) {
return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase();
}
function ensureNoneSentinel(list) {
const out = [];
let noneSeen = false;
(Array.isArray(list) ? list : []).forEach((term) => {
if (typeof term !== 'string' || !term.trim()) return;
if (isNoneSentinel(term)) {
if (!noneSeen) {
out.push(NONE_SENTINEL);
noneSeen = true;
}
return;
}
out.push(term.trim());
});
if (!noneSeen) out.push(NONE_SENTINEL);
return out;
}
function getDefaults() {
return {
versions: DEFAULT_VERSIONS.slice(),
editions: DEFAULT_EDITIONS.slice(),
showVersionBeforeEdition: false
};
}
async function loadConfig() {
const defaults = getDefaults();
const cfg = window.KefinTweaksConfig?.versionPreferences || {};
return {
versions: ensureNoneSentinel(
Array.isArray(cfg.versions) ? cfg.versions : defaults.versions
),
editions: ensureNoneSentinel(
Array.isArray(cfg.editions) ? cfg.editions : defaults.editions
),
showVersionBeforeEdition: cfg.showVersionBeforeEdition === true
};
}
async function saveConfig(versionPreferences) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
window.KefinTweaksConfig.versionPreferences = {
versions: ensureNoneSentinel(versionPreferences.versions),
editions: ensureNoneSentinel(versionPreferences.editions),
showVersionBeforeEdition: versionPreferences.showVersionBeforeEdition === true
};
if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
}
if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
function buildTermRowHTML(term, index, listKey) {
const isNone = isNoneSentinel(term);
const termCell = isNone
? `<span class="listItemBodyText kefin-vp-none-label" style="flex: 1; min-width: 0; font-weight: 600;"
data-list="${escapeHtml(listKey)}" data-index="${index}" data-term="${escapeHtml(NONE_SENTINEL)}">
${escapeHtml(NONE_SENTINEL)}
<span class="listItemBodyText secondary" style="font-weight: 400; font-size: 0.85em; margin-left: 0.5em;">(no match)</span>
</span>`
: `<input type="text" class="emby-input kefin-vp-term-input" value="${escapeHtml(term)}"
data-list="${escapeHtml(listKey)}" data-index="${index}"
style="flex: 1; min-width: 0;" aria-label="Term ${index + 1}" />`;
const removeBtn = isNone
? ''
: `<button type="button" class="emby-button raised kefin-vp-remove" data-list="${escapeHtml(listKey)}" data-index="${index}"
title="Remove" style="min-width: auto; padding: 0.35em 0.5em;">
<span class="material-icons" style="font-size: 1.1em; vertical-align: middle;">delete</span>
</button>`;
return `
<div class="listItem kefin-vp-term-row${isNone ? ' kefin-vp-none-row' : ''}"
data-list="${escapeHtml(listKey)}" data-index="${index}" data-is-none="${isNone ? 'true' : 'false'}"
style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.5em 0.65em; margin-bottom: 0.4em; display: flex; align-items: center; gap: 0.5em;${isNone ? ' background: rgba(255,255,255,0.04);' : ''}">
<span class="listItemBodyText secondary" style="min-width: 1.5em; text-align: right;">${index + 1}.</span>
${termCell}
<button type="button" class="emby-button raised kefin-vp-move-up" data-list="${escapeHtml(listKey)}" data-index="${index}"
title="Move up" style="min-width: auto; padding: 0.35em 0.5em;" ${index === 0 ? 'disabled' : ''}>
<span class="material-icons" style="font-size: 1.1em; vertical-align: middle;">arrow_upward</span>
</button>
<button type="button" class="emby-button raised kefin-vp-move-down" data-list="${escapeHtml(listKey)}" data-index="${index}"
title="Move down" style="min-width: auto; padding: 0.35em 0.5em;">
<span class="material-icons" style="font-size: 1.1em; vertical-align: middle;">arrow_downward</span>
</button>
${removeBtn}
</div>
`;
}
function buildListSectionHTML(title, hint, listKey, terms) {
return `
<div class="listItem kefin-vp-list-section" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin: 0; min-width: 0;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.35em; font-weight: 500;">${escapeHtml(title)}</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.5em; font-size: 0.9em;">${escapeHtml(hint)}</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.85em;">
None = sources with no matching terms in this list. It cannot be edited or deleted, but you can change its priority.
</div>
<div class="kefin-vp-term-list" data-list="${escapeHtml(listKey)}">
${terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('')}
</div>
<div style="display: flex; gap: 0.5em; margin-top: 0.65em; flex-wrap: wrap;">
<input type="text" class="emby-input kefin-vp-add-input" data-list="${escapeHtml(listKey)}"
placeholder="Add a term…" style="flex: 1; min-width: 140px;" />
<button type="button" class="emby-button raised button-submit kefin-vp-add-btn" data-list="${escapeHtml(listKey)}">
Add
</button>
</div>
</div>
</div>
`;
}
function ensureLayoutStyles() {
if (document.getElementById('kefin-vp-config-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-vp-config-styles';
style.textContent = `
.kefin-vp-lists-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
align-items: start;
height: calc(100% - 40px);
}
@media (max-width: 900px) {
.kefin-vp-lists-grid {
grid-template-columns: 1fr;
}
}
[data-name="kefin-modal-content"]:has(.kefin-vp-lists-grid) > div {
height: 100% !important;
}
[data-name="kefin-modal-content"]:has(.kefin-vp-lists-grid) > div > div {
height: 100%;
}
.listItem.kefin-vp-list-section {
height: calc(100% - 1.5em);
align-items: start;
}
.kefin-vp-label-order-card {
display: flex;
align-items: center;
gap: 1em;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 0.85em 1em;
margin-bottom: 1em;
}
.kefin-vp-label-order-text {
flex: 1;
min-width: 0;
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch {
position: relative;
width: 52px;
height: 28px;
border-radius: 14px;
border: none;
background: rgba(158, 158, 158, 0.5);
cursor: pointer;
flex-shrink: 0;
padding: 0;
transition: background-color 0.3s ease;
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"] {
background: rgba(0, 164, 220, 0.8);
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch::after {
content: "";
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
top: 3px;
left: 3px;
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"]::after {
left: calc(100% - 25px);
}
`;
document.head.appendChild(style);
}
function getLabelOrderDescription(showVersionBeforeEdition) {
return showVersionBeforeEdition
? 'Version will be listed before the edition (1080p - Theatrical)'
: 'Edition will be listed before the version (Theatrical - 1080p)';
}
function buildLabelOrderToggleHTML(showVersionBeforeEdition) {
const enabled = showVersionBeforeEdition === true;
return `
<div class="kefin-vp-label-order-card">
<input type="checkbox" id="kefin-vp-show-version-before-edition" style="display: none;"
${enabled ? 'checked' : ''} />
<div class="kefin-vp-label-order-text">
<div class="listItemBodyText" style="font-weight: 500;">List Version Before Edition</div>
<div class="listItemBodyText secondary kefin-vp-label-order-desc" style="margin-top: 0.35em; font-size: 0.9em;">
${escapeHtml(getLabelOrderDescription(enabled))}
</div>
</div>
<button type="button" class="kefin-vp-toggle-switch" data-checkbox-id="kefin-vp-show-version-before-edition"
data-enabled="${enabled}" aria-pressed="${enabled}"></button>
</div>
`;
}
function updateLabelOrderToggleUI(root, checked) {
const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
const toggleBtn = root.querySelector('.kefin-vp-toggle-switch');
const desc = root.querySelector('.kefin-vp-label-order-desc');
if (checkbox) checkbox.checked = checked === true;
if (toggleBtn) {
toggleBtn.dataset.enabled = checked === true;
toggleBtn.setAttribute('aria-pressed', String(checked === true));
}
if (desc) desc.textContent = getLabelOrderDescription(checked === true);
}
function buildConfigHTML(config) {
return `
<div style="max-width: 100%;">
<div class="listItemBodyText secondary" style="margin-bottom: 1em;">
Manage how different versions and editions of your items are displayed.
Terms are matched as case-insensitive substrings of the media source label.
Higher list positions are prioritized first.
</div>
${buildLabelOrderToggleHTML(config.showVersionBeforeEdition)}
<div class="kefin-vp-lists-grid">
${buildListSectionHTML(
'Version Terms',
'Resolution / quality labels (e.g. 4k, 1080p). Order is priority.',
'versions',
config.versions
)}
${buildListSectionHTML(
'Edition Terms',
'Edition labels (e.g. Theatrical, Director\'s Cut). Order is priority.',
'editions',
config.editions
)}
</div>
</div>
`;
}
function readTermsFromDom(root, listKey) {
const rows = root.querySelectorAll(`.kefin-vp-term-row[data-list="${listKey}"]`);
const terms = [];
rows.forEach((row) => {
if (row.dataset.isNone === 'true') {
terms.push(NONE_SENTINEL);
return;
}
const input = row.querySelector('.kefin-vp-term-input');
const value = input?.value?.trim();
if (value) terms.push(value);
});
return ensureNoneSentinel(terms);
}
function syncStateFromDom(root, state) {
state.versions = readTermsFromDom(root, 'versions');
state.editions = readTermsFromDom(root, 'editions');
const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
state.showVersionBeforeEdition = checkbox?.checked === true;
}
function refreshList(root, state, listKey) {
const listEl = root.querySelector(`.kefin-vp-term-list[data-list="${listKey}"]`);
if (!listEl) return;
state[listKey] = ensureNoneSentinel(state[listKey] || []);
const terms = state[listKey];
listEl.innerHTML = terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('');
const rows = listEl.querySelectorAll('.kefin-vp-term-row');
rows.forEach((row, i) => {
const down = row.querySelector('.kefin-vp-move-down');
if (down) down.disabled = i === rows.length - 1;
const up = row.querySelector('.kefin-vp-move-up');
if (up) up.disabled = i === 0;
});
}
function collectConfig(root) {
return {
versions: readTermsFromDom(root, 'versions'),
editions: readTermsFromDom(root, 'editions'),
showVersionBeforeEdition: root.querySelector('#kefin-vp-show-version-before-edition')?.checked === true
};
}
function bindLabelOrderToggle(root, state) {
root.addEventListener('click', (e) => {
const toggleBtn = e.target.closest('.kefin-vp-toggle-switch');
if (!toggleBtn || !root.contains(toggleBtn)) return;
e.preventDefault();
const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
if (!checkbox) return;
checkbox.checked = !checkbox.checked;
state.showVersionBeforeEdition = checkbox.checked;
updateLabelOrderToggleUI(root, checkbox.checked);
});
}
function bindListActions(root, state) {
root.addEventListener('click', (e) => {
const addBtn = e.target.closest('.kefin-vp-add-btn');
if (addBtn) {
const listKey = addBtn.dataset.list;
const input = root.querySelector(`.kefin-vp-add-input[data-list="${listKey}"]`);
const term = input?.value?.trim();
if (!term) return;
if (isNoneSentinel(term)) {
window.KefinTweaksToaster?.toast?.('"None" is a built-in priority slot and cannot be added as a term.');
if (input) input.value = '';
return;
}
syncStateFromDom(root, state);
state[listKey].push(term);
state[listKey] = ensureNoneSentinel(state[listKey]);
if (input) input.value = '';
refreshList(root, state, listKey);
return;
}
const removeBtn = e.target.closest('.kefin-vp-remove');
if (removeBtn) {
const listKey = removeBtn.dataset.list;
const index = parseInt(removeBtn.dataset.index, 10);
syncStateFromDom(root, state);
if (Number.isNaN(index)) return;
if (isNoneSentinel(state[listKey][index])) return;
state[listKey].splice(index, 1);
state[listKey] = ensureNoneSentinel(state[listKey]);
refreshList(root, state, listKey);
return;
}
const upBtn = e.target.closest('.kefin-vp-move-up');
if (upBtn) {
const listKey = upBtn.dataset.list;
const index = parseInt(upBtn.dataset.index, 10);
syncStateFromDom(root, state);
if (Number.isNaN(index) || index <= 0) return;
const list = state[listKey];
[list[index - 1], list[index]] = [list[index], list[index - 1]];
refreshList(root, state, listKey);
return;
}
const downBtn = e.target.closest('.kefin-vp-move-down');
if (downBtn) {
const listKey = downBtn.dataset.list;
const index = parseInt(downBtn.dataset.index, 10);
syncStateFromDom(root, state);
if (Number.isNaN(index) || index >= state[listKey].length - 1) return;
const list = state[listKey];
[list[index], list[index + 1]] = [list[index + 1], list[index]];
refreshList(root, state, listKey);
}
});
root.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const addInput = e.target.closest('.kefin-vp-add-input');
if (!addInput) return;
e.preventDefault();
const listKey = addInput.dataset.list;
const btn = root.querySelector(`.kefin-vp-add-btn[data-list="${listKey}"]`);
btn?.click();
});
}
async function openConfigModal() {
try {
ensureLayoutStyles();
const config = await loadConfig();
const state = {
versions: config.versions.slice(),
editions: config.editions.slice(),
showVersionBeforeEdition: config.showVersionBeforeEdition === true
};
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(state);
bindLabelOrderToggle(content, state);
bindListActions(content, state);
refreshList(content, state, 'versions');
refreshList(content, state, 'editions');
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-versionpreferences-config-btn">Save</button>
`;
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Item Version Preferences',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
const saveBtn = modal.dialogFooter.querySelector('#save-versionpreferences-config-btn');
saveBtn?.addEventListener('click', async () => {
const root = modal.dialogContent || content;
syncStateFromDom(root, state);
const toSave = collectConfig(root);
const success = await saveConfig(toSave);
if (success) {
window.KefinTweaksToaster?.toast?.('Configuration saved!');
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
});
if (window.innerWidth >= 900) {
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.height = '90vh';
}
LOG('Version Preferences configuration modal opened');
} catch (error) {
ERR('Error opening version preferences config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.versionPreferences = { openConfigModal };
console.log('[KefinTweaks VersionPreferences Config] Script loaded');
})();
@@ -0,0 +1,131 @@
// KefinTweaks Watch Together Configuration UI
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks WatchTogether Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks WatchTogether Config]', ...args);
const MODAL_ID = 'kefin-watchtogether-config';
const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.watchTogether || {};
}
async function saveConfig(watchTogetherConfig) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
window.KefinTweaksConfig.watchTogether = watchTogetherConfig;
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
}
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
function buildConfigHTML(config) {
const timeout = Number.isFinite(config.sessionTimeout * 1)
? config.sessionTimeout * 1
: DEFAULT_SESSION_TIMEOUT_MINUTES;
return `
<div style="max-width: 640px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Session timeout (minutes)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
After selecting who is watching, skip the Watch Together prompt for this many minutes
when starting another item on this device. Playback that continues on the video OSD
(autoplay) never re-prompts.
</div>
<div class="hsae-field-wrap">
<input type="number" id="wt-session-timeout" class="fld emby-input"
min="1" step="1" value="${timeout}">
</div>
</div>
</div>
</div>
`;
}
function collectConfig() {
const raw = parseInt(document.querySelector('#wt-session-timeout')?.value || String(DEFAULT_SESSION_TIMEOUT_MINUTES), 10);
const sessionTimeout = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_TIMEOUT_MINUTES;
return { sessionTimeout };
}
async function openConfigModal() {
try {
const config = await loadConfig();
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-watchtogether-config-btn">Save</button>
`;
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Watch Together',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
const saveBtn = modal.dialogFooter.querySelector('#save-watchtogether-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const next = collectConfig();
const success = await saveConfig(next);
if (success) {
window.KefinTweaksToaster?.toast?.('Watch Together configuration saved!');
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
if (modalInstance?.dialog) {
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '560px';
}
LOG('Watch Together configuration modal opened');
} catch (error) {
ERR('Error opening Watch Together config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.watchTogether = { openConfigModal };
console.log('[KefinTweaks WatchTogether Config] Script loaded');
})();
+92
View File
@@ -79,10 +79,102 @@
OfficialRatings: filters?.OfficialRatings || [],
Years: filters?.Years || []
};
},
/**
* Get watched or in progress movies from Jellyfin API
* @returns {Promise<Array>} - Array of movies
*/
getWatchedOrInProgressMovies: async function(options = {}) {
const { watchedMoviesResult, inProgressMoviesResult } = await Promise.all([this.getWatchedMovies(options), this.getInProgressMovies(options)]);
if (options.useCache) {
const watchedMovies = watchedMoviesResult.data.Items;
const inProgressMovies = inProgressMoviesResult.data.Items;
let isStale = Promise.all([watchedMoviesResult.isStalePromise, inProgressMoviesResult.isStalePromise]).then(([isWatchedStale, isInProgressStale]) => {
return isStale = isWatchedStale || isInProgressStale ? true : false;
});
let dataPromise = Promise.all([watchedMoviesResult.dataPromise, inProgressMoviesResult.dataPromise]).then(([watchedMoviesData, inProgressMoviesData]) => {
return {
data: {
Items: [...watchedMoviesData.Items, ...inProgressMoviesData.Items]
}
};
});
return {
data: {
Items: [...watchedMovies, ...inProgressMovies]
},
isStalePromise: isStalePromise,
dataPromise: dataPromise
};
}
// Otherwise, use the fetched data arrays directly
return {
Items: [...watchedMovies.Items, ...inProgressMovies.Items]
};
},
/**
* Get watched movies from Jellyfin API
* @param {Object} options - Options
* @param {boolean} options.useCache - Whether to use cache (default: false)
* @returns {Promise<Array>|Array} - Array of movies
*/
getWatchedMovies: async function(options = {}) {
// default TTL is 1 day
const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options;
const serverUrl = ApiClient.serverAddress();
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
// Use apiHelper.getQuery to get the data
if (!window.apiHelper) {
ERR('apiHelper is not available');
return [];
}
return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl });
},
getInProgressMovies: async function(options = {}) {
// default TTL is 1 day
const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options;
const serverUrl = ApiClient.serverAddress();
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsResumable&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
if (!window.apiHelper) {
ERR('apiHelper is not available');
return [];
}
return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl });
},
getCachedLibraries: function() {
return __libraries?.Items ? __libraries.Items : null;
}
};
async function initialize() {
ensureApiClient();
if (__libraries) {
return;
}
const libraries = await ApiClient.getItems();
__libraries = libraries;
}
let __libraries = null;
initialize();
// Expose dataHelper to global window object
window.dataHelper = dataHelper;
+65 -32
View File
@@ -10,30 +10,28 @@
// Track initialization state
let isInitialized = false;
let retries = 0;
const maxRetries = 10;
let pollingIntervalId = null;
const POLL_INTERVAL_MS = 1500;
/**
* Initialize WebSocket monitoring by hooking into ApiClient's WebSocket
* Returns the ApiClient WebSocket if available (e.g. after user has logged in).
*/
function getSocket() {
if (!window.ApiClient) return null;
return window.ApiClient.webSocket || window.ApiClient._webSocket || null;
}
/**
* Initialize WebSocket monitoring by hooking into ApiClient's WebSocket.
* Attempts once; returns true if hooked, false if socket not yet available.
*/
function initialize() {
if (isInitialized) return true;
try {
// Grab the actual socket
const socket = (window.ApiClient && (window.ApiClient.webSocket || window.ApiClient._webSocket)) || null;
if (!socket) {
if (retries >= maxRetries) {
ERR('Max retries reached, giving up on WebSocket initialization');
return false;
}
retries++;
setTimeout(() => {
if (!isInitialized) {
initialize();
}
}, 1000);
return;
}
const socket = getSocket();
if (!socket) return false;
// Store original handler if it exists
const originalHandler = socket.onmessage;
@@ -78,6 +76,28 @@
}
}
/**
* Poll until ApiClient WebSocket is available, then initialize once and stop.
* Safe to call multiple times; only one polling loop runs.
*/
function startPolling() {
if (isInitialized) return;
if (pollingIntervalId != null) return; // already polling
if (initialize()) return;
pollingIntervalId = setInterval(() => {
if (isInitialized) {
clearInterval(pollingIntervalId);
pollingIntervalId = null;
return;
}
if (initialize()) {
clearInterval(pollingIntervalId);
pollingIntervalId = null;
}
}, POLL_INTERVAL_MS);
}
/**
* Register a callback to listen for a specific WebSocket message type
* @param {string} messageType - The MessageType to listen for (e.g., 'UserDataChanged', 'PlaybackStopped')
@@ -98,31 +118,44 @@
if (!listeners[messageType]) {
listeners[messageType] = [];
}
// Avoid duplicate registrations of the same callback
if (listeners[messageType].includes(callback)) {
LOG(`Listener already registered for message type: ${messageType}`);
if (!isInitialized) {
startPolling();
}
return;
}
// Add the callback
listeners[messageType].push(callback);
LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total listeners)`);
// Ensure WebSocket is initialized
// Ensure we're polling for WebSocket if not yet initialized
if (!isInitialized) {
initialize();
startPolling();
}
}
/**
* Remove a callback registered with listen().
* @param {string} messageType
* @param {Function} callback
*/
function unlisten(messageType, callback) {
if (!listeners[messageType]) return;
listeners[messageType] = listeners[messageType].filter((cb) => cb !== callback);
LOG(`Unregistered listener for message type: ${messageType} (${listeners[messageType].length} remaining)`);
}
// Expose the API globally
window.websocketHelper = {
listen: listen
listen: listen,
unlisten: unlisten
};
// Try to initialize immediately
if (!initialize()) {
// If WebSocket isn't available yet, retry after a short delay
// This can happen if the page loads before the WebSocket connection is established
setTimeout(() => {
if (!isInitialized) {
initialize();
}
}, 1000);
}
// Start polling until ApiClient WebSocket is available (e.g. after user logs in)
startPolling();
})();
+404 -13
View File
@@ -8,6 +8,87 @@
console.log('[KefinTweaks Injector] Initializing...');
// Cache for resolved root URL (to avoid multiple API calls)
let resolvedRootCache = null;
// Cached Jellyfin major (10 / 11 / 12 …); null until resolved or if unavailable
let cachedJellyfinMajorVersion = null;
function getMajorServerVersion(version) {
if (!version || typeof version !== 'string') return null;
const versionParts = version.split('.');
if (versionParts[0] !== '10') {
const majorVersion = parseInt(versionParts[0], 10);
return Number.isNaN(majorVersion) ? null : majorVersion;
}
if (versionParts.length >= 2) {
const majorVersion = parseInt(versionParts[1], 10);
if (!Number.isNaN(majorVersion)) {
return majorVersion;
}
}
return null;
}
/**
* Resolve Jellyfin major version (e.g. 10 for 10.10.x, 11 for 10.11.x, 12 for 10.12.x).
* Mirrors skinManager detection (Jellyfin Web app version, else server version with brief poll).
* @returns {Promise<number|null>}
*/
async function getCurrentMajorServerVersion() {
try {
if (cachedJellyfinMajorVersion !== null) {
return cachedJellyfinMajorVersion;
}
// Brief wait — injector init can run before ApiClient version fields exist
if (!window.ApiClient?._appVersion && !window.ApiClient?._serverVersion) {
const startTime = Date.now();
while (Date.now() - startTime < 5000) {
if (window.ApiClient?._appVersion || window.ApiClient?._serverVersion) {
break;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
}
if (!window.ApiClient) {
return null;
}
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
cachedJellyfinMajorVersion = getMajorServerVersion(window.ApiClient._appVersion);
return cachedJellyfinMajorVersion;
}
if (!window.ApiClient._serverVersion) {
const startTime = Date.now();
while (Date.now() - startTime < 10000) {
if (window.ApiClient._serverVersion) {
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
cachedJellyfinMajorVersion = getMajorServerVersion(window.ApiClient._serverVersion);
return cachedJellyfinMajorVersion;
} catch (error) {
console.warn('[KefinTweaks Injector] Error getting server version:', error);
return null;
}
}
function isScriptCompatible(scriptDef, majorVersion) {
if (!scriptDef?.versions || !scriptDef.versions.length) return true;
if (majorVersion == null) return false;
return scriptDef.versions.includes(majorVersion);
}
function syncCachedMajorOnApi(majorVersion) {
if (window.KefinTweaks) {
window.KefinTweaks._jellyfinMajorVersion = majorVersion;
}
}
/**
* Extracts version string from root URL
@@ -68,6 +149,7 @@
search: true, // Enhanced search functionality
headerTabs: true, // Header tab enhancements
customMenuLinks: true, // Custom menu links functionality
hamburgerMenu: false, // Desktop v12 hamburger / left drawer
updoot: false, // Upvote functionality
backdropLeakFix: true, // Memory leak fixes
dashboardButtonFix: true, // Dashboard button fix
@@ -78,9 +160,13 @@
itemDetailsCollections: true, // Add related collections to item details pages
flattenSingleSeasonShows: true, // Flatten series with only 1 season to show episodes directly
seriesInfo: true, // Add series and season information to details pages
versionPreferences: true, // Reorder/sanitize media source versions on details pages
collections: true, // Collection sorting functionality
skinManager: true, // Skin selection and management
thumbnailScrubber: false, // Trickplay thumbnail scrub on hover (bottom 20px of video cards)
watchTogether: false, // Sync playstate to Watch Together group accounts on this device
userManager: false, // User templates for new/existing accounts (admin dashboard)
games: true, // Sidebar Games link → pages/games.html hub
// Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal)
// are automatically enabled when needed by other scripts
@@ -88,6 +174,18 @@
// Merge user config on top of defaults (allows users to override defaults)
...window.KefinTweaksConfig?.scripts
};
// Third-party libraries. Relative paths load from {kefinTweaksRoot}/scripts/; http(s) URLs use the CDN loaders.
const THIRD_PARTY_SCRIPTS = {
pickr: {
js: [
'thirdparty/pickr/pickr.min.js'
],
css: [
'thirdparty/pickr/pickr-nano.min.css'
]
}
};
// Script definitions with dependencies and metadata
const SCRIPT_DEFINITIONS = [
@@ -140,11 +238,69 @@
dependencies: [],
description: 'Home screen migration for KefinTweaks'
},
{
name: 'homeScreen-user-configuration',
script: '../configuration/homeScreen-user-configuration.js',
css: null,
dependencies: ['ui', 'utils'],
description: 'User home screen section preferences and bulk order editor'
},
{
name: 'homeScreenSectionConfigure',
script: 'homeScreenSectionConfigure.js',
css: null,
dependencies: ['homeScreen-user-configuration', 'modal', 'ui', 'cardBuilder'],
thirdParty: ['pickr'],
description: 'Inline configure popover for user home screen sections'
},
{
name: 'homeScreenPin',
script: 'homeScreenPin.js',
css: null,
dependencies: ['homeScreen-user-configuration', 'modal', 'utils'],
description: 'Pin items to the home screen from context menus'
},
{
name: 'homeScreenCustomItemsEditor',
script: '../configuration/homeScreenCustomItemsEditor.js',
css: null,
dependencies: [],
description: 'Shared custom items editor (wizard + advanced)'
},
{
name: 'homeScreenSectionEditor',
script: '../configuration/homeScreenSectionEditor.js',
css: '../configuration/homeScreenSectionEditor.css',
dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenAdvancedEditor'],
description: 'Home screen section editor (wizard + advanced)'
},
{
name: 'homeScreenEditorProfiles',
script: '../configuration/homeScreenEditorProfiles.js',
css: null,
dependencies: [],
description: 'Editor profile registry for home screen section editor'
},
{
name: 'homeScreenAdvancedEditor',
script: '../configuration/homeScreenAdvancedEditor.js',
css: '../configuration/homeScreenAdvancedEditor.css',
dependencies: ['ui', 'homeScreenCustomItemsEditor', 'homeScreenEditorProfiles'],
thirdParty: ['pickr'],
description: 'Advanced section editor UI for custom sections'
},
{
name: 'homescreenBenchmark',
script: '../configuration/homescreenBenchmark.js',
css: null,
dependencies: ['apiHelper'],
description: 'Home screen section query benchmarks for admin config'
},
{
name: 'homeScreen-configuration',
script: '../configuration/homeScreen-configuration.js',
css: '../configuration/homeScreen-configuration.css',
dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui'],
dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenSectionEditor', 'homescreenBenchmark'],
description: 'Home screen configuration for KefinTweaks'
},
{
@@ -168,6 +324,13 @@
dependencies: ['modal', 'toaster', 'utils'],
description: 'Series info configuration for KefinTweaks'
},
{
name: 'versionPreferences-configuration',
script: '../configuration/versionPreferences-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Item version preferences configuration for KefinTweaks'
},
{
name: 'skinManager-configuration',
script: '../configuration/skinManager-configuration.js',
@@ -274,6 +437,34 @@
dependencies: [],
description: 'WebSocket helper functions for Jellyfin operations'
},
{
name: 'watchTogether-configuration',
script: '../configuration/watchTogether-configuration.js',
css: null,
dependencies: ['modal', 'ui'],
description: 'Configuration UI for Watch Together'
},
{
name: 'watchTogether',
script: 'watchTogether.js',
css: null,
dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'websocketHelper', 'ui', 'watchTogether-configuration'],
description: 'Watch Together: sync watched/in-progress state to selected group accounts'
},
{
name: 'userManager-configuration',
script: '../configuration/userManager-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils', 'apiHelper', 'ui'],
description: 'Configuration UI for User Manager templates'
},
{
name: 'userManager',
script: 'userManager.js',
css: 'userManager.css',
dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'toaster', 'userManager-configuration'],
description: 'User Manager: apply policy templates on new user creation and bulk-edit existing users'
},
{
name: 'toaster',
script: 'toaster.js',
@@ -292,7 +483,7 @@
name: 'homeScreen',
script: 'homeScreen3.js',
css: 'homeScreen.css',
dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'homeScreen-migration', 'homeScreen-user-configuration'],
dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'],
priority: true, // Load immediately after dependencies to reduce UI disruption
description: 'Adds custom home screen sections'
},
@@ -317,11 +508,27 @@
dependencies: ['utils'],
description: 'Load and add custom menu links from configuration'
},
{
name: 'hamburgerMenu',
script: 'hamburgerMenu.js',
css: null,
dependencies: ['utils'],
versions: [12],
description: 'Desktop hamburger button that opens a v12-style left navigation drawer'
},
{
name: 'games',
script: 'games.js',
css: null,
dependencies: ['utils'],
description: 'Adds a Games sidebar link to the games hub page'
},
{
name: 'backdropLeakFix',
script: 'backdropLeakFix.js',
css: null,
dependencies: [],
versions: [10, 11],
description: 'Fixes issue that causes backdrop images to be continuously added to the page if the tab isn\'t focused.'
},
{
@@ -336,6 +543,7 @@
script: 'dashboardButtonFix.js',
css: null,
dependencies: [],
versions: [10, 11],
description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to'
},
{
@@ -392,6 +600,7 @@
script: 'itemDetailsCollections.js',
css: null,
dependencies: ['indexedDBCache', 'utils', 'cardBuilder'],
versions: [10, 11],
description: 'Adds related collections to item details pages showing which collections contain the current item'
},
{
@@ -408,6 +617,13 @@
dependencies: ['utils'],
description: 'Adds series and season information (seasons count, episodes count, end time) to details pages'
},
{
name: 'versionPreferences',
script: 'versionPreferences.js',
css: null,
dependencies: ['utils'],
description: 'Reorders and sanitizes media source version/edition options on item details pages'
},
{
name: 'collections',
script: 'collections.js',
@@ -450,7 +666,7 @@
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
/* style.textContent = `
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::after {
content: 'KefinTweaks ${versionDisplay}';
font-style: italic;
@@ -461,7 +677,7 @@
display: block;
}
`;
document.head.appendChild(style);
document.head.appendChild(style); */
}
// Auto-enable dependencies for enabled scripts (iteratively to handle transitive dependencies)
@@ -699,6 +915,118 @@
document.head.appendChild(script);
});
}
/**
* Load an absolute-URL stylesheet (CDN / third-party). Dedupes via DOM.
* @param {string} url
*/
async function loadExternalCSS(url) {
return new Promise((resolve, reject) => {
if (!url) {
resolve();
return;
}
const existingLink = document.querySelector(`link[href="${url}"]`);
if (existingLink) {
console.log(`[KefinTweaks Injector] External CSS already loaded: ${url}`);
resolve();
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = url;
link.onload = () => {
console.log(`[KefinTweaks Injector] External CSS loaded: ${url}`);
resolve();
};
link.onerror = () => {
console.warn(`[KefinTweaks Injector] Failed to load external CSS: ${url}`);
reject(new Error(`Failed to load external CSS: ${url}`));
};
document.head.appendChild(link);
});
}
/**
* Load an absolute-URL script (CDN / third-party). Dedupes via DOM.
* @param {string} url
*/
async function loadExternalScript(url) {
return new Promise((resolve, reject) => {
if (!url) {
resolve();
return;
}
const existingScript = document.querySelector(`script[src="${url}"]`);
if (existingScript) {
console.log(`[KefinTweaks Injector] External script already loaded: ${url}`);
resolve();
return;
}
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = () => {
console.log(`[KefinTweaks Injector] External script loaded: ${url}`);
resolve();
};
script.onerror = () => {
console.error(`[KefinTweaks Injector] Failed to load external script: ${url}`);
reject(new Error(`Failed to load external script: ${url}`));
};
document.head.appendChild(script);
});
}
function isAbsoluteUrl(url) {
return /^https?:\/\//i.test(url);
}
/**
* Load a named third-party library from THIRD_PARTY_SCRIPTS (css then js).
* Relative paths load from the script root; http(s) URLs use the external loaders.
* @param {string} name
*/
async function loadThirdParty(name) {
const def = THIRD_PARTY_SCRIPTS[name];
if (!def) {
console.warn(`[KefinTweaks Injector] Unknown thirdParty '${name}' — skipping`);
return;
}
const cssUrls = Array.isArray(def.css) ? def.css : [];
const jsUrls = Array.isArray(def.js) ? def.js : [];
for (const url of cssUrls) {
try {
if (isAbsoluteUrl(url)) {
await loadExternalCSS(url);
} else {
await loadCSS(url);
}
} catch (error) {
console.warn(`[KefinTweaks Injector] Third-party CSS failed for '${name}':`, error);
}
}
for (const url of jsUrls) {
try {
if (isAbsoluteUrl(url)) {
await loadExternalScript(url);
} else {
await loadScript(url);
}
} catch (error) {
console.warn(`[KefinTweaks Injector] Third-party script failed for '${name}':`, error);
}
}
}
// Recursively collect all dependencies for a given script
function collectAllDependencies(scriptDef, collected = new Set(), visited = new Set()) {
@@ -737,6 +1065,10 @@
if (!scriptRoot) {
throw new Error('kefinTweaksRoot is not configured');
}
for (const thirdPartyName of scriptDef.thirdParty || []) {
await loadThirdParty(thirdPartyName);
}
// Check if already loaded (match by root and filename, ignore suffix)
const isAlreadyLoaded = document.querySelector(`script[src*="${scriptRoot}${scriptDef.script}"]`);
@@ -757,7 +1089,7 @@
}
async function loadConfigurationJS() {
const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'apiHelper'];
const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'userManager-configuration', 'apiHelper'];
for (const depName of configDependencyNames) {
const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName);
if (depScript) {
@@ -784,6 +1116,11 @@
return;
}
// Resolve Jellyfin major for version-gated scripts (also used by configuration UI)
const majorVersion = await getCurrentMajorServerVersion();
syncCachedMajorOnApi(majorVersion);
console.log(`[KefinTweaks Injector] Jellyfin major version: ${majorVersion ?? 'unknown'}`);
// Check if KefinTweaks is enabled in config
const isKefinTweaksEnabled = window.KefinTweaksConfig?.enabled !== false;
@@ -806,8 +1143,10 @@
return;
}
// Get enabled scripts
const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]);
// Get enabled scripts compatible with this Jellyfin major
const enabledScripts = SCRIPT_DEFINITIONS.filter(script =>
ENABLED_SCRIPTS[script.name] && isScriptCompatible(script, majorVersion)
);
// Step 1: Collect all dependencies from all enabled scripts
const allDependencyNames = new Set();
@@ -887,11 +1226,25 @@
// Utility functions for debugging and configuration
window.KefinTweaks = {
_jellyfinMajorVersion: null,
// Get current configuration
getConfig: () => ({ ...ENABLED_SCRIPTS }),
// Get script definitions
getScripts: () => [...SCRIPT_DEFINITIONS],
getMajorServerVersion,
getCurrentMajorServerVersion,
getJellyfinMajorVersion: () => cachedJellyfinMajorVersion,
isScriptCompatible: (scriptNameOrDef) => {
const scriptDef = typeof scriptNameOrDef === 'string'
? SCRIPT_DEFINITIONS.find(s => s.name === scriptNameOrDef)
: scriptNameOrDef;
if (!scriptDef) return true;
return isScriptCompatible(scriptDef, cachedJellyfinMajorVersion);
},
// Check if a script is loaded
isScriptLoaded: (scriptName) => {
@@ -918,6 +1271,12 @@
if (!ENABLED_SCRIPTS[scriptName]) {
throw new Error(`Script '${scriptName}' is disabled in configuration`);
}
const major = await getCurrentMajorServerVersion();
syncCachedMajorOnApi(major);
if (!isScriptCompatible(scriptDef, major)) {
throw new Error(`Script '${scriptName}' is not compatible with this Jellyfin version`);
}
await loadScriptWithDependencies(scriptDef);
}
@@ -976,7 +1335,7 @@
const response = await fetch(`${server}/Plugins`, {
headers: {
'X-Emby-Token': token
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -1007,7 +1366,7 @@
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
headers: {
'X-Emby-Token': token
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -1035,7 +1394,7 @@
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Authorization': window.apiHelper.getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
@@ -1054,6 +1413,7 @@
// Ensure Watchlist tab exists in CustomTabs
// Kept unused for now — Watchlist is hosted via addCustomPage('#/watchlist').
async function ensureWatchlistTab() {
try {
if (!window.KefinTweaksConfig) {
@@ -1093,7 +1453,7 @@
const response = await fetch(`${server}/CustomTabs/Config`, {
headers: {
'X-Emby-Token': token
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -1130,6 +1490,37 @@
}
}
/**
* Remove the Watchlist Custom Tabs entry (exact ContentHtml match).
* Watchlist is now hosted via addCustomPage('#/watchlist').
*/
async function removeWatchlistFromCustomTabPlugin() {
try {
const pluginId = await findPlugin('Custom Tabs');
if (!pluginId) {
console.warn('[KefinTweaks Startup] CustomTabs plugin not found; nothing to remove');
return false;
}
const config = await getPluginConfig(pluginId);
const tabs = Array.isArray(config?.Tabs) ? config.Tabs : [];
const watchlistHtml = '<div class="sections watchlist"></div>';
const filtered = tabs.filter((tab) => tab.ContentHtml !== watchlistHtml);
if (filtered.length === tabs.length) {
console.log('[KefinTweaks Startup] No exact Watchlist Custom Tab to remove');
return true;
}
await savePluginConfig(pluginId, { Tabs: filtered });
console.log('[KefinTweaks Startup] Removed Watchlist tab from CustomTabs');
return true;
} catch (error) {
console.error('[KefinTweaks Startup] Error removing Watchlist Custom Tab:', error);
return false;
}
}
// Startup task - runs only for admin users
async function startupTask() {
console.log('[KefinTweaks Startup] Starting startup task...');
@@ -1149,8 +1540,8 @@
}
try {
// Task: Ensure Watchlist tab exists in CustomTabs
await ensureWatchlistTab();
// Fire-and-forget: remove leftover Watchlist Custom Tab (best-effort)
//removeWatchlistFromCustomTabPlugin();
console.log('[KefinTweaks Startup] Startup task completed successfully');
} catch (error) {
+37 -374
View File
@@ -1,15 +1,20 @@
window.KefinTweaksDefaultConfig = {
"kefinTweaksRoot": "",
"enabled": true,
"scripts": {
"watchlist": true,
"homeScreen": true,
"search": true,
"watchlist": true,
"headerTabs": true,
"customMenuLinks": true,
"hamburgerMenu": false,
"games": true,
"breadcrumbs": true,
"playlist": true,
"itemDetailsCollections": true,
"flattenSingleSeasonShows": true,
"seriesInfo": true,
"versionPreferences": true,
"collections": true,
"exclusiveElsewhere": false,
"backdropLeakFix": true,
@@ -17,379 +22,10 @@ window.KefinTweaksDefaultConfig = {
"infiniteScroll": true,
"removeContinue": true,
"subtitleSearch": true,
"skinManager": true
},
"homeScreen": {
"defaultItemLimit": 16,
"defaultSortOrder": "Random",
"defaultCardFormat": "Poster",
"recentlyAddedInLibrary": {},
"recentlyReleased": {
"enabled": true,
"order": 30,
"movies": {
"name": "Recently Released Movies",
"enabled": true,
"itemLimit": 16,
"sortOrder": "ReleaseDate",
"sortOrderDirection": "Descending",
"cardFormat": "Poster",
"order": 60,
"minAgeInDays": null,
"maxAgeInDays": null
},
"episodes": {
"name": "Recently Aired Episodes",
"enabled": true,
"itemLimit": 16,
"sortOrder": "PremiereDate",
"sortOrderDirection": "Descending",
"cardFormat": "Backdrop",
"order": 61,
"minAgeInDays": null,
"maxAgeInDays": null
}
},
"trending": {
"name": "Trending",
"enabled": false,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 32
},
"popularTVNetworks": {
"name": "Popular TV Networks",
"enabled": true,
"minimumShowsForNetwork": 5,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Thumb",
"order": 72
},
"watchlist": {
"name": "Watchlist",
"enabled": true,
"itemLimit": 16,
"sortOrder": "DateAdded",
"sortOrderDirection": "Descending",
"cardFormat": "Poster",
"order": 71
},
"watchAgain": {
"name": "Watch Again",
"enabled": true,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Random",
"order": 73
},
"upcoming": {
"name": "Upcoming",
"enabled": true,
"itemLimit": 48,
"cardFormat": "Thumb",
"order": 62
},
"imdbTop250": {
"name": "IMDb Top 250",
"enabled": true,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 70
},
"seasonal": {
"enabled": false,
"enableSeasonalAnimations": false,
"enableSeasonalBackground": false,
"defaultItemLimit": 16,
"defaultSortOrder": "Random",
"defaultCardFormat": "Poster",
"seasons": [
{
"id": "halloween",
"name": "Halloween",
"enabled": false,
"startDate": "10-01",
"endDate": "10-31",
"sections": [
{
"id": "halloween-tag",
"enabled": false,
"name": "Halloween Movies",
"type": "Tag",
"source": "halloween",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50
},
{
"id": "halloween-horror",
"enabled": false,
"name": "Horror Genre",
"type": "Genre",
"source": "Horror",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 51
},
{
"id": "halloween-thriller",
"enabled": false,
"name": "Thriller Genre",
"type": "Genre",
"source": "Thriller",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 52
}
]
},
{
"id": "thanksgiving",
"name": "Thanksgiving",
"enabled": false,
"startDate": "11-20",
"endDate": "11-30",
"order": 50,
"sections": [
{
"id": "seasonal-2-section-0",
"enabled": false,
"name": "Thanksgiving Movies",
"type": "Tag",
"source": "thanksgiving",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50,
"spotlight": true,
"discoveryEnabled": false,
"searchTerm": "",
"includeItemTypes": [
"Movie"
]
},
{
"id": "seasonal-2-section-1",
"enabled": false,
"name": "Thanksgiving Episodes",
"type": "Parent",
"source": "",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Thumb",
"order": 51,
"spotlight": false,
"discoveryEnabled": false,
"searchTerm": "thanksgiving",
"includeItemTypes": [
"Episode"
]
}
]
},
{
"id": "christmas",
"name": "Christmas",
"enabled": false,
"startDate": "12-01",
"endDate": "12-31",
"sections": [
{
"id": "seasonal-1-section-0",
"enabled": false,
"name": "Christmas Movies",
"type": "Tag",
"source": "christmas",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50,
"spotlight": true,
"discoveryEnabled": false,
"searchTerm": "",
"includeItemTypes": [
"Movie"
],
"additionalQueryOptions": []
},
{
"id": "seasonal-1-section-1",
"enabled": false,
"name": "Christmas Episodes",
"type": "Parent",
"source": "",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Thumb",
"order": 51,
"spotlight": false,
"discoveryEnabled": false,
"searchTerm": "christmas",
"includeItemTypes": [
"Episode"
],
"additionalQueryOptions": []
}
]
}
]
},
"discovery": {
"enabled": true,
"infiniteScroll": true,
"minPeopleAppearances": 10,
"minGenreMovieCount": 50,
"defaultItemLimit": 16,
"defaultSortOrder": "Random",
"defaultCardFormat": "Poster",
"sectionTypes": {
"spotlightGenre": {
"enabled": true,
"name": "Top Rated [Genre] Movies",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"spotlightNetwork": {
"enabled": true,
"name": "Top Rated Shows from [Studio]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"genreMovies": {
"enabled": true,
"name": "[Genre] Movies",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"studioShows": {
"enabled": true,
"name": "Shows from [Studio]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"collections": {
"enabled": true,
"name": "[Collection Name]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"minimumItems": 10
},
"becauseYouWatched": {
"enabled": true,
"name": "Because you watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"becauseYouLiked": {
"enabled": true,
"name": "Because you liked [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"starringTopActor": {
"enabled": true,
"name": "Starring [Actor]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"directedByTopDirector": {
"enabled": true,
"name": "Directed by [Director]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"writtenByTopWriter": {
"enabled": true,
"name": "Written by [Writer]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"becauseYouRecentlyWatched": {
"enabled": true,
"name": "Because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"starringActorRecentlyWatched": {
"enabled": true,
"name": "Starring [Actor] because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"directedByDirectorRecentlyWatched": {
"enabled": true,
"name": "Directed by [Director] because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"writtenByWriterRecentlyWatched": {
"enabled": true,
"name": "Written by [Writer] because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
}
},
"spotlightDiscoveryChance": 0.5,
"renderSpotlightAboveMatching": false,
"spotlight": {
"autoPlay": true,
"interval": 5000,
"showDots": true,
"showNavButtons": true,
"showClearArt": true
},
"randomizeOrder": false
},
"removeConflictingSections": true,
"customSections": []
},
"exclusiveElsewhere": {
"hideServerName": false
"skinManager": true,
"thumbnailScrubber": false,
"watchTogether": false,
"userManager": false
},
"search": {
"enableJellyseerr": false
@@ -397,6 +33,33 @@ window.KefinTweaksDefaultConfig = {
"flattenSingleSeasonShows": {
"hideSingleSeasonContainer": false
},
"seriesInfo": {
"use24HourTimeFormat": false
},
"versionPreferences": {
"versions": ["4k", "2160p", "1080p", "720p", "576p", "480p", "360p", "__none__"],
"editions": ["Theatrical", "Director's Cut", "Extended Edition", "Fan Edit", "__none__"],
"showVersionBeforeEdition": false
},
"thumbnailScrubber": {
"PreviewOnHover": false,
"UseVideoStream": true,
"FallbackAfterMs": 500,
"HOVER_PREVIEW_TIMEOUT_MS": 800,
"HOVER_TIMEOUT": 800,
"HOVER_PREVIEW_FRAME_MS": 250,
"SCRUB_ACTIVATION_DELAY_MS": 300
},
"watchTogether": {
"sessionTimeout": 30
},
"userManager": {
"defaultTemplateName": "",
"templates": []
},
"exclusiveElsewhere": {
"hideServerName": false
},
"skins": [],
"defaultSkin": null,
"themes": [],
+188 -118
View File
@@ -330,9 +330,12 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.maxHeight = '90vh';
dialog.style.maxWidth = '600px';
dialog.style.width = '90vw';
if (window.innerWidth < 900) {
dialog.style.maxHeight = '90vh';
dialog.style.maxWidth = '600px';
dialog.style.width = '90vw';
}
if (title) {
const header = document.createElement('div');
@@ -727,14 +730,15 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Create minimal config if loading fails
defaultConfig = {
kefinTweaksRoot: kefinTweaksRoot,
enabled: true,
scripts: {},
homeScreen: {},
exclusiveElsewhere: {},
search: {},
skins: [],
defaultSkin: null,
themes: [],
customMenuLinks: []
customMenuLinks: [],
optionalIncludes: []
};
}
@@ -1020,23 +1024,41 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Make function globally accessible
window.openKefinTweaksSourceModal = openKefinTweaksSourceModal;
// Prevent concurrent card inserts (async config fetch races past DOM checks)
let pluginCardAddInProgress = false;
let pluginCardObserver = null;
let tryRenderToken = 0;
function ensureSingleKefinTweaksPluginCard() {
const cards = document.querySelectorAll('[data-id="kefinTweaksPlugin"]');
for (let i = 1; i < cards.length; i++) {
cards[i].remove();
}
return cards[0] || null;
}
// Add plugin card to plugins page
function addKefinTweaksPluginCard(installedPlugins) {
if (!installedPlugins) {
return;
}
// Check if card already exists in either container
if (document.querySelector('[data-id="kefinTweaksPlugin"]')) {
if (ensureSingleKefinTweaksPluginCard()) {
return;
}
if (pluginCardAddInProgress) {
return;
}
pluginCardAddInProgress = true;
const pluginsPage = document.querySelector('#pluginsPage:not(.hide)');
if (!pluginsPage) {
pluginCardAddInProgress = false;
return;
}
// Find or create frontEndPlugins container
// Find or create frontEndPlugins container (only one)
let frontEndPlugins = pluginsPage.querySelector('.frontEndPlugins');
if (!frontEndPlugins) {
@@ -1067,22 +1089,25 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
installedPlugins.firstElementChild;
if (!existingCard) {
// Set up MutationObserver to watch for plugin cards being added
if (pluginCardObserver) {
// Already waiting for native plugin cards; keep in-progress lock
return;
}
console.log('[KefinTweaks Installer] No existing plugin card found, setting up MutationObserver');
const observer = new MutationObserver((mutations) => {
// Check if any child was added
pluginCardObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
// Check if we now have a card to clone
const cardToClone = installedPlugins.querySelector('.card[data-id]') ||
installedPlugins.querySelector(':first-child') ||
installedPlugins.firstElementChild;
if (cardToClone) {
console.log('[KefinTweaks Installer] Plugin card detected, disconnecting observer');
observer.disconnect();
// Try adding our card again
pluginCardObserver.disconnect();
pluginCardObserver = null;
pluginCardAddInProgress = false;
addKefinTweaksPluginCard(installedPlugins);
return;
}
@@ -1090,16 +1115,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
});
// Observe child additions to the installedPlugins container
observer.observe(installedPlugins, {
pluginCardObserver.observe(installedPlugins, {
childList: true,
subtree: false
});
// Disconnect after 10 seconds if no cards appear
setTimeout(() => {
observer.disconnect();
console.warn('[KefinTweaks Installer] MutationObserver timeout: No plugin cards appeared after 10 seconds');
if (pluginCardObserver) {
pluginCardObserver.disconnect();
pluginCardObserver = null;
pluginCardAddInProgress = false;
console.warn('[KefinTweaks Installer] MutationObserver timeout: No plugin cards appeared after 10 seconds');
}
}, 10000);
return;
@@ -1314,131 +1341,171 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
};
}
// Append to frontEndPlugins container instead of installedPlugins
// Append only if still missing (guards against concurrent async completions)
if (ensureSingleKefinTweaksPluginCard()) {
return;
}
frontEndPlugins.appendChild(card);
}).catch(err => {
console.error('[KefinTweaks Installer] Error getting config for card:', err);
}).finally(() => {
pluginCardAddInProgress = false;
});
}
// Check for plugins page and add card
function checkForPluginsPage(view, element, hash) {
if (hash && hash.includes('dashboard/plugins')) {
const pluginsPage = document.querySelector('#pluginsPage:not(.hide)');
if (!pluginsPage) {
console.log('[KefinTweaks Installer] Plugins page not found');
return;
}
let installedPlugins = pluginsPage.querySelector('.installedPlugins');
if (!installedPlugins) {
// Support for Jellyfin 10.11.X
installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div');
}
if (!installedPlugins || !pluginsPage) {
console.log('[KefinTweaks Installer] Installed plugins or plugins page not found');
return;
}
// Small delay to ensure DOM is ready
setTimeout(() => {
addKefinTweaksPluginCard(installedPlugins);
}, 100);
// Try to render the Front End Plugins card when on the plugins page
async function tryRenderPluginsPage(retryCount = 0) {
const maxRetries = 50; // ~5s (50 * 100ms)
if (retryCount === 0) {
tryRenderToken += 1;
}
}
const token = tryRenderToken;
// Check if user is logged in and admin
async function checkUserAndSetup(retryCount = 0) {
const maxRetries = 100; // 10 seconds (100 * 100ms)
try {
// Check if there is a valid config with a root url already, and load the injector if so
checkAndLoadInjector();
const hash = window.location.hash || '';
// Check if ApiClient is available and user is logged in
if (!window.ApiClient || !window.ApiClient._loggedIn) {
if (retryCount < maxRetries) {
setTimeout(() => {
checkUserAndSetup(retryCount + 1);
}, 100);
return;
} else {
console.log('[KefinTweaks Installer] User not logged in after polling, skipping setup');
return;
}
}
if (!hash.includes('dashboard/plugins')) {
return;
}
// Check if user is admin
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
console.log('[KefinTweaks Installer] User is not admin, skipping setup');
return;
}
const pluginsPage = document.querySelector('#pluginsPage:not(.hide)');
let installedPlugins = pluginsPage ? pluginsPage.querySelector('.installedPlugins') : null;
// User is logged in and is admin, proceed with setup
console.log('[KefinTweaks Installer] User is admin, setting up functionality');
if (!installedPlugins && pluginsPage) {
// Support for Jellyfin 10.11.X
installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div');
}
// Add CSS
const css = document.createElement('style');
css.textContent = `
[data-id="kefinTweaksPlugin"] .cardImageContainer::after,
[data-id="kefinTweaksPlugin"] .MuiButtonBase-root.MuiCardActionArea-root::after {
content: 'KefinTweaks';
position: absolute;
font-size: 2em;
top: 50%;
transform: translateY(-50%);
}
`;
document.head.appendChild(css);
// Hook into Emby.Page.onViewShow
setupOnViewShow();
} catch (error) {
console.error('[KefinTweaks Installer] Error checking user:', error);
// Retry on error
if (!pluginsPage || !installedPlugins) {
if (retryCount < maxRetries) {
setTimeout(() => {
checkUserAndSetup(retryCount + 1);
if (token !== tryRenderToken) {
return;
}
tryRenderPluginsPage(retryCount + 1);
}, 100);
}
return;
}
ensureSingleKefinTweaksPluginCard();
if (!(await isAdmin())) {
return;
}
if (token !== tryRenderToken) {
return;
}
addKefinTweaksPluginCard(installedPlugins);
}
// Hook into Emby.Page.onViewShow
function setupOnViewShow() {
const originalOnViewShow = window.Emby?.Page?.onViewShow;
if (window.Emby && window.Emby.Page) {
window.Emby.Page.onViewShow = function(...args) {
// Call original handler if it exists
if (originalOnViewShow) {
try {
originalOnViewShow.apply(this, args);
} catch (err) {
console.warn('[KefinTweaks Installer] Error in original onViewShow:', err);
}
}
// Check for plugins page
const hash = window.location.hash;
checkForPluginsPage(args[0], args[1], hash);
};
console.log('[KefinTweaks Installer] Hooked into Emby.Page.onViewShow');
} else {
// Retry if Emby.Page not ready yet
if (!(window.Emby && window.Emby.Page)) {
setTimeout(setupOnViewShow, 100);
return;
}
if (window.Emby.Page._kefinTweaksOnViewShowHooked) {
return;
}
window.Emby.Page._kefinTweaksOnViewShowHooked = true;
const originalOnViewShow = window.Emby.Page.onViewShow;
window.Emby.Page.onViewShow = function(...args) {
if (originalOnViewShow) {
try {
originalOnViewShow.apply(this, args);
} catch (err) {
console.warn('[KefinTweaks Installer] Error in original onViewShow:', err);
}
}
tryRenderPluginsPage();
};
console.log('[KefinTweaks Installer] Hooked into Emby.Page.onViewShow');
// Cover direct URL / reload when onViewShow already fired before the hook
tryRenderPluginsPage();
}
// Initialize the installer
function injectPluginCardCss() {
if (document.getElementById('kefinTweaksPluginCardCss')) {
return;
}
const css = document.createElement('style');
css.id = 'kefinTweaksPluginCardCss';
css.textContent = `
[data-id="kefinTweaksPlugin"] .cardImageContainer::after,
[data-id="kefinTweaksPlugin"] .MuiButtonBase-root.MuiCardActionArea-root::after {
content: 'KefinTweaks';
position: absolute;
font-size: 2em;
top: 50%;
transform: translateY(-50%);
}
`;
document.head.appendChild(css);
}
function shouldInjectCustomPageStyles(config) {
if (!config) return false;
const scripts = config.scripts || {};
const links = config.customMenuLinks;
return scripts.watchlist === true
|| scripts.games === true
|| (Array.isArray(links) && links.length > 0);
}
function injectCustomPageStyles(config) {
if (!shouldInjectCustomPageStyles(config)) return;
if (document.getElementById('kefin-custom-page-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-custom-page-styles';
style.textContent = `
#reactRoot .skinBody:has(.customPage:not(.hide)) #fallbackPage {
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;
}
#reactRoot:has(.MuiBox-root) .libraryPage:not(.noSecondaryNavPage).customPage {
padding-top: 0 !important;
}
`;
(document.head || document.documentElement).appendChild(style);
}
// Initialize the installer (no login/admin gate — admin is checked when rendering the card)
function initialize() {
// Start checking for logged in admin user
checkUserAndSetup();
checkAndLoadInjector();
injectPluginCardCss();
setupOnViewShow();
}
// Load injector.js from a specific root URL
@@ -1485,6 +1552,9 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
try {
const config = await getKefinTweaksConfig();
const root = config?.kefinTweaksRoot || '';
// Before injector/utils: hide fallback page chrome for custom routes
injectCustomPageStyles(config);
if (root && root !== '') {
await loadInjectorFromRoot(root);
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+1069
View File
File diff suppressed because it is too large Load Diff
+894
View File
@@ -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 = `
<div class="hiscores-header">
<h3>🏆 Leaderboard</h3>
</div>
<div class="hiscores-table-container">
<table class="hiscores-table">
<thead>
<tr>
<th class="rank-col">#</th>
<th class="name-col">Player</th>
<th class="score-col">Score</th>
<th class="guesses-col">Guessed</th>
<th class="date-col">Date</th>
</tr>
</thead>
<tbody>
`;
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 += `
<tr class="score-row ${rankClass}">
<td class="rank-cell">${index + 1}</td>
<td class="name-cell">${scoreName}</td>
<td class="score-cell">${parseInt(scorePoints).toLocaleString()}</td>
<td class="guesses-cell">${scoreGuesses}</td>
<td class="date-cell">${formattedDate}</td>
</tr>
`;
}
});
tableHtml += `
</tbody>
</table>
</div>
`;
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 += `
<div class="guess-item">
<span class="guess-number">${index + 1}.</span>
<span class="guess-movie">${guess.movieName}</span>
<span class="guess-time">${timeFormatted}</span>
<span class="guess-points">+${Math.round(guess.pointsEarned)}</span>
</div>
`;
});
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);
}
})();
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KefinTweaks Games</title>
<style>
:root {
--kefin-games-bg: #1a1b1e;
--kefin-games-card: #25262b;
--kefin-games-text: #f1f3f5;
--kefin-games-muted: #a6a7ab;
--kefin-games-accent: #74c0fc;
--kefin-games-border: rgba(255, 255, 255, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
background: var(--kefin-games-bg);
color: var(--kefin-games-text);
padding: 2rem 1.25rem 3rem;
}
.kefin-games-wrap {
max-width: 40rem;
margin: 0 auto;
}
h1 {
margin: 0 0 0.35em;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.kefin-games-lead {
margin: 0 0 1.75rem;
color: var(--kefin-games-muted);
font-size: 0.95rem;
line-height: 1.45;
}
.kefin-games-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.kefin-games-list a {
display: block;
text-decoration: none;
color: inherit;
background: var(--kefin-games-card);
border: 1px solid var(--kefin-games-border);
border-radius: 12px;
padding: 1.1rem 1.25rem;
transition:
border-color 0.15s ease,
transform 0.15s ease;
}
.kefin-games-list a:hover,
.kefin-games-list a:focus-visible {
border-color: var(--kefin-games-accent);
outline: none;
transform: translateY(-1px);
}
.kefin-games-list strong {
display: block;
font-size: 1.1rem;
margin-bottom: 0.35rem;
color: var(--kefin-games-accent);
}
.kefin-games-list span {
display: block;
font-size: 0.9rem;
color: var(--kefin-games-muted);
line-height: 1.4;
}
</style>
</head>
<body>
<div class="kefin-games-wrap">
<h1>Games</h1>
<p class="kefin-games-lead">Pick a game. Add more entries here when new games ship.</p>
<ul class="kefin-games-list">
<li>
<a class="kefin-games-link" href="#/movieLut">
<strong>Movie LUT Game</strong>
<span>Guess the movie from a color swatch made from still frames of the movie.</span>
</a>
</li>
<li>
<a class="kefin-games-link" href="#/3x3">
<strong>3x3 Game</strong>
<span>From a grid of 9 different movie backdrops, guess the most movies you can in 60 seconds.</span>
</a>
</li>
</ul>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+313
View File
@@ -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%);
}
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Movie LUT Game</title>
<script>
(function () {
function getRoot() {
var raw =
(window.KefinTweaksConfig && window.KefinTweaksConfig.kefinTweaksRoot) ||
(window.parent &&
window.parent !== window &&
window.parent.KefinTweaksConfig &&
window.parent.KefinTweaksConfig.kefinTweaksRoot) ||
"";
if (!raw) {
try {
var href = window.location.href;
var idx = href.lastIndexOf("/pages/");
if (idx !== -1) return href.slice(0, idx + 1);
} catch (e) {
/* ignore */
}
return "";
}
return raw.endsWith("/") ? raw : raw + "/";
}
window.__mlutRoot = getRoot();
if (window.__mlutRoot) {
var link = document.createElement("link");
link.id = "mlut-css";
link.rel = "stylesheet";
link.href = window.__mlutRoot + "pages/movieLut.css";
document.head.appendChild(link);
}
})();
</script>
</head>
<body class="mlut-body">
<div class="mlut-app">
<header class="mlut-header">
<h1>Movie LUT Game</h1>
</header>
<div class="mlut-scorebar" aria-live="polite">
<span>Score: <strong id="mlut-score">0</strong></span>
<span>Streak: <strong id="mlut-streak">0</strong></span>
</div>
<p id="mlut-status" class="mlut-status" data-tone="muted">Loading…</p>
<div class="mlut-stage">
<button type="button" id="mlut-prev" class="mlut-nav-btn" hidden aria-label="Previous swatch">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
</button>
<div id="mlut-card" class="mlut-card" aria-live="polite">
<div class="mlut-options" id="mlut-options" role="group" aria-label="Movie choices"></div>
<div class="mlut-swatch-wrap">
<canvas id="mlut-swatch" class="mlut-swatch" width="640" height="200" aria-label="Color barcode swatch"></canvas>
<div id="mlut-swatch-placeholder" class="mlut-swatch-placeholder">Preparing swatch…</div>
</div>
</div>
<button type="button" id="mlut-next" class="mlut-nav-btn" hidden aria-label="Next swatch">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path fill="currentColor" d="M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z" />
</svg>
</button>
<div id="mlut-reveal" class="mlut-reveal" hidden>
<svg id="mlut-reveal-connectors" class="mlut-reveal-connectors" aria-hidden="true"></svg>
<div id="mlut-reveal-tiles" class="mlut-reveal-tiles"></div>
</div>
</div>
<footer class="mlut-footer">Inspired by the <a href="https://game.movieluts.com/">Movie LUT Card Game</a></footer>
</div>
<script>
(function () {
var root = window.__mlutRoot || "";
var statusEl = document.getElementById("mlut-status");
if (!root) {
if (statusEl) {
statusEl.textContent =
"kefinTweaksRoot is not available. Open this page from Jellyfin with KefinTweaks configured.";
statusEl.dataset.tone = "error";
}
return;
}
if (document.getElementById("kefin-movieLut-js")) return;
var s = document.createElement("script");
s.id = "kefin-movieLut-js";
s.src = root + "pages/movieLut.js";
s.onerror = function () {
if (statusEl) {
statusEl.textContent = "Failed to load movieLut.js from " + root;
statusEl.dataset.tone = "error";
}
};
document.body.appendChild(s);
})();
</script>
</body>
</html>
+801
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 =
`<span class="mlut-option-letter">${LETTERS[i] || String(i + 1)})</span>` +
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');
})();
+1070
View File
File diff suppressed because it is too large Load Diff
+894
View File
@@ -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 = `
<div class="hiscores-header">
<h3>🏆 Leaderboard</h3>
</div>
<div class="hiscores-table-container">
<table class="hiscores-table">
<thead>
<tr>
<th class="rank-col">#</th>
<th class="name-col">Player</th>
<th class="score-col">Score</th>
<th class="guesses-col">Guessed</th>
<th class="date-col">Date</th>
</tr>
</thead>
<tbody>
`;
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 += `
<tr class="score-row ${rankClass}">
<td class="rank-cell">${index + 1}</td>
<td class="name-cell">${scoreName}</td>
<td class="score-cell">${parseInt(scorePoints).toLocaleString()}</td>
<td class="guesses-cell">${scoreGuesses}</td>
<td class="date-cell">${formattedDate}</td>
</tr>
`;
}
});
tableHtml += `
</tbody>
</table>
</div>
`;
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 += `
<div class="guess-item">
<span class="guess-number">${index + 1}.</span>
<span class="guess-movie">${guess.movieName}</span>
<span class="guess-time">${timeFormatted}</span>
<span class="guess-points">+${Math.round(guess.pointsEarned)}</span>
</div>
`;
});
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);
}
})();
+184
View File
@@ -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<Query>;
// 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<HomeScreenSection>;
}
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<string>; // 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<string>; // name;id1;id2;id3;...
pinnedParents: Array<string>; // name;id;type
}
/** Non-home layout/appearance prefs (same semicolon section strings as homeScreen.sections). */
interface KefinSectionState {
sections: Array<string>;
}
/*
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;
}
+319 -243
View File
@@ -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<Object>} 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
+101 -100
View File
@@ -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;
}
padding: 0.5em 2em;
margin: 0 !important;
border-radius: 0 !important;
width: initial !important;
max-width: initial !important;
white-space: nowrap;
}
+3181 -1000
View File
File diff suppressed because it is too large Load Diff
+3261 -860
View File
File diff suppressed because it is too large Load Diff
+218 -110
View File
@@ -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;
}
} */
.emby-scroller-container[data-section-id].hideIndicators .cardIndicators {
display: none;
}
+250 -136
View File
@@ -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 `
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Enable Jellyseerr</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Enable Jellyseerr integration for request functionality</div>
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Enable Seerr results from JellyfinEnhanced</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests</div>
${buildJellyfinCheckbox('search_enableJellyseerr', search.enableJellyseerr === true, 'Enabled')}
</div>
</div>
@@ -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 = `
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent build" aria-hidden="true"></span>
<span class="material-icons listItemIcon listItemIcon-transparent" aria-hidden="true"></span>
<div class="listItemBody">
<div class="listItemBodyText">Configure KefinTweaks</div>
<div class="listItemBodyText">KefinTweaks</div>
</div>
</div>
`;
@@ -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();
+50 -35
View File
@@ -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');
})();
})();
+2 -1
View File
@@ -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;
}
}
+2 -2
View File
@@ -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) {
-368
View File
@@ -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<Object|null>} - 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>} - 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');
})();
+126
View File
@@ -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(`<style>${css}</style>`);
});
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();
})();
+751
View File
@@ -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();
}
})();
+22 -12
View File
@@ -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) {
+153 -156
View File
@@ -1,36 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Oswald:[email protected]&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:[email protected]&family=Oswald:[email protected]&family=Prata&family=Roboto:ital,wght@0,100..900;1,100..900&family=Unbounded:[email protected]&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;
}
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;
} */
+4 -3
View File
@@ -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})`);
+399 -253
View File
@@ -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();
+176 -36
View File
@@ -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');
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42 -41
View File
@@ -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;
}
+124 -188
View File
@@ -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');
})();
})();
+15 -5
View File
@@ -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
+13 -1
View File
@@ -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>} - 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);
File diff suppressed because it is too large Load Diff
+129 -104
View File
@@ -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<void>|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() {
+2 -2
View File
@@ -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)
+49 -31
View File
@@ -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;
}
.searchResults\,:not(.smart-search-results),
.searchResults:not(.smart-search-results) {
display: none;
}
+261 -240
View File
@@ -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 = '<p style="color:#999;text-align:center;">No results found</p>';
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);
}
}
+144
View File
@@ -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();
+15 -2
View File
@@ -1,3 +1,16 @@
.nextUpSection {
display: none;
}
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;
}
+442 -470
View File
@@ -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>} - 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 <a> 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 <a> 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);
}
+57 -26
View File
@@ -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
]
};
+104 -125
View File
@@ -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?.());
});
}
+7 -14
View File
@@ -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()
}
});
-16
View File
@@ -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 <josh@joshmccarty.com>
* @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<e.length&&!window.requestAnimationFrame;++a)window.requestAnimationFrame=window[e[a]+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e[a]+"CancelAnimationFrame"]||window[e[a]+"CancelRequestAnimationFrame"];window.requestAnimationFrame||(window.requestAnimationFrame=function(e,a){var t=(new Date).getTime(),i=Math.max(0,16-Math.abs(t-n)),r=window.setTimeout(function(){e(t+i)},i);return n=t+i,r}),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(n){clearTimeout(n)})}();var a="flurry";function t(n,e){return Math.floor(Math.random()*(e-n+1)+n)}function i(a,i,r){var o=1===a.character.length?Array.isArray(a.character)?a.character[0]:a.character:Array.isArray(a.character)?a.character[t(0,a.character.length-1)]:Array.from(a.character)[t(0,a.character.length-1)];"string"==typeof a.character&&console&&console.warn&&console.warn("You are using a string for the Flurry character options. Please use an array of string(s) instead.");var s=t(-Math.abs(a.wind),r+Math.abs(a.wind)),c=s+t(a.wind-a.windVariance,a.wind+a.windVariance),l=t(a.small,a.large),u=a.speed/((t(1.2*l,.8*l)-a.small)/(a.large-a.small)+.5),m=a.height-l,w=t(a.rotation-a.rotationVariance,a.rotation+a.rotationVariance),d=Array.isArray(a.color)?a.color[Math.floor(Math.random()*a.color.length)]:a.color,h={transform:"translateX("+c+"px) translateY("+m+"px) rotateZ("+w+"deg)",opacity:0},f=n("<span></span>");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);
+21
View File
@@ -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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+378 -15
View File
@@ -19,6 +19,12 @@
const VIDEO_TYPES = ['Movie', 'Episode'];
/** When true (default), hover preview uses video stream instead of trickplay images. */
const USE_VIDEO_STREAM_DEFAULT = true;
/** Ms to wait for static stream start before fallback (0 = timeout disabled). */
const FALLBACK_AFTER_MS_DEFAULT = 500;
/** Ticks are 100-nanosecond units; 10_000 ticks = 1 ms */
const TICKS_PER_MS = 10_000;
@@ -115,7 +121,7 @@
box-shadow: 0 2px 12px rgba(0,0,0,0.5);
background: #000;
background-repeat: no-repeat;
border: 1px solid rgba(255, 255, 255, 0.85);
border: 3px solid rgba(255, 255, 255, 0.85);
}
.kefin-scrubber-timestamp {
margin-top: 4px; padding: 2px 8px;
@@ -161,18 +167,69 @@
pointer-events: none;
z-index: 18;
}
/* Video stream preview: sibling before cardImageContainer, overlays image area */
.kefin-hover-preview-sibling {
position: absolute;
overflow: hidden;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.kefin-hover-preview-sibling .kefin-hover-preview-box {
position: relative;
left: auto;
right: auto;
top: auto;
transform: none;
margin: 0;
}
.kefin-hover-preview-sibling video.kefin-hover-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
display: block;
}
.cardImageContainer .kefin-hover-preview-frame {
width: 100%;
height: 100%;
background-repeat: no-repeat;
}
.cardImageContainer .kefin-hover-preview-box video.kefin-hover-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
display: block;
}
.cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayContainer,
.cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayContainer {
background: none !important;
pointer-events: none;
transition: background 0.3s ease-in-out !important;
}
.cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayButton[data-action="resume"],
.cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayButton[data-action="resume"] {
display: none !important;
}
.cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayButton[data-action="resume"],
.cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayButton[data-action="resume"] {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
}
`;
document.head.appendChild(style);
}
/**
* Read thumbnail scrubber configuration from global config.
* Returns { previewOnHover: boolean, hoverTimeoutMs: number, scrubActivationDelayMs: number, hoverPreviewFrameMs?: number }.
* Returns { previewOnHover, hoverTimeoutMs, scrubActivationDelayMs, hoverPreviewFrameMs?, useVideoStream, fallbackAfterMs }.
*/
function getThumbnailScrubberConfig() {
const root = (window.KefinTweaksConfig && window.KefinTweaksConfig.thumbnailScrubber) || {};
@@ -195,11 +252,24 @@
? root.HOVER_PREVIEW_FRAME_MS * 1
: null;
// UseVideoStream defaults to true when unset.
const useVideoStream = root.UseVideoStream !== false;
// Prefer FallbackAfterMs; migrate legacy FallbackAfterSeconds if present.
let fallbackAfterMs = FALLBACK_AFTER_MS_DEFAULT;
if (Number.isFinite(root.FallbackAfterMs * 1) && root.FallbackAfterMs >= 0) {
fallbackAfterMs = root.FallbackAfterMs * 1;
} else if (Number.isFinite(root.FallbackAfterSeconds * 1) && root.FallbackAfterSeconds >= 0) {
fallbackAfterMs = root.FallbackAfterSeconds * 1000;
}
return {
previewOnHover: previewOnHover ?? HOVER_PREVIEW_DEFAULT_ENABLED,
hoverTimeoutMs,
scrubActivationDelayMs,
hoverPreviewFrameMs
hoverPreviewFrameMs,
useVideoStream: useVideoStream ?? USE_VIDEO_STREAM_DEFAULT,
fallbackAfterMs
};
}
@@ -212,6 +282,26 @@
}
}
function isUseVideoStreamEnabled() {
try {
return !!getThumbnailScrubberConfig().useVideoStream;
} catch (e) {
WARN('Error reading use video stream config', e);
return USE_VIDEO_STREAM_DEFAULT;
}
}
function getFallbackAfterMs() {
try {
const ms = getThumbnailScrubberConfig().fallbackAfterMs;
if (!Number.isFinite(ms) || ms <= 0) return 0;
return Math.round(ms);
} catch (e) {
WARN('Error reading fallback after config', e);
return FALLBACK_AFTER_MS_DEFAULT;
}
}
function getHoverPreviewTimeoutMs() {
try {
return getThumbnailScrubberConfig().hoverTimeoutMs || HOVER_PREVIEW_DEFAULT_TIMEOUT_MS;
@@ -241,7 +331,73 @@
}
/**
* Format positionTicks as running time (MM:SS or HH:MM:SS).
* Get Jellyfin API token for stream URLs. Uses ApiClient when available, else localStorage credentials.
*/
function getJellyfinToken() {
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken) {
return ApiClient.accessToken() || null;
}
try {
const creds = localStorage.getItem('jellyfin_credentials');
if (!creds) return null;
const parsed = JSON.parse(creds);
const server = parsed.Servers && parsed.Servers[0];
return server ? server.AccessToken || null : null;
} catch {
return null;
}
}
/**
* Resolve playable video id for Series/Season (first episode). Returns itemId for Movie/Episode.
*/
async function getFirstEpisodeId(itemId, itemType) {
if (itemType !== 'Series' && itemType !== 'Season') return itemId;
const token = getJellyfinToken();
if (!token) return null;
const baseUrl = typeof ApiClient !== 'undefined' && ApiClient.serverAddress
? ApiClient.serverAddress()
: window.location.origin;
try {
let seriesId = itemId;
let seasonId = itemId;
if (itemType === 'Series') {
const seasonsResp = await fetch(`${baseUrl}/Shows/${itemId}/Seasons`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!seasonsResp.ok) return null;
const seasons = await seasonsResp.json();
const firstSeason = (seasons.Items || []).find(s => s.IndexNumber === 1) || (seasons.Items || [])[0];
if (!firstSeason) return null;
seasonId = firstSeason.Id;
} else {
const itemResp = await fetch(`${baseUrl}/Items/${itemId}`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!itemResp.ok) return null;
const item = await itemResp.json();
seriesId = item.SeriesId || item.ParentId || itemId;
}
const episodesResp = await fetch(`${baseUrl}/Shows/${seriesId}/Episodes?seasonId=${seasonId}`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!episodesResp.ok) return null;
const episodes = await episodesResp.json();
const firstEpisode = (episodes.Items || []).find(e => e.IndexNumber === 1) || (episodes.Items || [])[0];
return firstEpisode ? firstEpisode.Id : null;
} catch (e) {
WARN('getFirstEpisodeId failed', itemId, e);
return null;
}
}
/**
* Uses Jellyfin's datetime.getDisplayRunningTime if available.
*/
function formatRunningTime(positionTicks) {
@@ -692,9 +848,58 @@
}
}
function clearPreviewFallbackTimer(state) {
if (!state) return;
if (state._previewFallbackTimer) {
clearTimeout(state._previewFallbackTimer);
state._previewFallbackTimer = null;
}
}
async function fallbackVideoPreviewToTrickplay(state, startTimeTicks) {
if (!state || !currentCardState || currentCardState !== state) return;
clearPreviewFallbackTimer(state);
try {
if (state._videoPreviewEl) {
try {
state._videoPreviewEl.pause();
state._videoPreviewEl.removeAttribute('src');
state._videoPreviewEl.load();
} catch (e) { /* ignore */ }
if (state._videoPreviewEl.parentNode) {
state._videoPreviewEl.parentNode.removeChild(state._videoPreviewEl);
}
}
} catch (e) { /* ignore */ }
state._videoPreviewEl = null;
if (state._videoPreviewWrapper && state._videoPreviewWrapper.parentNode) {
try {
state._videoPreviewWrapper.parentNode.removeChild(state._videoPreviewWrapper);
} catch (e) { /* ignore */ }
}
state._videoPreviewWrapper = null;
await ensureScrubberDataForHover(state);
if (!currentCardState || currentCardState !== state) return;
if (!state.scrubberData) {
// No trickplay: scrub stays disabled via data-no-trickplay, but video hover can retry next time.
state.hoverPreviewRunning = false;
return;
}
state.hoverPreviewPositionTicks = startTimeTicks || 0;
state.hoverPreviewFrameAccumMs = 0;
// hoverPreviewRunning was set before calling startVideoStreamPreview
startHoverPreviewLoop();
}
function stopHoverPreview() {
if (!currentCardState) return;
const state = currentCardState;
clearPreviewFallbackTimer(state);
if (state.hoverPreviewTimer) {
clearTimeout(state.hoverPreviewTimer);
state.hoverPreviewTimer = null;
@@ -708,6 +913,22 @@
clearInterval(hoverPreviewIntervalId);
hoverPreviewIntervalId = null;
}
if (state._videoPreviewEl) {
try {
state._videoPreviewEl.pause();
state._videoPreviewEl.removeAttribute('src');
state._videoPreviewEl.load();
} catch (e) { /* ignore */ }
state._videoPreviewEl = null;
}
if (state._videoPreviewWrapper) {
try {
if (state._videoPreviewWrapper.parentNode) {
state._videoPreviewWrapper.parentNode.removeChild(state._videoPreviewWrapper);
}
} catch (e) { /* ignore */ }
state._videoPreviewWrapper = null;
}
if (state.cardImageContainer) {
const box = state.cardImageContainer.querySelector('.kefin-hover-preview-box');
if (box && box._frame) {
@@ -725,10 +946,15 @@
function teardown() {
if (!currentCardState) return;
const state = currentCardState;
if (state._cardMouseLeaveHandler && state.card) {
state.card.removeEventListener('mouseleave', state._cardMouseLeaveHandler);
state._cardMouseLeaveHandler = null;
}
clearActivationTimer();
stopHoverPreview();
if (currentCardState.overlay) {
const overlay = currentCardState.overlay;
if (state.overlay) {
const overlay = state.overlay;
overlay.classList.remove('is-visible', 'is-zone-active', 'is-popover-open');
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
overlay._popoverOpen = false;
@@ -741,6 +967,111 @@
}
}
/**
* When video stream preview is enabled: show live video stream in a sibling div immediately before cardImageContainer.
* Starts with static=true; on timeout or play() failure, falls back to trickplay.
*/
async function startVideoStreamPreview(state, videoId, startTimeTicks) {
if (!state || !state.cardImageContainer || currentCardState !== state) return;
clearPreviewFallbackTimer(state);
const token = getJellyfinToken();
if (!token) {
WARN('startVideoStreamPreview: no API token');
return;
}
const baseUrl = typeof ApiClient !== 'undefined' && ApiClient.serverAddress
? ApiClient.serverAddress()
: window.location.origin;
const container = state.cardImageContainer;
const parent = container.parentNode;
if (!parent) return;
const rect = container.getBoundingClientRect();
const cw = rect.width || 0;
const ch = rect.height || 0;
if (cw <= 0 || ch <= 0) return;
// Fit 16:9 box inside container so video never overflows the card
let boxWidth = cw;
let boxHeight = Math.round(cw * 9 / 16);
if (boxHeight > ch) {
boxHeight = ch;
boxWidth = Math.round(ch * 16 / 9);
}
const wrapper = document.createElement('div');
wrapper.className = 'kefin-hover-preview-sibling';
const parentRect = parent.getBoundingClientRect();
const style = wrapper.style;
style.position = 'absolute';
style.left = (rect.left - parentRect.left) + 'px';
style.top = (rect.top - parentRect.top) + 'px';
style.width = rect.width + 'px';
style.height = rect.height + 'px';
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
const box = document.createElement('div');
box.className = 'kefin-hover-preview-box';
box.style.width = boxWidth + 'px';
box.style.height = boxHeight + 'px';
const frame = document.createElement('div');
frame.className = 'kefin-hover-preview-frame';
box.appendChild(frame);
box._frame = frame;
wrapper.appendChild(box);
container.after(wrapper);
state._videoPreviewWrapper = wrapper;
const videoUrl = `${baseUrl}/Videos/${videoId}/stream?static=true&startTimeTicks=${startTimeTicks}&ApiKey=${token}`;
const video = document.createElement('video');
video.className = 'kefin-hover-preview-video';
video.muted = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.controls = false;
video.src = videoUrl;
box.appendChild(video);
state._videoPreviewEl = video;
let fallbackStarted = false;
const clearStartupWatch = () => {
clearPreviewFallbackTimer(state);
video.removeEventListener('playing', onPlaybackStarted);
video.removeEventListener('loadeddata', onPlaybackStarted);
};
const onPlaybackStarted = () => {
clearStartupWatch();
};
const switchToTrickplay = async (reason) => {
if (fallbackStarted) return;
if (!currentCardState || currentCardState !== state) return;
if (state._videoPreviewEl !== video) return;
fallbackStarted = true;
clearStartupWatch();
WARN('Video stream preview ' + reason + ', falling back to trickplay', videoId);
await fallbackVideoPreviewToTrickplay(state, startTimeTicks);
};
video.addEventListener('playing', onPlaybackStarted);
video.addEventListener('loadeddata', onPlaybackStarted);
const fallbackAfterMs = getFallbackAfterMs();
if (fallbackAfterMs > 0) {
state._previewFallbackTimer = setTimeout(() => {
state._previewFallbackTimer = null;
switchToTrickplay('timed out');
}, fallbackAfterMs);
}
video.play().catch(() => {
switchToTrickplay('play() failed');
});
}
function startHoverPreviewLoop() {
if (!currentCardState || !currentCardState.hoverPreviewRunning || !currentCardState.scrubberData) {
hoverPreviewRafId = null;
@@ -860,14 +1191,40 @@
const state = currentCardState;
if (state.hoverPreviewRunning || state.hoverPreviewTimer) return;
// Without trickplay, only video-stream hover is possible.
if (!isUseVideoStreamEnabled() && state.overlay && state.overlay.hasAttribute('data-no-trickplay')) {
return;
}
const timeoutMs = getHoverPreviewTimeoutMs();
const timeout = timeoutMs < 0 ? 0 : timeoutMs;
state.hoverPreviewTimer = setTimeout(async () => {
state.hoverPreviewTimer = null;
if (!currentCardState || currentCardState.card !== state.card) return;
if (isUseVideoStreamEnabled()) {
const token = getJellyfinToken();
if (!token) return;
let videoId = state.itemId;
// Start from card's resume position (data-positionticks) when present
let startTimeTicks = 0;
const posAttr = state.card && state.card.getAttribute ? state.card.getAttribute('data-positionticks') : null;
if (posAttr != null && posAttr !== '' && !Number.isNaN(Number(posAttr))) {
startTimeTicks = Math.floor(Number(posAttr) / 10000);
}
state.hoverPreviewRunning = true;
await startVideoStreamPreview(state, videoId, startTimeTicks);
return;
}
await ensureScrubberDataForHover(state);
if (!currentCardState || currentCardState.card !== state.card) return;
if (!state.scrubberData) {
state.hoverPreviewRunning = false;
return;
}
state.hoverPreviewRunning = true;
state.hoverPreviewLastTs = performance.now();
// Start from card's data-positionticks when available, otherwise from 0.
@@ -886,9 +1243,14 @@
function attachCard(ctx) {
if (!ctx) return;
const overlay = getOrCreateScrubberOverlay(ctx.cardImageContainer);
if (overlay.hasAttribute('data-no-trickplay')) return;
const noTrickplay = overlay.hasAttribute('data-no-trickplay');
const card = ctx.card;
function handleCardMouseLeave() {
teardown();
}
card.addEventListener('mouseleave', handleCardMouseLeave);
currentCardState = {
card: ctx.card,
card: card,
cardImageContainer: ctx.cardImageContainer,
itemId: ctx.itemId,
overlay: overlay,
@@ -900,9 +1262,13 @@
hoverPreviewRunning: false,
hoverPreviewLastTs: 0,
hoverPreviewPositionTicks: 0,
hoverPreviewFrameAccumMs: 0
hoverPreviewFrameAccumMs: 0,
_cardMouseLeaveHandler: handleCardMouseLeave
};
overlay.classList.add('is-visible');
// Scrub overlay stays hidden when there is no trickplay; video hover still attaches.
if (!noTrickplay) {
overlay.classList.add('is-visible');
}
if (isHoverPreviewEnabled()) {
maybeStartHoverPreview();
}
@@ -986,10 +1352,7 @@
const overlay = state.overlay;
if (inZone) {
// When user actively scrubs, stop passive hover preview
if (isHoverPreviewEnabled()) {
stopHoverPreview();
}
// Keep hover preview running while scrubbing; it stops only when the cursor leaves the card
if (overlay.hasAttribute('data-no-trickplay')) return;
if (!state.activationTimer) {
const delayMs = getScrubActivationDelayMs();
@@ -1075,7 +1438,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': ApiClient.accessToken()
'Authorization': window.apiHelper.getAuthHeader()
},
body: JSON.stringify({ PlaybackPositionTicks: pop._positionTicks })
}).then(r => {
+159 -20
View File
@@ -1,6 +1,6 @@
// KefinTweaks Toaster
// Provides toast notification functionality using Jellyfin's existing toast system
// Usage: window.KefinTweaksToaster.toast(message, duration)
// Usage: window.KefinTweaksToaster.toast(message, duration, autoHide, options)
(function() {
'use strict';
@@ -11,6 +11,54 @@
const ERR = (...args) => console.error('[KefinTweaks Toaster]', ...args);
LOG('Initializing');
let stylesInjected = false;
function ensureToastStyles() {
if (stylesInjected) return;
stylesInjected = true;
const style = document.createElement('style');
style.textContent = `
.toast.kefin-toast-rich {
display: flex;
align-items: center;
gap: 0.75em;
max-width: min(28rem, calc(100vw - 2rem));
}
.toast.kefin-toast-rich .kefin-toast-message {
flex: 1 1 auto;
min-width: 0;
line-height: 1.35;
}
.toast.kefin-toast-rich .kefin-toast-link {
color: inherit;
text-decoration: underline;
cursor: pointer;
}
.toast.kefin-toast-rich .kefin-toast-action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
}
.toast.kefin-toast-rich .kefin-toast-action:hover {
background: rgba(255, 255, 255, 0.12);
}
.toast.kefin-toast-rich .kefin-toast-action .material-icons {
font-size: 1.25rem;
}
`;
document.head.appendChild(style);
}
/**
* Get the toast container
@@ -54,14 +102,117 @@
// Default to 3 seconds
return 3000;
}
function dismissToastElement(toastElement, container, customContainer) {
if (!toastElement?.parentNode) return;
toastElement.classList.add('toastHide');
setTimeout(() => {
if (toastElement.parentNode) {
toastElement.parentNode.removeChild(toastElement);
}
if (customContainer && container && !container.querySelector('.toast')) {
container.remove();
}
}, 3000);
}
function buildToastContent(toastElement, message, options) {
const link = options?.link;
const action = options?.action;
const rich = !!(link || action);
if (!rich) {
toastElement.textContent = message;
return;
}
ensureToastStyles();
toastElement.classList.add('kefin-toast-rich');
const messageEl = document.createElement('span');
messageEl.className = 'kefin-toast-message';
if (link?.label && typeof message === 'string' && message.includes(link.label)) {
const idx = message.indexOf(link.label);
if (idx > 0) {
messageEl.appendChild(document.createTextNode(message.slice(0, idx)));
}
const anchor = document.createElement('a');
anchor.className = 'kefin-toast-link';
anchor.href = link.href || '#';
anchor.textContent = link.label;
anchor.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (typeof link.onClick === 'function') {
link.onClick();
} else if (link.href) {
if (window.Dashboard?.navigate) {
window.Dashboard.navigate(link.href);
} else {
window.location.hash = link.href.startsWith('#') ? link.href : `#/${link.href}`;
}
}
});
messageEl.appendChild(anchor);
const after = message.slice(idx + link.label.length);
if (after) {
messageEl.appendChild(document.createTextNode(after));
}
} else {
messageEl.textContent = message;
if (link?.label) {
messageEl.appendChild(document.createTextNode(' '));
const anchor = document.createElement('a');
anchor.className = 'kefin-toast-link';
anchor.href = link.href || '#';
anchor.textContent = link.label;
anchor.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (typeof link.onClick === 'function') link.onClick();
else if (link.href && window.Dashboard?.navigate) {
window.Dashboard.navigate(link.href);
}
});
messageEl.appendChild(anchor);
}
}
toastElement.appendChild(messageEl);
if (action && typeof action.onClick === 'function') {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kefin-toast-action';
btn.title = action.label || 'Undo';
btn.setAttribute('aria-label', action.label || 'Undo');
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = action.icon || 'undo';
btn.appendChild(icon);
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
action.onClick();
} catch (e) {
ERR('Toast action failed:', e);
}
});
toastElement.appendChild(btn);
}
}
/**
* Display a toast message
* @param {string} message - The message to display
* @param {string|number} duration - Duration in seconds
* @param {boolean} autoHide - Whether to automatically hide the toast after duration (default: true)
* @param {{ link?: { label: string, href?: string, onClick?: Function }, action?: { icon?: string, label?: string, onClick: Function } }} [options]
* @returns {{ dismiss: Function, element: HTMLElement }|undefined}
*/
function toast(message, duration = '3', autoHide = true) {
function toast(message, duration = '3', autoHide = true, options = null) {
if (!message) {
WARN('Toast message is required');
return;
@@ -88,7 +239,7 @@
// Create the toast element with just 'toast' class initially
const toastElement = document.createElement('div');
toastElement.className = 'toast';
toastElement.textContent = message;
buildToastContent(toastElement, message, options || {});
// Add to container first
container.appendChild(toastElement);
@@ -100,6 +251,8 @@
// Now add toastVisible class - the browser will see the state change and apply the transition
toastElement.classList.add('toastVisible');
const dismiss = () => dismissToastElement(toastElement, container, customContainer);
// Only set up auto-hide if enabled
if (autoHide) {
@@ -108,25 +261,11 @@
// After the duration, start the fade-out
setTimeout(() => {
if (!toastElement.parentNode) {
return; // Already removed
}
// Add toastHide class to trigger fade-out
toastElement.classList.add('toastHide');
// Wait 1 second for Jellyfin's CSS to handle the fade-out, then remove
setTimeout(() => {
if (toastElement.parentNode) {
toastElement.parentNode.removeChild(toastElement);
LOG(`Removed toast: "${message}"`);
if (customContainer) {
container.remove();
}
}
}, 3000);
dismiss();
}, durationMs);
}
return { dismiss, element: toastElement };
}
// Expose toaster to global scope
+364 -73
View File
@@ -18,10 +18,9 @@
*/
createBadge(value, displayText) {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.className = 'tag-badge kt-autocomplete-badge';
badge.setAttribute('data-value', value);
badge.style.cssText = 'display: inline-flex; align-items: center; gap: 0.25em; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; margin: 0.25em; font-size: 0.9em;';
badge.innerHTML = `${displayText}<button type="button" class="tag-badge-remove" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0; margin-left: 0.25em; font-size: 1.2em; line-height: 1; display: flex; align-items: center;" title="Remove">×</button>`;
badge.innerHTML = `${displayText}<button type="button" class="tag-badge-remove kt-autocomplete-badge-remove" title="Remove"><span class="material-icons">close</span></button>`;
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 `
<span class="tag-badge" data-value="${value}" ${(type === 'Collection' || type === 'Playlist') ? 'data-loading="true"' : ''} style="display: inline-flex; align-items: center; gap: 0.25em; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; margin: 0.25em; font-size: 0.9em;">
<span class="tag-badge kt-autocomplete-badge" data-value="${value}" ${(type === 'Collection' || type === 'Playlist') ? 'data-loading="true"' : ''}>
${displayText}
<button type="button" class="tag-badge-remove" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0; margin-left: 0.25em; font-size: 1.2em; line-height: 1; display: flex; align-items: center;" title="Remove">×</button>
<button type="button" class="tag-badge-remove kt-autocomplete-badge-remove" title="Remove"><span class="material-icons">close</span></button>
</span>
`;
}).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 = `
<div class="${prefix}_${typeKey}_container" data-section-index="${sectionIndex}" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; position: static;">
<div style="position: relative;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5em;">
<div class="listItemBodyText">${label}</div>
<div style="display: flex; gap: 0.5em;">
${showRefresh ? `<button type="button" class="badge-refresh" data-section-index="${sectionIndex}" data-type="${type}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; color: rgba(255,255,255,0.87); cursor: pointer; font-size: 0.85em;" title="Refresh Data">🔄</button>` : ''}
<button type="button" class="badge-clear-all" data-section-index="${sectionIndex}" data-type="${type}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; color: rgba(255,255,255,0.87); cursor: pointer; font-size: 0.85em;" title="Clear All">Clear All</button>
</div>
<div class="kt-autocomplete-control tag-badge-container ${prefix}_${typeKey}_container" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" ${type === 'IncludeItemTypes' ? `data-available-types="${allAvailableItemTypes.join(',')}"` : ''}>
<div class="kt-autocomplete-header">
<div class="kt-autocomplete-text">
<div class="listItemBodyText kt-autocomplete-label">${label}</div>
${description ? `<div class="listItemBodyText secondary kt-autocomplete-desc">${description}</div>` : ''}
</div>
<div class="tag-badge-container" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" ${type === 'IncludeItemTypes' ? `data-available-types="${allAvailableItemTypes.join(',')}"` : ''} style="min-height: 2.5em; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.5em; margin-bottom: 0.5em; background: rgba(0,0,0,0.2); ${badgesBelowInput ? 'display: block;' : 'display: flex; gap: 0.5em; align-items: flex-start;'} overflow: hidden; position: relative;">
<div style="display: flex; align-items: center; gap: 0.25em; ${badgesBelowInput ? 'width: 100%; margin-bottom: 0.5em;' : 'flex: 0 0 auto; width: 200px; min-width: 200px;'}">
<input type="text" class="${prefix}_${typeKey} fld emby-input autocomplete-input" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" placeholder="${placeholderText}" autocomplete="off" style="flex: 1; border: none; background: transparent; outline: none; color: rgba(255,255,255,0.87); padding: 0.25em;">
<button type="button" class="autocomplete-chevron" data-section-index="${sectionIndex}" data-type="${type}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em; color: rgba(255,255,255,0.87); cursor: pointer; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px;" title="Show All Options">
<span class="material-icons" style="font-size: 18px;">keyboard_arrow_down</span>
</button>
</div>
<div class="tag-badge-wrapper" style="${badgesBelowInput ? 'width: 100%;' : 'flex: 1; min-width: 0;'} display: flex; flex-wrap: wrap; gap: 0.25em; align-items: flex-start; overflow-wrap: break-word;">
${badgesHtml}
</div>
<div class="kt-autocomplete-actions">
${showRefresh ? `<button type="button" class="kt-autocomplete-icon-btn badge-refresh" data-section-index="${sectionIndex}" data-type="${type}" title="Refresh data"><span class="material-icons">refresh</span></button>` : ''}
<button type="button" class="kt-autocomplete-icon-btn badge-clear-all" data-section-index="${sectionIndex}" data-type="${type}" title="Clear all"><span class="material-icons">clear_all</span></button>
</div>
<input type="hidden" class="${prefix}_${typeKey}_hidden" data-section-index="${sectionIndex}" value="${currentValues.map(v => typeof v === 'object' ? v.id : v).join(', ')}">
<div class="autocomplete-suggestions" data-section-index="${sectionIndex}" style="display: none; position: absolute; z-index: 10000; background: #1a1a1a !important; color: rgba(255,255,255,0.87) !important; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; max-height: 200px; overflow-y: auto; margin-top: 2px; width: 100%; box-sizing: border-box; opacity: 1 !important; top: 100%; left: 0;"></div>
</div>
<div class="kt-autocomplete-field">
<div class="kt-autocomplete-input-row">
<input type="text" class="${prefix}_${typeKey} fld emby-input autocomplete-input" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" placeholder="${placeholderText}" autocomplete="off">
<button type="button" class="kt-autocomplete-icon-btn autocomplete-chevron" data-section-index="${sectionIndex}" data-type="${type}" title="Show all options">
<span class="material-icons">keyboard_arrow_down</span>
</button>
</div>
<div class="autocomplete-suggestions" data-section-index="${sectionIndex}"></div>
</div>
<div class="tag-badge-wrapper kt-autocomplete-badges">${badgesHtml}</div>
<input type="hidden" class="${prefix}_${typeKey}_hidden" data-section-index="${sectionIndex}" value="${currentValues.map(v => typeof v === 'object' ? v.id : v).join(', ')}">
</div>
`;
@@ -826,41 +820,6 @@
`;
}
/**
* Build toggle slider HTML (simplified version for user editor)
*/
function buildToggleSliderHTML(checked, sectionId, onToggle) {
const isEnabled = checked !== false;
return `
<button type="button" class="toggle-slider section-toggle-switch" data-section-id="${sectionId}" data-enabled="${isEnabled}" style="
position: relative;
width: 60px;
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: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
">
<span style="
position: absolute;
left: ${isEnabled ? '32px' : '2px'};
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
"></span>
</button>
`;
}
/**
* Build section row HTML for order editor
*/
@@ -889,7 +848,11 @@
return `
<div class="listItem viewItem section-row" data-section-id="${sectionId}" data-section-type="${sectionType}" ${draggableAttr} ${dataAttrsStr}>
<span class="material-icons drag_handle"></span>
${buildToggleSliderHTML(isEnabled, sectionId)}
${buildToggleSlider('', isEnabled, '', {
wrapInLabel: false,
cssClass: 'toggle-slider section-toggle-switch',
dataAttributes: { 'section-id': sectionId }
})}
<div class="listItemBody">
<div style="display: flex; align-items: center; gap: 0.5em;">
<span>${sectionName.replace(/"/g, '&quot;')}</span>
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 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 `<option value="${value}" ${selected}>${optLabel}</option>`;
}).join('');
return `
<div class="hsae-field-wrap">
<label class="listItemBodyText" for="${id}" style="display: block; margin-bottom: 0.25em;">${label}</label>
<select id="${id}" class="fld emby-select emby-select-withcolor">
${optionsHTML}
</select>
</div>
`;
}
/**
* Build Jellyfin text input HTML
* @param {Record<string, string|number|boolean>} [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, '&amp;').replace(/"/g, '&quot;')}"`))
.join('');
return `
<div class="hsae-field-wrap">
<label class="listItemBodyText" for="${id}" style="display: block; margin-bottom: 0.25em;">${label}</label>
<input type="${type}" id="${id}" class="fld emby-input" value="${value || ''}" placeholder="${placeholder || ''}"${extraAttrs}>
</div>
`;
}
/**
* 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 = `
<label class="checkboxContainer emby-checkbox-label">
<input is="emby-checkbox" type="checkbox"${idAttr}
class="emby-checkbox${extraClass}" data-embycheckbox="true" ${checked ? 'checked' : ''}${dataAttrs}>
<span class="checkboxLabel">${safeLabel}</span>
<span class="checkboxOutline">
<span class="material-icons checkboxIcon checkboxIcon-checked check" aria-hidden="true"></span>
<span class="material-icons checkboxIcon checkboxIcon-unchecked" aria-hidden="true"></span>
</span>
</label>
`;
if (wrapInSection) {
return `<div class="sectioncheckbox">${control}</div>`;
}
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 `
<div class="kefin-toggle-card">
<input type="checkbox" id="${escapeHtml(id)}"${checkboxClassAttr}${checkboxDataAttrs} ${isEnabled ? 'checked' : ''} style="display: none;">
<div class="kefin-toggle-card-text">
<div class="listItemBodyText kefin-toggle-card-label">${escapeHtml(label)}</div>
${description ? `<div class="listItemBodyText secondary kefin-toggle-card-desc"${hintAttr}>${escapeHtml(description)}</div>` : ''}
</div>
${toggleBtn}
</div>
`;
}
/**
* 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 `
<div style="margin-bottom: 1.5em; display: flex; gap: 0.5em;">
<button type="button" class="enabled-toggle-btn ${isEnabled ? 'active' : ''}" data-enabled="true" style="
flex: 1;
padding: 0.75em 1.5em;
border: 2px solid ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(255,255,255,0.2)'};
border-radius: 4px;
background: ${isEnabled ? 'rgba(0, 164, 220, 0.2)' : 'transparent'};
color: ${isEnabled ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'};
font-weight: ${isEnabled ? '600' : '400'};
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
">
Enabled
</button>
<button type="button" class="enabled-toggle-btn ${!isEnabled ? 'active' : ''}" data-enabled="false" style="
flex: 1;
padding: 0.75em 1.5em;
border: 2px solid ${!isEnabled ? 'rgba(158, 158, 158, 0.8)' : 'rgba(255,255,255,0.2)'};
border-radius: 4px;
background: ${!isEnabled ? 'rgba(158, 158, 158, 0.2)' : 'transparent'};
color: ${!isEnabled ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.7)'};
font-weight: ${!isEnabled ? '600' : '400'};
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
">
Disabled
</button>
</div>
`;
}
/**
* 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 ? '' : `
<span class="toggle-slider-thumb" style="
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
left: ${isEnabled ? 'calc(100% - 25px)' : '3px'};
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
pointer-events: none;
"></span>`;
const toggleButton = `
<button type="button" class="${escapeHtml(cssClass)}" ${dataAttrsString} ${id ? `data-checkbox-id="${escapeHtml(id)}"` : ''} data-enabled="${isEnabled}" aria-pressed="${isEnabled}"${inlineStyle}>
${knobHtml}
</button>
`;
const hiddenCheckbox = includeHiddenCheckbox && id ? `
<input type="checkbox" id="${escapeHtml(id)}"${checkboxClassAttr} ${isEnabled ? 'checked' : ''} style="display: none;">
` : '';
if (wrapInLabel) {
return `
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
${hiddenCheckbox}
${toggleButton}
<span class="listItemBodyText">${escapeHtml(label || '')}</span>
</label>
`;
}
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');
+170 -10
View File
@@ -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<object>}
*/
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<boolean>}
*/
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.<featureKey>.
* @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.<featureKey> while preserving sibling keys.
* @param {string} featureKey
* @param {*} value
* @returns {Promise<boolean>}
*/
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.<featureKey> via object merge or updater function.
* @param {string} featureKey
* @param {object|Function} patchOrFn - Partial object to merge, or (current) => nextValue
* @returns {Promise<boolean>}
*/
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
};
+120
View File
@@ -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;
}
+568
View File
@@ -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(`<option value="${NONE}">${NONE}</option>`);
}
names.forEach((name) => {
options.push(`<option value="${escapeAttr(name)}">${escapeHtml(name)}</option>`);
});
return options.join('');
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, '&#39;');
}
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 = `
<div class="um-template-field-row">
<div class="selectContainer">
<label class="selectLabel" for="um-template-select">Template</label>
<select is="emby-select" id="um-template-select" class="emby-select-withcolor emby-select">
${getTemplateOptionsHtml(config, true)}
</select>
</div>
</div>
`;
const editTemplatesContainer = document.createElement('div');
editTemplatesContainer.className = 'um-template-field inputContainer';
editTemplatesContainer.innerHTML = `
<div class="um-template-field-row">
<button type="button" class="emby-button raised um-edit-templates-btn block" title="Edit Templates">
Edit Templates
</button>
</div>
`;
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 = `
<div class="selectContainer">
<label class="selectLabel" for="um-bulk-template">Template</label>
<select is="emby-select" id="um-bulk-template" class="emby-select-withcolor emby-select">
${getTemplateOptionsHtml(config, false)}
</select>
</div>
<div class="um-bulk-toolbar">
<button type="button" class="emby-button raised" id="um-bulk-select-all">Select all</button>
<button type="button" class="emby-button raised" id="um-bulk-select-none">Select none</button>
</div>
<div class="um-bulk-user-list">
${users.map((u) => `
<label class="um-bulk-user-item">
<input type="checkbox" class="um-bulk-user-check" value="${escapeAttr(u.Id)}" data-name="${escapeAttr(u.Name)}" />
<span>${escapeHtml(u.Name)}</span>
</label>
`).join('') || '<div style="padding:0.75em;opacity:0.7;">No users found</div>'}
</div>
<div class="um-bulk-status" id="um-bulk-status"></div>
`;
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 = `
<button type="button" class="emby-button raised" id="um-bulk-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-bulk-apply" disabled>Apply</button>
`;
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 = '<span class="material-icons edit" aria-hidden="true"></span>';
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();
}
})();
+2062 -118
View File
File diff suppressed because it is too large Load Diff
+351
View File
@@ -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();
}
})();
File diff suppressed because it is too large Load Diff
+1214 -1181
View File
File diff suppressed because it is too large Load Diff
+475 -476
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -1,13 +1,16 @@
.series-episodes-section {
order: 2;
order: 2;
}
.homeSectionsContainer .card[data-type="Folder"] a.cardImageContainer::after {
font-size: 2rem;
line-height: 2.4rem;
font-size: 2rem;
line-height: 2.4rem;
}
.layout-mobile .homeSectionsContainer .card[data-type="Folder"] a.cardImageContainer::after {
top: 0.5rem;
left: 0.5rem;
font-size: 1.1rem !important;
line-height: 1.3rem !important;
}
top: 0.5rem;
left: 0.5rem;
font-size: 1.1rem !important;
line-height: 1.3rem !important;
}
.innerCardFooter {
--cardFooterGradient: linear-gradient(1deg, rgba(0, 0, 0, 0.8), 40%, transparent) !important;
}
+29
View File
@@ -0,0 +1,29 @@
div#kefinTweaks-breadcrumbs-wrapper {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: auto;
background: linear-gradient(
180deg,
rgb(6.667% 5.49% 11.765% / 0.95) 0%,
rgb(6.667% 5.49% 11.765% / 0.9497204350490196) 6.25%,
rgb(6.667% 5.49% 11.765% / 0.9488817401960784) 12.5%,
rgb(6.667% 5.49% 11.765% / 0.9474839154411764) 18.75%,
rgb(6.667% 5.49% 11.765% / 0.9455269607843136) 25%,
rgb(6.667% 5.49% 11.765% / 0.9430108762254902) 31.25%,
rgb(6.667% 5.49% 11.765% / 0.9399356617647059) 37.5%,
rgb(6.667% 5.49% 11.765% / 0.9363013174019608) 43.75%,
rgb(6.667% 5.49% 11.765% / 0.9321078431372549) 50%,
rgb(6.667% 5.49% 11.765% / 0.9273552389705882) 56.25%,
rgb(6.667% 5.49% 11.765% / 0.9220435049019607) 62.5%,
rgb(6.667% 5.49% 11.765% / 0.9161726409313725) 68.75%,
rgb(6.667% 5.49% 11.765% / 0.9097426470588235) 75%,
rgb(6.667% 5.49% 11.765% / 0.9027535232843137) 81.25%,
rgb(6.667% 5.49% 11.765% / 0.8952052696078431) 87.5%,
rgb(6.667% 5.49% 11.765% / 0.8870978860294118) 93.75%,
rgb(6.667% 5.49% 11.765% / 0.8784313725490196) 100%
);
background-color: #110d20de;
margin: 0;
}
+20 -9
View File
@@ -1,10 +1,21 @@
div#kefinTweaks-breadcrumbs-wrapper {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: auto;
background: linear-gradient(180deg, rgb(6.667% 5.49% 11.765% / 0.95) 0%, rgb(6.667% 5.49% 11.765% / 0.9497204350490196) 6.25%, rgb(6.667% 5.49% 11.765% / 0.9488817401960784) 12.5%, rgb(6.667% 5.49% 11.765% / 0.9474839154411764) 18.75%, rgb(6.667% 5.49% 11.765% / 0.9455269607843136) 25%, rgb(6.667% 5.49% 11.765% / 0.9430108762254902) 31.25%, rgb(6.667% 5.49% 11.765% / 0.9399356617647059) 37.5%, rgb(6.667% 5.49% 11.765% / 0.9363013174019608) 43.75%, rgb(6.667% 5.49% 11.765% / 0.9321078431372549) 50%, rgb(6.667% 5.49% 11.765% / 0.9273552389705882) 56.25%, rgb(6.667% 5.49% 11.765% / 0.9220435049019607) 62.5%, rgb(6.667% 5.49% 11.765% / 0.9161726409313725) 68.75%, rgb(6.667% 5.49% 11.765% / 0.9097426470588235) 75%, rgb(6.667% 5.49% 11.765% / 0.9027535232843137) 81.25%, rgb(6.667% 5.49% 11.765% / 0.8952052696078431) 87.5%, rgb(6.667% 5.49% 11.765% / 0.8870978860294118) 93.75%, rgb(6.667% 5.49% 11.765% / 0.8784313725490196) 100%);
background-color: #110d20de;
margin: 0;
}
position: unset;
bottom: 0;
left: 0;
width: 100%;
height: auto;
background: none;
background-color: #110d20de;
margin: 0;
}
div#kefinTweaks-breadcrumbs-wrapper {
background: none;
position: unset;
}
:not(.layout-mobile) #indexPage .homeSectionsContainer .itemsContainer:not([data-layout="grid"]),
:not(.layout-mobile) #itemDetailPage .custom-scroller .itemsContainer:not([data-layout="grid"]) {
flex-wrap: nowrap !important;
}
.elsewhere-link-reset::before {
content: none !important;
}
+8
View File
@@ -0,0 +1,8 @@
.sectionTitleContainer.sectionTitleContainer-cards.padded-left > .sectionTitle.sectionTitle-cards {
display: inline-block;
}
.cardText-secondary {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
@@ -0,0 +1,3 @@
.itemDetailPage .listItemImage-large {
height: 11vw;
}
+44 -23
View File
@@ -1,37 +1,58 @@
.card[data-type="Genre"] a.cardImageContainer::after {
content: attr(aria-label);
font-size: 3em;
max-width: calc(100% - 1em);
white-space: pre-wrap;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
content: attr(aria-label);
font-size: 3em;
max-width: calc(100% - 1em);
white-space: pre-wrap;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
}
.layout-mobile .card[data-type="Genre"] a.cardImageContainer::after {
font-size: 2em;
font-size: 2em;
}
.card[data-type="Genre"] {
--genre-color: hsl(210 20% 25%);
--card-hue-step: 0deg;
--genre-color: hsl(calc(var(--row-hue) + var(--card-hue-step)) 55% 35%);
}
.card[data-type="Genre"]:nth-child(10n + 1) { --genre-color: hsl(0 55% 35%); } /* red */
.card[data-type="Genre"]:nth-child(10n + 2) { --genre-color: hsl(36 60% 35%); } /* orange */
.card[data-type="Genre"]:nth-child(10n + 3) { --genre-color: hsl(72 55% 35%); } /* yellow-green */
.card[data-type="Genre"]:nth-child(10n + 4) { --genre-color: hsl(108 50% 35%); } /* green */
.card[data-type="Genre"]:nth-child(10n + 5) { --genre-color: hsl(144 45% 35%); } /* teal */
.card[data-type="Genre"]:nth-child(10n + 6) { --genre-color: hsl(180 50% 35%); } /* cyan */
.card[data-type="Genre"]:nth-child(10n + 7) { --genre-color: hsl(216 55% 35%); } /* blue */
.card[data-type="Genre"]:nth-child(10n + 8) { --genre-color: hsl(252 50% 38%); } /* indigo */
.card[data-type="Genre"]:nth-child(10n + 9) { --genre-color: hsl(288 55% 38%); } /* purple */
.card[data-type="Genre"]:nth-child(10n + 10){ --genre-color: hsl(324 55% 38%); } /* magenta */
.card[data-type="Genre"]:nth-child(10n + 1) {
--card-hue-step: 0deg;
}
.card[data-type="Genre"]:nth-child(10n + 2) {
--card-hue-step: 36deg;
}
.card[data-type="Genre"]:nth-child(10n + 3) {
--card-hue-step: 72deg;
}
.card[data-type="Genre"]:nth-child(10n + 4) {
--card-hue-step: 108deg;
}
.card[data-type="Genre"]:nth-child(10n + 5) {
--card-hue-step: 144deg;
}
.card[data-type="Genre"]:nth-child(10n + 6) {
--card-hue-step: 180deg;
}
.card[data-type="Genre"]:nth-child(10n + 7) {
--card-hue-step: 216deg;
}
.card[data-type="Genre"]:nth-child(10n + 8) {
--card-hue-step: 252deg;
}
.card[data-type="Genre"]:nth-child(10n + 9) {
--card-hue-step: 288deg;
}
.card[data-type="Genre"]:nth-child(10n + 10) {
--card-hue-step: 324deg;
}
.card[data-type="Genre"] a.cardImageContainer {
background: var(--genre-color) !important;
background: var(--genre-color) !important;
}
.homeSectionsContainer .card[data-type="Genre"] .cardText {
visibility: hidden;
}
visibility: hidden;
}
@@ -0,0 +1,4 @@
.homePage .unairedIndicator,
.homePage .missingIndicator {
display: none;
}
@@ -1,4 +1,8 @@
/* Hide the title if a logo is present. Use visibility in case skins rely on it for spacing */
.libraryPage:not(.hide):has(.mainDetailButtons .btnPlaystate[data-type="Movie"], .mainDetailButtons .btnPlaystate[data-type="Series"]):has(.detailLogo:not(.hide)) .nameContainer {
visibility: hidden;
}
.libraryPage:not(.hide):has(.mainDetailButtons .btnPlaystate[data-type="Movie"], .mainDetailButtons .btnPlaystate[data-type="Series"]):has(
.detailLogo:not(.hide)
)
.nameContainer,
.libraryPage:not(.hide):has(.mainDetailButtons .btnPlaystate[data-type="Season"]):has(.detailLogo:not(.hide)) .parentName {
visibility: hidden;
}
@@ -0,0 +1,11 @@
/* Kept for compatibility; fade reveal is handled in cardBuilder.css. */
button.show-all-button,
.section-refresh-button,
.section-configure-button,
button.show-all-button,
.section-refresh-button,
.section-configure-button {
opacity: 0.65;
visibility: visible;
pointer-events: auto;
}
@@ -1,7 +0,0 @@
.custom-scroller-container:hover button.show-all-button,
.custom-scroller-container:hover .section-refresh-button {
visibility: visible;
}
button.show-all-button, .section-refresh-button {
visibility: hidden;
}
+44 -23
View File
@@ -1,37 +1,58 @@
.card[data-type="Studio"] a.cardImageContainer:not(.lazy-loaded)::after {
content: attr(aria-label);
font-size: 3em;
max-width: calc(100% - 1em);
white-space: pre-wrap;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
content: attr(aria-label);
font-size: 3em;
max-width: calc(100% - 1em);
white-space: pre-wrap;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
}
.layout-mobile .card[data-type="Studio"] a.cardImageContainer:not(.lazy-loaded)::after {
font-size: 2em;
font-size: 2em;
}
.card[data-type="Studio"] {
--genre-color: hsl(210 20% 25%);
--card-hue-step: 0deg;
--genre-color: hsl(calc(var(--row-hue) + var(--card-hue-step)) 55% 35%);
}
.card[data-type="Studio"]:nth-child(10n + 10) { --genre-color: hsl(0 55% 35%); } /* red */
.card[data-type="Studio"]:nth-child(10n + 9) { --genre-color: hsl(36 60% 35%); } /* orange */
.card[data-type="Studio"]:nth-child(10n + 8) { --genre-color: hsl(72 55% 35%); } /* yellow-green */
.card[data-type="Studio"]:nth-child(10n + 7) { --genre-color: hsl(108 50% 35%); } /* green */
.card[data-type="Studio"]:nth-child(10n + 6) { --genre-color: hsl(144 45% 35%); } /* teal */
.card[data-type="Studio"]:nth-child(10n + 5) { --genre-color: hsl(180 50% 35%); } /* cyan */
.card[data-type="Studio"]:nth-child(10n + 4) { --genre-color: hsl(216 55% 35%); } /* blue */
.card[data-type="Studio"]:nth-child(10n + 3) { --genre-color: hsl(252 50% 38%); } /* indigo */
.card[data-type="Studio"]:nth-child(10n + 2) { --genre-color: hsl(288 55% 38%); } /* purple */
.card[data-type="Studio"]:nth-child(10n + 1){ --genre-color: hsl(324 55% 38%); } /* magenta */
.card[data-type="Studio"]:nth-child(10n + 1) {
--card-hue-step: 0deg;
}
.card[data-type="Studio"]:nth-child(10n + 2) {
--card-hue-step: 36deg;
}
.card[data-type="Studio"]:nth-child(10n + 3) {
--card-hue-step: 72deg;
}
.card[data-type="Studio"]:nth-child(10n + 4) {
--card-hue-step: 108deg;
}
.card[data-type="Studio"]:nth-child(10n + 5) {
--card-hue-step: 144deg;
}
.card[data-type="Studio"]:nth-child(10n + 6) {
--card-hue-step: 180deg;
}
.card[data-type="Studio"]:nth-child(10n + 7) {
--card-hue-step: 216deg;
}
.card[data-type="Studio"]:nth-child(10n + 8) {
--card-hue-step: 252deg;
}
.card[data-type="Studio"]:nth-child(10n + 9) {
--card-hue-step: 288deg;
}
.card[data-type="Studio"]:nth-child(10n + 10) {
--card-hue-step: 324deg;
}
.card[data-type="Studio"] a.cardImageContainer:not(.lazy-loaded) {
background: var(--genre-color) !important;
background: var(--genre-color) !important;
}
.homeSectionsContainer .card[data-type="Studio"] .cardText {
visibility: hidden;
}
visibility: hidden;
}