New Features: Skin Manager, Collections on Details Page, Flatten TV Shows
No files matched your search
@@ -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
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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: '';
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
|
||||
})();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -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');
|
||||
})();
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||