diff --git a/README.md b/README.md index bd12f59..7dfd025 100644 --- a/README.md +++ b/README.md @@ -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** -injector
+
+ injector +
+
-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
``` -customtab
+
+ customtab +
+
+
## 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) +
-### 📋 Core Features +### Core Features #### **Watchlist Page** -- **Watchlist**: Add movies, series, seasons, and episodes to your watchlist with intelligent caching -watchlist
+- **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. -progress
+
+ watchlist +
+
-- **Movie History**: An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites. -history
+- **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. -statistics
+
+ progress +
+
+ +- **Movie History**: +An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites. + +
+ history +
+
+ +- **Statistics**: +A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched. + +
+ statistics +
+ +- **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 +
-- **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.) -homescreen
-- **Watchlist Integration**: Dedicated watchlist section on home screen -homescreen
+- **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.) + +
+ homescreen +
+
+ +- **Watchlist Integration**: +Dedicated watchlist section on home screen + +
+ homescreen +
+
#### **Enhanced Search** -- **Performance Improvements**: Defaults to searching in Movies/TV/People as most searches are for these items. Options to search specific categories or all categories like the default Jellyfin search functionality. -- **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 -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. -### 🎨 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 + +
+ search +
+
+
+ +### UI Enhancements #### **Watchlist Support** -- **Watchlist Toggle**: Watchlist toggle button added to all item card overlays -watchlistoverlay
+- **Watchlist Toggle**: +Watchlist toggle button added to all item card overlays + +
+ watchlistoverlay +
+
+
+ + +#### **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. + + + + + + + + + + +
Top Header ButtonUser Display Preferences
+
+ +- **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. +
+ #### **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 -breadcrumbs
+- **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 -custommenu
+- **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 -subtitlesearch
+
+ breadcrumbs +
+
-- **Remove Continue Watching**: Adds a card overlay button to remove items from Continue Watching for all resumable items -continuewatching
+- **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 +
+ custommenu +
+
-### 🔧 System Improvements +- **Subtitle Search**: +Search and download subtitles directly from the video OSD + +
+ subtitlesearch +
+
+ +- **Remove Continue Watching**: +Adds a card overlay button to remove items from Continue Watching for all resumable items + +
+ continuewatching +
+
+ +- **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. + +
+ flattenshows +
+
+ +- **Collections on Movie/Series Details page**: +This shows an "Included In" section on the Item Details page which displays any Collections that the item is a part of. + +
+ itemdetailscollections +
+
+
+ + +### 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 +
-### 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` |
## 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. +
-## 📄 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 +
+ +## 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 +
### 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 diff --git a/backdropLeakFix.js b/backdropLeakFix.js index 19075c7..752f216 100644 --- a/backdropLeakFix.js +++ b/backdropLeakFix.js @@ -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'); diff --git a/breadcrumbs.js b/breadcrumbs.js index cf47cfa..e55f70c 100644 --- a/breadcrumbs.js +++ b/breadcrumbs.js @@ -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); } diff --git a/cardBuilder.js b/cardBuilder.js index 7de16ce..baa65e6 100644 --- a/cardBuilder.js +++ b/cardBuilder.js @@ -41,7 +41,7 @@ * @returns {Promise} - 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'); })(); diff --git a/customMenuLinks.js b/customMenuLinks.js index 8a0ac84..f3deed2 100644 --- a/customMenuLinks.js +++ b/customMenuLinks.js @@ -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'); diff --git a/dashboardButtonFix.js b/dashboardButtonFix.js index 7900554..e0e8235 100644 --- a/dashboardButtonFix.js +++ b/dashboardButtonFix.js @@ -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(); diff --git a/defaultSkin.css b/defaultSkin.css new file mode 100644 index 0000000..1f1e82b --- /dev/null +++ b/defaultSkin.css @@ -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; +} \ No newline at end of file diff --git a/exclusiveElsewhere.js b/exclusiveElsewhere.js index ef54697..384abfe 100644 --- a/exclusiveElsewhere.js +++ b/exclusiveElsewhere.js @@ -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'); diff --git a/flattenSingleSeasonShows.js b/flattenSingleSeasonShows.js new file mode 100644 index 0000000..8499a0d --- /dev/null +++ b/flattenSingleSeasonShows.js @@ -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 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'); +})(); + diff --git a/headerTabs.js b/headerTabs.js index 25efe65..c6a524a 100644 --- a/headerTabs.js +++ b/headerTabs.js @@ -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...'); diff --git a/homeScreen.css b/homeScreen.css index 6aaa781..ccce776 100644 --- a/homeScreen.css +++ b/homeScreen.css @@ -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: ''; } \ No newline at end of file diff --git a/homeScreen.js b/homeScreen.js index db65464..93d168b 100644 --- a/homeScreen.js +++ b/homeScreen.js @@ -2,15 +2,15 @@ // Adds custom scrollable sections to the home screen // Home screen sections are generated from playlists // All playlists used for the home screen sections must be public -// Requires: cardBuilder.js module to be loaded before this script +// Requires: cardBuilder.js, localStorageCache.js, utils.js modules to be loaded before this script (function() { 'use strict'; // Common logging function - const LOG = (...args) => console.log('[HomeScreen]', ...args); - const WARN = (...args) => console.warn('[HomeScreen]', ...args); - const ERR = (...args) => console.error('[HomeScreen]', ...args); + const LOG = (...args) => console.log('[KefinTweaks HomeScreen]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreen]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HomeScreen]', ...args); LOG('Initializing...'); @@ -28,23 +28,22 @@ style.textContent = ` /* Discovery section loading indicator */ .discovery-loading-indicator { - display: flex; flex-direction: column; gap: 10px; justify-content: center; align-items: center; padding: 20px; - margin: 20px 0; border-radius: 8px; font-size: 14px; color: #666; - opacity: 0; transition: opacity 0.3s ease; order: 9999 !important; + position: relative; } .discovery-loading-indicator.show { opacity: 1; + display: flex; } .discovery-loading-indicator .spinner { @@ -82,13 +81,25 @@ // Create loading indicator element function createDiscoveryLoadingIndicator() { - const loadingDiv = document.createElement('div'); + let loadingDiv = document.getElementById('discovery-loading-indicator'); + if (loadingDiv) { + return loadingDiv; + } + + loadingDiv = document.createElement('div'); loadingDiv.className = 'discovery-loading-indicator'; loadingDiv.id = 'discovery-loading-indicator'; + loadingDiv.style.visibility = 'hidden'; loadingDiv.innerHTML = `
- Loading more discovery sections... `; + + // Find the home sections container and append the loading indicator + const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + if (container) { + container.appendChild(loadingDiv); + } + return loadingDiv; } @@ -97,23 +108,12 @@ let loadingIndicator = document.getElementById('discovery-loading-indicator'); if (!loadingIndicator) { - loadingIndicator = createDiscoveryLoadingIndicator(); - - // Find the home sections container and append the loading indicator - const container = document.querySelector('.homeSectionsContainer'); - if (container) { - container.appendChild(loadingIndicator); - } else { - // Fallback: append to body - document.body.appendChild(loadingIndicator); - } + createDiscoveryLoadingIndicator(); } - // Show with animation - setTimeout(() => { - loadingIndicator.classList.add('show'); - }, 10); - + loadingIndicator.classList.add('show'); + loadingIndicator.style.visibility = 'visible'; + loadingIndicator.style.display = 'flex'; LOG('Discovery loading indicator shown'); } @@ -121,15 +121,13 @@ function hideDiscoveryLoadingIndicator() { const loadingIndicator = document.getElementById('discovery-loading-indicator'); if (loadingIndicator) { - loadingIndicator.classList.remove('show'); - - // Remove from DOM after animation - setTimeout(() => { - if (loadingIndicator.parentNode) { - loadingIndicator.parentNode.removeChild(loadingIndicator); - } - }, 300); - + loadingIndicator.classList.remove('show'); + loadingIndicator.style.visibility = 'hidden'; + + if (!enableInfiniteScroll) { + loadingIndicator.style.display = 'none'; + } + LOG('Discovery loading indicator hidden'); } } @@ -209,6 +207,7 @@ // People and genre configuration const minPeopleAppearances = window.KefinTweaksConfig?.homeScreen?.minPeopleAppearances || 10; const minGenreMovieCount = window.KefinTweaksConfig?.homeScreen?.minGenreMovieCount || 50; + const minimumShowsForNetwork = window.KefinTweaksConfig?.homeScreen?.minimumShowsForNetwork || 5; const enableInfiniteScroll = window.KefinTweaksConfig?.homeScreen?.enableInfiniteScroll !== false; // Default to true // Seasonal configuration @@ -240,8 +239,12 @@ let renderedActors = new Set(); // Track rendered actor IDs let renderedDirectors = new Set(); // Track rendered director IDs let renderedWriters = new Set(); // Track rendered writer IDs - let renderedWatchedMovies = new Set(); // Track rendered watched movie IDs + let renderedStudios = new Set(); // Track rendered studio IDs + let renderedWatchedMovies = new Set(); // Track rendered watched movie IDs for "Because you watched" sections let renderedFavoriteMovies = new Set(); // Track rendered favorite movie IDs + let renderedStarringWatchedMovies = new Set(); // Track movies used for "Starring X since you watched Y" sections + let renderedDirectedWatchedMovies = new Set(); // Track movies used for "Directed by X since you watched Y" sections + let renderedWrittenWatchedMovies = new Set(); // Track movies used for "Written by X since you watched Y" sections // Pre-rendered sections waiting to be revealed let hiddenDiscoverySections = []; // Array of pre-rendered section DOM elements @@ -252,6 +255,9 @@ let preloadedSectionElements = []; // Preloaded section DOM elements (hidden) let isPreloadingSections = false; // Prevent parallel preloading let discoveryScrollHandler = null; // Reference to scroll handler for cleanup + let discoveryWheelHandler = null; // Reference to wheel handler for cleanup + let discoveryTouchStartHandler = null; // Reference to touchstart handler for cleanup + let discoveryTouchMoveHandler = null; // Reference to touchmove handler for cleanup /************ Helpers ************/ @@ -420,7 +426,8 @@ const scrollableContainer = await window.cardBuilder.renderCardsFromIds( limitedItemIds, section.name ?? itemData.Name, - viewMoreUrl + viewMoreUrl, + true ); // Add data attribute to track rendered sections @@ -449,7 +456,16 @@ */ async function renderAllCustomSections(container) { const results = await Promise.all( - customHomeSections.map(section => renderCustomSection(section, container)) + customHomeSections.map(section => { + // Check if section is already on the page + const sectionContainer = container.querySelector(`[data-custom-section-id="${section.id}"]`); + if (sectionContainer) { + LOG(`Section ${section.name} already on the page, skipping...`); + return false; + } + + return renderCustomSection(section, container); + }) ); const successCount = results.filter(Boolean).length; @@ -490,21 +506,28 @@ const sectionsToRender = []; - // Add new movies section if enabled - if (enableNewMovies) { + // Add new movies section if enabled and not already on the page + const newMoviesContainer = container.querySelector('[data-custom-section-id="new-movies"]'); + + if (enableNewMovies && !newMoviesContainer) { sectionsToRender.push(renderNewMoviesSection(container)); } - // Add new episodes section if enabled - if (enableNewEpisodes) { + // Add new episodes section if enabled and not already on the page + const newEpisodesContainer = container.querySelector('[data-custom-section-id="new-episodes"]'); + + if (enableNewEpisodes && !newEpisodesContainer) { sectionsToRender.push(renderNewEpisodesSection(container)); } // Add trending sections if enabled (stubs for now) - if (enableTrending) { +/* const trendingMoviesContainer = container.querySelector('[data-custom-section-id="trending-movies"]'); + const trendingSeriesContainer = container.querySelector('[data-custom-section-id="trending-series"]'); + + if (enableTrending && !trendingMoviesContainer && !trendingSeriesContainer) { sectionsToRender.push(renderTrendingMoviesSection(container)); sectionsToRender.push(renderTrendingSeriesSection(container)); - } + } */ if (sectionsToRender.length === 0) { return; @@ -899,6 +922,7 @@ .sort((a, b) => b.directorCount - a.directorCount) .slice(0, 100) .map(person => ({ + id: person.id, name: person.name, count: person.directorCount, itemIds: person.directorItemIds @@ -908,7 +932,8 @@ .filter(person => person.writerCount >= minPeopleAppearances) .sort((a, b) => b.writerCount - a.writerCount) .slice(0, 100) - .map(person => ({ + .map(person => ({ + id: person.id, name: person.name, count: person.writerCount, itemIds: person.writerItemIds @@ -918,7 +943,8 @@ .filter(person => person.actorCount >= minPeopleAppearances) .sort((a, b) => b.actorCount - a.actorCount) .slice(0, 100) - .map(person => ({ + .map(person => ({ + id: person.id, name: person.name, count: person.actorCount, itemIds: person.actorItemIds @@ -1330,6 +1356,239 @@ return getRandomItem(favorites); } + /** + * Gets random recently watched movie with heavy weighting for first 5 + * @param {Set} trackingSet - Optional set to track rendered movies (defaults to renderedWatchedMovies) + * @returns {Object|null} - Random recently watched movie or null + */ + function getRandomRecentlyWatchedMovie(trackingSet = null) { + const movies = getMovieHistoryData(); + if (movies.length === 0) return null; + + // Sort by LastPlayedDate descending and take top 10 + const sortedMovies = movies + .filter(movie => movie.UserData && movie.UserData.Played && movie.UserData.LastPlayedDate) + .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate)) + .slice(0, 10); + + if (sortedMovies.length === 0) return null; + + // Use provided tracking set or default to renderedWatchedMovies + const usedTrackingSet = trackingSet || renderedWatchedMovies; + + // Filter out already rendered movies based on the tracking set + const availableMovies = sortedMovies.filter(movie => + movie.Id && !usedTrackingSet.has(movie.Id) + ); + + if (availableMovies.length === 0) return null; + + // Heavy weighting: 70% chance for first 5, 30% for rest + let selectedMovie; + if (Math.random() < 0.7 && availableMovies.length >= 5) { + // Pick from first 5 + const firstFive = availableMovies.slice(0, 5); + selectedMovie = firstFive[Math.floor(Math.random() * firstFive.length)]; + } else { + // Pick from all available + selectedMovie = availableMovies[Math.floor(Math.random() * availableMovies.length)]; + } + + // Track rendered movie in the appropriate set + if (selectedMovie && selectedMovie.Id) { + usedTrackingSet.add(selectedMovie.Id); + } + + return selectedMovie; + } + + /** + * Fetches and caches Popular TV Networks (studios) from all TV show libraries + * @returns {Promise} - Array of studio objects sorted by ChildCount (descending) + */ + async function fetchAndCachePopularTVNetworks() { + const cache = new window.LocalStorageCache(); + const cacheKey = 'popularTVNetworks'; + + // Check cache first + const cached = cache.get(cacheKey); + if (cached) { + LOG('Using cached Popular TV Networks'); + return cached; + } + + try { + LOG('Fetching Popular TV Networks...'); + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + // Get root libraries + const librariesResponse = await apiClient.getItems(); + const libraries = librariesResponse.Items || []; + + // Filter for TV show libraries + const tvLibraries = libraries.filter(lib => lib.CollectionType === 'tvshows'); + + if (tvLibraries.length === 0) { + LOG('No TV show libraries found'); + return []; + } + + // Fetch studios from all TV libraries + const allStudios = []; + for (const library of tvLibraries) { + try { + const studiosUrl = `${serverUrl}/Studios?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series&Recursive=true&Fields=DateCreated%2CPrimaryImageAspectRatio&StartIndex=0&ParentId=${library.Id}&userId=${userId}`; + const response = await fetch(studiosUrl, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!response.ok) { + WARN(`Failed to fetch studios for library ${library.Name}: ${response.status}`); + continue; + } + + const data = await response.json(); + const studios = data.Items || []; + allStudios.push(...studios); + } catch (err) { + ERR(`Error fetching studios for library ${library.Name}:`, err); + } + } + + // Deduplicate studios by Id + const uniqueStudios = []; + const seenIds = new Set(); + for (const studio of allStudios) { + if (!seenIds.has(studio.Id)) { + seenIds.add(studio.Id); + uniqueStudios.push(studio); + } + } + + // Sort by ChildCount (descending) + uniqueStudios.sort((a, b) => (b.ChildCount || 0) - (a.ChildCount || 0)); + + // Filter by minimum shows + const filteredStudios = uniqueStudios.filter(studio => + (studio.ChildCount || 0) >= minimumShowsForNetwork + ); + + LOG(`Fetched ${filteredStudios.length} Popular TV Networks (filtered from ${uniqueStudios.length})`); + + // Cache the results + cache.set(cacheKey, filteredStudios); + + return filteredStudios; + } catch (err) { + ERR('Error fetching Popular TV Networks:', err); + return []; + } + } + + /** + * Gets Popular TV Networks from cache or fetches if not cached + * @returns {Promise} - Array of studio objects + */ + async function getPopularTVNetworks() { + const cache = new window.LocalStorageCache(); + const cacheKey = 'popularTVNetworks'; + + const cached = cache.get(cacheKey); + if (cached && cached.length > 0) { + return cached; + } + + return await fetchAndCachePopularTVNetworks(); + } + + /** + * Fetches items (Series) by studio ID + * @param {string} studioId - Studio ID + * @returns {Promise} - Array of series items + */ + async function fetchItemsByStudio(studioId) { + try { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + // Use Items endpoint with StudioIds parameter, sort by random + const url = `${serverUrl}/Items?IncludeItemTypes=Series&Recursive=true&SortBy=Random&Fields=PrimaryImageAspectRatio%2CDateCreated%2COverview%2CProductionYear%2CPeople&StudioIds=${studioId}&userId=${userId}&Limit=32`; + + const response = await fetch(url, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data.Items || []; + } catch (err) { + ERR(`Error fetching items by studio ${studioId}:`, err); + return []; + } + } + + /** + * Fetches items by person ID (for actors, directors, writers) + * @param {string} personId - Person ID + * @param {string} includeItemTypes - Item types to include (default: 'Movie') + * @returns {Promise} - Array of items + */ + async function fetchItemsByPersonId(personId, includeItemTypes = 'Movie') { + try { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + // Use Items endpoint with PersonIds parameter, sort by random, include People field + const url = `${serverUrl}/Items?IncludeItemTypes=${includeItemTypes}&PersonIds=${personId}&Recursive=true&SortBy=Random&Fields=PrimaryImageAspectRatio%2CDateCreated%2COverview%2CProductionYear%2CPeople&userId=${userId}&Limit=32`; + + const response = await fetch(url, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data.Items || []; + } catch (err) { + ERR(`Error fetching items by person ${personId}:`, err); + return []; + } + } + + /** + * Filters items to only include those where the person has the specified type (Actor, Director, Writer) + * @param {Array} items - Array of items with People field + * @param {string} personId - Person ID to check + * @param {string} personType - Type to filter by ('Actor', 'Director', 'Writer') + * @returns {Array} - Filtered array of items + */ + function filterPeopleByType(items, personId, personType) { + return items.filter(item => { + if (!item.People || !Array.isArray(item.People)) return false; + return item.People.some(person => + person.Id === personId && person.Type === personType + ); + }); + } + /** * Fetches newest movies from Jellyfin API * @returns {Promise} - Array of newest movie items @@ -1487,7 +1746,8 @@ const scrollableContainer = window.cardBuilder.renderCards( movies, 'New Movies', - null + null, + true ); // Add data attributes to track rendered sections @@ -1529,7 +1789,8 @@ const scrollableContainer = window.cardBuilder.renderCards( episodes, 'New Episodes', - null + null, + true ); // Add data attributes to track rendered sections @@ -1555,6 +1816,13 @@ */ function renderWatchlistSection(container) { try { + // Check if section is already on the page + const sectionContainer = container.querySelector('[data-custom-section-id="watchlist"]'); + if (sectionContainer) { + LOG('Watchlist section already on the page, skipping...'); + return false; + } + const watchlistItems = getWatchlistData(); if (watchlistItems.length === 0) { @@ -1600,6 +1868,68 @@ } } + /** + * Renders Popular TV Networks section + * @param {HTMLElement} container - Container to append the section to + * @returns {Promise} - Success status + */ + async function renderPopularTVNetworksSection(container) { + try { + // Check if section is already on the page + const sectionContainer = container.querySelector('[data-custom-section-id="popular-tv-networks"]'); + if (sectionContainer) { + LOG('Popular TV Networks section already on the page, skipping...'); + return false; + } + + let networks = await getPopularTVNetworks(); + + if (networks.length === 0) { + return false; + } + + // Remove any networks without Thumb images + networks = networks.filter(network => network.ImageTags?.Thumb); + + // Shuffle and take 16 random networks + const shuffled = [...networks].sort(() => Math.random() - 0.5); + const limitedNetworks = shuffled.slice(0, 16); + + if (limitedNetworks.length === 0) { + return false; + } + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping Popular TV Networks section"); + return false; + } + + // Render the scrollable container with the network/studio items + const scrollableContainer = window.cardBuilder.renderCards( + limitedNetworks, + 'Popular TV Networks', + null, + true, + 'backdrop' + ); + + // Add data attribute to track rendered sections + scrollableContainer.setAttribute('data-custom-section-id', 'popular-tv-networks'); + scrollableContainer.setAttribute('data-custom-section-name', 'Popular TV Networks'); + scrollableContainer.style.order = 101; + + // Append to container + container.appendChild(scrollableContainer); + + return true; + + } catch (err) { + ERR('Error rendering Popular TV Networks section:', err); + return false; + } + } + /** * Fetches full item objects from API using item IDs * @param {Array} itemIds - Array of item IDs to fetch @@ -1614,7 +1944,7 @@ // Use the Items endpoint to fetch multiple items by IDs const idsParam = itemIds.join(','); - const url = `${ApiClient.serverAddress()}/Items?Ids=${idsParam}&UserId=${userId}&Fields=MediaSources,MediaStreams,Chapters,People,Genres,Studios,Taglines&EnableTotalRecordCount=false&EnableImageTypes=Primary,Backdrop,Thumb,Logo`; + const url = `${ApiClient.serverAddress()}/Items?Ids=${idsParam}&UserId=${userId}&EnableTotalRecordCount=false&EnableImageTypes=Primary,Backdrop,Thumb,Logo`; const response = await ApiClient.fetch({ url, method: 'GET' }); const data = await response.json(); @@ -1670,7 +2000,8 @@ return { type: 'director', data: randomDirector, - items: items + items: items, + viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}` }; } } @@ -1695,7 +2026,8 @@ return { type: 'writer', data: randomWriter, - items: items + items: items, + viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}` }; } } @@ -1720,7 +2052,8 @@ return { type: 'actor', data: randomActor, - items: items + items: items, + viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}` }; } } @@ -1758,6 +2091,243 @@ }; } } + return null; + })(), + + // 7. Shows from [Studio Name] + (async () => { + const networks = await getPopularTVNetworks(); + if (networks && networks.length > 0) { + // Filter out already rendered studios + const availableNetworks = networks.filter(network => + network.Id && !renderedStudios.has(network.Id) + ); + + if (availableNetworks.length > 0) { + const randomNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; + const items = await fetchItemsByStudio(randomNetwork.Id); + + if (items && items.length > 0) { + // Track rendered studio + renderedStudios.add(randomNetwork.Id); + + // Shuffle and limit + const shuffled = [...items].sort(() => Math.random() - 0.5); + const limitedItems = shuffled.slice(0, 16); + + return { + type: 'studio', + data: randomNetwork, + items: limitedItems + }; + } + } + } + return null; + })(), + + // 8. Because you recently watched [Movie Name] + (async () => { + const randomRecentMovie = getRandomRecentlyWatchedMovie(); + if (randomRecentMovie) { + const watchedData = await preloadBecauseYouWatchedSection(randomRecentMovie); + if (watchedData) { + return { + type: 'watched-recent', + data: randomRecentMovie, + items: watchedData.items + }; + } + } + return null; + })(), + + // 9. Starring [Actor Name] since you recently watched [Movie Name] + (async () => { + const recentMovie = getRandomRecentlyWatchedMovie(renderedStarringWatchedMovies); + if (!recentMovie) return null; + + // Fetch the movie with People field included + try { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; + const movieResponse = await fetch(movieUrl, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!movieResponse.ok) return null; + + const movieData = await movieResponse.json(); + if (!movieData.People || !Array.isArray(movieData.People)) return null; + + // Get first 3 actors + const actors = movieData.People.filter(p => p.Type === 'Actor').slice(0, 3); + if (actors.length === 0) return null; + + // Pick random actor + const selectedActor = actors[Math.floor(Math.random() * actors.length)]; + + // Fetch items for this actor + const items = await fetchItemsByPersonId(selectedActor.Id, 'Movie'); + + // Remove the source movie and filter for items where person is Actor + const filteredItems = filterPeopleByType(items, selectedActor.Id, 'Actor') + .filter(item => item.Id !== recentMovie.Id); + + if (filteredItems.length > 0) { + // Limit to 16 + const limitedItems = filteredItems.slice(0, 16); + + return { + type: 'actor-recent', + data: { + person: selectedActor, + movie: recentMovie + }, + items: limitedItems, + viewMoreUrl: `#/details?id=${selectedActor.Id}&serverId=${ApiClient.serverId()}` + }; + } + } catch (err) { + ERR('Error generating actor-recent section:', err); + } + + return null; + })(), + + // 10. Directed by [Director Name] since you recently watched [Movie Name] + (async () => { + const recentMovie = getRandomRecentlyWatchedMovie(renderedDirectedWatchedMovies); + if (!recentMovie) return null; + + // Fetch the movie with People field included + try { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; + const movieResponse = await fetch(movieUrl, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!movieResponse.ok) return null; + + const movieData = await movieResponse.json(); + if (!movieData.People || !Array.isArray(movieData.People)) return null; + + // Get directors + const directors = movieData.People.filter(p => p.Type === 'Director'); + if (directors.length === 0) return null; + + // Pick random director + const selectedDirector = directors[Math.floor(Math.random() * directors.length)]; + + // Check if already rendered (use same set as regular directors) + if (renderedDirectors.has(selectedDirector.Name)) return null; + + // Fetch items for this director + const items = await fetchItemsByPersonId(selectedDirector.Id, 'Movie'); + + // Remove the source movie and filter for items where person is Director + const filteredItems = filterPeopleByType(items, selectedDirector.Id, 'Director') + .filter(item => item.Id !== recentMovie.Id); + + if (filteredItems.length > 0) { + // Track rendered director + renderedDirectors.add(selectedDirector.Name); + + // Limit to 16 + const limitedItems = filteredItems.slice(0, 16); + + return { + type: 'director-recent', + data: { + person: selectedDirector, + movie: recentMovie + }, + items: limitedItems, + viewMoreUrl: `#/details?id=${selectedDirector.Id}&serverId=${ApiClient.serverId()}` + }; + } + } catch (err) { + ERR('Error generating director-recent section:', err); + } + + return null; + })(), + + // 11. Written by [Writer Name] since you recently watched [Movie Name] + (async () => { + const recentMovie = getRandomRecentlyWatchedMovie(renderedWrittenWatchedMovies); + if (!recentMovie) return null; + + // Fetch the movie with People field included + try { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; + const movieResponse = await fetch(movieUrl, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!movieResponse.ok) return null; + + const movieData = await movieResponse.json(); + if (!movieData.People || !Array.isArray(movieData.People)) return null; + + // Get writers + const writers = movieData.People.filter(p => p.Type === 'Writer'); + if (writers.length === 0) return null; + + // Pick random writer + const selectedWriter = writers[Math.floor(Math.random() * writers.length)]; + + // Check if already rendered (use same set as regular writers) + if (renderedWriters.has(selectedWriter.Name)) return null; + + // Fetch items for this writer + const items = await fetchItemsByPersonId(selectedWriter.Id, 'Movie'); + + // Remove the source movie and filter for items where person is Writer + const filteredItems = filterPeopleByType(items, selectedWriter.Id, 'Writer') + .filter(item => item.Id !== recentMovie.Id); + + if (filteredItems.length > 0) { + // Track rendered writer + renderedWriters.add(selectedWriter.Name); + + // Limit to 16 + const limitedItems = filteredItems.slice(0, 16); + + return { + type: 'writer-recent', + data: { + person: selectedWriter, + movie: recentMovie + }, + items: limitedItems, + viewMoreUrl: `#/details?id=${selectedWriter.Id}&serverId=${ApiClient.serverId()}` + }; + } + } catch (err) { + ERR('Error generating writer-recent section:', err); + } + return null; })() ]; @@ -1852,7 +2422,7 @@ const firstGroup = discoveryBuffer[0]; if (firstGroup) { LOG('Pre-rendering first discovery group for smooth reveal'); - preRenderDiscoveryGroup(firstGroup).then(preRendered => { + preRenderDiscoveryGroup(discoveryBuffer.shift()).then(preRendered => { hiddenDiscoverySections.push(...preRendered); LOG(`Pre-rendered first group: ${preRendered.length} sections ready`); }).catch(err => { @@ -1916,71 +2486,115 @@ try { LOG('Rendering next discovery group from buffer'); + + const loadMoreButton = container.querySelector('.load-more-discovery-btn'); + + if (loadMoreButton) { + loadMoreButton.remove(); + } // Show loading indicator - if (enableInfiniteScroll) { - showDiscoveryLoadingIndicator(); - } + showDiscoveryLoadingIndicator(); isRenderingDiscoveryGroup = true; LOG(`Rendering group from buffer (${discoveryBuffer.length} groups available)`); - - // Check if we have pre-rendered sections ready to reveal - if (hiddenDiscoverySections.length > 0) { - LOG(`Revealing ${hiddenDiscoverySections.length} pre-rendered sections with animation`); - const sectionsToReveal = hiddenDiscoverySections.splice(0); // Take all sections - - // Reveal sections with smooth animation - const revealedCount = await revealSectionsSequentially(container, sectionsToReveal); - - if (revealedCount > 0) { - LOG(`Successfully revealed ${revealedCount} pre-rendered sections`); + + setTimeout(async () => { + async function renderDiscoveryGroup() { + // Check if we have pre-rendered sections ready to reveal + if (hiddenDiscoverySections.length > 0) { + LOG(`Revealing ${hiddenDiscoverySections.length} pre-rendered sections with animation`); + const sectionsToReveal = hiddenDiscoverySections.splice(0); // Take all sections + + // Reveal sections with smooth animation + const revealedCount = await revealSectionsSequentially(container, sectionsToReveal); + + if (revealedCount > 0) { + LOG(`Successfully revealed ${revealedCount} pre-rendered sections`); + + // Pre-render next group from buffer in background + if (discoveryBuffer.length > 0) { + const nextGroup = discoveryBuffer.shift(); + if (nextGroup) { + const preRendered = await preRenderDiscoveryGroup(nextGroup); + hiddenDiscoverySections.push(...preRendered); + } + } + + // Trigger background refill of buffer to update UI state + ensureDiscoveryBuffer().catch(err => { + ERR('Error refilling discovery buffer:', err); + }); - // Pre-render next group from buffer in background - if (discoveryBuffer.length > 0) { - const nextGroup = discoveryBuffer.shift(); - if (nextGroup) { - const preRendered = await preRenderDiscoveryGroup(nextGroup); - hiddenDiscoverySections.push(...preRendered); + // Hide loading indicator + hideDiscoveryLoadingIndicator(); + + return revealedCount > 0; } } - // Trigger background refill of buffer to update UI state - ensureDiscoveryBuffer().catch(err => { - ERR('Error refilling discovery buffer:', err); - }); - - return revealedCount > 0; - } - } - - // Fallback: if no pre-rendered sections, render directly from buffer (legacy behavior) - const groupToRender = discoveryBuffer.shift(); - if (groupToRender && groupToRender.length > 0) { - let renderedCount = 0; - - for (const sectionData of groupToRender) { - try { - const success = await renderDiscoverySection(sectionData, container); - if (success) { - renderedCount++; + // Fallback: if no pre-rendered sections, render directly from buffer (legacy behavior) + const groupToRender = discoveryBuffer.shift(); + if (groupToRender && groupToRender.length > 0) { + // Remove loading indicator from DOM before rendering sections to prevent scroll jump + const loadingIndicator = document.getElementById('discovery-loading-indicator'); + let loadingIndicatorClone = null; + if (loadingIndicator && loadingIndicator.parentNode) { + loadingIndicatorClone = loadingIndicator.cloneNode(true); + loadingIndicator.remove(); + LOG('Removed loading indicator from DOM before rendering sections (fallback path)'); } - } catch (err) { - ERR(`Error rendering section ${sectionData.type}:`, err); + + let renderedCount = 0; + + for (const sectionData of groupToRender) { + try { + const success = await renderDiscoverySection(sectionData, container); + if (success) { + renderedCount++; + } + } catch (err) { + ERR(`Error rendering section ${sectionData.type}:`, err); + } + } + + LOG(`Rendered ${renderedCount}/${groupToRender.length} sections directly from buffer`); + + // Re-add loading indicator at the end of the container + if (loadingIndicatorClone) { + container.appendChild(loadingIndicatorClone); + LOG('Re-added loading indicator at the end of sections (fallback path)'); + } + + // Trigger background refill of buffer to update UI state + ensureDiscoveryBuffer().catch(err => { + ERR('Error refilling discovery buffer:', err); + }); + + // Hide loading indicator + hideDiscoveryLoadingIndicator(); + + return renderedCount > 0; } + + return false; + } + + await renderDiscoveryGroup(); + + if (!enableInfiniteScroll) { + setupLoadMoreButton(container); } - LOG(`Rendered ${renderedCount}/${groupToRender.length} sections directly from buffer`); - - // Trigger background refill of buffer to update UI state - ensureDiscoveryBuffer().catch(err => { - ERR('Error refilling discovery buffer:', err); + // Scroll down a tiny bit with a smooth animation to start revealing the sections + window.scrollBy({ + top: 100, + behavior: 'smooth' }); - - return renderedCount > 0; - } - + isRenderingDiscoveryGroup = false; + }, 100); + return false; } catch (err) { @@ -1988,9 +2602,6 @@ return false; } finally { isRenderingDiscoveryGroup = false; - - // Hide loading indicator - hideDiscoveryLoadingIndicator(); } } @@ -2005,7 +2616,6 @@ const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); if (homeSectionsContainer) { await renderNextDiscoveryGroup(homeSectionsContainer, loadMoreButton); - // renderNextDiscoveryGroup now triggers buffer refill automatically } else { ERR('Home sections container not found'); } @@ -2079,6 +2689,16 @@ LOG(`Revealing ${sections.length} pre-rendered sections with scroll-triggered animation`); + // Remove loading indicator from DOM before revealing sections to prevent scroll jump + // We'll re-add it at the end after sections are revealed + const loadingIndicator = document.getElementById('discovery-loading-indicator'); + let loadingIndicatorClone = null; + if (loadingIndicator && loadingIndicator.parentNode) { + loadingIndicatorClone = loadingIndicator.cloneNode(true); + loadingIndicator.remove(); + LOG('Removed loading indicator from DOM before revealing sections'); + } + // Add all sections to DOM with initial animation class (hidden) sections.forEach(section => { section.style.display = 'none'; @@ -2105,15 +2725,17 @@ // Remove display: none and observe sections sections.forEach(section => { - section.style.display = ''; - - // Force reflow to ensure the initial hidden state is rendered - void section.offsetHeight; - + section.style.display = ''; // Observe section for viewport entry observer.observe(section); }); + // Re-add loading indicator at the end of the container (now it's at the end in DOM order too) + if (loadingIndicatorClone) { + container.appendChild(loadingIndicatorClone); + LOG('Re-added loading indicator at the end of sections'); + } + LOG(`Revealed ${sections.length} sections with scroll-triggered animation`); return sections.length; } @@ -2250,6 +2872,26 @@ sectionName = `Because you liked ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; sectionId = `liked-${sectionData.data.Id}`; break; + case 'studio': + sectionName = `Shows from ${sectionData.data.Name}`; + sectionId = `studio-${sectionData.data.Id}`; + break; + case 'watched-recent': + sectionName = `Because you recently watched ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; + sectionId = `watched-recent-${sectionData.data.Id}`; + break; + case 'actor-recent': + sectionName = `Starring ${sectionData.data.person.Name} since you recently watched ${sectionData.data.movie.Name} ${sectionData.data.movie.ProductionYear ? `(${sectionData.data.movie.ProductionYear})` : ''}`; + sectionId = `actor-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; + break; + case 'director-recent': + sectionName = `Directed by ${sectionData.data.person.Name} since you recently watched ${sectionData.data.movie.Name} ${sectionData.data.movie.ProductionYear ? `(${sectionData.data.movie.ProductionYear})` : ''}`; + sectionId = `director-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; + break; + case 'writer-recent': + sectionName = `Written by ${sectionData.data.person.Name} since you recently watched ${sectionData.data.movie.Name} ${sectionData.data.movie.ProductionYear ? `(${sectionData.data.movie.ProductionYear})` : ''}`; + sectionId = `writer-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; + break; default: return false; } @@ -2278,14 +2920,14 @@ WARN("cardBuilder.renderCards not available, skipping discovery section"); return false; } - scrollableContainer = window.cardBuilder.renderCards(limitedItems, sectionName, viewMoreUrl); + scrollableContainer = window.cardBuilder.renderCards(limitedItems, sectionName, viewMoreUrl, true); } else { // Use renderCardsFromIds for ID arrays if (!window.cardBuilder.renderCardsFromIds) { WARN("cardBuilder.renderCardsFromIds not available, skipping discovery section"); return false; } - scrollableContainer = await window.cardBuilder.renderCardsFromIds(limitedItems, sectionName, viewMoreUrl); + scrollableContainer = await window.cardBuilder.renderCardsFromIds(limitedItems, sectionName, viewMoreUrl, true); } // Add data attributes to track rendered sections @@ -2315,7 +2957,16 @@ * @param {HTMLElement} container - Container to watch for scroll */ function setupInfiniteLoading(container) { + const homePage = document.querySelector('.homePage:not(.hide)'); + + if (!homePage || homePage.dataset.discoveryReady === 'true') { + return; + } + + homePage.dataset.discoveryReady = 'true'; + if (enableInfiniteScroll) { + homePage.dataset.infiniteScroll = 'true'; setupScrollBasedLoading(container); } else { setupLoadMoreButton(container); @@ -2326,11 +2977,28 @@ * Removes scroll-based infinite loading handler */ function removeScrollBasedLoading() { + // Fade out message for "loading more" + const loadingIndicator = document.querySelector('#discovery-loading-indicator'); + if (discoveryScrollHandler) { window.removeEventListener('scroll', discoveryScrollHandler); discoveryScrollHandler = null; LOG('Scroll-based infinite loading for discovery sections removed'); } + if (discoveryWheelHandler) { + window.removeEventListener('wheel', discoveryWheelHandler); + discoveryWheelHandler = null; + LOG('Wheel-based trigger for discovery sections removed'); + } + if (discoveryTouchStartHandler) { + window.removeEventListener('touchstart', discoveryTouchStartHandler); + discoveryTouchStartHandler = null; + } + if (discoveryTouchMoveHandler) { + window.removeEventListener('touchmove', discoveryTouchMoveHandler); + discoveryTouchMoveHandler = null; + LOG('Touch-based trigger for discovery sections removed'); + } } /** @@ -2343,54 +3011,122 @@ let lastScrollTop = 0; let scrollTimeout = null; + let lastTouchY = null; const handleScroll = () => { // Ensure we're on the home page before proceeding const currentView = window.KefinTweaksUtils?.getCurrentView(); const isHomePage = currentView === 'home' || currentView === 'home.html'; + // Only enable scroll-based loading on the home page if (!isHomePage) { - LOG('Not on home page, skipping scroll-based loading setup'); return; } + // Only enable scroll-based loading on the home page first tab const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); if (activeTab !== '0') { - LOG('Not on home page, skipping scroll-based loading setup'); return; } if (isRenderingDiscoveryGroup) return; - + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - // Calculate distance from bottom (in pixels) - const distanceFromBottom = documentHeight - (scrollTop + windowHeight); - - // Trigger when user is 1500px from the bottom and scrolling down - const triggerDistance = 3000; - - if (distanceFromBottom <= triggerDistance && scrollTop > lastScrollTop) { - // Clear any existing timeout - if (scrollTimeout) { - clearTimeout(scrollTimeout); - } - - // Debounce the scroll event - scrollTimeout = setTimeout(async () => { - LOG(`User scrolled to ${distanceFromBottom.toFixed(0)}px from bottom, rendering more discovery sections from buffer...`); - await renderNextDiscoveryGroup(container); - }, 500); - } - lastScrollTop = scrollTop; }; // Store handler reference and add scroll listener discoveryScrollHandler = handleScroll; window.addEventListener('scroll', handleScroll, { passive: true }); + + // Also trigger when user attempts to scroll down while already at the bottom + const handleWheel = (event) => { + // Ensure we're on the home page before proceeding + const currentView = window.KefinTweaksUtils?.getCurrentView(); + const isHomePage = currentView === 'home' || currentView === 'home.html'; + if (!isHomePage) return; + + // Only on first tab + const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); + if (activeTab !== '0') return; + + if (isRenderingDiscoveryGroup) return; + + // Only react to downward scroll attempts + if (event.deltaY <= 0) return; + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + const atBottom = scrollTop + windowHeight >= documentHeight - 2; + if (!atBottom) return; + + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + scrollTimeout = setTimeout(async () => { + LOG('User attempted to scroll past bottom; loading next discovery group...'); + const homeScreenSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + await renderNextDiscoveryGroup(homeScreenSectionsContainer); + }, 200); + }; + + discoveryWheelHandler = handleWheel; + window.addEventListener('wheel', handleWheel, { passive: true }); + + // Touch support: trigger when user swipes up (scroll down) while already at bottom + const handleTouchStart = (event) => { + const touch = event.touches && event.touches[0]; + lastTouchY = touch ? touch.clientY : null; + }; + + const handleTouchMove = (event) => { + // Ensure we're on the home page before proceeding + const currentView = window.KefinTweaksUtils?.getCurrentView(); + const isHomePage = currentView === 'home' || currentView === 'home.html'; + if (!isHomePage) return; + + // Only on first tab + const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); + if (activeTab !== '0') return; + + if (isRenderingDiscoveryGroup) return; + + const touch = event.touches && event.touches[0]; + if (!touch) return; + + if (lastTouchY == null) { + lastTouchY = touch.clientY; + return; + } + + const deltaY = touch.clientY - lastTouchY; // negative when swiping up (scrolling down) + lastTouchY = touch.clientY; + + // Only react to upward swipe which scrolls content down + if (deltaY >= 0) return; + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + const atBottom = scrollTop + windowHeight >= documentHeight - 2; + if (!atBottom) return; + + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + scrollTimeout = setTimeout(async () => { + LOG('User swiped up at bottom; loading next discovery group...'); + await renderNextDiscoveryGroup(container); + }, 200); + }; + + discoveryTouchStartHandler = handleTouchStart; + discoveryTouchMoveHandler = handleTouchMove; + window.addEventListener('touchstart', handleTouchStart, { passive: true }); + window.addEventListener('touchmove', handleTouchMove, { passive: true }); LOG('Scroll-based infinite loading for discovery sections enabled'); } @@ -2406,7 +3142,7 @@ // Create load more button loadMoreButton = document.createElement('button'); loadMoreButton.textContent = 'Discover More'; - loadMoreButton.className = 'load-more-discovery-btn'; + loadMoreButton.className = 'load-more-discovery-btn raised button-submit emby-button'; loadMoreButton.style.cssText = ` order: 9998 !important; display: block; @@ -2522,7 +3258,8 @@ const scrollableContainer = window.cardBuilder.renderCards( limitedItems, 'Halloween Movies', - null + null, + true ); scrollableContainer.setAttribute('data-custom-section-id', 'halloween-movies'); @@ -2562,7 +3299,8 @@ const scrollableContainer = window.cardBuilder.renderCards( limitedItems, 'Horror Movies', - halloweenViewMoreUrls.horror + halloweenViewMoreUrls.horror, + true ); scrollableContainer.setAttribute('data-custom-section-id', 'horror-movies'); @@ -2602,7 +3340,8 @@ const scrollableContainer = window.cardBuilder.renderCards( limitedItems, 'Thriller Movies', - halloweenViewMoreUrls.thriller + halloweenViewMoreUrls.thriller, + true ); scrollableContainer.setAttribute('data-custom-section-id', 'thriller-movies'); @@ -2637,15 +3376,29 @@ halloweenMovies = movieData.halloweenMovies; horrorMovies = movieData.horrorMovies; thrillerMovies = movieData.thrillerMovies; - halloweenViewMoreUrls = movieData.viewMoreUrls; - + halloweenViewMoreUrls = movieData.viewMoreUrls; + } + + let sectionsToRender = []; + + if (halloweenMovies.length > 0) { + if (!container.querySelector('[data-custom-section-id="halloween-movies"]')) { + sectionsToRender.push(renderHalloweenMoviesSection(container)); + } + if (!container.querySelector('[data-custom-section-id="horror-movies"]')) { + sectionsToRender.push(renderHorrorMoviesSection(container)); + } + if (!container.querySelector('[data-custom-section-id="thriller-movies"]')) { + sectionsToRender.push(renderThrillerMoviesSection(container)); + } + } + + if (sectionsToRender.length === 0) { + LOG('No Halloween sections to render, skipping...'); + return; } - const results = [ - renderHalloweenMoviesSection(container), - renderHorrorMoviesSection(container), - renderThrillerMoviesSection(container) - ]; + const results = await Promise.all(sectionsToRender); const successCount = results.filter(Boolean).length; LOG(`Rendered ${successCount}/${results.length} Halloween sections`); @@ -2766,10 +3519,6 @@ return; } - if (retries > 0) { - LOG(`Found home sections container after ${retries} retries (${retries * 100}ms)`); - } - // Check if sections are already rendered const hasRenderedCustomSections = customHomeSections.some(section => homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`) @@ -2786,6 +3535,8 @@ if (hasRenderedCustomSections || hasRenderedNewAndTrendingSections || hasRenderedDiscoverySections) { return; } + + createDiscoveryLoadingIndicator(); // Set processing flag to prevent parallel execution isProcessing = true; @@ -2809,6 +3560,7 @@ // Render watchlist section renderWatchlistSection(homeSectionsContainer), + renderPopularTVNetworksSection(homeSectionsContainer), // Start preloading first batch of discovery sections (non-blocking) // This will also set up infinite loading handlers if buffer has content @@ -3173,5 +3925,9 @@ return deduplicated; }; + window.discoveryBuffer = function() { + return discoveryBuffer; + }; + LOG('Home screen functionality initialized'); })(); diff --git a/indexedDBCache.js b/indexedDBCache.js new file mode 100644 index 0000000..9a27edc --- /dev/null +++ b/indexedDBCache.js @@ -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'); + +})(); + diff --git a/infiniteScroll.js b/infiniteScroll.js index af67d13..7df4372 100644 --- a/infiniteScroll.js +++ b/infiniteScroll.js @@ -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; } diff --git a/injector.js b/injector.js index 0459f64..2a1ce1e 100644 --- a/injector.js +++ b/injector.js @@ -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(); } diff --git a/itemDetailsCollections.js b/itemDetailsCollections.js new file mode 100644 index 0000000..6ca9216 --- /dev/null +++ b/itemDetailsCollections.js @@ -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'); +})(); + diff --git a/kefinTweaks.js b/kefinTweaks.js index 6fca325..4582c8e 100644 --- a/kefinTweaks.js +++ b/kefinTweaks.js @@ -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'); })(); diff --git a/localStorageCache.js b/localStorageCache.js index 2b8dbff..c42b3b3 100644 --- a/localStorageCache.js +++ b/localStorageCache.js @@ -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 { diff --git a/pages/images/appearanceheader.png b/pages/images/appearanceheader.png new file mode 100644 index 0000000..1fcf159 Binary files /dev/null and b/pages/images/appearanceheader.png differ diff --git a/pages/images/appearanceusersettings.png b/pages/images/appearanceusersettings.png new file mode 100644 index 0000000..dee8e18 Binary files /dev/null and b/pages/images/appearanceusersettings.png differ diff --git a/pages/images/audiosettings.png b/pages/images/audiosettings.png new file mode 100644 index 0000000..c537fa5 Binary files /dev/null and b/pages/images/audiosettings.png differ diff --git a/pages/images/flattenshows.png b/pages/images/flattenshows.png new file mode 100644 index 0000000..6ee60e5 Binary files /dev/null and b/pages/images/flattenshows.png differ diff --git a/pages/images/itemdetailscollections.png b/pages/images/itemdetailscollections.png new file mode 100644 index 0000000..b0e4f1e Binary files /dev/null and b/pages/images/itemdetailscollections.png differ diff --git a/pages/images/watchlist.png b/pages/images/watchlist.png index 658d5e9..3d8b17b 100644 Binary files a/pages/images/watchlist.png and b/pages/images/watchlist.png differ diff --git a/playlist.js b/playlist.js index 7373b58..aec73b0 100644 --- a/playlist.js +++ b/playlist.js @@ -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} - 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'); })(); diff --git a/removeContinue.js b/removeContinue.js index 2e9a991..b9e69d1 100644 --- a/removeContinue.js +++ b/removeContinue.js @@ -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...'); diff --git a/search.js b/search.js index 8093167..e022342 100644 --- a/search.js +++ b/search.js @@ -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); diff --git a/skinManager.js b/skinManager.js index 7a1c9a9..4d6f4f4 100644 --- a/skinManager.js +++ b/skinManager.js @@ -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'); + } } /** diff --git a/skins/elegantKefin.css b/skins/elegantKefin.css index 230e054..d4d3ff2 100644 --- a/skins/elegantKefin.css +++ b/skins/elegantKefin.css @@ -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; } \ No newline at end of file diff --git a/subtitleSearch.js b/subtitleSearch.js index 128d71d..7567d78 100644 --- a/subtitleSearch.js +++ b/subtitleSearch.js @@ -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 = ` -
- Downloading subtitle... - `; - - // 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 = ` - ✓ - Subtitle downloaded and activated! - `; - - // 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 = ` - ⚠ - ${message} - `; - - // 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) { diff --git a/toaster.js b/toaster.js new file mode 100644 index 0000000..c11ba3b --- /dev/null +++ b/toaster.js @@ -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'); +})(); diff --git a/utils.js b/utils.js index c4dec8f..05c3626 100644 --- a/utils.js +++ b/utils.js @@ -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} 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} 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); } diff --git a/watchlist.css b/watchlist.css index 8149863..65ea2dd 100644 --- a/watchlist.css +++ b/watchlist.css @@ -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; } diff --git a/watchlist.js b/watchlist.js index d2fdf30..69bf329 100644 --- a/watchlist.js +++ b/watchlist.js @@ -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) {