New Features: Skin Manager, Collections on Details Page, Flatten TV Shows

This commit is contained in:
HighImKevin committed 2025-11-04 06:52:16 -05:00
1 parent 072cfcbe39
commit a09dd8e7f6
34 files changed
+2791 -810

No files matched your search

+245 -71
View File
@@ -1,31 +1,35 @@
# KefinTweaks for Jellyfin
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). 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. There is no configuration required, but if you wish to customize some of the features, there are options to do so.
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).
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.
There is no configuration required, but if you wish to customize some of the features, there are options to do so.
## Table of Contents
- [📦 Installation](#-installation)
- [Installation](#-installation)
- [Prerequisites](#prerequisites)
- [Setup Instructions](#setup-instructions)
- [Features Overview](#features-overview)
- [📋 Core Features](#-core-features)
- [Core Features](#-core-features)
- [Watchlist Page](#watchlist-page)
- [Enhanced Home Screen](#enhanced-home-screen)
- [Enhanced Search](#enhanced-search)
- [🎨 UI Enhancements](#-ui-enhancements)
- [UI Enhancements](#-ui-enhancements)
- [Watchlist Support](#watchlist-support)
- [Navigation Improvements](#navigation-improvements)
- [🔧 System Improvements](#-system-improvements)
- [Performance & Stability](#performance--stability)
- [Branding & Customization](#branding--customization)
- [🛠️ Script Details](#️-script-details)
- [Core Scripts](#core-scripts)
- [Script Details](#-script-details)
- [Dependency Scripts](#dependency-scripts)
- [Feature Scripts](#feature-scripts)
- [Auto-Inject Dependencies](#auto-inject-dependencies)
- [📄 License](#-license)
- [🙏 Acknowledgments](#-acknowledgments)
- [📞 Support](#-support)
- [🔮 Roadmap](#-roadmap)
- [License](#-license)
- [Acknowledgments](#-acknowledgments)
- [Support](#-support)
- [Roadmap](#-roadmap)
- [Planned Features](#planned-features)
- [Version History](#version-history)
@@ -48,18 +52,28 @@ KefinTweaks is a comprehensive collection of enhancements and customizations for
3. **Ensure the script is enabled**
4. **Save your changes**
<img src="pages/images/injector.png" alt="injector" style="max-width: 600px; margin: 0 auto; display: block;"/><br>
<div align="center">
<img src="pages/images/injector.png" alt="injector" style="max-width: 100%; height: auto;"/>
</div>
<br/>
5. **Required for Watchlist: Add a new tab to your Custom Tabs plugin with the following content**
5. **Required for Watchlist:**
Add a new tab to your Custom Tabs plugin with the following content:
```html
<div class="sections watchlist"></div>
```
<img src="pages/images/customtab.png" alt="customtab" style="max-width: 600px; margin: 0 auto; display: block;"/><br>
<div align="center">
<img src="pages/images/customtab.png" alt="customtab" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<hr>
## Features Overview
KefinTweaks provides a modular system of enhancements that can be individually enabled or disabled based on your needs. Each script is designed to work independently while sharing common utilities and dependencies.
As mentioned above, most of the functionality included in KefinTweaks are features that have been requested by the community. Some of these requests have been pending for 5 years or more. I feel that a lot of the top requests have functionality that is essential to the Jellyfin experience but I have also included less requested features, as well as some things that I just personally needed or wanted.
### ✅ **Feature Requests Completed**
- ✅ [Remove items from Continue Watching](https://features.jellyfin.org/posts/517/add-an-option-to-remove-an-item-from-continue-watching)
@@ -69,90 +83,234 @@ KefinTweaks provides a modular system of enhancements that can be individually e
- ✅ [On-Demand Subtitle Search in Video OSD](https://features.jellyfin.org/posts/3385/on-demand-subtitle-search)
- ✅ [Add genres and recommendations to home screen](https://features.jellyfin.org/posts/3501/add-genres-and-recommendations-to-home-screen)
- ✅ [Improved Playlist UX](https://features.jellyfin.org/posts/2823/playlist-moviesummary)
- ✅ [Flattening TV Shows with 1 season](https://features.jellyfin.org/posts/8/flattening-tv-shows) [[2]](https://features.jellyfin.org/posts/3352/add-option-to-flatten-single-season-tv-shows-skip-season-level)
- ✅ [List all collections that an item belongs to on the details page](https://features.jellyfin.org/posts/540/list-all-collections-that-a-movie-belong-to-in-movie-details) [[2]](https://features.jellyfin.org/posts/3540/collection-data-present-on-content-within-the-collection)
- ✅ [Custom Skins/Themes](https://features.jellyfin.org/posts/2509/themes-skins-or-clone-plex-layout-to-convert-the-rest-of-the-plex-and-emby-user-base-to-jellyfin) [[2]](https://features.jellyfin.org/posts/2616/theme-skins)
- ✅ [TV Season Selection at the Series level](https://forum.jellyfin.org/t-tv-season-selection-when-browsing-seasons)
### 🚧 **Feature Requests Planned**
- 🚧 [Flattening TV Shows with 1 season](https://features.jellyfin.org/posts/8/flattening-tv-shows)
- 🚧 [List all collections that an item belongs to on the details page](https://features.jellyfin.org/posts/540/list-all-collections-that-a-movie-belong-to-in-movie-details)
- 🚧 [Add end time to detail page for entire shows and seasons](https://features.jellyfin.org/posts/3470/add-info-ends-at-hh-mm-for-each-season-and-whole-show)
- 🚧 [Add end time to detail page for entire shows and seasons](https://features.jellyfin.org/posts/3470/add-info-ends-at-hh-mm-for-each-season-and-whole-show)
- 🚧 [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>
### 📋 Core Features
### Core Features
#### **Watchlist Page**
- **Watchlist**: Add movies, series, seasons, and episodes to your watchlist with intelligent caching
<img src="pages/images/watchlist.png" alt="watchlist" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Watchlist**:
Tired of forgetting everything you wanted to watch? Add movies, series, seasons, and episodes to your Watchlist!
- **Series Progress**: An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched.
<img src="pages/images/progress.png" alt="progress" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
<div align="center">
<img src="pages/images/watchlist.png" alt="watchlist" style="max-width: 100%; height: auto;"/>
</div>
<br/>
- **Movie History**: An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites.
<img src="pages/images/history.png" alt="history" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Series Progress**:
An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched.
- **Statistics**: A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched.
<img src="pages/images/statistics.png" alt="statistics" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
<div align="center">
<img src="pages/images/progress.png" alt="progress" style="max-width: 100%; height: auto;"/>
</div>
<br/>
- **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/>
- **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>
- **Auto-Remove**:
Automatically removes watched items from your watchlist when playback completes.
- **Real-time Updates**:
Cached watchlist updates when toggling watchlist status from card overlays
<hr>
- **Auto-Remove**: Automatically removes watched items from your watchlist when playback completes.
- **Real-time Updates**: Cached watchlist updates when toggling watchlist status from card overlays
#### **Enhanced Home Screen**
- **Custom Sections**: Add playlist or collection-based sections to your home screen
- **New & Trending**: 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
- **Seasonal Content**: Seasonally-themed sections (Halloween, Christmas, etc.)
<img src="pages/images/seasonalsection.png" alt="homescreen" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Watchlist Integration**: Dedicated watchlist section on home screen
<img src="pages/images/homescreen.png" alt="homescreen" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Custom Sections**:
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)
- **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
- **Seasonal Content**:
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/>
- **Watchlist Integration**:
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/>
#### **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.
- **Jellyseerr Support**: Zero-config support for Jellyseerr results if [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) is installed
- **Meilisearch Support**: Zero-config support for searching with the [Meilisearch](https://github.com/arnesacnussem/jellyfin-plugin-meilisearch) plugin
<img src="pages/images/search.png" alt="search" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **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.
### 🎨 UI Enhancements
- **Jellyseerr Support**:
Zero-config support for Jellyseerr results if [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) is installed
- **Meilisearch Support**:
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;"/>
</div>
<br/>
<hr>
### UI Enhancements
#### **Watchlist Support**
- **Watchlist Toggle**: Watchlist toggle button added to all item card overlays
<img src="pages/images/watchlistoverlay.png" alt="watchlistoverlay" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
- **Watchlist Toggle**:
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;"/>
</div>
<br/>
<hr>
#### **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.
<table align="center" style="width: 100%;">
<tr>
<th style="text-align:center; width: 44.2%;">Top Header Button</th>
<th style="text-align:center;">User Display Preferences</th>
</tr>
<tr>
<td style="width: 35%;"><img src="pages/images/appearanceheader.png" width="1000"/></td>
<td style="width: 65%;"><img src="pages/images/appearanceusersettings.png" width="1000"/></td>
</tr>
</table>
<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.
- **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.
<hr>
#### **Navigation Improvements**
- **Header Tab Enhancements**: Improved tab navigation and functionality, supports linking to specific tabs
- **Breadcrumb Navigation**: Clear navigation paths for Movies, Series, Seasons, Episodes, Music Artists, and Albums
<img src="pages/images/breadcrumbs.png" alt="breadcrumbs" style="max-width: 500px; margin: 0 auto; display: block;"/><br>
- **Header Tab Enhancements**:
Improved tab navigation and functionality, supports linking to specific tabs
- **Custom Menu Integration**: Add custom menu links to the side navigation drawer menu
<img src="pages/images/custommenu.png" alt="custommenu" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
- **Breadcrumb Navigation**:
Clear navigation paths for Movies, Series, Seasons, Episodes, Music Artists, Albums and Songs
- **Subtitle Search**: Search and download subtitles directly from the video OSD
<img src="pages/images/subtitlesearch.png" alt="subtitlesearch" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
<div align="center">
<img src="pages/images/breadcrumbs.png" alt="breadcrumbs" style="max-width: 100%; height: auto;"/>
</div>
<br/>
- **Remove Continue Watching**: Adds a card overlay button to remove items from Continue Watching for all resumable items
<img src="pages/images/continuewatching.png" alt="continuewatching" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
- **Custom Menu Integration**:
Add custom menu links to the side navigation drawer menu
- **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
<div align="center">
<img src="pages/images/custommenu.png" alt="custommenu" style="max-width: 100%; height: auto;"/>
</div>
<br/>
### 🔧 System Improvements
- **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/>
- **Remove 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/>
- **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
- **Flatten Single Season Shows**:
This will display the list of episodes from the first season on the Series details page for Shows which only have 1 season.
<div align="center">
<img src="pages/images/flattenshows.png" alt="flattenshows" style="max-width: 100%; height: auto;"/>
</div>
<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.
<div align="center">
<img src="pages/images/itemdetailscollections.png" alt="itemdetailscollections" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<hr>
### 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
- **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.
- **LocalStorage Caching**: 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
- **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
- **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.
- **LocalStorage Caching**:
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
#### **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).
- **Ratings and Comments**: Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-updoot) backend script
- **Exclusive Elsewhere**:
Custom branding for items which aren't available on any external streaming providers. Requires [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced).
## 🛠️ Script Details
- **Ratings and Comments**:
Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-updoot) backend script
<hr>
### Core Scripts
## Script Details
### Dependency Scripts
| Script | Description |
|--------|-------------|
| `utils.js` | Page view management and common utilities |
| `cardBuilder.js` | Enhanced card building functionality |
| `localStorageCache.js` | Caching layer with TTL management |
| `indexedDBCache.js` | IndexedDB-based caching for large datasets |
| `modal.js` | Generic modal system for dialogs |
| `toaster.js` | Toast notification system |
| `userConfig.js` | User-specific configuration for skins and settings |
### Feature Scripts
@@ -169,24 +327,28 @@ KefinTweaks provides a modular system of enhancements that can be individually e
| `dashboardButtonFix.js` | Dashboard button behavior fix | None |
| `infiniteScroll.js` | Infinite scroll functionality | `cardBuilder` |
| `removeContinue.js` | Remove continue watching functionality | None |
| `subtitleSearch.js` | Subtitle search in video OSD | None |
| `subtitleSearch.js` | Subtitle search in video OSD | `toaster` |
| `breadcrumbs.js` | Breadcrumb navigation | `utils` |
| `playlist.js` | Playlist view enhancements | `cardBuilder`, `utils` |
| `itemDetailsCollections.js` | Collections display on item details pages | `indexedDBCache`, `utils`, `cardBuilder` |
| `flattenSingleSeasonShows.js` | Flatten single-season shows to show episodes directly | `cardBuilder`, `utils` |
| `skinManager.js` | Skin selection and management | `utils`, `userConfig` |
<br>
## Auto-Inject Dependencies
KefinTweaks automatically enables required dependencies when you enable a script that needs them. For example:
- Enabling `watchlist` automatically enables `cardBuilder`, `localStorageCache`, and `modal`
- Enabling `watchlist` automatically enables `cardBuilder`, `localStorageCache`, `modal`, and `utils`
This ensures all scripts have their required dependencies without manual configuration.
<hr>
## 📄 License
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
## Acknowledgments
- **Jellyfin Team**: For the amazing media server platform
- **[n00bcodr](https://github.com/n00bcodr)**: For JellyfinEnhanced, the JS Injector Plugin and moral support
@@ -194,16 +356,28 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- **[BobHasNoSoul](https://github.com/BobHasNoSoul)**: For the jellyfin-updoot functionality
- **[The Jellyfin Community](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ)**: For sharing knowledge and providing a welcoming enviornment to ask questions and learn
## 📞 Support
## Support
- **Issues**: You can report bugs and request features on GitHub Issues, or visit the [Jellyfin Community Discord](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ) to DM me (username: HighImKevin)
- **Documentation**: This README and the inline comments in kefinTweaks.js and the other scripts is the only real documentation available at this time
- **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.
## 🔮 Roadmap
- **Documentation**:
This README and the inline comments in kefinTweaks.js and the other scripts is the only real documentation available at this time
<hr>
## Roadmap
### Planned Features
- **Jellyfin 10.11 Support**: Full compatibility with upcoming Jellyfin versions
- **UI Configuration**: Allow admins to customize the configuration through the UI instead of the JS script
- **Jellyfin 10.11 Support**:
- **UI Configuration**:
Allow admins to customize the configuration through the UI instead of the JS script
Allow users to also customize various configuration options through the UI
<hr>
### Version History
- **v1.0.0**: Initial release for Jellyfin 10.10.7
- **v0.2.0**: New Features: Skin Manager, Collections on Details Page, Flatten TV Shows
- **v0.1.3**: Improve Watchlist UI and Search UX
- **v0.1.2**: Added proper support for Base URLs configured in Jellyfin
- **v0.1.1**: Fixes for Watchlist UI elements and performance improvements for Watchlist and Search
- **v0.1.0**: Initial release for Jellyfin 10.10.7
+1 -1
View File
@@ -5,7 +5,7 @@
'use strict';
// Common logging function
const LOG = (...args) => console.log('[BackdropLeakFix]', ...args);
const LOG = (...args) => console.log('[KefinTweaks BackdropLeakFix]', ...args);
LOG('Script loaded - JS Injector mode');
+169 -22
View File
@@ -1,6 +1,7 @@
// KefinTweaks Breadcrumbs
// Adds breadcrumb navigation to item detail pages
// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum
// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum, Audio
// Requires: utils.js module to be loaded before this script
(function() {
'use strict';
@@ -9,7 +10,7 @@
// Configuration
const CONFIG = {
supportedTypes: ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum'],
supportedTypes: ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio'],
minWidth: 768, // Only show on screens wider than 768px
};
@@ -22,8 +23,10 @@
// Track parent IDs to avoid unnecessary API calls
let lastSeasonsParentId = null;
let lastAlbumsParentId = null;
let lastSongsParentId = null;
let cachedSeasonsData = null;
let cachedAlbumsData = null;
let cachedSongsData = null;
// Smart API call functions that check parent ID
async function getSeasonsIfNeeded(parentId) {
@@ -52,6 +55,19 @@
return albums;
}
async function getSongsIfNeeded(parentId) {
if (lastSongsParentId === parentId && cachedSongsData) {
log('Using existing songs data for parent:', parentId);
return cachedSongsData;
}
log('Fetching songs for parent:', parentId);
const songs = await getSongs(parentId);
lastSongsParentId = parentId;
cachedSongsData = songs;
return songs;
}
// Simplified breadcrumb structure definition
async function getTargetBreadcrumbStructure(item) {
const structure = {
@@ -64,7 +80,17 @@
let queryParams = '';
if (item.ParentId) {
queryParams = `?topParentId=${item.ParentId}&serverId=${ApiClient.serverId()}`;
// Get the top parent item from the ancestors with a Type = CollectionFolder
const ancestors = await ApiClient.getAncestorItems(item.Id);
const collectionFolder = ancestors.find(ancestor => ancestor.Type === 'CollectionFolder');
const userRootFolder = ancestors.find(ancestor => ancestor.Type === 'UserRootFolder');
if (collectionFolder) {
queryParams = `?topParentId=${collectionFolder.Id}&serverId=${ApiClient.serverId()}`;
} else if (userRootFolder) {
queryParams = `?topParentId=${userRootFolder.Id}&serverId=${ApiClient.serverId()}`;
} else {
queryParams = `?serverId=${ApiClient.serverId()}`;
}
}
else if (ApiClient.serverId()) {
queryParams = `?serverId=${ApiClient.serverId()}`;
@@ -116,7 +142,21 @@
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}`, clickable: true },
{ text: item.AlbumArtist, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: item.Name, url: null, clickable: true, popover: true }
{ text: item.Name, url: null, clickable: true, popover: true },
{ text: 'All Songs', url: null, clickable: true, popover: true }
];
} else if (item.Type === 'Audio') {
// Get album details for the album name and artist info
const albumDetails = await getItemDetails(item.ParentId);
const albumName = albumDetails ? albumDetails.Name : 'Unknown Album';
const artistName = albumDetails ? albumDetails.AlbumArtist : 'Unknown Artist';
const artistId = albumDetails ? albumDetails.ParentId : null;
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}`, clickable: true },
{ text: artistName, url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null, clickable: !!artistId },
{ text: albumName, url: null, clickable: true, popover: true },
{ text: item.Name, url: null, clickable: false }
];
}
@@ -155,9 +195,10 @@
const needsPopoverUpdate = targetElement.popover && (
existingText !== targetElement.text ||
existingClickable !== targetClickable ||
// Force update for "All Seasons" and "All Albums" elements since the underlying data changes
// Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes
targetElement.text === 'All Seasons' ||
targetElement.text === 'All Albums'
targetElement.text === 'All Albums' ||
targetElement.text === 'All Songs'
);
if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) {
@@ -305,6 +346,16 @@
});
showPopover(element, popover);
});
} else if (elementDef.text === 'All Songs') {
// Music Album page - always show popover
const songs = await getSongsIfNeeded(item.Id);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(songs, null, (selectedSong) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSong.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
} else if (item.Type === 'Season') {
// Season page - check if single season
const seasons = await getSeasonsIfNeeded(item.ParentId);
@@ -337,8 +388,19 @@
});
}
} else if (item.Type === 'MusicAlbum') {
// Music album page - check if single album
const albums = await getAlbumsIfNeeded(item.ParentId);
// Music album page - clicking album name should show albums from all AlbumArtists
// Prefer AlbumArtists on the item, fall back to fetching details
let albumArtists = item.AlbumArtists;
if (!albumArtists || !Array.isArray(albumArtists) || albumArtists.length === 0) {
const albumDetails = await getItemDetails(item.Id);
albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : [];
}
const artistIds = (albumArtists || []).map(a => a.Id).filter(Boolean);
const albums = artistIds.length > 0
? await getAlbumsByArtistIds(artistIds)
: await getAlbumsIfNeeded(item.ParentId);
if (albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
@@ -352,6 +414,29 @@
showPopover(element, popover);
});
}
} else if (item.Type === 'Audio') {
// Audio (Song) page - clicking album name should show albums from all AlbumArtists of the album
const albumDetails = await getItemDetails(item.ParentId);
const albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : [];
const artistIds = albumArtists.map(a => a.Id).filter(Boolean);
const albums = artistIds.length > 0
? await getAlbumsByArtistIds(artistIds)
: await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null);
if (albums && albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
element.addEventListener('click', (e) => {
e.stopPropagation();
const currentAlbum = albumDetails || null;
const popover = createPopover(albums || [], currentAlbum, (selectedAlbum) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
}
}
}
@@ -481,7 +566,16 @@
selectedItemElement = itemElement; // Store reference to selected item
}
itemElement.textContent = item.Name || `Season ${item.IndexNumber}`;
// Format display text based on item type
let displayText = item.Name;
if (item.Type === 'Season' || item.Type === 'Episode') {
displayText = item.Name || `Season ${item.IndexNumber}`;
} else if (item.Type === 'Audio') {
// For songs, show track number if available
displayText = item.IndexNumber ? `${padNumber(item.IndexNumber)}. ${item.Name}` : item.Name;
}
itemElement.textContent = displayText;
itemElement.addEventListener('click', () => {
log('Popover item clicked:', item.Name);
onItemClick(item);
@@ -560,7 +654,7 @@
log('Retrieved item details:', response.Name, response.Type);
return response;
} catch (err) {
error('Failed to get item details:', err);
console.error('Failed to get item details:', err);
return null;
}
}
@@ -602,6 +696,59 @@
}
}
async function getAlbumsByArtistIds(artistIds) {
try {
const idsParam = Array.isArray(artistIds) ? artistIds.join(',') : String(artistIds || '');
const url = ApiClient.getUrl('Items', {
SortOrder: 'Descending',
IncludeItemTypes: 'MusicAlbum',
Recursive: true,
Fields: 'ParentId',
Limit: 200,
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: idsParam,
SortBy: 'PremiereDate,ProductionYear,Sortname'
});
const response = await ApiClient.getJSON(url);
const items = response.Items || [];
// Deduplicate by Id when multiple artists overlap
const unique = [];
const seen = new Set();
for (const it of items) {
if (it && it.Id && !seen.has(it.Id)) {
seen.add(it.Id);
unique.push(it);
}
}
log('Retrieved albums by artist IDs:', unique.length);
return unique;
} catch (err) {
error('Failed to get albums by artist IDs:', err);
return [];
}
}
async function getSongs(albumId) {
try {
const url = ApiClient.getUrl('Items', {
ParentId: albumId,
IncludeItemTypes: 'Audio',
Recursive: false,
Fields: 'ParentId',
Limit: 100,
StartIndex: 0,
SortBy: 'IndexNumber,SortName'
});
const response = await ApiClient.getJSON(url);
log('Retrieved songs:', response.Items?.length || 0);
return response.Items || [];
} catch (err) {
error('Failed to get songs:', err);
return [];
}
}
function createSeparator() {
const separator = document.createElement('span');
separator.className = 'kefinTweaks-breadcrumb-separator';
@@ -612,7 +759,7 @@
// Page change handler
// Simplified page change handler
async function handlePageChange() {
async function handlePageChange(item = null) {
try {
// Close any active popovers first
closePopover();
@@ -627,20 +774,18 @@
hideBreadcrumbs();
return;
}
// Extract item ID
const itemId = getItemIdFromPath(currentPath);
if (!itemId) {
log('No item ID found in path, hiding and clearing breadcrumbs');
// Check if the user is logged in
if (!ApiClient._loggedIn) {
log('User is not logged in, hiding and clearing breadcrumbs');
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Get item details
const item = await getItemDetails(itemId);
if (!item) {
log('Failed to get item details, hiding and clearing breadcrumbs');
// Extract item ID
if (!item || !item.Id) {
log('No item ID found, hiding and clearing breadcrumbs');
clearBreadcrumbs();
hideBreadcrumbs();
return;
@@ -733,10 +878,12 @@
log('Registering breadcrumb handler with KefinTweaksUtils');
// Register handler for all pages (breadcrumbs can appear on any detail page)
window.KefinTweaksUtils.onViewPage((view, element) => {
window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => {
try {
// Await the item promise to get the actual item data
const item = await itemPromise;
// Run our custom code
handlePageChange();
handlePageChange(item);
} catch (err) {
error('Breadcrumb page change handler failed:', err);
}
+23 -11
View File
@@ -41,7 +41,7 @@
* @returns {Promise<HTMLElement>} - The constructed scrollable container
*/
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
const LOG = (...args) => console.log('[CardBuilder]', ...args);
const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args);
if (!itemIds || itemIds.length === 0) {
LOG(`No item IDs provided for section: ${title}`);
@@ -50,14 +50,16 @@
try {
// Fetch all items at once
const items = await Promise.all(
itemIds.map(id => ApiClient.getItem(ApiClient.getCurrentUserId(), id))
);
const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
Ids: itemIds.join(','),
Recursive: false
});
const items = response.Items;
LOG(`Fetched ${items.length} items for section: ${title}`);
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
} catch (error) {
console.error('[CardBuilder] Error fetching items:', error);
console.error('[KefinTweaks CardBuilder] Error fetching items:', error);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
}
@@ -176,8 +178,10 @@
let imageUrl = '';
if (item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags[0]) {
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
@@ -453,9 +457,7 @@
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
const LOG = (...args) => console.log('[CardBuilder]', ...args);
function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
// Create the main vertical section container
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
@@ -514,7 +516,17 @@
scroller.setAttribute('data-centerfocus', 'card');
scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller';
scroller.setAttribute('data-scroll-mode-x', 'custom');
scroller.style.overflow = 'hidden';
// Enable smooth native horizontal touch scrolling (no snapping, no buttons)
scroller.style.overflowX = 'auto';
scroller.style.overflowY = 'hidden';
scroller.style.scrollSnapType = 'none';
// Allow both axes so vertical page scroll isn't blocked when gesture starts over the scroller
scroller.style.touchAction = 'auto';
// Keep horizontal scroll self-contained but allow vertical to bubble to page
scroller.style.overscrollBehaviorX = 'contain';
scroller.style.overscrollBehaviorY = 'auto';
// iOS inertia scrolling
scroller.style.webkitOverflowScrolling = 'touch';
// Create items container
const itemsContainer = document.createElement('div');
@@ -573,5 +585,5 @@
// Expose the cardBuilder to the global window object
window.cardBuilder = cardBuilder;
console.log('[CardBuilder] Module loaded and available at window.cardBuilder');
console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder');
})();
+4 -3
View File
@@ -1,14 +1,15 @@
// 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
// Requires: utils.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[CustomMenuLinks]', ...args);
const WARN = (...args) => console.warn('[CustomMenuLinks]', ...args);
const ERR = (...args) => console.error('[CustomMenuLinks]', ...args);
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');
+5 -3
View File
@@ -17,18 +17,20 @@
e.stopImmediatePropagation();
e.preventDefault();
console.log("[DashboardButtonFix]: No history to go back. Redirecting to home page.");
console.log("[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.");
// Changes this to the page you'd like to return to by default
const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html';
Dashboard.navigate(`/${homeUrl}`);
}
}, true);
observer.disconnect();
}
// Observe DOM in case button is recreated
const obs = new MutationObserver(attachHandler);
obs.observe(document.body, { childList: true, subtree: true });
const observer = new MutationObserver(attachHandler);
observer.observe(document.body, { childList: true, subtree: true });
// Initial run
attachHandler();
+26
View File
@@ -0,0 +1,26 @@
.docspinner {
contain: layout style size;
top: 6em;
right: calc(1em + 7px);
left: unset;
position: fixed;
width: 3em;
height: 3em;
z-index: 9999999;
margin: 0;
pointer-events: none;
}
/* Item Details Page */
div#seriesScheduleSection {
order: 2;
}
.detailPageContent>* {
order: 1;
}
.commentsSection {
order: 3;
}
+3 -3
View File
@@ -20,9 +20,9 @@
'use strict';
// Common logging function
const LOG = (...args) => console.log('[ExclusiveElsewhere]', ...args);
const WARN = (...args) => console.warn('[ExclusiveElsewhere]', ...args);
const ERR = (...args) => console.error('[ExclusiveElsewhere]', ...args);
const LOG = (...args) => console.log('[KefinTweaks ExclusiveElsewhere]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks ExclusiveElsewhere]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ExclusiveElsewhere]', ...args);
LOG('Script loaded - JS Injector mode');
+211
View File
@@ -0,0 +1,211 @@
// 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 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&EnableImageTypes=Primary`;
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
const 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');
}
// Render Season 1 section using renderCards
const seasonSection = window.cardBuilder.renderCards(
episodes,
'Season 1',
viewMoreUrl,
false, // 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);
// 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, 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) {
existingSection.remove();
}
delete activePage.dataset.flattenedSeriesId;
// 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);
}
},
{
immediate: true,
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');
})();
+3 -3
View File
@@ -6,9 +6,9 @@
'use strict';
// Common logging function
const LOG = (...args) => console.log('[HeaderTabs]', ...args);
const WARN = (...args) => console.warn('[HeaderTabs]', ...args);
const ERR = (...args) => console.error('[HeaderTabs]', ...args);
const LOG = (...args) => console.log('[KefinTweaks HeaderTabs]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HeaderTabs]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HeaderTabs]', ...args);
LOG('Initializing...');
+43 -1
View File
@@ -32,7 +32,7 @@
left: 0;
right: 0;
bottom: -25%;
background-image: url('/files/KefinTweaks/pages/images/halloween.jpg');
background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(https://ranaldsgift.github.io/KefinTweaks/pages/images/halloween.jpg);
background-position: 0px -250px;
background-size: auto 102%;
}
@@ -44,4 +44,46 @@
.sectionTitleContainer.sectionTitleContainer-cards.padded-left > .sectionTitle.sectionTitle-cards {
display: flex !important;
}
div#discovery-loading-indicator:not(.show)::before {
content: 'Loading...';
position: absolute;
top: 50%;
left: 0;
right: 0;
bottom: 0;
transform: translateY(-50%);
visibility: visible;
text-align: center;
opacity: 1;
}
#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before {
content: 'Discover More';
}
div#discovery-loading-indicator:not(.show)::after {
content: '\E313';
font-family: 'Material Icons';
position: absolute;
top: calc(50% + 0.4em);
left: 50%;
transform: translateX(-50%) translateY(-50%);
font-size: 1.5em;
color: #ffffff;
visibility: visible;
opacity: 1;
}
.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator {
opacity: 0;
}
#indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
display: none;
}
#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after {
content: '';
}
+908 -152
View File
File diff suppressed because it is too large. Load diff
+229
View File
@@ -0,0 +1,229 @@
// KefinTweaks IndexedDB Cache Manager
// Provides TTL caching with large storage capacity using IndexedDB
// Usage: For caches that exceed localStorage limits
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks IndexedDBCache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks IndexedDBCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks IndexedDBCache]', ...args);
const DB_NAME = 'KefinTweaksCache';
const DB_VERSION = 1;
const STORE_NAME = 'cache';
// Generic IndexedDB cache manager
class IndexedDBCache {
constructor(prefix = 'kefinTweaks_', ttl = 24 * 60 * 60 * 1000) {
this.prefix = prefix;
this.ttl = ttl;
this.db = null;
}
// Open database connection
async openDB() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
ERR('Failed to open IndexedDB:', request.error);
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
}
};
});
}
// Generate cache key with user context
getCacheKey(cacheName, userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
return `${this.prefix}${cacheName}_${user}`;
}
// Check if cache is valid (not expired)
async isCacheValid(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(key);
return new Promise((resolve) => {
request.onsuccess = () => {
const data = request.result;
if (!data) {
resolve(false);
return;
}
const now = Date.now();
const ttl = data.ttl || this.ttl;
resolve((now - data.timestamp) < ttl);
};
request.onerror = () => {
WARN('Error checking cache validity:', request.error);
resolve(false);
};
});
} catch (error) {
ERR('Error in isCacheValid:', error);
return false;
}
}
// Get cached data if valid
async get(cacheName, userId = null) {
if (!(await this.isCacheValid(cacheName, userId))) {
return null;
}
const key = this.getCacheKey(cacheName, userId);
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(key);
return new Promise((resolve) => {
request.onsuccess = () => {
const data = request.result;
resolve(data ? data.payload : null);
};
request.onerror = () => {
WARN('Error getting cache:', request.error);
resolve(null);
};
});
} catch (error) {
ERR('Error in get:', error);
return null;
}
}
// Store data with timestamp
async set(cacheName, data, userId = null, customTtl = null) {
const key = this.getCacheKey(cacheName, userId);
const ttl = customTtl || this.ttl;
const payload = {
key: key,
timestamp: Date.now(),
ttl: ttl,
payload: data
};
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(payload);
return new Promise((resolve) => {
request.onsuccess = () => {
LOG(`Cached data for ${cacheName}: ${Array.isArray(data) ? data.length : 'object'} items (TTL: ${ttl / (1000 * 60 * 60)}h)`);
resolve(true);
};
request.onerror = () => {
WARN('Failed to store data:', request.error);
resolve(false);
};
});
} catch (error) {
ERR('Error in set:', error);
return false;
}
}
// Clear specific cache
async clear(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(key);
return new Promise((resolve) => {
request.onsuccess = () => {
LOG(`Cleared cache for ${cacheName}`);
resolve(true);
};
request.onerror = () => {
WARN('Error clearing cache:', request.error);
resolve(false);
};
});
} catch (error) {
ERR('Error in clear:', error);
return false;
}
}
// Clear all caches for current user
async clearAll(userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
const userSuffix = `_${user}`;
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.openCursor();
return new Promise((resolve) => {
let count = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.value.key;
if (key.startsWith(this.prefix) && key.endsWith(userSuffix)) {
cursor.delete();
count++;
}
cursor.continue();
} else {
LOG(`Cleared all caches for user ${user}: ${count} entries`);
resolve(count);
}
};
request.onerror = () => {
WARN('Error clearing all caches:', request.error);
resolve(0);
};
});
} catch (error) {
ERR('Error in clearAll:', error);
return false;
}
}
}
// Create global instance
window.IndexedDBCache = IndexedDBCache;
LOG('IndexedDBCache manager loaded');
})();
+9 -7
View File
@@ -1,11 +1,15 @@
// KefinTweaks Infinite Scroll
// Adds infinite scrolling to media library pages (Movies, TV, Music)
// Requires: cardBuilder.js module to be loaded before this script
(function () {
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY = 1000;
// Common logging functions
const LOG = (...args) => console.log('[InfiniteScroll]', ...args);
const WARN = (...args) => console.warn('[InfiniteScroll]', ...args);
const ERR = (...args) => console.error('[InfiniteScroll]', ...args);
const LOG = (...args) => console.log('[KefinTweaks InfiniteScroll]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks InfiniteScroll]', ...args);
const ERR = (...args) => console.error('[KefinTweaks InfiniteScroll]', ...args);
LOG('Initializing...');
@@ -383,20 +387,18 @@
if (!page) return null;
let activeTab = null;
if (page === 'tv.html') {
if (page === 'tv.html' || page === 'tv') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html') {
} else if (page === 'movies.html' || page === 'movies') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) {
LOG('No active tab found for page:', page);
return null;
}
const container = activeTab.querySelector(".itemsContainer");
if (!container) {
LOG('No itemsContainer found in active tab:', activeTab);
return null;
}
+145 -8
View File
@@ -6,6 +6,7 @@
'use strict';
console.log('[KefinTweaks Injector] Initializing...');
const versionNumber = "0.2.0";
// Configuration: Start with defaults, then merge user config
// This allows new scripts to work out of the box without requiring config updates
@@ -24,6 +25,8 @@
removeContinue: true, // Remove from continue watching functionality
subtitleSearch: true, // Subtitle search functionality
playlist: true, // Playlist view page modifications
itemDetailsCollections: true, // Add related collections to item details pages
flattenSingleSeasonShows: true, // Flatten series with only 1 season to show episodes directly
skinManager: true, // Skin selection and management
// Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal)
@@ -52,7 +55,7 @@
{
name: 'skinManager',
script: 'skinManager.js',
css: null,
css: 'defaultSkin.css',
dependencies: ['utils', 'userConfig'],
description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading'
},
@@ -70,6 +73,13 @@
dependencies: [],
description: 'localStorage-based caching layer with 24-hour TTL and manual refresh'
},
{
name: 'indexedDBCache',
script: 'indexedDBCache.js',
css: null,
dependencies: [],
description: 'IndexedDB-based caching layer for large datasets with TTL support'
},
{
name: 'modal',
script: 'modal.js',
@@ -77,6 +87,13 @@
dependencies: [],
description: 'Generic modal system for Jellyfin-style dialogs'
},
{
name: 'toaster',
script: 'toaster.js',
css: null,
dependencies: [],
description: 'Toast notification system using Jellyfin\'s existing toast functionality'
},
{
name: 'watchlist',
script: 'watchlist.js',
@@ -158,7 +175,7 @@
name: 'subtitleSearch',
script: 'subtitleSearch.js',
css: 'subtitleSearch.css',
dependencies: [],
dependencies: ['toaster'],
description: 'Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources'
},
{
@@ -175,8 +192,124 @@
dependencies: ['cardBuilder', 'utils'],
description: 'Modifies playlist view page behavior to navigate to item details instead of playing, and adds play button to playlist items'
},
{
name: 'itemDetailsCollections',
script: 'itemDetailsCollections.js',
css: null,
dependencies: ['indexedDBCache', 'utils', 'cardBuilder'],
description: 'Adds related collections to item details pages showing which collections contain the current item'
},
{
name: 'flattenSingleSeasonShows',
script: 'flattenSingleSeasonShows.js',
css: null,
dependencies: ['cardBuilder', 'utils'],
description: 'Flattens series with only 1 season to show episodes directly on the series details page'
},
];
// Use this for development
//const urlSuffix = '';
const urlSuffix = `?v=${new Date().getTime()}`;
// Fetch version date from GitHub releases API
async function fetchVersionDate(version) {
try {
const response = await fetch(`https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/tags/v${version}`);
if (!response.ok) {
// Try latest release if tag doesn't exist
const latestResponse = await fetch(`https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest`);
if (!latestResponse.ok) throw new Error('Failed to fetch release data');
const data = await latestResponse.json();
return new Date(data.published_at);
}
const data = await response.json();
return new Date(data.published_at);
} catch (error) {
console.warn('[KefinTweaks Injector] Failed to fetch version date from GitHub:', error);
return null;
}
}
// Inject dashboard drawer version badge CSS using dynamic version number
function injectVersionBadgeCSS() {
const styleId = 'kefinTweaks-version-css';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft {
position: relative;
}
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::after {
content: 'KefinTweaks v${versionNumber}';
font-style: italic;
text-align: center;
margin-top: 10px;
color: #b7b7b7;
font-size: 0.9em;
display: block;
cursor: help;
}
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::before {
content: attr(data-version-date);
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(-100%);
background-color: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
margin-bottom: 5px;
z-index: 1000;
}
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft:hover::before {
opacity: 1;
}
`;
document.head.appendChild(style);
// Fetch and set version date
fetchVersionDate(versionNumber).then(date => {
if (date) {
const drawer = document.querySelector('.MuiPaper-root.MuiDrawer-paperAnchorLeft');
if (drawer) {
// Format date as "Released: MM/DD/YYYY"
const formattedDate = `Released: ${date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}`;
drawer.setAttribute('data-version-date', formattedDate);
} else {
// Wait for drawer to be available
const observer = new MutationObserver((mutations, obs) => {
const drawer = document.querySelector('.MuiPaper-root.MuiDrawer-paperAnchorLeft');
if (drawer) {
const formattedDate = `Released: ${date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}`;
drawer.setAttribute('data-version-date', formattedDate);
obs.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
}
});
}
// Auto-enable dependencies for enabled scripts
function autoEnableDependencies() {
let hasChanges = false;
@@ -227,14 +360,14 @@
// Get the root path for scripts from configuration
function getScriptRoot() {
return window.KefinTweaksConfig?.scriptRoot || 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
return window.KefinTweaksConfig?.scriptRoot || 'https://ranaldsgift.github.io/KefinTweaks/';
}
// Load a CSS file
function loadCSS(filename) {
return new Promise((resolve, reject) => {
// Check if CSS is already loaded
const existingLink = document.querySelector(`link[href*="${filename}"]`);
const existingLink = document.querySelector(`link[href="${getScriptRoot()}${filename}${urlSuffix}"]`);
if (existingLink) {
console.log(`[KefinTweaks Injector] CSS already loaded: ${filename}`);
resolve();
@@ -244,7 +377,7 @@
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `${getScriptRoot()}${filename}`;
link.href = `${getScriptRoot()}${filename}${urlSuffix}`;
link.onload = () => {
console.log(`[KefinTweaks Injector] CSS loaded: ${filename}`);
@@ -264,7 +397,7 @@
function loadScript(filename) {
return new Promise((resolve, reject) => {
// Check if script is already loaded
const existingScript = document.querySelector(`script[src="${getScriptRoot()}${filename}"]`);
const existingScript = document.querySelector(`script[src="${getScriptRoot()}${filename}${urlSuffix}"]`);
if (existingScript) {
console.log(`[KefinTweaks Injector] Script already loaded: ${filename}`);
resolve();
@@ -272,7 +405,7 @@
}
const script = document.createElement('script');
script.src = `${getScriptRoot()}${filename}`;
script.src = `${getScriptRoot()}${filename}${urlSuffix}`;
script.async = true;
script.onload = () => {
@@ -445,8 +578,12 @@
// Start initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
document.addEventListener('DOMContentLoaded', () => {
injectVersionBadgeCSS();
initialize();
});
} else {
injectVersionBadgeCSS();
initialize();
}
+245
View File
@@ -0,0 +1,245 @@
// 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';
const LOG = (...args) => console.log('[KefinTweaks ItemDetailsCollections]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks ItemDetailsCollections]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ItemDetailsCollections]', ...args);
const CACHE_NAME = 'collections';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
const POLL_INTERVAL = 1000; // 1 second
// Populate collections cache
async function populateCollectionsCache() {
// Check if user is logged in and poll for 5 seconds if not
if (ApiClient._loggedIn === false) {
WARN('User not logged in, retrying in ' + POLL_INTERVAL + ' milliseconds');
setTimeout(populateCollectionsCache, POLL_INTERVAL);
return;
}
if (!window.IndexedDBCache) {
WARN('IndexedDBCache not available');
return;
}
const cache = new window.IndexedDBCache('kefinTweaks_', CACHE_TTL);
// Check if cache is valid
if (await cache.isCacheValid(CACHE_NAME)) {
LOG('Collections cache is still valid');
return;
}
LOG('Populating collections cache...');
try {
const allCollections = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
IncludeItemTypes: 'BoxSet,CollectionFolder',
Recursive: true
});
const cacheData = [];
for (const coll of allCollections.Items) {
const items = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
ParentId: coll.Id,
});
// 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);
}
}
// Get parent collections for an item ID (returns collection items that can be passed to renderCards)
async function getParentCollections(itemId) {
if (!window.IndexedDBCache) {
WARN('IndexedDBCache not available');
return [];
}
const cache = new window.IndexedDBCache('kefinTweaks_', CACHE_TTL);
const cacheData = await cache.get(CACHE_NAME);
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;
}
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) {
return;
}
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;
}
// 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;
}
try {
// Render collections section using renderCards with cached item data
const collectionsSection = window.cardBuilder.renderCards(
parentCollectionItems,
'Included In',
null,
true,
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');
}
const sectionTitleContainer = collectionsSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
// Add to detailPageContent
const similarSection = detailPageContent.querySelector('#similarCollapsible');
if (similarSection) {
similarSection.before(collectionsSection);
} else {
detailPageContent.appendChild(collectionsSection);
}
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');
setTimeout(initializeCollectionsHook, 1000);
return;
}
LOG('Registering collections handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, itemPromise) => {
// 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;
// Small delay to ensure details DOM is ready
setTimeout(async () => {
if (item && item.Id) {
await renderCollectionsSection(item);
}
}, 100);
},
{
immediate: true,
pages: ['details']
}
);
LOG('Collections hook initialized');
}
// Initialize cache population on script load
function initialize() {
if (!window.IndexedDBCache || !window.ApiClient || !window.ApiClient.getCurrentUserId) {
WARN('Dependencies not available, retrying in 1 second');
setTimeout(initialize, 1000);
return;
}
LOG('Initializing collections cache...');
populateCollectionsCache();
// Initialize collections hook
initializeCollectionsHook();
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
LOG('Script loaded successfully');
})();
+16 -10
View File
@@ -2,7 +2,9 @@
// Copy this entire file into your Jellyfin JS injector plugin
// Modify the configuration below to enable/disable features and customize settings
//
// VERSION: 1.0.0 - Compatible with Jellyfin 10.10.7
// VERSION: 0.2.0
// Full compatibility with Jellyfin 10.10.7
// Untested compatibility with Jellyfin 10.11+
//
// FEATURES OVERVIEW:
// =================
@@ -10,7 +12,6 @@
//
// PREREQUISITES:
// ============
// - Jellyfin 10.10.7
// - JS Injector plugin https://github.com/n00bcodr/Jellyfin-JavaScript-Injector
// - Custom Tabs plugin for watchlist functionality https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs
// - JellyfinEnhanced plugin for Jellyseerr search functionality and ExclusiveElsewhere https://github.com/n00bcodr/Jellyfin-Enhanced
@@ -59,16 +60,21 @@
// Major feature enhancements
homeScreen: true, // Custom home screen sections and discovery engine
search: true, // Enhanced search with better UI and Jellyseerr integration
// REQUIRES: Watchlist requires Custom Tabs plugin https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs
// REQUIREMENTS: Watchlist requires Custom Tabs plugin https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs
watchlist: true, // Complete watchlist management with progress tracking
// UI and navigation improvements
headerTabs: true, // Improved header tab functionality and behavior
customMenuLinks: true, // Load and add custom menu links from configuration
breadcrumbs: true, // Breadcrumb navigation for Movies, Series, Seasons, Episodes, Music
playlist: true, // Enhanced playlist view with better navigation
itemDetailsCollections: true, // Shows collections on item details pages (Included In section)
flattenSingleSeasonShows: true, // Flattens single-season shows to display episodes directly on series page
// Reviews and recommendations
// updoot: false, // This is unsupported for now but will be worked on in the future
// Branding and external integrations
updoot: false, // EXPERIMENTAL: Integration with Jellyfin Updoot upvote functionality - Needs more work
// REQUIRES: ExclusiveElsewhere requires JellyfinEnhanced plugin https://github.com/n00bcodr/Jellyfin-Enhanced
exclusiveElsewhere: false, // Custom branding when items aren't available on streaming services
@@ -77,13 +83,12 @@
dashboardButtonFix: true, // Proper back button behavior on dashboard
infiniteScroll: true, // Smooth infinite scrolling. Supports Movie and TV libraries. Music to be added in the future.
// Playback and media enhancements
// Quality of life improvements
removeContinue: true, // Add "Remove from Continue Watching" functionality
subtitleSearch: true, // Search and download subtitles directly from video OSD
playlist: true, // Enhanced playlist view with better navigation
// UI customization
skinManager: true // Skin selection and management for display preferences
skinManager: true // Skin selection and management - adds skin dropdown to header and display preferences
},
// ============================================================================
@@ -113,6 +118,7 @@
enableInfiniteScroll: false, // Use infinite scroll vs Load More button
minPeopleAppearances: 10, // Minimum movie appearances for people to be featured
minGenreMovieCount: 50, // Minimum movie count for genres to be included
minimumShowsForNetwork: 5, // Minimum show count for TV networks to be featured
// WATCHLIST SECTION
// Requires watchlist script to be enabled
@@ -202,7 +208,7 @@
// Default skin to use when no skin is selected
// This overrides the default selection from userConfig.js
defaultSkin: null, // Set to skin name (e.g., 'Dark Theme') or null to use userConfig default
defaultSkin: null, // Set to skin name (e.g., 'ElegantFin') or null to use userConfig default
// ============================================================================
@@ -260,12 +266,12 @@
// ============================================================================
// Load the injector script which handles loading individual scripts based on configuration
const script = document.createElement("script");
script.src = `${KEFIN_TWEAKS_CONFIG.scriptRoot}injector.js`;
script.src = `${KEFIN_TWEAKS_CONFIG.scriptRoot}injector.js?v=${new Date().getTime()}`;
script.async = true;
document.head.appendChild(script);
console.log('[KefinTweaks] Central configuration loaded. Available at window.KefinTweaksConfig');
console.log('[KefinTweaks] Version 1.0.0 - Compatible with Jellyfin 10.10.7');
console.log('[KefinTweaks] Version 0.2.0 - Compatible with Jellyfin 10.10.7');
})();
+3 -3
View File
@@ -6,9 +6,9 @@
'use strict';
// Common logging function
const LOG = (...args) => console.log('[LocalStorageCache]', ...args);
const WARN = (...args) => console.warn('[LocalStorageCache]', ...args);
const ERR = (...args) => console.error('[LocalStorageCache]', ...args);
const LOG = (...args) => console.log('[KefinTweaks LocalStorageCache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks LocalStorageCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks LocalStorageCache]', ...args);
// Generic localStorage cache manager with chunked storage support
class LocalStorageCache {
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+19 -123
View File
@@ -1,89 +1,18 @@
// Jellyfin Playlist Script
// Modifies playlist view page behavior to navigate to item details instead of playing
// Adds play button to playlist items
// Requires: cardBuilder.js module to be loaded before this script
// Requires: utils.js, cardBuilder.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[Playlist]', ...args);
const WARN = (...args) => console.warn('[Playlist]', ...args);
const ERR = (...args) => console.error('[Playlist]', ...args);
const LOG = (...args) => console.log('[KefinTweaks Playlist]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Playlist]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Playlist]', ...args);
LOG('Initializing...');
let currentPlaylistId = null;
/**
* Dynamically checks if playlist items have already been modified
* @returns {boolean} - True if playlist items have been modified
*/
function isPlaylistPageModified() {
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
return false;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return false;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
if (playlistItems.length === 0) {
return false;
}
// Check if any playlist item has a play button (indicating modification)
for (const item of playlistItems) {
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (buttonsContainer && buttonsContainer.querySelector('.playBtn')) {
return true;
}
}
return false;
}
/**
* Checks if the current page is a playlist page by fetching the item details
* @param {string} itemId - The item ID from the URL
* @returns {Promise<boolean>} - True if the item is a Playlist
*/
async function isPlaylistPage(itemId) {
try {
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
const url = `${serverUrl}/Items/${itemId}`;
const response = await fetch(url, {
headers: {
"Authorization": `MediaBrowser Token="${token}"`
}
});
if (!response.ok) {
WARN(`Failed to fetch item details: ${response.status}`);
return false;
}
const item = await response.json();
LOG(`Item type: ${item.Type}, Name: ${item.Name}`);
return item.Type === 'Playlist';
} catch (error) {
ERR('Error checking if item is playlist:', error);
return false;
}
}
/**
* Modifies playlist item click behavior to navigate to details page
*/
@@ -130,8 +59,6 @@
});
item.dataset.customPlaylistButton = 'true';
LOG(`Modified click handler for playlist item: ${playlistItemId}`);
});
}
@@ -216,8 +143,6 @@
// Prepend the play button to the container
buttonsContainer.insertBefore(playButton, buttonsContainer.firstChild);
LOG(`Added play button to playlist item ${index}: ${playlistItemId}`);
});
LOG(`Added play buttons to ${playlistItems.length} playlist items`);
@@ -227,15 +152,12 @@
* Main function to modify the playlist page
* @param {string} itemId - The playlist item ID
*/
async function modifyPlaylistPage(itemId) {
if (isPlaylistPageModified()) {
async function modifyPlaylistPage() {
if (document.querySelector('.libraryPage:not(.hide)')?.dataset?.kefinPlaylist === 'true') {
LOG('Playlist page already modified, skipping');
return;
}
LOG(`Modifying playlist page for item: ${itemId}`);
currentPlaylistId = itemId;
// Poll for page elements to be ready (every 100ms for up to 10 seconds)
const pollInterval = 100;
const maxAttempts = 100; // 100ms * 100 = 10 seconds
@@ -276,8 +198,14 @@
}
return;
}
if (libraryPage.dataset.kefinPlaylist === 'true') {
LOG('Playlist page already modified, skipping');
return;
}
// Elements found, proceed with modifications
libraryPage.dataset.kefinPlaylist = 'true';
modifyPlaylistItemClicks();
addPlayButtons();
LOG('Playlist page modifications complete');
@@ -286,14 +214,6 @@
pollForElements();
}
/**
* Resets the current playlist ID when navigating away from playlist page
*/
function resetModificationState() {
currentPlaylistId = null;
LOG('Reset playlist modification state');
}
/**
* Initialize playlist hook using utils
*/
@@ -307,36 +227,12 @@
LOG('Registering playlist handler with KefinTweaksUtils');
// Register handler for details pages
window.KefinTweaksUtils.onViewPage((view, element) => {
LOG('Details page detected via utils');
// Check if we're on a details page
const currentUrl = window.location.hash;
if (currentUrl.includes('#/details')) {
const urlParams = new URLSearchParams(currentUrl.split('?')[1]);
const itemId = urlParams.get('id');
if (itemId) {
LOG(`Details page detected with item ID: ${itemId}`);
// Reset state when navigating to a new page
resetModificationState();
// Check if this is a playlist page
isPlaylistPage(itemId).then(isPlaylist => {
if (isPlaylist) {
LOG(`Playlist page detected: ${itemId}`);
modifyPlaylistPage(itemId);
} else {
LOG(`Not a playlist page: ${itemId}`);
}
}).catch(error => {
ERR('Error checking playlist page:', error);
});
}
} else {
// Not on a details page, reset state
resetModificationState();
window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => {
// Await the item promise to get the actual item data
const item = await itemPromise;
if (item && item.Type === 'Playlist') {
modifyPlaylistPage();
return;
}
}, {
immediate: true,
@@ -349,5 +245,5 @@
// Initialize the hook when the script loads
initializePlaylistHook();
console.log('[Playlist] Module loaded and ready');
console.log('[KefinTweaks Playlist] Module loaded and ready');
})();
+3 -3
View File
@@ -9,9 +9,9 @@
(function() {
'use strict';
const LOG = (...args) => console.log('[RemoveContinue]', ...args);
const WARN = (...args) => console.warn('[RemoveContinue]', ...args);
const ERROR = (...args) => console.error('[RemoveContinue]', ...args);
const LOG = (...args) => console.log('[KefinTweaks RemoveContinue]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks RemoveContinue]', ...args);
const ERROR = (...args) => console.error('[KefinTweaks RemoveContinue]', ...args);
LOG('Initializing...');
+17 -62
View File
@@ -1,9 +1,12 @@
// Jellyfin Smart Search - Dual Input + Persistent Toggle + Query Copy
// KefinTweaks Enhanced Search
// Enhanced search functionality with better UI and Jellyseerr integration
// Requires: cardBuilder.js, utils.js modules to be loaded before this script
(function() {
'use strict';
const LOG = (...args) => console.log('[SmartSearch]', ...args);
const WARN = (...args) => console.warn('[SmartSearch]', ...args);
const ERR = (...args) => console.error('[SmartSearch]', ...args);
const LOG = (...args) => console.log('[KefinTweaks Search]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Search]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Search]', ...args);
LOG('Initializing...');
@@ -11,10 +14,6 @@
const CONFIG = window.KefinTweaksConfig?.search || {
enableJellyseerr: false // Toggle for Jellyseerr integration
};
// Track if we should block XHR requests (only for direct URL navigation with query params)
let shouldBlockXHR = false;
//let hasBlockedInitialSearch = false;
// Override XHR to block automatic search requests from direct URL navigation
const OriginalXHR = window.XMLHttpRequest;
@@ -25,20 +24,16 @@
xhr.open = function(method, url, ...args) {
// Check if this is a search request we want to block
// Only block if we're in smart search mode and haven't blocked yet
const isSearchRequest = url.includes('/Items?') &&
(url.includes('searchTerm=') || url.includes('query='));
const isSearchPage = window.location.hash.includes('#/search');
if (isSearchRequest && shouldBlockXHR) {
LOG('Blocking initial automatic search XHR request:', url);
//hasBlockedInitialSearch = true;
// Disable blocking after this first request
shouldBlockXHR = false;
LOG('XHR blocking disabled after initial search');
if (isSearchRequest && isSearchPage) {
LOG('Blocking default Jellyfin search request:', url);
// Override send to do nothing
xhr.send = function() {
LOG('Initial automatic search XHR blocked');
// Simulate a successful response
setTimeout(() => {
xhr.readyState = 4;
@@ -62,30 +57,6 @@
return xhr;
};
// Immediate interception for URL queries - run this as early as possible
(function() {
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('#/search') && window.location.hash.includes('query=')) {
const hashQuery = window.location.hash.split('query=')[1];
if (hashQuery) {
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
queryFromUrl = decodeURIComponent(queryFromUrl).replace(/\+/g, ' ');
}
}
LOG('Early interception - URL query:', queryFromUrl);
LOG('Current pathname:', window.location.pathname);
LOG('Current hash:', window.location.hash);
if (queryFromUrl && window.location.pathname.includes('#/search')) {
LOG('URL query detected, enabling XHR blocking for automatic search prevention');
shouldBlockXHR = true; // Enable XHR blocking for initial URL query
}
})();
// config
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist'];
@@ -199,20 +170,6 @@
}
}
// Show/hide loading spinner
function setLoadingSpinner(show) {
const spinner = document.querySelector('.docspinner.mdl-spinner');
if (spinner) {
if (show) {
spinner.classList.add('mdlSpinnerActive');
LOG('Loading spinner shown');
} else {
spinner.classList.remove('mdlSpinnerActive');
LOG('Loading spinner hidden');
}
}
}
// perform smart search
async function performSmartSearch(searchTerm, searchType = 'core') {
LOG('performSmartSearch()', { searchTerm, searchType });
@@ -248,7 +205,7 @@
}
// Show loading spinner
setLoadingSpinner(true);
Dashboard.showLoadingMsg();
try {
const typeGroups = searchType === 'music'
@@ -312,15 +269,13 @@
// Cache the results
cacheResults(searchTerm, searchType, results);
// Hide loading spinner
setLoadingSpinner(false);
return results;
} catch (e) {
ERR('performSmartSearch error', e);
// Hide loading spinner on error too
setLoadingSpinner(false);
return results;
} finally {
// Hide loading spinner
Dashboard.hideLoadingMsg()
}
}
@@ -605,8 +560,8 @@
const term = (smartInput.value || '').trim();
if (!term) {
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.innerHTML='';
setLoadingSpinner(false); // Hide spinner when clearing results
resultsContainer.innerHTML='';
Dashboard.hideLoadingMsg();
return;
}
if (timer) clearTimeout(timer);
+44 -15
View File
@@ -1,6 +1,7 @@
// KefinTweaks Skin Manager
// Manages skin selection and CSS loading for the display settings page
// Adds a skin dropdown to mypreferencesdisplay.html and handles skin switching
// Requires: utils.js, userConfig.js modules to be loaded before this script
(function() {
'use strict';
@@ -74,6 +75,10 @@
// Tooltip management
let currentTooltip = null;
// Store unregister functions for handlers
let unregisterDisplayPreferencesHandler = null;
let unregisterAnyPageHandler = null;
/**
* Create and show a tooltip
* @param {string} text - The tooltip text
@@ -208,7 +213,7 @@
const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY);
if (selectedColorSchemeName && selectedSkin.colorSchemes) {
const selectedColorScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === selectedColorSchemeName);
if (selectedColorScheme) {
if (selectedColorScheme && selectedColorScheme.url) {
const cssColorSchemesLink = document.getElementById('cssColorSchemes');
if (!cssColorSchemesLink || !cssColorSchemesLink.hasAttribute('data-kefin-color-schemes')) {
WARN(`Color scheme verification failed: Color scheme '${selectedColorSchemeName}' not properly applied`);
@@ -225,7 +230,7 @@
* Initialize the skin manager with verification and retry logic
*/
function initialize(retryCount = 0) {
const MAX_RETRIES = 3;
const MAX_RETRIES = 10;
if (!window.KefinTweaksUtils) {
ERR('KefinTweaksUtils not available - skin manager cannot initialize');
@@ -242,19 +247,7 @@
loadSelectedTheme();
loadSelectedColorScheme();
// Register for the display preferences page
window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, {
pages: ['mypreferencesdisplay'],
immediate: true
});
// Register for all pages to add the appearance button
window.KefinTweaksUtils.onViewPage(handleAnyPage, {
pages: [],
immediate: true
});
LOG('SkinManager initialized');
LOG('SkinManager initializing...');
// Verify skin application after a short delay to allow CSS to load
setTimeout(() => {
@@ -266,13 +259,42 @@
}, 500); // Wait 500ms before retry
} else {
ERR(`Skin application verification failed after ${MAX_RETRIES} attempts - giving up`);
// Register handlers even if verification failed (give up case)
registerHandlers();
}
} else {
LOG('Skin application verified successfully');
// Register handlers after successful verification
registerHandlers();
}
}, 200); // Wait 200ms for CSS to load before verification
}
/**
* Register the onViewPage handlers
*/
function registerHandlers() {
// Only register if not already registered
if (unregisterDisplayPreferencesHandler) {
LOG('Handlers already registered, skipping');
return;
}
// Register for the display preferences page
unregisterDisplayPreferencesHandler = window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, {
pages: ['mypreferencesdisplay'],
immediate: true
});
// Register for all pages to add the appearance button
unregisterAnyPageHandler = window.KefinTweaksUtils.onViewPage(handleAnyPage, {
pages: [],
immediate: true
});
LOG('SkinManager handlers registered');
}
/**
* Handle the display preferences page
* @param {string} view - The view name
@@ -351,6 +373,13 @@
}
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');
}
}
/**
+13
View File
@@ -75,4 +75,17 @@
.slide .watchlist-button:not(:hover) {
background: transparent;
}
.docspinner {
contain: layout style size;
top: 1em;
right: calc(1em + 7px);
left: unset;
position: fixed;
width: 3em;
height: 3em;
z-index: 9999999;
margin: 0;
pointer-events: none;
}
+47 -183
View File
@@ -1,14 +1,15 @@
// Jellyfin Subtitle Search Script
// Adds subtitle search functionality to the video OSD
// Allows users to search and download subtitles from remote sources
// Requires: toaster.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[SubtitleSearch]', ...args);
const WARN = (...args) => console.warn('[SubtitleSearch]', ...args);
const ERR = (...args) => console.error('[SubtitleSearch]', ...args);
const LOG = (...args) => console.log('[KefinTweaks SubtitleSearch]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks SubtitleSearch]', ...args);
const ERR = (...args) => console.error('[KefinTweaks SubtitleSearch]', ...args);
LOG('Initializing...');
@@ -41,6 +42,11 @@
WARN('ApiClient not available, cannot check subtitle permissions');
return false;
}
if (!ApiClient._loggedIn) {
LOG('User is not logged in, cannot check subtitle permissions');
return false;
}
try {
const user = await ApiClient.getCurrentUser();
@@ -549,7 +555,7 @@
}
/**
* Get session data by matching PlayState.MediaSourceId
* Get session data using device ID
*/
async function getSession() {
try {
@@ -559,15 +565,20 @@
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const deviceId = ApiClient.deviceId();
// Get current item ID
const itemId = getCurrentItemId();
if (!itemId) {
throw new Error('No current item ID found');
if (!deviceId) {
throw new Error('Device ID not available');
}
// Get item details to find MediaSourceId
const itemResponse = await fetch(`${serverUrl}/Items/${itemId}`, {
if (!serverUrl) {
throw new Error('Server URL not available');
}
LOG('Getting session with deviceId:', deviceId);
// Get session from the endpoint with device ID
const response = await fetch(`${serverUrl}/Sessions?deviceId=${deviceId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
@@ -575,47 +586,24 @@
}
});
if (!itemResponse.ok) {
throw new Error(`HTTP ${itemResponse.status}: ${itemResponse.statusText}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const itemData = await itemResponse.json();
const mediaSourceId = itemData.MediaSources?.[0]?.Id;
const sessions = await response.json();
if (!mediaSourceId) {
throw new Error('No MediaSourceId found for current item');
// Return the first session if available, or the sessions array itself
if (Array.isArray(sessions) && sessions.length > 0) {
LOG('Found session:', sessions[0].Id);
return sessions[0];
} else if (sessions && !Array.isArray(sessions)) {
// If it's not an array, it might be a single session object
LOG('Found session:', sessions.Id);
return sessions;
} else {
throw new Error('No active session found for device');
}
LOG('Found MediaSourceId:', mediaSourceId);
// Get active sessions
const sessionsResponse = await fetch(`${serverUrl}/Sessions`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
}
});
if (!sessionsResponse.ok) {
throw new Error(`HTTP ${sessionsResponse.status}: ${sessionsResponse.statusText}`);
}
const sessions = await sessionsResponse.json();
// Find session with matching MediaSourceId
const matchingSession = sessions.find(session =>
session.PlayState &&
session.PlayState.MediaSourceId === mediaSourceId
);
if (!matchingSession) {
throw new Error('No active session found with matching MediaSourceId');
}
LOG('Found matching session:', matchingSession.Id);
return matchingSession;
} catch (error) {
ERR('Error getting session:', error);
throw error;
@@ -852,52 +840,12 @@
}
/**
* Show download progress UI in top-right corner
* Show download progress message
*/
function showDownloadProgress() {
// Remove any existing progress indicators
hideDownloadProgress();
const progressToast = document.createElement('div');
progressToast.id = 'subtitleDownloadProgress';
progressToast.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.95);
color: white;
padding: 15px 20px;
border-radius: 8px;
z-index: 99999;
display: flex;
align-items: center;
gap: 12px;
font-family: 'Roboto', sans-serif;
font-size: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
border: 2px solid #eed922;
min-width: 200px;
`;
progressToast.innerHTML = `
<div style="width: 16px; height: 16px; border: 2px solid #eed922; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
<span>Downloading subtitle...</span>
`;
// Add CSS animation if not already present
if (!document.querySelector('#subtitleProgressStyles')) {
const style = document.createElement('style');
style.id = 'subtitleProgressStyles';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Downloading subtitle...', null, false);
}
document.body.appendChild(progressToast);
}
/**
@@ -906,56 +854,9 @@
function showDownloadSuccess() {
hideDownloadProgress();
const successToast = document.createElement('div');
successToast.id = 'subtitleDownloadSuccess';
successToast.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.95);
color: white;
padding: 15px 20px;
border-radius: 8px;
z-index: 99999;
display: flex;
align-items: center;
gap: 12px;
font-family: 'Roboto', sans-serif;
font-size: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
border: 2px solid #4caf50;
min-width: 200px;
animation: fadeInOut 3s ease-in-out;
`;
successToast.innerHTML = `
<span style="font-size: 16px;">✓</span>
<span>Subtitle downloaded and activated!</span>
`;
// Add CSS animation
if (!document.querySelector('#subtitleSuccessStyles')) {
const style = document.createElement('style');
style.id = 'subtitleSuccessStyles';
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; transform: scale(0.8); }
20% { opacity: 1; transform: scale(1); }
80% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.8); }
}
`;
document.head.appendChild(style);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Subtitle downloaded and activated!');
}
document.body.appendChild(successToast);
// Auto-remove after 3 seconds
setTimeout(() => {
if (successToast.parentNode) {
successToast.remove();
}
}, 3000);
}
/**
@@ -964,56 +865,16 @@
function showDownloadError(message) {
hideDownloadProgress();
const errorToast = document.createElement('div');
errorToast.id = 'subtitleDownloadError';
errorToast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(211, 47, 47, 0.95);
color: white;
padding: 20px 30px;
border-radius: 8px;
z-index: 99999;
display: flex;
align-items: center;
gap: 15px;
font-family: 'Roboto', sans-serif;
font-size: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
animation: fadeInOut 4s ease-in-out;
`;
errorToast.innerHTML = `
<span style="font-size: 20px;">⚠</span>
<span>${message}</span>
`;
// Prepend before the first dialog container instead of appending to body
const firstDialog = document.querySelector('.dialogContainer');
if (firstDialog) {
document.body.insertBefore(errorToast, firstDialog);
} else {
document.body.appendChild(errorToast);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(message || 'Failed to download subtitle', '5');
}
// Auto-remove after 4 seconds
setTimeout(() => {
if (errorToast.parentNode) {
errorToast.remove();
}
}, 4000);
}
/**
* Hide download progress UI
* Hide download progress UI (no-op now, kept for compatibility)
*/
function hideDownloadProgress() {
const progressToast = document.querySelector('#subtitleDownloadProgress');
if (progressToast) {
progressToast.remove();
}
// No longer needed since we use toaster.toast() which auto-dismisses
}
/**
@@ -1186,7 +1047,10 @@
LOG('User has subtitle management permission, initializing...');
// Add CC button if it doesn't exist
addCCButton();
if (window.location.hash.includes('#/video')) {
LOG('Video page detected, adding CC button');
addCCButton();
}
// Set up observer for subtitle dialogs
if (subtitleDialogObserver) {
+139
View File
@@ -0,0 +1,139 @@
// KefinTweaks Toaster
// Provides toast notification functionality using Jellyfin's existing toast system
// Usage: window.KefinTweaksToaster.toast(message, duration)
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks Toaster]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Toaster]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Toaster]', ...args);
LOG('Initializing');
/**
* Get the toast container
* The container is the last child at the root of body with class toastContainer
* @returns {HTMLElement|null} The toast container element or null if not found
*/
function getToastContainer() {
// Look for existing toastContainer at the root of body (last child)
const container = document.querySelector('body > .toastContainer:last-child');
if (container) {
return container;
}
// Fallback: try to find any toastContainer
const anyContainer = document.querySelector('.toastContainer');
if (anyContainer) {
return anyContainer;
}
return null;
}
/**
* Convert duration to milliseconds
* @param {string|number} duration - Duration in seconds (string like "3s") or milliseconds (number)
* @returns {number} Duration in milliseconds
*/
function parseDuration(duration) {
if (typeof duration === 'number') {
return duration;
}
if (typeof duration === 'string') {
// Remove 's' if present and convert to number
const seconds = parseFloat(duration.replace('s', ''));
if (!isNaN(seconds)) {
return seconds * 1000;
}
}
// Default to 3 seconds
return 3000;
}
/**
* 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)
*/
function toast(message, duration = '3', autoHide = true) {
if (!message) {
WARN('Toast message is required');
return;
}
let container = getToastContainer();
let customContainer = false;
if (!container) {
// Create a new toast container
const newContainer = document.createElement('div');
newContainer.className = 'toastContainer';
document.body.appendChild(newContainer);
container = newContainer;
customContainer = true;
}
// Remove any existing toast element from the container
const existingToast = container.querySelector('.toast');
if (existingToast) {
existingToast.remove();
LOG('Removed existing toast to make room for new one');
}
// Create the toast element with just 'toast' class initially
const toastElement = document.createElement('div');
toastElement.className = 'toast';
toastElement.textContent = message;
// Add to container first
container.appendChild(toastElement);
LOG(`Displaying toast: "${message}"`);
// Force a browser reflow by reading a layout property
// This ensures the element is rendered with just 'toast' class before adding 'toastVisible'
void toastElement.offsetHeight;
// Now add toastVisible class - the browser will see the state change and apply the transition
toastElement.classList.add('toastVisible');
// Only set up auto-hide if enabled
if (autoHide) {
// Calculate duration in milliseconds
const durationMs = parseDuration(duration);
// 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);
}, durationMs);
}
}
// Expose toaster to global scope
window.KefinTweaksToaster = {
toast
};
LOG('Initialized successfully');
LOG('Available at window.KefinTweaksToaster');
})();
+100 -2
View File
@@ -48,6 +48,12 @@
// Array to store registered handlers
const handlers = [];
// Cache item per page view to avoid multiple fetches
let cachedItem = null;
let cachedItemId = null;
let fetchInProgress = null; // Track the promise of an in-progress fetch
let fetchItemId = null; // Track which item ID is currently being fetched
/**
* Register a callback to be called when page view changes
* @param {Function} callback - Function to call when page view changes
@@ -76,7 +82,9 @@
const currentView = getCurrentView();
if (currentView && shouldCallHandler(handlerConfig, currentView)) {
try {
callback(currentView, document);
// Pass the promise (not awaited) for consistency with notifyHandlers
const itemPromise = getCurrentItem();
callback(currentView, document, itemPromise);
} catch (err) {
ERR('Error in immediate handler call:', err);
}
@@ -93,16 +101,106 @@
};
}
/**
* Get item ID from URL parameters
* Works with both hash-based routing and traditional query strings
* @returns {string|null} Item ID or null if not found
*/
function getItemIdFromUrl() {
try {
const match = window.location.href.match(/[\?&]id=([^&]+)/);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Fetch item by ID
* @param {string} itemId - The item ID
* @returns {Promise<Object|null>} The item object or null if not found
*/
async function fetchItemById(itemId) {
if (!itemId || !window.ApiClient || !window.ApiClient.getItem || !window.ApiClient.getCurrentUserId) {
return null;
}
try {
const userId = window.ApiClient.getCurrentUserId();
const item = await window.ApiClient.getItem(userId, itemId);
return item;
} catch (error) {
WARN('Error fetching item:', error);
return null;
}
}
/**
* Get current item from URL, using cache if available
* @returns {Promise<Object|null>} The item object or null if not found
*/
async function getCurrentItem() {
const itemId = getItemIdFromUrl();
// If no item ID, return null
if (!itemId) {
return null;
}
// If we have a cached item for this ID, return it
if (cachedItem && cachedItemId === itemId) {
return cachedItem;
}
// If a fetch is already in progress for this item, wait for it
if (fetchInProgress && fetchItemId === itemId) {
return await fetchInProgress;
}
// Start a new fetch and store the promise
fetchItemId = itemId;
fetchInProgress = (async () => {
try {
const item = await fetchItemById(itemId);
cachedItem = item;
cachedItemId = itemId;
return item;
} finally {
// Clear the in-progress flag when done (only if still for this item)
if (fetchItemId === itemId) {
fetchInProgress = null;
fetchItemId = null;
}
}
})();
return await fetchInProgress;
}
/**
* Notify all registered handlers of a view change
* @param {string} view - The view name
* @param {Element} element - The view element
*/
function notifyHandlers(view, element) {
// Clear cache when view changes to ensure fresh data
const currentItemId = getItemIdFromUrl();
if (cachedItemId !== currentItemId) {
cachedItem = null;
cachedItemId = null;
fetchInProgress = null; // Clear any in-progress fetch for previous item
fetchItemId = null;
}
// Get the item promise once - will be shared across all handlers
// The promise is cached/fetched by getCurrentItem(), ensuring only one API call
const itemPromise = getCurrentItem();
handlers.forEach((config) => {
if (shouldCallHandler(config, view)) {
try {
config.callback(view, element);
// Pass the promise as third parameter - handlers can await if needed
config.callback(view, element, itemPromise);
} catch (err) {
ERR('Error in onViewPage handler:', err);
}
+10 -2
View File
@@ -1840,7 +1840,7 @@ div[data-tab="watchlist"] .loading-message {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48;
}
.slide .watchlist-button::before {
content: 'bookmark';
content: 'bookmark_outline';
font-family: 'Material Icons';
font-weight: 600;
display: inline-block;
@@ -1858,7 +1858,15 @@ div[data-tab="watchlist"] .loading-message {
-webkit-tap-highlight-color: #fff0;
}
.card:has(button[data-played="true"]) .watchlist-button {
.mainDetailButtons:has(.btnPlay.hide) .watchlist-icon, .mainDetailButtons:has(.btnPlay.hide) .btnRecommend {
display: none;
}
.card:has(button[data-played="true"]) .watchlist-button,
.card:not(:has(button[data-played])) .watchlist-button,
.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon,
.mainDetailButtons:not(:has(button[data-played])) .watchlist-icon,
.mainDetailButtons:not(:has(button[data-played])) .btnRecommend {
display: none;
}
+111 -122
View File
@@ -1,6 +1,6 @@
// Jellyfin Watchlist Script
// Adds watchlist functionality throughout Jellyfin interface
// Requires: cardBuilder.js module to be loaded before this script
// Requires: cardBuilder.js, localStorageCache.js, modal.js, utils.js modules to be loaded before this script
// Requirement #2: Custom Tabs plugin
/*
In the Custom Tabs plugin, add a new tab with the following HTML content:
@@ -13,9 +13,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
'use strict';
// Common logging function
const LOG = (...args) => console.log('[Watchlist]', ...args);
const WARN = (...args) => console.warn('[Watchlist]', ...args);
const ERR = (...args) => console.error('[Watchlist]', ...args);
const LOG = (...args) => console.log('[KefinTweaks Watchlist]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Watchlist]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Watchlist]', ...args);
LOG('Initializing...');
@@ -3331,6 +3331,18 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
LOG('Hidden watchlist button on item detail page of watched item');
}
}
// Check for any cards on the page for the item that was just watched and hide the watchlist button if found
const itemCards = document.querySelectorAll('.card[data-id="' + itemId + '"]');
if (itemCards.length > 0) {
for (const card of itemCards) {
const watchlistIcon = card.querySelector('.watchlist-button');
if (watchlistIcon) {
watchlistIcon.dataset.active = "false";
LOG('Hidden watchlist button on card for watched item');
}
}
}
LOG('Successfully removed played item from watchlist and updated caches:', item.Name);
} else {
@@ -3360,7 +3372,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
}
}
// Hide the watchlist button if we are on the item detail page of the item that was just watched
// Unhide the watchlist button if we are on the item detail page of the item that was just watched
const watchlistIcon = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon');
if (watchlistIcon) {
watchlistIcon.style.display = "block";
@@ -4955,7 +4967,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
// Use cardBuilder to create scrollable container
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.renderCards) {
const scrollableContainer = window.cardBuilder.renderCards(items, getTypeDisplayName(type));
const scrollableContainer = window.cardBuilder.renderCards(items, getTypeDisplayName(type), null);
container.innerHTML = '';
container.appendChild(scrollableContainer);
@@ -4969,53 +4981,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
return { type, itemCount: items.length };
}
async function renderCards(container, type) {
if (!container) {
WARN("Container not found for type:", type);
return { type, itemCount: 0 };
}
const items = await fetchLikedItems(type);
// Hide section if no items
if (!items || items.length === 0) {
container.style.display = 'none';
return { type, itemCount: 0 };
}
// Sort items by release date descending (newest first)
const sortedItems = items.sort((a, b) => {
const dateA = new Date(a.PremiereDate || a.ProductionYear || 0);
const dateB = new Date(b.PremiereDate || b.ProductionYear || 0);
return dateB - dateA; // Descending order (newest first)
});
// Check if content has changed by comparing data-ids
if (compareDataIds(container, sortedItems)) {
LOG(`Skipping ${type} render - content unchanged (${sortedItems.length} items)`);
container.style.display = '';
return { type, itemCount: sortedItems.length };
}
// Show section and use cardBuilder to create scrollable container
container.style.display = '';
// Use cardBuilder to create scrollable container
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.renderCards) {
const scrollableContainer = window.cardBuilder.renderCards(sortedItems, getTypeDisplayName(type));
container.innerHTML = '';
container.appendChild(scrollableContainer);
LOG(`Rendered ${sortedItems.length} ${type} items using cardBuilder (sorted by release date)`);
} else {
ERR("cardBuilder not available - this should not happen with proper dependency management");
container.style.display = 'none';
return { type, itemCount: 0 };
}
return { type, itemCount: sortedItems.length };
}
function getTypeDisplayName(itemType) {
const typeMap = {
'Movie': 'Movies',
@@ -5755,30 +5720,35 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
watchlistButton.appendChild(watchlistIcon);
// Add click event listener
watchlistButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Toggle watchlist status
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
watchlistButton.dataset.active = newRating;
// Update icon and title based on state
const isActive = watchlistButton.dataset.active === 'true';
// Icon state is handled by CSS class, no need to change textContent
watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
// Update watchlist cache immediately
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
});
// Add click event listener
watchlistButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Toggle watchlist status
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
watchlistButton.dataset.active = newRating;
// Update icon and title based on state
const isActive = watchlistButton.dataset.active === 'true';
// Icon state is handled by CSS class, no need to change textContent
watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
// Update watchlist cache immediately
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
});
// Check if item type is supported for watchlist
if (itemType !== "Movie" && itemType !== "Series" && itemType !== "Season" && itemType !== "Episode") {
LOG('Item type not supported for watchlist:', itemType);
return;
}
if (!ApiClient._loggedIn) {
LOG('User is not logged in, skipping watchlist button');
return;
}
// Check item's current watchlist status and set initial state
ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
@@ -5788,6 +5758,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
watchlistButton.title = 'Remove from Watchlist';
}
}).catch(err => {
console.log(card);
ERR('Error fetching item data for watchlist button:', err);
});
@@ -5855,7 +5826,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
/************ Item Detail Page Observer ************/
// Function to add watchlist button to item detail page
function addDetailPageWatchlistButton() {
function addDetailPageWatchlistButton(item = null) {
const trailerButton = document.querySelector('.itemDetailPage:not(.hide) .btnPlayTrailer');
if (!trailerButton) {
@@ -5905,71 +5876,89 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
}
});
// Get item data to check if it should be shown and current state
ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
// Only show for Movies, Series, Seasons, and Episodes
if (item.Type !== "Movie" && item.Type !== "Series" && item.Type !== "Season" && item.Type !== "Episode") {
watchlistIcon.style.display = "none";
}
if (item.UserData && item.UserData.Played) {
watchlistIcon.style.display = "none";
}
if (item) {
// Set initial state based on current watchlist status
if (item.UserData && item.UserData.Likes) {
watchlistIcon.dataset.active = 'true';
watchlistIcon.title = 'Remove from Watchlist';
}
}).catch(err => {
ERR('Error fetching item data:', err);
});
} else {
// Get item data to check if it should be shown and current state
ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
// Only show for Movies, Series, Seasons, and Episodes
if (item.Type !== "Movie" && item.Type !== "Series" && item.Type !== "Season" && item.Type !== "Episode") {
watchlistIcon.style.display = "none";
}
if (item.UserData && item.UserData.Played) {
watchlistIcon.style.display = "none";
}
// Set initial state based on current watchlist status
if (item.UserData && item.UserData.Likes) {
watchlistIcon.dataset.active = 'true';
watchlistIcon.title = 'Remove from Watchlist';
}
}).catch(err => {
ERR('Error fetching item data:', err);
});
}
// Add button after trailer button
trailerButton.after(watchlistIcon);
}
// Function to monitor item detail pages
function monitorItemDetailPage() {
const observer = new MutationObserver(() => {
const visibleItemDetailPage = document.querySelector('.itemDetailPage:not(.hide)');
if (!visibleItemDetailPage) {
return;
}
// Function to monitor item detail pages using onViewPage
function initializeItemDetailPageHandler() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
setTimeout(initializeItemDetailPageHandler, 1000);
return;
}
LOG('Registering item detail page handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => {
// Await the item promise to get the actual item data
const item = await itemPromise;
// Check if watchlist button already exists
const existingButton = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon');
if (existingButton) {
return;
}
// Get item ID from URL to check type before adding button
const itemId = window.location.href.substring(window.location.href.indexOf("id=") + 3, window.location.href.indexOf("id=") + 35);
if (!itemId) {
return;
}
// Check if item type is supported for watchlist
ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
if (item.Type === "Movie" || item.Type === "Series" || item.Type === "Season" || item.Type === "Episode") {
addDetailPageWatchlistButton();
// Small delay to ensure details DOM is ready
setTimeout(() => {
const visibleItemDetailPage = document.querySelector('.itemDetailPage:not(.hide)');
if (!visibleItemDetailPage) {
return;
}
}).catch(err => {
ERR('Error fetching item data for detail page type check:', err);
});
if (visibleItemDetailPage.dataset.watchlistButtonAdded === 'true') {
return;
}
// Check if watchlist button already exists
const existingButton = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon');
if (existingButton) {
return;
}
// Check if item is provided and if item type is supported for watchlist
if (item && (item.Type === "Movie" || item.Type === "Series" || item.Type === "Season" || item.Type === "Episode")) {
visibleItemDetailPage.dataset.watchlistButtonAdded = 'true';
addDetailPageWatchlistButton(item);
}
}, 100);
}, {
immediate: true,
pages: ['details']
});
// Start observing the entire document body
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
LOG('Item detail page handler initialized via KefinTweaksUtils');
}
// Initialize the item detail page observer
monitorItemDetailPage();
// Initialize the item detail page handler
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeItemDetailPageHandler);
} else {
initializeItemDetailPageHandler();
}
// Function to add watchlist button to a slide
function addSlideWatchlistButton(slide) {