diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7a094e0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 ranaldsgift
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..39556d3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,165 @@
+# 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.
+
+## 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.
+
+### 📋 Core Features
+
+#### **Watchlist Page**
+- **Watchlist**: Add movies, series, seasons, and episodes to your watchlist with intelligent caching
+
+
+- **Series Progress**: An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched.
+
+
+- **Movie History**: An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites.
+
+
+- **Statistics**: A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched.
+
+
+- **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.)
+- **Watchlist Integration**: Dedicated watchlist section on home screen
+
+
+#### **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
+
+
+### 🎨 UI Enhancements
+
+#### **Watchlist Support**
+- **Watchlist Toggle**: Watchlist toggle button added to all item card overlays
+
+
+#### **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
+
+
+- **Custom Menu Integration**: Add custom menu links to the side navigation drawer menu
+
+
+- **Subtitle Search**: Search and download subtitles directly from the video OSD
+
+
+- **Remove Continue Watching**: Adds a card overlay button to remove items from Continue Watching for all resumable items
+
+
+- **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
+
+### 🔧 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
+
+#### **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
+
+## 📦 Installation
+
+### Prerequisites
+- Jellyfin 10.10.7 or later
+- [JS Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector) plugin installed and configured
+- [Custom Tabs](https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs) plugin for watchlist functionality
+- [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) plugin for Jellyseerr search functionality and ExclusiveElsewhere
+
+### Setup Instructions
+
+0. **Install Prerequisites listed above**
+1. **Add a new script to your JS Injector Plugin**
+2. **Copy this entire contents of kefinTweaks.js into the new script**
+
+**Optionally update the "scriptRoot" in the KefinTweaks script if you wish to customize and host these scripts yourself.**
+
+3. **Ensure the script is enabled**
+4. **Save your changes**
+
+
+
+5. **Required for Watchlist: Add a new tab to your Custom Tabs plugin with the following content**
+ ```html
+
+ ```
+
+
+## 🛠️ Script Details
+
+### Core Scripts
+
+| Script | Description |
+|--------|-------------|
+| `utils.js` | Page view management and common utilities |
+| `cardBuilder.js` | Enhanced card building functionality |
+| `localStorageCache.js` | Caching layer with TTL management |
+| `modal.js` | Generic modal system for dialogs |
+
+### Feature Scripts
+
+| Script | Description |
+|--------|-------------|
+| `watchlist.js` | Complete watchlist management system |
+| `homeScreen.js` | Custom home screen sections |
+| `search.js` | Enhanced search functionality |
+| `headerTabs.js` | Header tab improvements |
+| `customMenu.js` | Custom menu link handling |
+| `exclusiveElsewhere.js` | Custom branding for unavailable content |
+| `updoot.js` | Upvote functionality integration |
+| `backdropLeakFix.js` | Memory leak fixes |
+| `dashboardButtonFix.js` | Dashboard button behavior fix |
+| `infiniteScroll.js` | Infinite scroll functionality |
+| `removeContinue.js` | Remove continue watching functionality |
+| `subtitleSearch.js` | Subtitle search in video OSD |
+| `breadcrumbs.js` | Breadcrumb navigation |
+| `playlist.js` | Playlist view enhancements |
+
+
+## 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`
+
+This ensures all scripts have their required dependencies without manual configuration.
+
+## 📄 License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## 🙏 Acknowledgments
+
+- **Jellyfin Team**: For the amazing media server platform
+- **[n00bcodr](https://github.com/n00bcodr)**: For JellyfinEnhanced, the JS Injector Plugin and moral support
+- **[IAmParadox27](https://github.com/IAmParadox27)**: For the Custom Tabs Plugin
+- **[BobHasNoSoul](https://github.com/BobHasNoSoul)**: For the jellyfin-updoot functionality
+- **[The Jellyfin Community](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ)**: For sharing knowledge and providing a welcoming enviornment to ask questions and learn
+
+## 📞 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
+
+## 🔮 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
+
+### Version History
+- **v1.0.0**: Initial release for Jellyfin 10.10.7
diff --git a/breadcrumbNav.css b/breadcrumbNav.css
new file mode 100644
index 0000000..c7db4d4
--- /dev/null
+++ b/breadcrumbNav.css
@@ -0,0 +1,104 @@
+/* KefinTweaks Breadcrumbs Styles */
+
+.kefinTweaks-breadcrumbs-wrapper {
+ margin-left: -.5em;
+ transition: background 0.2s ease;
+}
+
+.kefinTweaks-breadcrumbs-wrapper:hover {
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 0.5em;
+}
+
+.kefinTweaks-breadcrumbs {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 1.17em;
+ font-family: inherit;
+ pointer-events: auto;
+ position: relative;
+ padding: 8px 12px;
+ border-radius: 4px;
+ word-wrap: break-word;
+ display: inline-block;
+}
+
+.kefinTweaks-popover-container {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 1001;
+}
+
+.kefinTweaks-breadcrumbs span {
+ display: inline;
+}
+
+.kefinTweaks-breadcrumbs span:hover {
+ color: rgba(255, 255, 255, 1);
+}
+
+/* Hover effects for clickable breadcrumbs only */
+.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
+ text-decoration: underline !important;
+}
+
+/* Responsive behavior */
+@media (max-width: 768px) {
+ .kefinTweaks-breadcrumbs-wrapper {
+ display: none !important;
+ }
+}
+
+/* Popover styles for seasons and albums */
+.kefinTweaks-popover {
+ position: relative;
+ background-color: rgba(29, 38, 53, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ padding: 0;
+ min-width: 200px;
+ max-height: 300px;
+ overflow-y: auto;
+ z-index: 1001;
+ display: block;
+ margin-top: 8px;
+}
+
+.kefinTweaks-popover-item {
+ padding: 8px 16px 8px 24px;
+ cursor: pointer;
+ color: rgba(255, 255, 255, 0.8);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ position: relative;
+}
+
+.kefinTweaks-popover-item:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 1);
+}
+
+.kefinTweaks-popover-item:last-child {
+ border-bottom: none;
+}
+
+.kefinTweaks-popover-item.selected {
+ color: rgba(255, 255, 255, 1);
+ font-weight: bold;
+}
+
+.kefinTweaks-popover-item.selected::before {
+ content: '>';
+ position: absolute;
+ left: 8px;
+ top: calc(50% - 1px);
+ transform: translateY(-50%);
+ font-weight: bold;
+}
+
+.skinHeader {
+ contain: none;
+}
+
+.headerLeft {
+ overflow: visible;
+}
\ No newline at end of file
diff --git a/breadcrumbs.js b/breadcrumbs.js
new file mode 100644
index 0000000..3de39b4
--- /dev/null
+++ b/breadcrumbs.js
@@ -0,0 +1,821 @@
+// KefinTweaks Breadcrumbs
+// Adds breadcrumb navigation to item detail pages
+// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum
+
+(function() {
+ 'use strict';
+
+ console.log('[KefinTweaks Breadcrumbs] Initializing...');
+
+ // Configuration
+ const CONFIG = {
+ supportedTypes: ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum'],
+ minWidth: 768, // Only show on screens wider than 768px
+ headerHeight: 80 // Position below .skinHeader
+ };
+
+ // State management
+ let currentBreadcrumbs = null;
+ let breadcrumbContainer = null;
+ let activePopover = null;
+ let currentItemId = null;
+
+ // Track parent IDs to avoid unnecessary API calls
+ let lastSeasonsParentId = null;
+ let lastAlbumsParentId = null;
+ let cachedSeasonsData = null;
+ let cachedAlbumsData = null;
+
+ // Smart API call functions that check parent ID
+ async function getSeasonsIfNeeded(parentId) {
+ if (lastSeasonsParentId === parentId && cachedSeasonsData) {
+ log('Using existing seasons data for parent:', parentId);
+ return cachedSeasonsData;
+ }
+
+ log('Fetching seasons for parent:', parentId);
+ const seasons = await getSeasons(parentId);
+ lastSeasonsParentId = parentId;
+ cachedSeasonsData = seasons;
+ return seasons;
+ }
+
+ async function getAlbumsIfNeeded(parentId) {
+ if (lastAlbumsParentId === parentId && cachedAlbumsData) {
+ log('Using existing albums data for parent:', parentId);
+ return cachedAlbumsData;
+ }
+
+ log('Fetching albums for parent:', parentId);
+ const albums = await getAlbums(parentId);
+ lastAlbumsParentId = parentId;
+ cachedAlbumsData = albums;
+ return albums;
+ }
+
+ // Simplified breadcrumb structure definition
+ async function getTargetBreadcrumbStructure(item) {
+ const structure = {
+ elements: [],
+ item: item
+ };
+
+ if (item.Type === 'Movie') {
+ structure.elements = [
+ { text: 'Movies', url: '#/movies.html', clickable: true },
+ { text: item.Name, url: null, clickable: false }
+ ];
+ } else if (item.Type === 'Series') {
+ structure.elements = [
+ { text: 'Shows', url: '#/tv.html', clickable: true },
+ { text: item.Name, url: null, clickable: false },
+ { text: 'All Seasons', url: null, clickable: true, popover: true }
+ ];
+ } else if (item.Type === 'Season') {
+ // Get series details for the series name
+ const seriesDetails = await getItemDetails(item.ParentId);
+ const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series';
+
+ structure.elements = [
+ { text: 'Shows', url: '#/tv.html', clickable: true },
+ { text: seriesName, url: `#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
+ { text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true }
+ ];
+ } else if (item.Type === 'Episode') {
+ // Get series details for the series name
+ const seriesDetails = await getItemDetails(item.SeriesId);
+ const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series';
+
+ // Get season details for the season name
+ const seasonDetails = await getItemDetails(item.ParentId);
+ const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`;
+
+ structure.elements = [
+ { text: 'Shows', url: '#/tv.html', clickable: true },
+ { text: seriesName, url: `#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true },
+ { text: seasonName, url: null, clickable: true, popover: true },
+ { text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false }
+ ];
+ } else if (item.Type === 'MusicArtist') {
+ structure.elements = [
+ { text: 'Music', url: '#/music.html', clickable: true },
+ { text: item.Name, url: null, clickable: false },
+ { text: 'All Albums', url: null, clickable: true, popover: true }
+ ];
+ } else if (item.Type === 'MusicAlbum') {
+ structure.elements = [
+ { text: 'Music', url: '#/music.html', clickable: true },
+ { text: item.AlbumArtist, url: `#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
+ { text: item.Name, url: null, clickable: true, popover: true }
+ ];
+ }
+
+ return structure;
+ }
+
+ // Compare existing breadcrumbs with target structure
+ function compareBreadcrumbStructures(existing, target) {
+ const differences = [];
+
+ // Check if we have the right number of elements
+ const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
+ child.classList.contains('kefinTweaks-breadcrumb-element')
+ );
+
+ log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length);
+
+ // Compare each element
+ for (let i = 0; i < Math.max(existingElements.length, target.elements.length); i++) {
+ const existingElement = existingElements[i];
+ const targetElement = target.elements[i];
+
+ if (!existingElement && targetElement) {
+ // Need to add new element
+ differences.push({ action: 'add', index: i, element: targetElement });
+ } else if (existingElement && !targetElement) {
+ // Need to remove element
+ differences.push({ action: 'remove', index: i });
+ } else if (existingElement && targetElement) {
+ // Compare existing vs target
+ const existingText = existingElement.textContent;
+ const existingClickable = existingElement.style.cursor === 'pointer';
+ const targetClickable = targetElement.clickable;
+
+ // Check if popover functionality needs to be updated
+ const needsPopoverUpdate = targetElement.popover && (
+ existingText !== targetElement.text ||
+ existingClickable !== targetClickable ||
+ // Force update for "All Seasons" and "All Albums" elements since the underlying data changes
+ targetElement.text === 'All Seasons' ||
+ targetElement.text === 'All Albums'
+ );
+
+ if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) {
+ differences.push({ action: 'update', index: i, element: targetElement });
+ }
+ }
+ }
+
+ return differences;
+ }
+
+ // Apply breadcrumb differences intelligently
+ async function applyBreadcrumbDifferences(differences, targetStructure) {
+ if (differences.length === 0) {
+ log('No breadcrumb differences found, keeping existing structure');
+ return;
+ }
+
+ log('Applying breadcrumb differences:', differences.length, 'changes needed');
+
+ // Get existing elements
+ const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
+ child.classList.contains('kefinTweaks-breadcrumb-element')
+ );
+
+ // Process differences in reverse order to maintain indices
+ for (let i = differences.length - 1; i >= 0; i--) {
+ const diff = differences[i];
+
+ if (diff.action === 'add') {
+ // Add new element at the specified index
+ const newElement = await createBreadcrumbElementFromStructure(diff.element, targetStructure.item);
+ const separator = createSeparator();
+
+ if (diff.index === 0) {
+ breadcrumbContainer.insertBefore(newElement, breadcrumbContainer.firstChild);
+ breadcrumbContainer.insertBefore(separator, newElement.nextSibling);
+ } else {
+ const insertAfter = existingElements[diff.index - 1];
+ if (insertAfter) {
+ breadcrumbContainer.insertBefore(separator, insertAfter.nextSibling);
+ breadcrumbContainer.insertBefore(newElement, separator.nextSibling);
+ } else {
+ breadcrumbContainer.appendChild(separator);
+ breadcrumbContainer.appendChild(newElement);
+ }
+ }
+
+ log('Added new breadcrumb element:', diff.element.text);
+
+ } else if (diff.action === 'remove') {
+ // Remove element and its separator
+ const elementToRemove = existingElements[diff.index];
+ if (elementToRemove) {
+ const separatorToRemove = elementToRemove.previousSibling;
+ if (separatorToRemove && separatorToRemove.classList.contains('kefinTweaks-breadcrumb-separator')) {
+ breadcrumbContainer.removeChild(separatorToRemove);
+ }
+ breadcrumbContainer.removeChild(elementToRemove);
+ log('Removed breadcrumb element at index:', diff.index);
+ }
+
+ } else if (diff.action === 'update') {
+ // Update existing element
+ const elementToUpdate = existingElements[diff.index];
+ if (elementToUpdate) {
+ await updateBreadcrumbElement(elementToUpdate, diff.element, targetStructure.item);
+ log('Updated breadcrumb element:', diff.element.text);
+ }
+ }
+ }
+ }
+
+ // Create breadcrumb element from structure definition
+ async function createBreadcrumbElementFromStructure(elementDef, item) {
+ const element = document.createElement('span');
+ element.className = 'kefinTweaks-breadcrumb-element';
+ element.textContent = elementDef.text;
+
+ if (elementDef.clickable) {
+ element.style.cursor = 'pointer';
+
+ if (elementDef.url) {
+ // Direct link
+ element.addEventListener('click', () => {
+ window.location.hash = elementDef.url;
+ });
+ } else if (elementDef.popover) {
+ // Popover functionality
+ await setupPopoverForElement(element, elementDef, item);
+ }
+ } else {
+ element.style.cursor = 'default';
+ }
+
+ return element;
+ }
+
+ // Update existing breadcrumb element
+ async function updateBreadcrumbElement(element, elementDef, item) {
+ // Update text content
+ element.textContent = elementDef.text;
+
+ // Remove old event listeners by cloning
+ const newElement = element.cloneNode(true);
+ element.parentNode.replaceChild(newElement, element);
+
+ // Set cursor style
+ if (elementDef.clickable) {
+ newElement.style.cursor = 'pointer';
+
+ if (elementDef.url) {
+ // Direct link
+ newElement.addEventListener('click', () => {
+ window.location.hash = elementDef.url;
+ });
+ } else if (elementDef.popover) {
+ // Popover functionality
+ await setupPopoverForElement(newElement, elementDef, item);
+ }
+ } else {
+ newElement.style.cursor = 'default';
+ }
+ }
+
+ // Setup popover functionality for an element
+ async function setupPopoverForElement(element, elementDef, item) {
+ if (elementDef.text === 'All Seasons') {
+ // Series page - always show popover
+ const seasons = await getSeasonsIfNeeded(item.Id);
+ element.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const popover = createPopover(seasons, null, (selectedSeason) => {
+ window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
+ });
+ showPopover(element, popover);
+ });
+ } else if (elementDef.text === 'All Albums') {
+ // Music Artist page - always show popover
+ const albums = await getAlbumsIfNeeded(item.Id);
+ element.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const popover = createPopover(albums, null, (selectedAlbum) => {
+ window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
+ });
+ showPopover(element, popover);
+ });
+ } else if (item.Type === 'Season') {
+ // Season page - check if single season
+ const seasons = await getSeasonsIfNeeded(item.ParentId);
+ if (seasons.length === 1) {
+ element.style.cursor = 'default';
+ } else {
+ element.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const popover = createPopover(seasons, item, (selectedSeason) => {
+ window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
+ });
+ showPopover(element, popover);
+ });
+ }
+ } else if (item.Type === 'Episode') {
+ // Episode page - check if single season
+ const seasons = await getSeasonsIfNeeded(item.SeriesId);
+ if (seasons.length === 1) {
+ element.addEventListener('click', () => {
+ window.location.hash = `#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`;
+ });
+ } else {
+ const currentSeason = seasons.find(season => season.Id === item.ParentId);
+ element.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const popover = createPopover(seasons, currentSeason, (selectedSeason) => {
+ window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
+ });
+ showPopover(element, popover);
+ });
+ }
+ } else if (item.Type === 'MusicAlbum') {
+ // Music album page - check if single album
+ const albums = await getAlbumsIfNeeded(item.ParentId);
+ if (albums.length === 1) {
+ element.addEventListener('click', () => {
+ window.location.hash = `#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
+ });
+ } else {
+ element.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const popover = createPopover(albums, item, (selectedAlbum) => {
+ window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
+ });
+ showPopover(element, popover);
+ });
+ }
+ }
+ }
+
+ // Utility functions
+ function log(message, ...args) {
+ console.log(`[KefinTweaks Breadcrumbs] ${message}`, ...args);
+ }
+
+ function error(message, ...args) {
+ console.error(`[KefinTweaks Breadcrumbs] ${message}`, ...args);
+ }
+
+ function debugBreadcrumbLinks() {
+ if (!breadcrumbContainer) return;
+
+ const children = Array.from(breadcrumbContainer.children);
+ log('Breadcrumb structure:');
+ children.forEach((child, index) => {
+ if (child.style.cursor === 'pointer') {
+ log(` Element ${index}: "${child.textContent}" (clickable)`);
+ } else {
+ log(` Element ${index}: "${child.textContent}" (static)`);
+ }
+ });
+ }
+
+ function ensureSeriesNameIsClickable(seriesId) {
+ if (!breadcrumbContainer) return;
+
+ const children = Array.from(breadcrumbContainer.children);
+
+ // Find the series name element (should be the 3rd element: Shows / Series Name / ...)
+ if (children.length >= 3) {
+ const seriesElement = children[2]; // Shows (0) / Series Name (2) / ...
+
+ // Check if it's already clickable
+ if (seriesElement.style.cursor !== 'pointer') {
+ log('Making series name clickable:', seriesElement.textContent);
+
+ // Make it clickable
+ seriesElement.style.cursor = 'pointer';
+
+ // Add click handler
+ seriesElement.onclick = () => {
+ window.location.hash = `#/details?id=${seriesId}&serverId=${ApiClient.serverId()}`;
+ };
+
+ log('Series name is now clickable and links to series page:', seriesId);
+ } else {
+ // Update existing click handler to ensure it points to the series page
+ seriesElement.onclick = () => {
+ window.location.hash = `#/details?id=${seriesId}&serverId=${ApiClient.serverId()}`;
+ };
+ log('Updated series name click handler to point to series page:', seriesId);
+ }
+ }
+ }
+
+ function isWideScreen() {
+ return window.innerWidth > CONFIG.minWidth;
+ }
+
+ function getItemIdFromPath(path) {
+ const match = path.match(/[?&]id=([^&]+)/);
+ return match ? match[1] : null;
+ }
+
+ function padNumber(num, length = 2) {
+ return num.toString().padStart(length, '0');
+ }
+
+ // DOM manipulation
+ function createBreadcrumbContainer() {
+ if (breadcrumbContainer) {
+ return breadcrumbContainer;
+ }
+
+ // Find the header left container
+ const headerLeft = document.querySelector('.skinHeader .headerLeft');
+ if (!headerLeft) {
+ error('Could not find .skinHeader .headerLeft element');
+ return null;
+ }
+
+ // Create main wrapper
+ const wrapper = document.createElement('div');
+ wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
+ wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
+ wrapper.style.cssText = `
+ display: none;
+ `;
+
+ // Create breadcrumb container (for background styling)
+ breadcrumbContainer = document.createElement('div');
+ breadcrumbContainer.id = 'kefinTweaks-breadcrumbs';
+ breadcrumbContainer.className = 'kefinTweaks-breadcrumbs';
+
+ // Create popover container
+ const popoverContainer = document.createElement('div');
+ popoverContainer.id = 'kefinTweaks-popover-container';
+ popoverContainer.className = 'kefinTweaks-popover-container';
+ popoverContainer.style.cssText = `
+ position: absolute;
+ top: calc(100% - 15px);
+ left: 0;
+ z-index: 1001;
+ `;
+
+ wrapper.appendChild(breadcrumbContainer);
+ wrapper.appendChild(popoverContainer);
+ headerLeft.appendChild(wrapper);
+
+ log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
+ return breadcrumbContainer;
+ }
+
+ function showBreadcrumbs() {
+ const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
+ const wideScreen = isWideScreen();
+
+ // Race condition protection: verify we're still on a supported page
+ const currentPath = Emby.Page.lastPath || '';
+ const isSupportedPage = currentPath.startsWith('/details?');
+
+ log('showBreadcrumbs called - wrapper exists:', !!wrapper, 'isWideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage, 'currentPath:', currentPath);
+
+ if (wrapper && wideScreen && isSupportedPage) {
+ wrapper.style.display = 'block';
+ log('Breadcrumbs shown successfully');
+ } else {
+ log('Breadcrumbs not shown - wrapper:', !!wrapper, 'wideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage);
+ }
+ }
+
+ function hideBreadcrumbs() {
+ const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
+ if (wrapper) {
+ wrapper.style.display = 'none';
+ }
+ }
+
+ function clearBreadcrumbs() {
+ if (breadcrumbContainer) {
+ breadcrumbContainer.innerHTML = '';
+ currentBreadcrumbs = null;
+ currentItemId = null;
+ }
+ closePopover();
+ }
+
+
+
+
+
+
+ function closePopover() {
+ if (activePopover) {
+ activePopover.remove();
+ activePopover = null;
+ }
+ }
+
+ function createPopover(items, currentItem, onItemClick) {
+ const popover = document.createElement('div');
+ popover.className = 'kefinTweaks-popover';
+ popover.style.display = 'block'; // Ensure it's visible
+
+ log('Creating popover with', items.length, 'items');
+
+ let selectedItemElement = null;
+
+ items.forEach(item => {
+ const itemElement = document.createElement('div');
+ itemElement.className = 'kefinTweaks-popover-item';
+
+ if (currentItem && item.Id === currentItem.Id) {
+ itemElement.classList.add('selected');
+ selectedItemElement = itemElement; // Store reference to selected item
+ }
+
+ itemElement.textContent = item.Name || `Season ${item.IndexNumber}`;
+ itemElement.addEventListener('click', () => {
+ log('Popover item clicked:', item.Name);
+ onItemClick(item);
+ closePopover();
+ });
+
+ popover.appendChild(itemElement);
+ });
+
+ // Add auto-scroll functionality
+ if (selectedItemElement) {
+ // Use setTimeout to ensure the popover is rendered before scrolling
+ setTimeout(() => {
+ selectedItemElement.scrollIntoView({
+ behavior: 'instant',
+ block: 'nearest',
+ inline: 'nearest'
+ });
+ log('Auto-scrolled to selected item:', selectedItemElement.textContent);
+ }, 10);
+ }
+
+ return popover;
+ }
+
+ function showPopover(triggerElement, popover) {
+ closePopover();
+
+ activePopover = popover;
+
+ log('Showing popover for element:', triggerElement.textContent);
+
+ // Add popover to the dedicated popover container
+ const popoverContainer = document.getElementById('kefinTweaks-popover-container');
+ if (popoverContainer) {
+ // Calculate the left offset to align with the trigger element
+ // Since breadcrumbs are now in the header, we need to calculate relative to the page
+ const triggerRect = triggerElement.getBoundingClientRect();
+ const popoverContainerRect = popoverContainer.getBoundingClientRect();
+
+ // Calculate offset from popover container to trigger element
+ const leftOffset = triggerRect.left - popoverContainerRect.left - 22;
+ popover.style.left = `${leftOffset}px`;
+
+ popoverContainer.appendChild(popover);
+ log('Popover added to popover container with left offset:', leftOffset);
+ } else {
+ log('Popover container not found, falling back to trigger element');
+ triggerElement.style.position = 'relative';
+ triggerElement.appendChild(popover);
+ }
+
+ // Close popover when clicking outside
+ const closeHandler = (e) => {
+ if (!popover.contains(e.target) && !triggerElement.contains(e.target)) {
+ closePopover();
+ document.removeEventListener('click', closeHandler);
+ }
+ };
+
+ setTimeout(() => {
+ document.addEventListener('click', closeHandler);
+ }, 100);
+ }
+
+ // API functions
+ async function getItemDetails(itemId) {
+ try {
+ const currentUserId = ApiClient.getCurrentUserId();
+ if (!currentUserId) {
+ throw new Error('No user ID found');
+ }
+
+ const url = ApiClient.getUrl(`Users/${currentUserId}/Items/${itemId}`);
+ const response = await ApiClient.getJSON(url);
+ log('Retrieved item details:', response.Name, response.Type);
+ return response;
+ } catch (err) {
+ error('Failed to get item details:', err);
+ return null;
+ }
+ }
+
+ async function getSeasons(seriesId) {
+ try {
+ const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`, {
+ userId: ApiClient.getCurrentUserId(),
+ imageTypeLimit: 1
+ });
+ const response = await ApiClient.getJSON(url);
+ log('Retrieved seasons:', response.Items?.length || 0);
+ return response.Items || [];
+ } catch (err) {
+ error('Failed to get seasons:', err);
+ return [];
+ }
+ }
+
+ async function getAlbums(artistId) {
+ try {
+ const url = ApiClient.getUrl('Items', {
+ SortOrder: 'Descending',
+ IncludeItemTypes: 'MusicAlbum',
+ Recursive: true,
+ Fields: 'ParentId',
+ Limit: 100,
+ StartIndex: 0,
+ CollapseBoxSetItems: false,
+ AlbumArtistIds: artistId,
+ SortBy: 'PremiereDate,ProductionYear,Sortname'
+ });
+ const response = await ApiClient.getJSON(url);
+ log('Retrieved albums:', response.Items?.length || 0);
+ return response.Items || [];
+ } catch (err) {
+ error('Failed to get albums:', err);
+ return [];
+ }
+ }
+
+ // Breadcrumb generation
+ function createBreadcrumbElement(text, url = null, isClickable = true) {
+ const element = document.createElement('span');
+ element.className = 'kefinTweaks-breadcrumb-element';
+ element.textContent = text;
+
+ if (url && isClickable) {
+ element.style.cursor = 'pointer';
+ element.addEventListener('click', () => {
+ window.location.hash = url;
+ });
+ }
+
+ return element;
+ }
+
+ function createSeparator() {
+ const separator = document.createElement('span');
+ separator.className = 'kefinTweaks-breadcrumb-separator';
+ separator.textContent = ' / ';
+ separator.style.margin = '0 8px';
+ return separator;
+ }
+
+ // Page change handler
+ // Simplified page change handler
+ async function handlePageChange() {
+ try {
+ // Close any active popovers first
+ closePopover();
+
+ const currentPath = Emby.Page.lastPath;
+ log('Page changed to:', currentPath);
+
+ // Check if we're on a details page
+ if (!currentPath || !currentPath.startsWith('/details?')) {
+ log('Not a details page, hiding and clearing breadcrumbs');
+ clearBreadcrumbs();
+ hideBreadcrumbs();
+ return;
+ }
+
+ // Extract item ID
+ const itemId = getItemIdFromPath(currentPath);
+ if (!itemId) {
+ log('No item ID found in path, 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');
+ clearBreadcrumbs();
+ hideBreadcrumbs();
+ return;
+ }
+
+ // Check if item type is supported
+ if (!CONFIG.supportedTypes.includes(item.Type)) {
+ log('Item type not supported:', item.Type, 'hiding and clearing breadcrumbs');
+ clearBreadcrumbs();
+ hideBreadcrumbs();
+ return;
+ }
+
+ // Determine target breadcrumb structure
+ const targetStructure = await getTargetBreadcrumbStructure(item);
+ log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text));
+
+ // Check if we need to create breadcrumbs from scratch
+ if (!breadcrumbContainer || !currentBreadcrumbs) {
+ log('Creating breadcrumbs from scratch');
+ await createBreadcrumbsFromScratch(targetStructure);
+ } else {
+ // Compare existing with target and apply differences
+ const differences = compareBreadcrumbStructures(currentBreadcrumbs, targetStructure);
+ await applyBreadcrumbDifferences(differences, targetStructure);
+ }
+
+ // Update state
+ currentBreadcrumbs = targetStructure;
+ currentItemId = item.Id;
+
+ log('About to show breadcrumbs...');
+ showBreadcrumbs();
+ log('Breadcrumbs updated successfully');
+
+ } catch (err) {
+ error('Error handling page change:', err);
+ clearBreadcrumbs();
+ hideBreadcrumbs();
+ }
+ }
+
+ // Create breadcrumbs from scratch
+ async function createBreadcrumbsFromScratch(targetStructure) {
+ // Create container if it doesn't exist
+ if (!breadcrumbContainer) {
+ breadcrumbContainer = createBreadcrumbContainer();
+ }
+
+ // Clear existing content
+ clearBreadcrumbs();
+
+ // Add all elements
+ for (let i = 0; i < targetStructure.elements.length; i++) {
+ const elementDef = targetStructure.elements[i];
+ const element = await createBreadcrumbElementFromStructure(elementDef, targetStructure.item);
+
+ breadcrumbContainer.appendChild(element);
+
+ // Add separator after each element except the last
+ if (i < targetStructure.elements.length - 1) {
+ const separator = createSeparator();
+ breadcrumbContainer.appendChild(separator);
+ }
+ }
+
+ log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements');
+ }
+
+ // Window resize handler
+ function handleResize() {
+ if (currentBreadcrumbs && breadcrumbContainer) {
+ if (isWideScreen()) {
+ showBreadcrumbs();
+ } else {
+ hideBreadcrumbs();
+ }
+ }
+ }
+
+ // Initialize breadcrumbs
+ function initialize() {
+ log('Initializing breadcrumbs...');
+
+ // Use utils for page change detection
+ if (window.KefinTweaksUtils) {
+ log('Registering breadcrumb handler with KefinTweaksUtils');
+
+ // Register handler for all pages (breadcrumbs can appear on any detail page)
+ window.KefinTweaksUtils.onViewPage((view, element) => {
+ try {
+ // Run our custom code
+ handlePageChange();
+ } catch (err) {
+ error('Breadcrumb page change handler failed:', err);
+ }
+ }, {
+ immediate: true,
+ pages: [] // Empty array means all pages
+ });
+ } else {
+ error('KefinTweaksUtils not available, breadcrumbs may not work correctly');
+ }
+
+ // Add resize listener
+ window.addEventListener('resize', handleResize);
+
+ // Initial check
+ handlePageChange();
+
+ log('Breadcrumbs initialized successfully');
+ }
+
+ // Start initialization when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initialize);
+ } else {
+ initialize();
+ }
+
+})();
diff --git a/cardBuilder.js b/cardBuilder.js
index 20f1842..fce4294 100644
--- a/cardBuilder.js
+++ b/cardBuilder.js
@@ -20,7 +20,7 @@
* Renders a scrollable container with cards
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
- * @param {string} viewMoreUrl - Optional URL to make title clickable
+ * @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click
* @param {number} sectionIndex - Index for debugging
* @returns {HTMLElement} - The constructed scrollable container
*/
@@ -158,6 +158,21 @@
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ } else if (item.ImageTags?.Thumb) {
+ const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
+ cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ } else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) {
+ const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ } else if ((item.Type === 'Episode') && item.ParentThumbImageTag) {
+ const imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ } else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
+ const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
+ cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
+ const imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
// No image - add icon as inner element
const iconSpan = document.createElement('span');
@@ -210,12 +225,47 @@
// Button container for additional overlay buttons (watchlist, etc.)
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
+
+ // Watched button
+ const watchedButton = document.createElement('button');
+ watchedButton.setAttribute('is', 'emby-playstatebutton');
+ watchedButton.type = 'button';
+ watchedButton.setAttribute('data-action', 'none');
+ watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
+ watchedButton.setAttribute('data-id', item.Id);
+ watchedButton.setAttribute('data-serverid', serverId);
+ watchedButton.setAttribute('data-itemtype', item.Type);
+ watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
+ watchedButton.title = 'Mark played';
- // Add play button to button container
- buttonContainer.appendChild(playButton);
+ const watchedIcon = document.createElement('span');
+ watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
+ watchedIcon.setAttribute('aria-hidden', 'true');
+ watchedButton.appendChild(watchedIcon);
+ buttonContainer.appendChild(watchedButton);
+
+ // Favorite button
+ const favoriteButton = document.createElement('button');
+ favoriteButton.setAttribute('is', 'emby-ratingbutton');
+ favoriteButton.type = 'button';
+ favoriteButton.setAttribute('data-action', 'none');
+ favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
+ favoriteButton.setAttribute('data-id', item.Id);
+ favoriteButton.setAttribute('data-serverid', serverId);
+ favoriteButton.setAttribute('data-itemtype', item.Type);
+ favoriteButton.setAttribute('data-likes', '');
+ favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
+ favoriteButton.title = 'Add to favorites';
+
+ const favoriteIcon = document.createElement('span');
+ favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
+ favoriteIcon.setAttribute('aria-hidden', 'true');
+ favoriteButton.appendChild(favoriteIcon);
+ buttonContainer.appendChild(favoriteButton);
// Assemble overlay
cardOverlayContainer.appendChild(overlayLink);
+ cardOverlayContainer.appendChild(playButton);
cardOverlayContainer.appendChild(buttonContainer);
// Card text container - different structure for episodes
@@ -301,6 +351,7 @@
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
+ const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
@@ -310,8 +361,8 @@
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
- episodeLink.title = item.Name || 'Unknown Episode';
- episodeLink.textContent = item.Name || 'Unknown Episode';
+ episodeLink.title = episodeName;
+ episodeLink.textContent = episodeName;
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
@@ -344,7 +395,7 @@
// Create the main vertical section container
const verticalSection = document.createElement('div');
- verticalSection.className = 'verticalSection emby-scroller-container';
+ verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
// Create section title
const sectionTitle = document.createElement('h2');
@@ -353,10 +404,19 @@
if (viewMoreUrl) {
// Create clickable title with chevron icon
const titleLink = document.createElement('a');
- titleLink.href = viewMoreUrl;
titleLink.className = 'sectionTitle-link sectionTitleTextButton';
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
+ // Handle both URL and function
+ if (typeof viewMoreUrl === 'function') {
+ titleLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ viewMoreUrl();
+ });
+ } else {
+ titleLink.href = viewMoreUrl;
+ }
+
const titleText = document.createElement('span');
titleText.textContent = title;
@@ -372,6 +432,14 @@
sectionTitle.textContent = title;
}
+ // Create "Show All" button (will be added after items are created)
+ const showAllButton = document.createElement('button');
+ showAllButton.type = 'button';
+ showAllButton.className = 'show-all-button';
+ showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer;';
+ showAllButton.textContent = 'Show All';
+ showAllButton.title = 'Show all items in a grid layout';
+
// Create scroll buttons container
const scrollButtons = document.createElement('div');
//scrollButtons.setAttribute('is', 'emby-scrollbuttons');
@@ -436,6 +504,33 @@
scroller.appendChild(itemsContainer);
+ // Add "Show All" button to title if there are more than 20 items
+ if (items.length > 20) {
+ sectionTitle.appendChild(showAllButton);
+ }
+
+ // Toggle between scroll and grid view
+ let isShowingAll = false;
+ const originalItemsContainerStyle = itemsContainer.style.cssText;
+
+ showAllButton.addEventListener('click', () => {
+ if (isShowingAll) {
+ // Switch back to scroll view
+ itemsContainer.style.cssText = originalItemsContainerStyle;
+ scrollButtons.style.display = '';
+ showAllButton.textContent = 'Show All';
+ showAllButton.title = 'Show all items in a grid layout';
+ isShowingAll = false;
+ } else {
+ // Switch to grid view
+ itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;';
+ scrollButtons.style.display = 'none';
+ showAllButton.textContent = 'Show Less';
+ showAllButton.title = 'Show items in scrollable layout';
+ isShowingAll = true;
+ }
+ });
+
// Scroll functionality - calculate dynamically when needed
let scrollPosition = 0;
@@ -638,33 +733,18 @@
// Add CSS to hide emby-scrollbuttons that aren't custom-scrollbuttons and improve touch experience
const style = document.createElement('style');
- style.textContent = `
- .smart-search-results .emby-scrollbuttons {
- display: none;
- }
- .layout-desktop .smart-search-results .emby-scrollbuttons:not(.custom-scrollbuttons) {
- display: block !important;
- }
-
+ style.textContent = `
/* Native horizontal scrolling experience - ONLY for our custom scrollers */
- .emby-scroller.custom-scroller {
- -webkit-overflow-scrolling: touch !important;
- touch-action: pan-x !important;
- overscroll-behavior-x: contain !important;
- }
-
- /* Improve touch responsiveness */
- .emby-scroller.custom-scroller .scrollSlider {
- touch-action: pan-x !important;
- -webkit-user-select: none !important;
- user-select: none !important;
- }
+
/* Ensure smooth scrolling on mobile */
@media (max-width: 768px) {
.emby-scroller.custom-scroller {
scroll-behavior: smooth !important;
}
+ .custom-scroller-container .emby-scrollbuttons {
+ display: none;
+ }
}
`;
document.head.appendChild(style);
diff --git a/customMenuLinks.js b/customMenuLinks.js
new file mode 100644
index 0000000..8a0ac84
--- /dev/null
+++ b/customMenuLinks.js
@@ -0,0 +1,112 @@
+// 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
+
+(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);
+
+ LOG('Script loaded - JS Injector mode');
+
+ // Get custom menu links configuration
+ function getCustomMenuLinksConfig() {
+ return window.KefinTweaksConfig?.customMenuLinks || [];
+ }
+
+ // Initialize custom menu links
+ async function initializeCustomMenuLinks() {
+ const customMenuLinks = getCustomMenuLinksConfig();
+
+ if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) {
+ LOG('No custom menu links configured');
+ return;
+ }
+
+ LOG(`Loading ${customMenuLinks.length} custom menu links`);
+
+ // Check if utils is available
+ if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
+ ERR('KefinTweaksUtils.addCustomMenuLink not available');
+ return;
+ }
+
+ // Add each custom menu link
+ const addPromises = customMenuLinks.map(async (linkConfig, index) => {
+ try {
+ // Validate link configuration
+ if (!linkConfig.name || !linkConfig.url) {
+ WARN(`Custom menu link at index ${index} is missing required properties (name, url)`);
+ return false;
+ }
+
+ const {
+ name,
+ icon = 'link', // Default icon
+ url,
+ openInNewTab = false
+ } = linkConfig;
+
+ LOG(`Adding custom menu link: ${name}`);
+
+ const success = await window.KefinTweaksUtils.addCustomMenuLink(
+ name,
+ icon,
+ url,
+ openInNewTab
+ );
+
+ if (success) {
+ LOG(`Successfully added custom menu link: ${name}`);
+ } else {
+ WARN(`Failed to add custom menu link: ${name}`);
+ }
+
+ return success;
+ } catch (error) {
+ ERR(`Error adding custom menu link at index ${index}:`, error);
+ return false;
+ }
+ });
+
+ // Wait for all links to be processed
+ const results = await Promise.all(addPromises);
+ const successCount = results.filter(Boolean).length;
+
+ LOG(`Custom menu links initialization complete: ${successCount}/${customMenuLinks.length} links added successfully`);
+ }
+
+ // Wait for utils to be available and then initialize
+ function waitForUtilsAndInitialize() {
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
+ LOG('Utils available, initializing custom menu links');
+ initializeCustomMenuLinks();
+ return;
+ }
+
+ LOG('Waiting for KefinTweaksUtils to be available');
+
+ // Poll for utils availability
+ const checkInterval = setInterval(() => {
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
+ clearInterval(checkInterval);
+ LOG('Utils available, initializing custom menu links');
+ initializeCustomMenuLinks();
+ }
+ }, 100);
+
+ // Fallback timeout after 10 seconds
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ WARN('KefinTweaksUtils not available after 10 seconds');
+ }, 10000);
+ }
+
+ // Start initialization
+ waitForUtilsAndInitialize();
+
+ LOG('Custom menu links functionality initialized');
+})();
\ No newline at end of file
diff --git a/exclusiveElsewhere.js b/exclusiveElsewhere.js
index bb648f2..ef54697 100644
--- a/exclusiveElsewhere.js
+++ b/exclusiveElsewhere.js
@@ -26,18 +26,25 @@
LOG('Script loaded - JS Injector mode');
+ // Get configuration from global config
+ const config = window.KefinTweaksConfig?.exclusiveElsewhere || {
+ hideServerName: false // Default fallback
+ };
+
const observer = new MutationObserver(() => {
const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div');
if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) {
return;
}
+ const serverName = config.hideServerName ? '' : ApiClient.serverName();
+
const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a');
if (link && !link.classList.contains('exclusive')) {
LOG('Modifying elsewhere link to show exclusive branding');
- link.innerHTML = "Only available on Jellyfin";
+ link.innerHTML = `Only available on ${serverName}`;
link.classList.add("exclusive");
- link.title = "Jellyfin Exclusive";
+ link.title = "Exclusive";
link.disable = true;
}
});
diff --git a/headerTabs.js b/headerTabs.js
index 866ece1..22e85f5 100644
--- a/headerTabs.js
+++ b/headerTabs.js
@@ -23,7 +23,6 @@
for (const match of hashMatches) {
const pageFromHash = match.replace('#/', '');
if (SUPPORTED_PAGES.includes(pageFromHash)) {
- LOG('Current page detected:', pageFromHash);
return pageFromHash;
}
}
@@ -60,10 +59,18 @@
let newHash = currentHash;
if (currentHash.includes('?')) {
- // Replace existing tab parameter
+ // Replace existing tab parameter and clear watchlist-specific params
const [hashPath, hashSearch] = currentHash.split('?');
const params = new URLSearchParams(hashSearch);
+ // Clear watchlist-specific parameters when switching header tabs
+ params.delete('pageTab');
+ params.delete('page');
+ params.delete('sort');
+ params.delete('sortDirection');
+ params.delete('movieSort');
+ params.delete('movieSortDirection');
+
if (tabIndex === 0) {
params.delete('tab');
} else {
@@ -76,6 +83,8 @@
// Add tab parameter
if (tabIndex !== 0) {
newHash = `${currentHash}?tab=${tabIndex}`;
+ } else {
+ newHash = `${currentHash}`;
}
}
@@ -115,6 +124,7 @@
}
}
+ window.scrollTo(0, 0);
LOG('Tab clicked:', currentPage, tabIndex);
}
}
@@ -125,6 +135,17 @@
if (!headerTabs) return;
const currentTabFromUrl = getCurrentTabFromUrl();
+
+ // Patch for media bar because it usually reacts slowly and hides later
+ // Hide media bar slideshow if we aren't specifically on the home page first tab
+ const currentPage = getCurrentPage();
+ if (currentPage !== 'home.html' || currentTabFromUrl !== 0) {
+ const mediaBarSlideshow = document.getElementById('slides-container');
+ if (mediaBarSlideshow) {
+ mediaBarSlideshow.style.display = 'none';
+ }
+ }
+
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
const activeButton = headerTabs.querySelector('.emby-tab-button-active');
@@ -146,13 +167,23 @@
if (correctButton) {
correctButton.classList.add('emby-tab-button-active');
LOG('Set active tab button to index:', currentTabFromUrl);
- } else {
- // If no correct button found, set the first one (index 0) as active
- /* const firstButton = buttons[0];
- if (firstButton) {
- firstButton.classList.add('emby-tab-button-active');
- LOG('No matching tab found, set first button as active');
- } */
+ }
+
+ const activeTabContent = document.querySelector('.libraryPage:not(.hide) .pageTabContent.is-active');
+ const activeTabContentIndex = activeTabContent ? activeTabContent.getAttribute('data-index') : null;
+
+ // Remove is-active class from the active tab content if it's not the current tab
+ if (activeTabContentIndex && activeTabContentIndex !== currentTabFromUrl) {
+ activeTabContent.classList.remove('is-active');
+ }
+
+ // Add is-active class to the current tab content if it's not already active
+ if (!activeTabContentIndex || activeTabContentIndex !== currentTabFromUrl) {
+ const targetContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabFromUrl}"]`);
+ if (targetContent) {
+ targetContent.classList.add('is-active');
+ LOG('Set active tab content:', currentTabFromUrl);
+ }
}
}
@@ -274,11 +305,16 @@
// Monitor for page changes
function setupPageMonitor() {
let lastPage = null;
+ let lastUrl = window.location.href;
function checkPageChange() {
const newPage = getCurrentPage();
- if (newPage !== lastPage) {
+ const currentUrl = window.location.href;
+
+ // Check if page changed OR if URL changed (including query params)
+ if (newPage !== lastPage || currentUrl !== lastUrl) {
lastPage = newPage;
+ lastUrl = currentUrl;
if (newPage) {
LOG('Page change detected:', newPage);
// Setup observers when page changes
@@ -296,12 +332,80 @@
// Check on hash changes
window.addEventListener('hashchange', checkPageChange);
+ // Fallback: check URL periodically but less frequently
+/* urlCheckInterval = setInterval(() => {
+ if (window.location.href !== lastUrl) {
+ checkPageChange();
+ }
+ }, 500); */
+
// Check periodically in case we miss something
- setInterval(checkPageChange, 1000);
+ //setInterval(checkPageChange, 1000);
+ }
+
+ // Setup home navigation click handler with retry logic
+/* function setupHomeNavClickHandler() {
+ const homeNavOption = document.querySelector('.mainDrawer .navMenuOption[href="#/home.html"]');
+ if (homeNavOption) {
+ homeNavOption.addEventListener('click', function(event) {
+ const headerTabs = document.querySelector('.headerTabs');
+ if (headerTabs) {
+ const firstTab = headerTabs.querySelector('.emby-tab-button[data-index="0"]');
+ if (firstTab) {
+ LOG('Triggering click on first homepage tab');
+ firstTab.click();
+ } else {
+ WARN('First homepage tab not found');
+ }
+ } else {
+ WARN('Header tabs not found');
+ }
+ });
+ LOG('Added click listener to home navigation option');
+ return true; // Success
+ } else {
+ WARN('Home navigation option not found, will retry...');
+ return false; // Failed, needs retry
+ }
+ }
+
+ // Retry setup until successful
+ function retryHomeNavSetup() {
+ const maxRetries = 50; // Try for up to 25 seconds (50 * 500ms)
+ let retryCount = 0;
+
+ const retryInterval = setInterval(() => {
+ if (setupHomeNavClickHandler()) {
+ clearInterval(retryInterval);
+ LOG('Home navigation click handler successfully attached');
+ } else {
+ retryCount++;
+ if (retryCount >= maxRetries) {
+ clearInterval(retryInterval);
+ ERR('Failed to attach home navigation click handler after maximum retries');
+ }
+ }
+ }, 500);
+ } */
+
+ // Register onViewPage handler to sync active tab state
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) {
+ window.KefinTweaksUtils.onViewPage((view, element) => {
+ LOG('onViewPage handler triggered for view:', view);
+ // Sync active tab state when page view changes
+ syncActiveTabState();
+ }, {
+ pages: SUPPORTED_PAGES, // Only trigger for supported pages
+ immediate: false
+ });
+ LOG('Registered onViewPage handler for syncActiveTabState');
+ } else {
+ WARN('KefinTweaksUtils.onViewPage not available');
}
// Initialize
setupPageMonitor();
+ //retryHomeNavSetup();
LOG('Header tabs functionality initialized');
diff --git a/homeScreen.css b/homeScreen.css
index 67e7e97..3756fbe 100644
--- a/homeScreen.css
+++ b/homeScreen.css
@@ -1,13 +1,33 @@
/* This will put the Continue Watching and Next Up sections at the top of the home screen
You can order your custom sections between those and the rest of the sections by using 3-99 for the section order
- If you want your custom section to appear after the normal sections, use 101+ for the section order */
-
+ Discovery sections are 101-110 if they are enabled
+ If you want your custom section to appear after all other sections, use 111+ for the section order */
+.sections.homeSectionsContainer {
+ display: flex;
+ flex-direction: column;
+}
.sections.homeSectionsContainer>div {
order: 100;
}
.sections.homeSectionsContainer>div:has(div[data-positionticks]) {
- order: 1;
+ order: 2;
}
.sections.homeSectionsContainer>div:has(a[href*="type=nextup"]) {
- order: 2;
+ order: 3;
+}
+
+
+
+/* Halloween theme */
+
+.home-screen.halloween-theme #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: -25%;
+ background-image: url('/files/KefinTweaks/pages/images/halloween.jpg');
+ background-position: 0px -250px;
+ background-size: auto 102%;
}
\ No newline at end of file
diff --git a/homeScreen.js b/homeScreen.js
index e7d96ce..ac2270e 100644
--- a/homeScreen.js
+++ b/homeScreen.js
@@ -1,5 +1,7 @@
// Jellyfin Home Screen Script
// 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
(function() {
@@ -12,22 +14,252 @@
LOG('Script loaded - JS Injector mode');
+ // Add CSS for discovery section loading indicator
+ function addDiscoveryLoadingCSS() {
+ const styleId = 'discovery-loading-styles';
+
+ // Check if style already exists
+ if (document.getElementById(styleId)) {
+ return;
+ }
+
+ const style = document.createElement('style');
+ style.id = styleId;
+ 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;
+ }
+
+ .discovery-loading-indicator.show {
+ opacity: 1;
+ }
+
+ .discovery-loading-indicator .spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #f3f3f3;
+ border-top: 2px solid #3498db;
+ border-radius: 50%;
+ animation: discovery-spin 1s linear infinite;
+ }
+
+ @keyframes discovery-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+ `;
+
+ document.head.appendChild(style);
+ LOG('Added discovery loading CSS styles');
+ }
+
+ // Create loading indicator element
+ function createDiscoveryLoadingIndicator() {
+ const loadingDiv = document.createElement('div');
+ loadingDiv.className = 'discovery-loading-indicator';
+ loadingDiv.id = 'discovery-loading-indicator';
+ loadingDiv.innerHTML = `
+
+ Loading more discovery sections...
+ `;
+ return loadingDiv;
+ }
+
+ // Show loading indicator
+ function showDiscoveryLoadingIndicator() {
+ 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);
+ }
+ }
+
+ // Show with animation
+ setTimeout(() => {
+ loadingIndicator.classList.add('show');
+ }, 10);
+
+ LOG('Discovery loading indicator shown');
+ }
+
+ // Hide loading indicator
+ 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);
+
+ LOG('Discovery loading indicator hidden');
+ }
+ }
+
+ // Body class management for home page and themes
+ function manageBodyClasses() {
+ const body = document.body;
+ const currentView = window.KefinTweaksUtils?.getCurrentView();
+ const isHomePage = currentView === 'home' || window.location.pathname.includes('home.html');
+
+ // Manage home-screen class
+ if (isHomePage && body) {
+ body.classList.add('home-screen');
+ } else {
+ body.classList.remove('home-screen');
+ }
+
+ // Manage theme classes (only on home page)
+ if (isHomePage) {
+ // Remove all existing theme classes
+ const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme'];
+ themeClasses.forEach(themeClass => body.classList.remove(themeClass));
+
+ // Add appropriate theme class based on current date
+ if (isHalloweenPeriod()) {
+ body.classList.add('halloween-theme');
+ } else if (isChristmasPeriod()) {
+ body.classList.add('christmas-theme');
+ } else if (isValentinesPeriod()) {
+ body.classList.add('valentines-theme');
+ } else if (isNewYearsPeriod()) {
+ body.classList.add('newyear-theme');
+ }
+ } else {
+ // Remove all theme classes when not on home page
+ const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme'];
+ themeClasses.forEach(themeClass => body.classList.remove(themeClass));
+ }
+ }
+
+ // Initialize body class management
+ manageBodyClasses();
+
+ // Initialize discovery loading CSS
+ addDiscoveryLoadingCSS();
+
+ // Set up page view monitoring
+ if (window.KefinTweaksUtils) {
+ window.KefinTweaksUtils.onViewPage(manageBodyClasses, {
+ immediate: true,
+ pages: []
+ });
+ LOG('Registered body class management for home page');
+ } else {
+ WARN('KefinTweaksUtils not available, body class management may not work correctly');
+ }
+
// Custom home sections configuration
- const customHomeSections = [
- // Add home screen sections here with the following format:
- /* {
- name: 'Section Name',
- id: 'playlistId', <- Make sure the playlist is Public
- maxItems: 16, <- Maximum number of items to display on the home screen
- order: 3 <- Order of the section on the home screen
- }, */
- ];
+ // Read from centralized config or use defaults
+ // name: The name of the section that will appear on the home screen
+ // id: The ID of the playlist that will be used to fetch the items for the section
+ // maxItems: The maximum number of items to display in the section
+ // order: The flex order of the section on the home screen (requires css to take advantage of this)
+ const customHomeSections = window.KefinTweaksConfig?.homeScreen?.customSections || [];
+
+ // New and Trending sections configuration
+ const enableNewAndTrending = window.KefinTweaksConfig?.homeScreen?.enableNewAndTrending !== false; // Default to true
+ const enableNewMovies = window.KefinTweaksConfig?.homeScreen?.enableNewMovies !== false; // Default to true
+ const enableNewEpisodes = window.KefinTweaksConfig?.homeScreen?.enableNewEpisodes !== false; // Default to true
+ const enableTrending = window.KefinTweaksConfig?.homeScreen?.enableTrending || false; // Default to false (stubs for now)
+
+ // Discovery sections configuration
+ const enableWatchlist = window.KefinTweaksConfig?.homeScreen?.enableWatchlist || false;
+ const enableDiscovery = window.KefinTweaksConfig?.homeScreen?.enableDiscovery || false;
+ const enableSeasonal = window.KefinTweaksConfig?.homeScreen?.enableSeasonal || false;
+
+ // People and genre configuration
+ const minPeopleAppearances = window.KefinTweaksConfig?.homeScreen?.minPeopleAppearances || 10;
+ const minGenreMovieCount = window.KefinTweaksConfig?.homeScreen?.minGenreMovieCount || 50;
+ const enableInfiniteScroll = window.KefinTweaksConfig?.homeScreen?.enableInfiniteScroll !== false; // Default to true
+
+ // Seasonal configuration
+ const SEASONAL_ITEM_LIMIT = window.KefinTweaksConfig?.homeScreen?.seasonalItemLimit || 16;
// Processing flag to prevent parallel execution
let isProcessing = false;
+
+ // Seasonal data storage
+ let halloweenMovies = [];
+ let horrorMovies = [];
+ let thrillerMovies = [];
+ let halloweenViewMoreUrls = {
+ horror: null,
+ thriller: null
+ };
+
+ // Discovery sections data storage
+ let moviesTopPeople = null;
+ let renderedSections = new Set(); // Track rendered sections to avoid duplicates
+ let isGeneratingDiscoverySections = false; // Prevent parallel generation (legacy)
+ let isRenderingDiscoveryGroup = false; // Prevent parallel rendering from buffer
+ let isInitializingPeopleCache = false; // Prevent parallel people cache initialization
+ let isPeopleCacheComplete = false; // Track if all people data has been loaded
+ let preloadedSections = []; // Preloaded sections ready for instant rendering
+
+ // Discovery buffer system
+ let discoveryBuffer = []; // Buffer holding groups of discovery items (always 2 groups)
+ let additionalDiscoveryContent = true; // Flag indicating if more discovery content is available
+ let preloadedSectionElements = []; // Preloaded section DOM elements (hidden)
+ let isPreloadingSections = false; // Prevent parallel preloading
/************ Helpers ************/
+/**
+ * Fetches item data from Jellyfin API to determine type
+ * @param {string} itemId - The item ID to fetch
+ * @returns {Promise} - Item data with type information
+ */
+async function fetchItemData(itemId) {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ const url = `${serverUrl}/Items/${itemId}`;
+
+ try {
+ 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;
+ } catch (err) {
+ ERR(`Failed to fetch item data for ${itemId}:`, err);
+ return null;
+ }
+}
+
/**
* Fetches playlist data from Jellyfin API
* @param {string} playlistId - The playlist ID to fetch
@@ -60,27 +292,87 @@ async function fetchPlaylistData(playlistId) {
}
/**
- * Generates the view more URL for a playlist
- * @param {string} playlistId - The playlist ID
- * @returns {string} - The complete URL for the playlist page
+ * Fetches collection data from Jellyfin API
+ * @param {string} collectionId - The collection ID to fetch
+ * @returns {Promise} - Collection data with Items array
*/
-function generateViewMoreUrl(playlistId) {
+async function fetchCollectionData(collectionId) {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+
+ const url = `${serverUrl}/Users/${userId}/Items?ParentId=${collectionId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CCanDelete%2CMediaSourceCount`;
+
+ try {
+ 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;
+ } catch (err) {
+ ERR(`Failed to fetch collection data for ${collectionId}:`, err);
+ return { Items: [] };
+ }
+}
+
+/**
+ * Generates the view more URL for a playlist or collection
+ * @param {string} itemId - The item ID
+ * @param {string} itemType - The item type ('Playlist' or 'BoxSet')
+ * @returns {string} - The complete URL for the item page
+ */
+function generateViewMoreUrl(itemId, itemType) {
const apiClient = window.ApiClient;
const serverId = apiClient.serverId();
- return `/web/#/list.html?parentId=${playlistId}&serverId=${serverId}`;
+
+ if (itemType === 'BoxSet') {
+ // For collections, use the collection page
+ return `#/details?id=${itemId}&serverId=${serverId}`;
+ } else {
+ // For playlists, use the list page
+ return `#/list.html?parentId=${itemId}&serverId=${serverId}`;
+ }
}
/**
* Renders a custom home section
* @param {Object} section - Section configuration object
* @param {HTMLElement} container - Container to append the section to
- * @returns {Promise} - Success status
+ * @returns {boolean} - Success status
*/
async function renderCustomSection(section, container) {
try {
- // Fetch playlist data
- const playlistData = await fetchPlaylistData(section.id);
- const itemIds = playlistData.ItemIds || [];
+ // First, fetch item data to determine type
+ const itemData = await fetchItemData(section.id);
+ if (!itemData) {
+ ERR(`Failed to fetch item data for section ${section.name}`);
+ return false;
+ }
+
+ let itemIds = [];
+ let itemType = itemData.Type;
+
+ // Handle different item types
+ if (itemType === 'Playlist') {
+ // Fetch playlist data
+ const playlistData = await fetchPlaylistData(section.id);
+ itemIds = playlistData.ItemIds || [];
+ } else if (itemType === 'BoxSet') {
+ // Fetch collection data
+ const collectionData = await fetchCollectionData(section.id);
+ itemIds = (collectionData.Items || []).map(item => item.Id);
+ } else {
+ ERR(`Unsupported item type ${itemType} for section ${section.name}`);
+ return false;
+ }
// Limit to maxItems
const limitedItemIds = itemIds.slice(0, section.maxItems);
@@ -90,7 +382,7 @@ async function renderCustomSection(section, container) {
}
// Generate view more URL
- const viewMoreUrl = generateViewMoreUrl(section.id);
+ const viewMoreUrl = generateViewMoreUrl(section.id, itemType);
// Check if cardBuilder is available
if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCardsFromIds) {
@@ -101,13 +393,14 @@ async function renderCustomSection(section, container) {
// Render the scrollable container
const scrollableContainer = await window.cardBuilder.renderCardsFromIds(
limitedItemIds,
- section.name,
+ section.name ?? itemData.Name,
viewMoreUrl
);
// Add data attribute to track rendered sections
scrollableContainer.setAttribute('data-custom-section-id', section.id);
scrollableContainer.setAttribute('data-custom-section-name', section.name);
+ scrollableContainer.setAttribute('data-custom-section-type', itemType);
if (section.order) {
scrollableContainer.style.order = section.order;
@@ -137,6 +430,2549 @@ async function renderAllCustomSections(container) {
LOG(`Rendered ${successCount}/${customHomeSections.length} custom sections`);
}
+/**
+ * Renders trending movies section (stub for future implementation)
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+function renderTrendingMoviesSection(container) {
+ // TODO: Implement trending movies section
+ LOG('Trending Movies section not yet implemented');
+ return false;
+}
+
+/**
+ * Renders trending series section (stub for future implementation)
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+function renderTrendingSeriesSection(container) {
+ // TODO: Implement trending series section
+ LOG('Trending Series section not yet implemented');
+ return false;
+}
+
+/**
+ * Renders all new and trending sections
+ * @param {HTMLElement} container - Container to append sections to
+ */
+async function renderAllNewAndTrendingSections(container) {
+ if (!enableNewAndTrending) {
+ LOG('New and Trending sections disabled, skipping...');
+ return;
+ }
+
+ const sectionsToRender = [];
+
+ // Add new movies section if enabled
+ if (enableNewMovies) {
+ sectionsToRender.push(renderNewMoviesSection(container));
+ }
+
+ // Add new episodes section if enabled
+ if (enableNewEpisodes) {
+ sectionsToRender.push(renderNewEpisodesSection(container));
+ }
+
+ // Add trending sections if enabled (stubs for now)
+ if (enableTrending) {
+ sectionsToRender.push(renderTrendingMoviesSection(container));
+ sectionsToRender.push(renderTrendingSeriesSection(container));
+ }
+
+ if (sectionsToRender.length === 0) {
+ return;
+ }
+
+ const results = await Promise.all(sectionsToRender);
+ const successCount = results.filter(Boolean).length;
+ LOG(`Rendered ${successCount}/${sectionsToRender.length} new and trending sections`);
+}
+
+/************ Seasonal Section Helpers ************/
+
+/**
+ * Checks if current date is within Halloween period (Oct 1-31)
+ * @returns {boolean} - True if within Halloween period
+ */
+function isHalloweenPeriod() {
+ const now = new Date();
+ const month = now.getMonth() + 1; // getMonth() returns 0-11
+ const day = now.getDate();
+ return month === 10 && day >= 1 && day <= 31;
+}
+
+/**
+ * Checks if current date is within Christmas period (Dec 1-30)
+ * @returns {boolean} - True if within Christmas period
+ */
+function isChristmasPeriod() {
+ const now = new Date();
+ const month = now.getMonth() + 1;
+ const day = now.getDate();
+ return month === 12 && day >= 1 && day <= 30;
+}
+
+/**
+ * Checks if current date is within New Years period (Dec 31 + Jan 1)
+ * @returns {boolean} - True if within New Years period
+ */
+function isNewYearsPeriod() {
+ const now = new Date();
+ const month = now.getMonth() + 1;
+ const day = now.getDate();
+ return (month === 12 && day === 31) || (month === 1 && day === 1);
+}
+
+/**
+ * Checks if current date is within Valentine's Day period (Feb 14)
+ * @returns {boolean} - True if within Valentine's Day period
+ */
+function isValentinesPeriod() {
+ const now = new Date();
+ const month = now.getMonth() + 1;
+ const day = now.getDate();
+ return month === 2 && day === 14;
+}
+
+/**
+ * Fetches all movie genres from Jellyfin API and caches them
+ * @returns {Promise} - Array of genre objects with Name, ImageTags, and Id
+ */
+async function fetchAndCacheMovieGenres() {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ try {
+ const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, {
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const genres = data.Items || [];
+
+ // Optimize genres to only include essential fields
+ const optimizedGenres = genres.map(genre => ({
+ Id: genre.Id,
+ Name: genre.Name,
+ ImageTags: genre.ImageTags || {},
+ MovieCount: genre.MovieCount
+ }));
+
+ // Cache the optimized genres
+ const cache = new window.LocalStorageCache();
+ cache.set('movieGenres', optimizedGenres);
+ LOG(`Cached ${optimizedGenres.length} movie genres`);
+
+ return optimizedGenres;
+ } catch (err) {
+ ERR('Failed to fetch movie genres:', err);
+ return [];
+ }
+}
+
+/**
+ * Gets a genre ID from cached movie genres
+ * @param {string} genreName - Name of the genre to find
+ * @returns {Promise} - Genre ID or null if not found
+ */
+async function getGenreId(genreName) {
+ const cache = new window.LocalStorageCache();
+
+ // Try to get from cache first
+ let movieGenres = cache.get('movieGenres');
+
+ // If not in cache or cache is invalid, fetch and cache
+ if (!movieGenres || movieGenres.length === 0) {
+ LOG('Movie genres not in cache, fetching...');
+ movieGenres = await fetchAndCacheMovieGenres();
+ }
+
+ // Find the genre by name (case insensitive)
+ const genre = movieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase());
+ return genre ? genre.Id : null;
+}
+
+/**
+ * Fetches Halloween movie data from Jellyfin API
+ * @returns {Promise} - Object containing halloween, horror, and thriller movies
+ */
+async function fetchHalloweenMovieData() {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+ const serverId = apiClient.serverId();
+
+ // No longer fetching people data for Halloween
+ const fieldsParam = '';
+
+ // Fetch genre IDs for ViewMore URLs
+ const [horrorGenreId, thrillerGenreId] = await Promise.all([
+ getGenreId('Horror'),
+ getGenreId('Thriller')
+ ]);
+
+ const queries = [
+ {
+ name: 'halloween',
+ url: `${serverUrl}/Items?userId=${userId}&Tags=halloween&IncludeItemTypes=Movie&Recursive=true&Limit=200${fieldsParam}`,
+ viewMoreUrl: null
+ },
+ {
+ name: 'horror',
+ url: `${serverUrl}/Items?userId=${userId}&Genres=Horror&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`,
+ viewMoreUrl: horrorGenreId ? `/web/#/list.html?genreId=${horrorGenreId}&serverId=${serverId}` : null
+ },
+ {
+ name: 'thriller',
+ url: `${serverUrl}/Items?userId=${userId}&Genres=Thriller&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`,
+ viewMoreUrl: thrillerGenreId ? `/web/#/list.html?genreId=${thrillerGenreId}&serverId=${serverId}` : null
+ }
+ ];
+
+ try {
+ const results = await Promise.all(
+ queries.map(async (query) => {
+ const response = await fetch(query.url, {
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ return {
+ name: query.name,
+ items: data.Items || []
+ };
+ })
+ );
+
+ // Process results
+ const halloweenItems = results.find(r => r.name === 'halloween')?.items || [];
+ const horrorItems = results.find(r => r.name === 'horror')?.items || [];
+ const thrillerItems = results.find(r => r.name === 'thriller')?.items || [];
+
+ // Combine all items into halloweenMovies, removing duplicates by ID
+ const allItems = [...halloweenItems];
+ const existingIds = new Set(halloweenItems.map(item => item.Id));
+
+ // Add horror items that aren't already in halloween
+ horrorItems.forEach(item => {
+ if (!existingIds.has(item.Id)) {
+ allItems.push(item);
+ existingIds.add(item.Id);
+ }
+ });
+
+ // Add thriller items that aren't already in halloween or horror
+ thrillerItems.forEach(item => {
+ if (!existingIds.has(item.Id)) {
+ allItems.push(item);
+ existingIds.add(item.Id);
+ }
+ });
+
+ return {
+ allHalloweenMovies: allItems,
+ halloweenMovies: halloweenItems,
+ horrorMovies: horrorItems,
+ thrillerMovies: thrillerItems,
+ viewMoreUrls: {
+ horror: queries.find(q => q.name === 'horror')?.viewMoreUrl || null,
+ thriller: queries.find(q => q.name === 'thriller')?.viewMoreUrl || null
+ }
+ };
+
+ } catch (err) {
+ ERR('Failed to fetch Halloween movie data:', err);
+ return {
+ allHalloweenMovies: [],
+ halloweenMovies: [],
+ horrorMovies: [],
+ thrillerMovies: [],
+ viewMoreUrls: {
+ horror: null,
+ thriller: null
+ }
+ };
+ }
+}
+
+
+
+/**
+ * Fetches and processes top people data from all movies using pagination
+ * @returns {Promise} - Top people data or null if failed
+ */
+async function fetchTopPeople() {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+ const cache = new window.LocalStorageCache();
+
+ try {
+ LOG('Starting paginated fetch of movies for people data processing...');
+
+ let allMovies = [];
+ let startIndex = 0;
+ const limit = 500;
+ let hasMoreData = true;
+
+ // Load existing partial data if available
+ const existingData = cache.get('movies_top_people');
+ if (existingData && !existingData.isComplete) {
+ const moviesProcessed = countMoviesFromPeopleData(existingData);
+ if (moviesProcessed > 0) {
+ startIndex = moviesProcessed;
+ LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
+ }
+ }
+
+ // Fetch movies in chunks of 500
+ while (hasMoreData) {
+ const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=Movie&Recursive=true&Fields=People&Limit=${limit}&StartIndex=${startIndex}`;
+
+ LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
+
+ 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();
+ const movies = data.Items || [];
+
+ if (movies.length === 0) {
+ hasMoreData = false;
+ LOG('No more movies to fetch');
+ } else {
+ allMovies = allMovies.concat(movies);
+ startIndex += movies.length;
+
+ LOG(`Fetched ${movies.length} movies, total: ${allMovies.length}`);
+
+ // Process and cache partial data after each chunk
+ const partialPeopleData = processPeopleData(allMovies);
+ if (partialPeopleData) {
+ // Mark as incomplete and store movie count for resuming
+ partialPeopleData.isComplete = movies.length < limit; // Complete if we got less than limit
+ partialPeopleData.moviesProcessedCount = allMovies.length;
+
+ // Update the global moviesTopPeople immediately so discovery sections can use it
+ moviesTopPeople = partialPeopleData;
+
+ // Cache partial data
+ cache.set('movies_top_people', partialPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
+ LOG(`Updated moviesTopPeople with partial data: ${partialPeopleData.actors.length} actors, ${partialPeopleData.directors.length} directors, ${partialPeopleData.writers.length} writers (${allMovies.length} movies processed)`);
+ }
+
+ // If we got less than the limit, we've reached the end
+ if (movies.length < limit) {
+ hasMoreData = false;
+ isPeopleCacheComplete = true;
+ LOG('Reached end of movies, people cache is complete');
+ }
+ }
+ }
+
+ // Final processing of all data
+ LOG(`Final processing of ${allMovies.length} movies...`);
+ const finalPeopleData = processPeopleData(allMovies);
+
+ if (finalPeopleData) {
+ // Mark as complete and remove movies count to save space
+ finalPeopleData.isComplete = true;
+ delete finalPeopleData.moviesProcessedCount;
+
+ // Update global variable
+ moviesTopPeople = finalPeopleData;
+
+ // Cache final data
+ cache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
+ LOG(`Final cached people data: ${finalPeopleData.actors.length} actors, ${finalPeopleData.directors.length} directors, ${finalPeopleData.writers.length} writers`);
+ }
+
+ return finalPeopleData;
+
+ } catch (err) {
+ ERR('Failed to fetch top people data:', err);
+ return null;
+ }
+}
+
+/**
+ * Processes people data from movies array
+ * @param {Array} movies - Array of movie objects with People data
+ * @returns {Object|null} - Processed people data or null if failed
+ */
+function processPeopleData(movies) {
+ try {
+ const peopleMap = new Map();
+
+ movies.forEach(movie => {
+ if (movie.People) {
+ movie.People.forEach(person => {
+ const key = person.Id;
+ if (!peopleMap.has(key)) {
+ peopleMap.set(key, {
+ id: person.Id,
+ name: person.Name,
+ directorCount: 0,
+ writerCount: 0,
+ actorCount: 0,
+ directorItemIds: [],
+ writerItemIds: [],
+ actorItemIds: []
+ });
+ }
+
+ const personData = peopleMap.get(key);
+ if (person.Type === 'Director') {
+ personData.directorCount++;
+ if (!personData.directorItemIds.includes(movie.Id)) {
+ personData.directorItemIds.push(movie.Id);
+ }
+ } else if (person.Type === 'Writer') {
+ personData.writerCount++;
+ if (!personData.writerItemIds.includes(movie.Id)) {
+ personData.writerItemIds.push(movie.Id);
+ }
+ } else if (person.Type === 'Actor') {
+ personData.actorCount++;
+ if (!personData.actorItemIds.includes(movie.Id)) {
+ personData.actorItemIds.push(movie.Id);
+ }
+ }
+ });
+ }
+ });
+
+ // Filter and sort people by count, taking top 100 per category
+ const allPeople = Array.from(peopleMap.values());
+
+ const topDirectors = allPeople
+ .filter(person => person.directorCount >= minPeopleAppearances)
+ .sort((a, b) => b.directorCount - a.directorCount)
+ .slice(0, 100)
+ .map(person => ({
+ name: person.name,
+ count: person.directorCount,
+ itemIds: person.directorItemIds
+ }));
+
+ const topWriters = allPeople
+ .filter(person => person.writerCount >= minPeopleAppearances)
+ .sort((a, b) => b.writerCount - a.writerCount)
+ .slice(0, 100)
+ .map(person => ({
+ name: person.name,
+ count: person.writerCount,
+ itemIds: person.writerItemIds
+ }));
+
+ const topActors = allPeople
+ .filter(person => person.actorCount >= minPeopleAppearances)
+ .sort((a, b) => b.actorCount - a.actorCount)
+ .slice(0, 100)
+ .map(person => ({
+ name: person.name,
+ count: person.actorCount,
+ itemIds: person.actorItemIds
+ }));
+
+ return {
+ actors: topActors,
+ directors: topDirectors,
+ writers: topWriters
+ };
+
+ } catch (err) {
+ ERR('Failed to process people data:', err);
+ return null;
+ }
+}
+
+
+/**
+ * Initializes the people cache in the background
+ */
+async function initializePeopleCache() {
+ // Prevent concurrent initialization
+ if (isInitializingPeopleCache) {
+ LOG('People cache initialization already in progress, skipping...');
+ return;
+ }
+
+ // Check if we already have complete data
+ if (moviesTopPeople !== null && isPeopleCacheComplete) {
+ LOG('People cache already loaded and complete');
+ return;
+ }
+
+ const cache = new window.LocalStorageCache();
+
+ // Check if we have valid cached data
+ if (cache.isCacheValid('movies_top_people')) {
+ const cachedData = cache.get('movies_top_people');
+
+ // Check if data is complete
+ if (cachedData.isComplete) {
+ moviesTopPeople = cachedData;
+ isPeopleCacheComplete = true;
+ LOG('Loaded complete top people data from cache');
+ return;
+ } else if (cachedData.moviesProcessedCount) {
+ // Use the stored count for resuming
+ const moviesProcessed = cachedData.moviesProcessedCount;
+ if (moviesProcessed > 0 && moviesProcessed % 500 === 0) {
+ // We have a complete set of 500-item chunks, continue loading
+ moviesTopPeople = cachedData;
+ isPeopleCacheComplete = false;
+ LOG(`Loaded partial top people data from cache (${moviesProcessed} movies), will continue fetching...`);
+ } else if (moviesProcessed > 0) {
+ // We have incomplete data, use what we have but don't continue loading
+ moviesTopPeople = cachedData;
+ isPeopleCacheComplete = true;
+ LOG(`Loaded incomplete top people data from cache (${moviesProcessed} movies), not divisible by 500, using as-is`);
+ return;
+ } else {
+ // We have partial data without count info, load it and continue fetching
+ moviesTopPeople = cachedData;
+ isPeopleCacheComplete = false;
+ LOG('Loaded partial top people data from cache, will continue fetching...');
+ }
+ } else {
+ // No moviesProcessedCount stored, treat as incomplete and continue fetching
+ moviesTopPeople = cachedData;
+ isPeopleCacheComplete = false;
+ LOG('Loaded partial top people data from cache (no count stored), will continue fetching...');
+ }
+ }
+
+ // Set flag to prevent concurrent calls
+ isInitializingPeopleCache = true;
+
+ try {
+ // Fetch/continue fetching data in the background
+ LOG('Fetching/continuing top people data in background...');
+ const result = await fetchTopPeople();
+ if (result) {
+ moviesTopPeople = result;
+ isPeopleCacheComplete = result.isComplete || false;
+ }
+ } finally {
+ // Always reset the flag
+ isInitializingPeopleCache = false;
+ }
+}
+
+/**
+ * Navigates to the watchlist tab by clicking the appropriate tab button
+ */
+function gotoWatchlistTab() {
+ const visibleWatchlistTab = document.querySelector('.libraryPage:not(.hide) .pageTabContent:has(.watchlist.sections)');
+ const visibleWatchlistTabIndex = visibleWatchlistTab ? visibleWatchlistTab.getAttribute('data-index') : null;
+
+ if (visibleWatchlistTabIndex) {
+ const tabButton = document.querySelector(`.headerTabs .emby-tab-button[data-index="${visibleWatchlistTabIndex}"]`);
+ if (tabButton) {
+ tabButton.click();
+
+ const watchlistTabButton = document.querySelector('.libraryPage:not(.hide) .pageTabContent:has(.watchlist.sections) .watchlist-tabs button:not(.active)[data-tab="watchlist"]');
+ if (watchlistTabButton) {
+ watchlistTabButton.click();
+ } else {
+ WARN('Watchlist tab button not found');
+ }
+ } else {
+ WARN('Watchlist tab button not found, navigating with page handler');
+ const pageHandler = Emby?.Page;
+
+ if (pageHandler && visibleWatchlistTabIndex) {
+ pageHandler.show(`/home.html?tab=${visibleWatchlistTabIndex}`);
+ return;
+ }
+ }
+ } else {
+ WARN('Watchlist tab not found');
+ }
+}
+
+/************ Discovery Section Helpers ************/
+
+/**
+ * Fetches similar content for a movie or series
+ * @param {string} itemId - The item ID
+ * @param {string} type - 'Movie' or 'Series'
+ * @returns {Promise} - Array of similar items
+ */
+async function fetchSimilarContent(itemId, type) {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ const endpoint = type === 'Movie' ? 'Movies' : 'Shows';
+ // Double the limit to account for filtering out played items
+ // Sadly Jellyfin API doesn't seem to support filtering out played items in the Similar endpoint
+ const url = `${serverUrl}/${endpoint}/${itemId}/Similar?limit=32`;
+
+ try {
+ 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();
+ const allItems = data.Items || [];
+
+ // Filter out played items
+ const unplayedItems = allItems.filter(item => !item.UserData?.Played);
+
+ // If we have enough unplayed items, randomly select 16
+ if (unplayedItems.length >= 16) {
+ // Shuffle array and take first 16
+ const shuffled = [...unplayedItems].sort(() => Math.random() - 0.5);
+ return shuffled.slice(0, 16);
+ }
+
+ // If we don't have enough unplayed items, return what we have
+ // (shuffled to avoid always showing the same items)
+ return [...unplayedItems].sort(() => Math.random() - 0.5);
+
+ } catch (err) {
+ ERR(`Failed to fetch similar content for ${type} ${itemId}:`, err);
+ return [];
+ }
+}
+
+/**
+ * Fetches items with IsFavorite filter
+ * @param {string} includeItemTypes - Item types to include (e.g., 'Movie,Series')
+ * @returns {Promise} - Array of favorite items
+ */
+async function fetchFavoriteItems(includeItemTypes = 'Movie,Series') {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+
+ const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=${includeItemTypes}&SortBy=DatePlayed&SortOrder=Descending&Recursive=true&Limit=16&Filters=IsFavorite`;
+
+ try {
+ 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(`Failed to fetch favorite items:`, err);
+ return [];
+ }
+}
+
+/**
+ * Fetches items by person (director or actor)
+ * @param {string} personId - The person ID
+ * @param {string} includeItemTypes - Item types to include (e.g., 'Movie')
+ * @returns {Promise} - Array of items by person
+ */
+async function fetchPersonItems(personId, includeItemTypes = 'Movie') {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ const url = `${serverUrl}/Items?personIds=${personId}&IncludeItemTypes=${includeItemTypes}&Recursive=true&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&limit=16`;
+
+ try {
+ 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(`Failed to fetch items for person ${personId}:`, err);
+ return [];
+ }
+}
+
+/**
+ * Gets random item from array
+ * @param {Array} items - Array of items
+ * @returns {Object|null} - Random item or null if array is empty
+ */
+function getRandomItem(items) {
+ if (!items || items.length === 0) return null;
+ return items[Math.floor(Math.random() * items.length)];
+}
+
+/**
+ * Shuffles an array and returns a new shuffled copy
+ * @param {Array} array - Array to shuffle
+ * @returns {Array} - New shuffled array
+ */
+function shuffleArray(array) {
+ if (!array || array.length === 0) return [];
+ const shuffled = [...array];
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ return shuffled;
+}
+
+/**
+ * Gets top N most frequent people from movie history
+ * @param {Array} movies - Array of movie objects
+ * @param {string} personType - 'Director' or 'Actor'
+ * @param {number} limit - Number of top people to return
+ * @returns {Array} - Array of person objects with counts
+ */
+function getTopPeople(movies, personType, limit = 25) {
+ const personCounts = {};
+
+ movies.forEach(movie => {
+ if (movie.People) {
+ movie.People.forEach(person => {
+ if (person.Type === personType) {
+ const key = person.Id;
+ if (!personCounts[key]) {
+ personCounts[key] = {
+ ...person,
+ count: 0
+ };
+ }
+ personCounts[key].count++;
+ }
+ });
+ }
+ });
+
+ return Object.values(personCounts)
+ .sort((a, b) => b.count - a.count)
+ .slice(0, limit);
+}
+
+/**
+ * Gets watchlist data from localStorageCache
+ * @returns {Array} - Combined watchlist items (movies + series)
+ */
+function getWatchlistData() {
+ if (typeof window.LocalStorageCache === 'undefined') {
+ WARN('LocalStorageCache not available');
+ return [];
+ }
+
+ const cache = new window.LocalStorageCache();
+ const movies = cache.get('watchlist_movies') || [];
+ const series = cache.get('watchlist_series') || [];
+
+ return [...movies, ...series].sort((a, b) => {
+ const dateA = new Date(a.PremiereDate || 0);
+ const dateB = new Date(b.PremiereDate || 0);
+ return dateB - dateA;
+ });
+}
+
+/**
+ * Gets movie history data from localStorageCache
+ * @returns {Array} - Array of watched movies
+ */
+function getMovieHistoryData() {
+ if (typeof window.LocalStorageCache === 'undefined') {
+ WARN('LocalStorageCache not available');
+ return [];
+ }
+
+ const cache = new window.LocalStorageCache();
+ return cache.get('movies') || [];
+}
+
+/**
+ * Gets series progress data from localStorageCache
+ * @returns {Array} - Array of series with progress data
+ */
+function getSeriesProgressData() {
+ if (typeof window.LocalStorageCache === 'undefined') {
+ WARN('LocalStorageCache not available');
+ return [];
+ }
+
+ const cache = new window.LocalStorageCache();
+ return cache.getChunked('progress') || [];
+}
+
+/**
+ * Gets random watched movie from history (last 25 by DatePlayed)
+ * @returns {Object|null} - Random watched movie or null
+ */
+function getRandomWatchedMovie() {
+ const movies = getMovieHistoryData();
+ if (movies.length === 0) return null;
+
+ // Sort by DatePlayed descending and take last 25
+ const sortedMovies = movies
+ .filter(movie => movie.UserData && movie.UserData.Played)
+ .sort((a, b) => new Date(b.UserData.LastPlayedDate || 0) - new Date(a.UserData.LastPlayedDate || 0))
+ .slice(0, 25);
+
+ return getRandomItem(sortedMovies);
+}
+
+/**
+ * Gets random watched series from progress (last 25 by lastWatchedEpisode date)
+ * @returns {Object|null} - Random watched series or null
+ */
+function getRandomWatchedSeries() {
+ const series = getSeriesProgressData();
+ if (series.length === 0) return null;
+
+ // Sort by lastWatchedEpisode date and take last 25
+ const sortedSeries = series
+ .filter(s => s.lastWatchedEpisode && s.lastWatchedEpisode.UserData && s.lastWatchedEpisode.UserData.Played)
+ .sort((a, b) => new Date(b.lastWatchedEpisode.UserData.LastPlayedDate || 0) - new Date(a.lastWatchedEpisode.UserData.LastPlayedDate || 0))
+ .slice(0, 25);
+
+ const randomSeries = getRandomItem(sortedSeries);
+
+ return randomSeries ? randomSeries.series : null;
+}
+
+/**
+ * Gets random favorite movie
+ * @returns {Promise} - Random favorite movie or null
+ */
+async function getRandomFavoriteMovie() {
+ const favorites = await fetchFavoriteItems('Movie');
+ return getRandomItem(favorites);
+}
+
+/**
+ * Gets random favorite series
+ * @returns {Promise} - Random favorite series or null
+ */
+async function getRandomFavoriteSeries() {
+ const favorites = await fetchFavoriteItems('Series');
+ return getRandomItem(favorites);
+}
+
+/**
+ * Gets random director from top 25 most-watched movie directors
+ * @returns {Object|null} - Random director or null
+ */
+function getRandomDirector() {
+ const movies = getMovieHistoryData();
+ if (movies.length === 0) return null;
+
+ const topDirectors = getTopPeople(movies, 'Director', 25);
+ return getRandomItem(topDirectors);
+}
+
+/**
+ * Gets random actor from top 25 most-watched movie actors
+ * @returns {Object|null} - Random actor or null
+ */
+function getRandomActor() {
+ const movies = getMovieHistoryData();
+ if (movies.length === 0) return null;
+
+ const topActors = getTopPeople(movies, 'Actor', 25);
+ return getRandomItem(topActors);
+}
+
+/**
+ * Fetches newest movies from Jellyfin API
+ * @returns {Promise} - Array of newest movie items
+ */
+async function fetchNewMovies() {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+
+ const today = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD
+ const url = `${serverUrl}/Items?IncludeItemTypes=Movie&SortBy=PremiereDate&SortOrder=Descending&Limit=16&Fields=PremiereDate&Recursive=true&MaxPremiereDate=${today}&userId=${userId}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'X-Emby-Token': token
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ return data.Items || [];
+
+ } catch (err) {
+ ERR('Failed to fetch new movies:', err);
+ return [];
+ }
+}
+
+/**
+ * Fetches newest episodes from the last 7 days
+ * Deduplicates episodes from the same series that aired on the same date,
+ * keeping only the episode with the lowest index number
+ * @returns {Promise} - Array of deduplicated newest episode items
+ */
+async function fetchNewEpisodes() {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+
+ // Compute ISO date range for the last 7 days
+ const now = new Date();
+ const maxDate = now.toISOString();
+ const minDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
+
+ const url = `${serverUrl}/Items?IncludeItemTypes=Episode` +
+ `&LocationTypes=FileSystem` +
+ `&Recursive=true` +
+ `&SortBy=PremiereDate&SortOrder=Descending` +
+ `&MinPremiereDate=${encodeURIComponent(minDate)}` +
+ `&MaxPremiereDate=${encodeURIComponent(maxDate)}` +
+ `&Limit=100` +
+ `&Fields=PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path` +
+ `&userId=${userId}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: { 'X-Emby-Token': token }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const episodes = data.Items || [];
+
+ // Deduplicate episodes from the same series that aired on the same date
+ const deduplicatedEpisodes = deduplicateEpisodesBySeriesAndDate(episodes);
+
+ LOG(`Fetched ${episodes.length} episodes, deduplicated to ${deduplicatedEpisodes.length} episodes`);
+
+ return deduplicatedEpisodes;
+
+ } catch (err) {
+ ERR('Failed to fetch new episodes:', err);
+ return [];
+ }
+}
+
+/**
+ * Deduplicates episodes from the same series that aired on the same date
+ * Keeps only the episode with the lowest index number (first episode of season)
+ * @param {Array} episodes - Array of episode objects
+ * @returns {Array} - Deduplicated array of episodes
+ */
+function deduplicateEpisodesBySeriesAndDate(episodes) {
+ if (!episodes || episodes.length === 0) return [];
+
+ // Group episodes by series name and premiere date
+ const groupedEpisodes = new Map();
+
+ episodes.forEach(episode => {
+ const seriesName = episode.SeriesName || 'Unknown Series';
+ const premiereDate = episode.PremiereDate || '';
+ const key = `${seriesName}|${premiereDate}`;
+
+ if (!groupedEpisodes.has(key)) {
+ groupedEpisodes.set(key, []);
+ }
+
+ groupedEpisodes.get(key).push(episode);
+ });
+
+ // For each group, keep only the episode with the lowest index number
+ const deduplicatedEpisodes = [];
+
+ groupedEpisodes.forEach(episodeGroup => {
+ if (episodeGroup.length === 1) {
+ // Only one episode for this series/date, keep it
+ deduplicatedEpisodes.push(episodeGroup[0]);
+ } else {
+ // Multiple episodes for this series/date, find the one with lowest index number
+ const episodeWithLowestIndex = episodeGroup.reduce((lowest, current) => {
+ const currentIndex = current.IndexNumber || 999999;
+ const lowestIndex = lowest.IndexNumber || 999999;
+ return currentIndex < lowestIndex ? current : lowest;
+ });
+
+ deduplicatedEpisodes.push(episodeWithLowestIndex);
+
+ LOG(`Deduplicated ${episodeGroup.length} episodes from "${episodeGroup[0].SeriesName}" on ${episodeGroup[0].PremiereDate}, kept episode ${episodeWithLowestIndex.IndexNumber}`);
+ }
+ });
+
+ return deduplicatedEpisodes;
+}
+
+/**
+ * Renders New Movies section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {Promise} - Success status
+ */
+async function renderNewMoviesSection(container) {
+ try {
+ const movies = await fetchNewMovies();
+
+ if (movies.length === 0) {
+ return false;
+ }
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping New Movies section");
+ return false;
+ }
+
+ // Render the scrollable container
+ const scrollableContainer = window.cardBuilder.renderCards(
+ movies,
+ 'New Movies',
+ null
+ );
+
+ // Add data attributes to track rendered sections
+ scrollableContainer.setAttribute('data-custom-section-id', 'new-movies');
+ scrollableContainer.setAttribute('data-custom-section-name', 'New Movies');
+ scrollableContainer.style.order = 30;
+
+ // Append to container
+ container.appendChild(scrollableContainer);
+
+ return true;
+
+ } catch (err) {
+ ERR('Error rendering New Movies section:', err);
+ return false;
+ }
+}
+
+/**
+ * Renders New Episodes section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {Promise} - Success status
+ */
+async function renderNewEpisodesSection(container) {
+ try {
+ const episodes = await fetchNewEpisodes();
+
+ if (episodes.length === 0) {
+ return false;
+ }
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping New Episodes section");
+ return false;
+ }
+
+ // Render the scrollable container
+ const scrollableContainer = window.cardBuilder.renderCards(
+ episodes,
+ 'New Episodes',
+ null
+ );
+
+ // Add data attributes to track rendered sections
+ scrollableContainer.setAttribute('data-custom-section-id', 'new-episodes');
+ scrollableContainer.setAttribute('data-custom-section-name', 'New Episodes');
+ scrollableContainer.style.order = 31;
+
+ // Append to container
+ container.appendChild(scrollableContainer);
+
+ return true;
+
+ } catch (err) {
+ ERR('Error rendering New Episodes section:', err);
+ return false;
+ }
+}
+
+/**
+ * Renders watchlist section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+function renderWatchlistSection(container) {
+ try {
+ const watchlistItems = getWatchlistData();
+
+ if (watchlistItems.length === 0) {
+ return false;
+ }
+
+ // Get 16 random items from watchlist
+ const shuffled = [...watchlistItems].sort(() => Math.random() - 0.5);
+ const limitedItems = shuffled.slice(0, 16);
+
+ if (limitedItems.length === 0) {
+ return false;
+ }
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping watchlist section");
+ return false;
+ }
+
+ // Render the scrollable container with function-based navigation
+ const scrollableContainer = window.cardBuilder.renderCards(
+ limitedItems,
+ 'My Watchlist',
+ gotoWatchlistTab
+ );
+
+ // Add data attribute to track rendered sections
+ scrollableContainer.setAttribute('data-custom-section-id', 'watchlist');
+ scrollableContainer.setAttribute('data-custom-section-name', 'Your Watchlist');
+ scrollableContainer.style.order = 32;
+
+ // Append to container
+ container.appendChild(scrollableContainer);
+
+ return true;
+
+ } catch (err) {
+ ERR('Error rendering watchlist section:', err);
+ return false;
+ }
+}
+
+/**
+ * Fetches full item objects from API using item IDs
+ * @param {Array} itemIds - Array of item IDs to fetch
+ * @returns {Promise} Array of full item objects
+ */
+async function fetchItemsByIds(itemIds) {
+ try {
+ if (!itemIds || itemIds.length === 0) return [];
+
+ const userId = ApiClient.getCurrentUserId();
+ const serverId = ApiClient.serverId();
+
+ // 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 response = await ApiClient.fetch({ url, method: 'GET' });
+ const data = await response.json();
+
+ return data.Items || [];
+
+ } catch (err) {
+ ERR('Error fetching items by IDs:', err);
+ return [];
+ }
+}
+
+/**
+ * Generates a single group of discovery sections (6 sections total)
+ * @returns {Promise} Array of section data objects
+ */
+async function generateDiscoveryGroup() {
+ try {
+ LOG('Generating single discovery group...');
+
+ // Run all async operations in parallel for maximum speed
+ const preloadPromises = [
+ // 1. [Genre] Movies
+ (async () => {
+ const randomGenre = await getRandomGenre();
+ if (randomGenre) {
+ const genreData = await preloadGenreSection(randomGenre);
+ if (genreData) {
+ return {
+ type: 'genre',
+ data: randomGenre,
+ items: genreData.items,
+ viewMoreUrl: genreData.viewMoreUrl
+ };
+ }
+ }
+ return null;
+ })(),
+
+ // 2. Directed by [Director] (async - fetch actual items)
+ (async () => {
+ if (moviesTopPeople && moviesTopPeople.directors.length > 0) {
+ const randomDirector = getRandomItem(moviesTopPeople.directors);
+ if (randomDirector && randomDirector.itemIds && randomDirector.itemIds.length > 0) {
+ // Validate that itemIds contain actual valid IDs (not undefined/null)
+ const validItemIds = randomDirector.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== '');
+ if (validItemIds.length > 0) {
+ // Fetch actual items from API
+ const shuffledIds = shuffleArray(validItemIds).slice(0, 16);
+ const items = await fetchItemsByIds(shuffledIds);
+
+ if (items && items.length > 0) {
+ return {
+ type: 'director',
+ data: randomDirector,
+ items: items
+ };
+ }
+ }
+ }
+ }
+ return null;
+ })(),
+
+ // 3. Written by [Writer] (async - fetch actual items)
+ (async () => {
+ if (moviesTopPeople && moviesTopPeople.writers.length > 0) {
+ const randomWriter = getRandomItem(moviesTopPeople.writers);
+ if (randomWriter && randomWriter.itemIds && randomWriter.itemIds.length > 0) {
+ // Validate that itemIds contain actual valid IDs (not undefined/null)
+ const validItemIds = randomWriter.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== '');
+ if (validItemIds.length > 0) {
+ // Fetch actual items from API
+ const shuffledIds = shuffleArray(validItemIds).slice(0, 16);
+ const items = await fetchItemsByIds(shuffledIds);
+
+ if (items && items.length > 0) {
+ return {
+ type: 'writer',
+ data: randomWriter,
+ items: items
+ };
+ }
+ }
+ }
+ }
+ return null;
+ })(),
+
+ // 4. Starring [Actor] (async - fetch actual items)
+ (async () => {
+ if (moviesTopPeople && moviesTopPeople.actors.length > 0) {
+ const randomActor = getRandomItem(moviesTopPeople.actors);
+ if (randomActor && randomActor.itemIds && randomActor.itemIds.length > 0) {
+ // Validate that itemIds contain actual valid IDs (not undefined/null)
+ const validItemIds = randomActor.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== '');
+ if (validItemIds.length > 0) {
+ // Fetch actual items from API
+ const shuffledIds = shuffleArray(validItemIds).slice(0, 16);
+ const items = await fetchItemsByIds(shuffledIds);
+
+ if (items && items.length > 0) {
+ return {
+ type: 'actor',
+ data: randomActor,
+ items: items
+ };
+ }
+ }
+ }
+ }
+ return null;
+ })(),
+
+ // 5. Because you watched [Movie]
+ (async () => {
+ const randomWatchedMovie = getRandomWatchedMovie();
+ if (randomWatchedMovie) {
+ const watchedData = await preloadBecauseYouWatchedSection(randomWatchedMovie);
+ if (watchedData) {
+ return {
+ type: 'watched',
+ data: randomWatchedMovie,
+ items: watchedData.items
+ };
+ }
+ }
+ return null;
+ })(),
+
+ // 6. Because you liked [Movie]
+ (async () => {
+ const randomLikedMovie = await getRandomFavoriteMovie();
+ if (randomLikedMovie) {
+ const likedData = await preloadBecauseYouLikedSection(randomLikedMovie);
+ if (likedData) {
+ return {
+ type: 'liked',
+ data: randomLikedMovie,
+ items: likedData.items
+ };
+ }
+ }
+ return null;
+ })()
+ ];
+
+ // Wait for all parallel operations to complete
+ const preloadResults = await Promise.all(preloadPromises);
+
+ // Filter out null results and collect valid sections
+ const sections = preloadResults.filter(section => section !== null);
+
+ LOG(`Generated discovery group with ${sections.length} sections`);
+ return sections;
+
+ } catch (err) {
+ ERR('Error generating discovery group:', err);
+ return [];
+ }
+}
+
+/**
+ * Ensures the discovery buffer has 2 groups of discovery items
+ * Simple approach: always maintain 2 groups in buffer
+ */
+async function ensureDiscoveryBuffer() {
+ const targetBufferSize = 2;
+
+ if (discoveryBuffer.length >= targetBufferSize) {
+ LOG(`Discovery buffer already has ${discoveryBuffer.length} groups (target: ${targetBufferSize})`);
+ return;
+ }
+
+ LOG(`Ensuring discovery buffer has ${targetBufferSize} groups (current: ${discoveryBuffer.length})`);
+
+ // Generate groups in parallel to fill the buffer
+ const groupsToGenerate = targetBufferSize - discoveryBuffer.length;
+ const generationPromises = [];
+
+ for (let i = 0; i < groupsToGenerate; i++) {
+ generationPromises.push(generateDiscoveryGroup());
+ }
+
+ try {
+ const newGroups = await Promise.all(generationPromises);
+
+ // Add non-empty groups to buffer
+ newGroups.forEach(group => {
+ if (group && group.length > 0) {
+ discoveryBuffer.push(group);
+ }
+ });
+
+ LOG(`Added ${newGroups.filter(g => g && g.length > 0).length} groups to discovery buffer (total: ${discoveryBuffer.length})`);
+
+ // If we couldn't generate any new groups, mark that additional content is not available
+ if (newGroups.every(group => !group || group.length === 0)) {
+ additionalDiscoveryContent = false;
+ LOG('No additional discovery content available');
+ }
+
+ } catch (err) {
+ ERR('Error ensuring discovery buffer:', err);
+ additionalDiscoveryContent = false;
+ }
+}
+
+/**
+ * Preloads the next batch of discovery sections in the background
+ * Uses the new buffer-based system for better performance
+ */
+async function preloadNextSections() {
+ if (isPreloadingSections) {
+ LOG('Preloading already in progress, skipping...');
+ return;
+ }
+
+ isPreloadingSections = true;
+
+ try {
+ LOG('Preloading discovery sections using buffer system...');
+
+ // Ensure buffer has 2 groups (simple constant buffer)
+ await ensureDiscoveryBuffer();
+
+ LOG(`Discovery buffer populated with ${discoveryBuffer.length} groups`);
+
+ } catch (err) {
+ ERR('Error preloading discovery sections:', err);
+ } finally {
+ isPreloadingSections = false;
+ }
+}
+
+/**
+ * Renders the next group of discovery sections from the buffer
+ * This is the unified function used by both infinite scroll and load more button
+ * @param {HTMLElement} container - Container to render sections into
+ * @param {HTMLElement} loadMoreButton - Optional load more button to update state
+ * @returns {Promise} - Success status
+ */
+async function renderNextDiscoveryGroup(container, loadMoreButton = null) {
+ if (isRenderingDiscoveryGroup) {
+ LOG('Discovery group rendering already in progress, skipping...');
+ return false;
+ }
+
+ isRenderingDiscoveryGroup = true;
+
+ try {
+ LOG('Rendering next discovery group from buffer');
+
+ // Show loading indicator
+ showDiscoveryLoadingIndicator();
+
+ // Check if we have a group in the buffer to render
+ if (discoveryBuffer.length > 0) {
+ LOG(`Rendering group from buffer (${discoveryBuffer.length} groups available)`);
+
+ // Get the first group from buffer
+ const groupToRender = discoveryBuffer.shift();
+
+ // Render the group immediately
+ if (groupToRender && groupToRender.length > 0) {
+ 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 from buffer`);
+
+ // Update button state if provided
+ if (loadMoreButton) {
+ if (discoveryBuffer.length > 0 || additionalDiscoveryContent) {
+ loadMoreButton.textContent = 'Discover More';
+ loadMoreButton.disabled = false;
+ loadMoreButton.style.opacity = '1';
+ } else {
+ // No more content available, hide button
+ loadMoreButton.style.display = 'none';
+ }
+ }
+
+ return renderedCount > 0;
+ }
+ } else {
+ LOG('No groups in buffer, ensuring buffer is populated...');
+
+ // Ensure buffer is populated
+ await ensureDiscoveryBuffer();
+
+ // Try again after buffer is populated
+ if (discoveryBuffer.length > 0) {
+ return await renderNextDiscoveryGroup(container, loadMoreButton);
+ } else {
+ // Still no content, hide button if provided
+ if (loadMoreButton) {
+ loadMoreButton.style.display = 'none';
+ }
+ return false;
+ }
+ }
+
+ return false;
+
+ } catch (err) {
+ ERR('Error rendering next discovery group:', err);
+ return false;
+ } finally {
+ isRenderingDiscoveryGroup = false;
+
+ // Hide loading indicator
+ hideDiscoveryLoadingIndicator();
+ }
+}
+
+/**
+ * Handles the "Discover More" button click using the buffer system
+ * @param {HTMLElement} loadMoreButton - The button element
+ */
+async function handleDiscoverMoreClick(loadMoreButton) {
+ try {
+ LOG('Discover More button clicked');
+
+ const homeSectionsContainer = document.querySelector('.homeSectionsContainer');
+ if (homeSectionsContainer) {
+ await renderNextDiscoveryGroup(homeSectionsContainer, loadMoreButton);
+
+ // Trigger background refill of buffer (don't await)
+ ensureDiscoveryBuffer().catch(err => {
+ ERR('Error refilling discovery buffer:', err);
+ });
+ } else {
+ ERR('Home sections container not found');
+ }
+
+ } catch (err) {
+ ERR('Error handling Discover More click:', err);
+
+ // Reset button state on error
+ loadMoreButton.textContent = 'Discover More';
+ loadMoreButton.disabled = false;
+ loadMoreButton.style.opacity = '1';
+ }
+}
+
+/**
+ * Preloads genre section data
+ * @param {Object} genre - Genre object
+ * @returns {Promise} - Genre data with items and viewMoreUrl
+ */
+async function preloadGenreSection(genre) {
+ try {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+ const serverId = apiClient.serverId();
+
+ const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=16`;
+
+ 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();
+ const items = data.Items || [];
+
+ if (items.length === 0) return null;
+
+ const viewMoreUrl = `/web/#/list.html?genreId=${genre.Id}&serverId=${serverId}`;
+
+ return {
+ items: items,
+ viewMoreUrl: viewMoreUrl,
+ hasFullItems: true
+ };
+
+ } catch (err) {
+ ERR(`Error preloading genre section for ${genre.Name}:`, err);
+ return null;
+ }
+}
+
+/**
+ * Preloads "because you watched" section data
+ * @param {Object} movie - Movie object
+ * @returns {Promise} - Similar content data
+ */
+async function preloadBecauseYouWatchedSection(movie) {
+ try {
+ const items = await fetchSimilarContent(movie.Id, 'Movie');
+ if (items.length === 0) return null;
+
+ return {
+ items: items.slice(0, 16),
+ hasFullItems: true
+ };
+
+ } catch (err) {
+ ERR(`Error preloading because you watched section for ${movie.Name}:`, err);
+ return null;
+ }
+}
+
+/**
+ * Preloads "because you liked" section data
+ * @param {Object} movie - Movie object
+ * @returns {Promise} - Similar content data
+ */
+async function preloadBecauseYouLikedSection(movie) {
+ try {
+ const items = await fetchSimilarContent(movie.Id, 'Movie');
+ if (items.length === 0) return null;
+
+ return {
+ items: items.slice(0, 16),
+ hasFullItems: true
+ };
+
+ } catch (err) {
+ ERR(`Error preloading because you liked section for ${movie.Name}:`, err);
+ return null;
+ }
+}
+
+/**
+ * Generates discovery sections with infinite loading behavior
+ * @param {HTMLElement} container - Container to append sections to
+ * @returns {Promise} - Success status
+ */
+async function generateDiscoverySections(container) {
+ if (isGeneratingDiscoverySections) {
+ LOG('Discovery sections generation already in progress, skipping...');
+ return false;
+ }
+
+ isGeneratingDiscoverySections = true;
+
+ // Show loading indicator
+ showDiscoveryLoadingIndicator();
+
+ try {
+ // Ensure people cache is initialized
+ if (!moviesTopPeople) {
+ await initializePeopleCache();
+
+ // If people cache is still not available (initialization failed or in progress),
+ // skip people-related sections
+ if (!moviesTopPeople) {
+ LOG('People cache not available, skipping people-related sections');
+ }
+ }
+
+ let successCount = 0;
+
+ // First, try to unhide preloaded sections
+ if (preloadedSectionElements.length >= 6) {
+ LOG('Unhiding preloaded sections for instant display');
+ const sectionsToUnhide = preloadedSectionElements.splice(0, 6); // Take first 6 and remove them
+
+ for (const sectionElement of sectionsToUnhide) {
+ try {
+ // Unhide the section
+ sectionElement.style.display = '';
+ sectionElement.removeAttribute('data-preloaded');
+ successCount++;
+ } catch (err) {
+ ERR('Error unhiding preloaded section:', err);
+ }
+ }
+
+ // Also remove corresponding data
+ preloadedSections.splice(0, 6);
+
+ LOG(`Unhid ${successCount} preloaded discovery sections`);
+ } else if (preloadedSections.length >= 6) {
+ // Fallback: use preloaded data to render sections
+ LOG('Using preloaded data for instant rendering');
+ const sectionsToRender = preloadedSections.splice(0, 6); // Take first 6 and remove them
+
+ const results = await Promise.all(
+ sectionsToRender.map(section => renderDiscoverySection(section, container))
+ );
+ successCount = results.filter(Boolean).length;
+
+ LOG(`Generated ${successCount}/${sectionsToRender.length} discovery sections from preloaded data`);
+ } else {
+ // Generate new sections if not enough preloaded
+ LOG('Generating new sections (not enough preloaded)');
+ const sectionsToRender = await generateNewSections();
+
+ const results = await Promise.all(
+ sectionsToRender.map(section => renderDiscoverySection(section, container))
+ );
+ successCount = results.filter(Boolean).length;
+
+ LOG(`Generated ${successCount}/${sectionsToRender.length} new discovery sections`);
+ }
+
+ // Immediately start preloading next batch (don't await)
+ preloadNextSections();
+
+ return successCount > 0;
+
+ } catch (err) {
+ ERR('Error generating discovery sections:', err);
+ return false;
+ } finally {
+ isGeneratingDiscoverySections = false;
+
+ // Hide loading indicator
+ hideDiscoveryLoadingIndicator();
+ }
+}
+
+/**
+ * Generates new sections when preloaded data is not available
+ * @returns {Promise} - Array of section data
+ */
+async function generateNewSections() {
+ const sectionsToRender = [];
+
+ // 1. [Genre] Movies
+ const randomGenre = await getRandomGenre();
+ if (randomGenre) {
+ const genreData = await preloadGenreSection(randomGenre);
+ if (genreData) {
+ sectionsToRender.push({
+ type: 'genre',
+ data: randomGenre,
+ items: genreData.items,
+ viewMoreUrl: genreData.viewMoreUrl
+ });
+ }
+ }
+
+ // 2. Directed by [Director]
+ if (moviesTopPeople && moviesTopPeople.directors.length > 0) {
+ const randomDirector = getRandomItem(moviesTopPeople.directors);
+ if (randomDirector) {
+ sectionsToRender.push({
+ type: 'director',
+ data: randomDirector,
+ items: shuffleArray(randomDirector.itemIds).slice(0, 16)
+ });
+ }
+ }
+
+ // 3. Written by [Writer]
+ if (moviesTopPeople && moviesTopPeople.writers.length > 0) {
+ const randomWriter = getRandomItem(moviesTopPeople.writers);
+ if (randomWriter) {
+ sectionsToRender.push({
+ type: 'writer',
+ data: randomWriter,
+ items: shuffleArray(randomWriter.itemIds).slice(0, 16)
+ });
+ }
+ }
+
+ // 4. Starring [Actor]
+ if (moviesTopPeople && moviesTopPeople.actors.length > 0) {
+ const randomActor = getRandomItem(moviesTopPeople.actors);
+ if (randomActor) {
+ sectionsToRender.push({
+ type: 'actor',
+ data: randomActor,
+ items: shuffleArray(randomActor.itemIds).slice(0, 16)
+ });
+ }
+ }
+
+ // 5. Because you watched [Movie]
+ const randomWatchedMovie = getRandomWatchedMovie();
+ if (randomWatchedMovie) {
+ const watchedData = await preloadBecauseYouWatchedSection(randomWatchedMovie);
+ if (watchedData) {
+ sectionsToRender.push({
+ type: 'watched',
+ data: randomWatchedMovie,
+ items: watchedData.items
+ });
+ }
+ }
+
+ // 6. Because you liked [Movie]
+ const randomLikedMovie = await getRandomFavoriteMovie();
+ if (randomLikedMovie) {
+ const likedData = await preloadBecauseYouLikedSection(randomLikedMovie);
+ if (likedData) {
+ sectionsToRender.push({
+ type: 'liked',
+ data: randomLikedMovie,
+ items: likedData.items
+ });
+ }
+ }
+
+ return sectionsToRender;
+}
+
+/**
+ * Renders a discovery section (unified function for both preloaded and dynamic sections)
+ * @param {Object|string} sectionData - Preloaded section data object OR section type string
+ * @param {HTMLElement} container - Container to append section to
+ * @returns {Promise} - Success status
+ */
+async function renderDiscoverySection(sectionData, container) {
+ try {
+ let sectionName = '';
+ let items = [];
+ let viewMoreUrl = null;
+ let sectionId = '';
+ let order = 200 + renderedSections.size;
+
+ // Handle preloaded section data
+ if (typeof sectionData === 'object' && sectionData.type) {
+ if (!sectionData.items || sectionData.items.length === 0) return false;
+
+ items = sectionData.items;
+ viewMoreUrl = sectionData.viewMoreUrl || null;
+
+ switch (sectionData.type) {
+ case 'genre':
+ sectionName = `${sectionData.data.Name} Movies`;
+ sectionId = `genre-${sectionData.data.Name.toLowerCase()}`;
+ break;
+ case 'director':
+ sectionName = `Directed by ${sectionData.data.name}`;
+ sectionId = `director-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`;
+ break;
+ case 'writer':
+ sectionName = `Written by ${sectionData.data.name}`;
+ sectionId = `writer-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`;
+ break;
+ case 'actor':
+ sectionName = `Starring ${sectionData.data.name}`;
+ sectionId = `actor-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`;
+ break;
+ case 'watched':
+ sectionName = `Because you watched ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`;
+ sectionId = `watched-${sectionData.data.Id}`;
+ break;
+ case 'liked':
+ sectionName = `Because you liked ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`;
+ sectionId = `liked-${sectionData.data.Id}`;
+ break;
+ default:
+ return false;
+ }
+ }
+ // Handle dynamic section generation (legacy support)
+ else if (typeof sectionData === 'string') {
+ const sectionType = sectionData;
+
+ switch (sectionType) {
+ case 'watched-movie': {
+ const randomMovie = getRandomWatchedMovie();
+ if (!randomMovie) return false;
+
+ sectionName = `Because you watched ${randomMovie.Name} ${randomMovie.ProductionYear ? `(${randomMovie.ProductionYear})` : ''}`;
+ items = await fetchSimilarContent(randomMovie.Id, 'Movie');
+ sectionId = 'watched-movie';
+ order = 102;
+ break;
+ }
+
+ case 'watched-series': {
+ const randomSeries = getRandomWatchedSeries();
+ if (!randomSeries) return false;
+
+ sectionName = `Because you watched ${randomSeries.Name} ${randomSeries.ProductionYear ? `(${randomSeries.ProductionYear})` : ''}`;
+ items = await fetchSimilarContent(randomSeries.Id, 'Series');
+ sectionId = 'watched-series';
+ order = 103;
+ break;
+ }
+
+ case 'liked-movie': {
+ const randomMovie = await getRandomFavoriteMovie();
+ if (!randomMovie) return false;
+
+ sectionName = `Because you liked ${randomMovie.Name} ${randomMovie.ProductionYear ? `(${randomMovie.ProductionYear})` : ''}`;
+ items = await fetchSimilarContent(randomMovie.Id, 'Movie');
+ sectionId = 'liked-movie';
+ order = 104;
+ break;
+ }
+
+ case 'liked-series': {
+ const randomSeries = await getRandomFavoriteSeries();
+ if (!randomSeries) return false;
+
+ sectionName = `Because you liked ${randomSeries.Name} ${randomSeries.ProductionYear ? `(${randomSeries.ProductionYear})` : ''}`;
+ items = await fetchSimilarContent(randomSeries.Id, 'Series');
+ sectionId = 'liked-series';
+ order = 105;
+ break;
+ }
+
+ case 'directed-by': {
+ const randomDirector = getRandomDirector();
+ if (!randomDirector) return false;
+
+ sectionName = `Directed by ${randomDirector.Name}`;
+ items = await fetchPersonItems(randomDirector.Id, 'Movie');
+ sectionId = 'directed-by';
+ order = 106;
+
+ // Generate view more URL for person details
+ const apiClient = window.ApiClient;
+ const serverId = apiClient.serverId();
+ viewMoreUrl = `/web/#/details?id=${randomDirector.Id}&serverId=${serverId}`;
+ break;
+ }
+
+ case 'starring': {
+ const randomActor = getRandomActor();
+ if (!randomActor) return false;
+
+ sectionName = `Starring ${randomActor.Name}`;
+ items = await fetchPersonItems(randomActor.Id, 'Movie');
+ sectionId = 'starring';
+ order = 107;
+
+ // Generate view more URL for person details
+ const apiClient = window.ApiClient;
+ const serverId = apiClient.serverId();
+ viewMoreUrl = `/web/#/details?id=${randomActor.Id}&serverId=${serverId}`;
+ break;
+ }
+
+ default:
+ return false;
+ }
+ }
+
+ if (items.length === 0) {
+ return false;
+ }
+
+ // Limit to 16 items
+ const limitedItems = items.slice(0, 16);
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined') {
+ WARN("cardBuilder not available, skipping discovery section");
+ return false;
+ }
+
+ // Determine if we have full items or just IDs
+ const hasFullItems = limitedItems.length > 0 && limitedItems[0] && typeof limitedItems[0] === 'object' && limitedItems[0].Id;
+
+ let scrollableContainer;
+ if (hasFullItems) {
+ // Use renderCards for full item objects
+ if (!window.cardBuilder.renderCards) {
+ WARN("cardBuilder.renderCards not available, skipping discovery section");
+ return false;
+ }
+ scrollableContainer = window.cardBuilder.renderCards(limitedItems, sectionName, viewMoreUrl);
+ } 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);
+ }
+
+ // Add data attributes to track rendered sections
+ scrollableContainer.setAttribute('data-custom-section-id', sectionId);
+ scrollableContainer.setAttribute('data-custom-section-name', sectionName);
+ scrollableContainer.style.order = order;
+
+ // Append to container
+ container.appendChild(scrollableContainer);
+
+ // Track rendered section for preloaded sections
+ if (typeof sectionData === 'object') {
+ renderedSections.add(sectionId);
+ }
+
+ return true;
+
+ } catch (err) {
+ ERR(`Error rendering discovery section:`, err);
+ return false;
+ }
+}
+
+
+/**
+ * Sets up infinite loading for discovery sections
+ * @param {HTMLElement} container - Container to watch for scroll
+ */
+function setupInfiniteLoading(container) {
+ if (enableInfiniteScroll) {
+ setupScrollBasedLoading(container);
+ } else {
+ setupLoadMoreButton(container);
+ }
+}
+
+/**
+ * Sets up scroll-based infinite loading
+ * @param {HTMLElement} container - Container to watch for scroll
+ */
+function setupScrollBasedLoading(container) {
+ let lastScrollTop = 0;
+ let scrollTimeout = null;
+
+ const handleScroll = () => {
+ if (isRenderingDiscoveryGroup) return;
+
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
+ const windowHeight = window.innerHeight;
+ const documentHeight = document.documentElement.scrollHeight;
+
+ // Check if user has scrolled to bottom 30% of the page
+ const scrollPercentage = (scrollTop + windowHeight) / documentHeight;
+
+ if (scrollPercentage >= 0.7 && scrollTop > lastScrollTop) {
+ // Clear any existing timeout
+ if (scrollTimeout) {
+ clearTimeout(scrollTimeout);
+ }
+
+ // Debounce the scroll event
+ scrollTimeout = setTimeout(async () => {
+ LOG('User scrolled to bottom 30%, rendering more discovery sections from buffer...');
+ await renderNextDiscoveryGroup(container);
+ }, 500);
+ }
+
+ lastScrollTop = scrollTop;
+ };
+
+ // Add scroll listener
+ window.addEventListener('scroll', handleScroll, { passive: true });
+
+ LOG('Scroll-based infinite loading for discovery sections enabled');
+}
+
+/**
+ * Sets up load more button for discovery sections
+ * @param {HTMLElement} container - Container to append button to
+ */
+function setupLoadMoreButton(container) {
+ // Create load more button
+ const loadMoreButton = document.createElement('button');
+ loadMoreButton.textContent = 'Discover More';
+ loadMoreButton.className = 'load-more-discovery-btn';
+ loadMoreButton.style.cssText = `
+ order: 9998 !important;
+ display: block;
+ width: 100%;
+ max-width: 400px;
+ margin: 20px auto;
+ padding: 12px 24px;
+ background: var(--btnSubmitColor);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 8px rgba(0, 164, 220, 0.3);
+ `;
+
+ // Add hover effects
+ loadMoreButton.addEventListener('mouseenter', () => {
+ loadMoreButton.style.transform = 'translateY(-2px)';
+ loadMoreButton.style.boxShadow = '0 4px 12px rgba(0, 164, 220, 0.4)';
+ });
+
+ loadMoreButton.addEventListener('mouseleave', () => {
+ loadMoreButton.style.transform = 'translateY(0)';
+ loadMoreButton.style.boxShadow = '0 2px 8px rgba(0, 164, 220, 0.3)';
+ });
+
+ // Add click handler using new buffer system
+ loadMoreButton.addEventListener('click', async () => {
+ await handleDiscoverMoreClick(loadMoreButton);
+ });
+
+ // Append button to container
+ container.appendChild(loadMoreButton);
+
+ LOG('Load more button for discovery sections enabled');
+}
+
+/**
+ * Gets a random genre that hasn't been rendered yet
+ * @returns {Promise} - Random genre or null
+ */
+async function getRandomGenre() {
+ const cache = new window.LocalStorageCache();
+ let movieGenres = cache.get('movieGenres');
+
+ if (!movieGenres || movieGenres.length === 0) {
+ movieGenres = await fetchAndCacheMovieGenres();
+ }
+
+ if (movieGenres.length === 0) return null;
+
+ // Filter out already rendered genres and genres with insufficient movie count
+ const availableGenres = movieGenres.filter(genre =>
+ !renderedSections.has(`genre-${genre.Name.toLowerCase()}`) &&
+ (genre.MovieCount || 0) >= minGenreMovieCount
+ );
+
+ if (availableGenres.length === 0) return null;
+
+ const randomGenre = getRandomItem(availableGenres);
+ if (randomGenre) {
+ renderedSections.add(`genre-${randomGenre.Name.toLowerCase()}`);
+ }
+
+ return randomGenre;
+}
+
+/**
+ * Renders a genre section
+ * @param {Object} genre - Genre object
+ * @param {HTMLElement} container - Container to append section to
+ * @returns {Promise} - Success status
+ */
+async function renderGenreSection(genre, container) {
+ try {
+ const apiClient = window.ApiClient;
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+ const userId = apiClient.getCurrentUserId();
+ const serverId = apiClient.serverId();
+
+ const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=16`;
+
+ 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();
+ const items = data.Items || [];
+
+ if (items.length === 0) return false;
+
+ const viewMoreUrl = `/web/#/list.html?genreId=${genre.Id}&serverId=${serverId}`;
+
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping genre section");
+ return false;
+ }
+
+ const scrollableContainer = window.cardBuilder.renderCards(
+ items,
+ `${genre.Name} Movies`,
+ viewMoreUrl
+ );
+
+ scrollableContainer.setAttribute('data-custom-section-id', `genre-${genre.Name.toLowerCase()}`);
+ scrollableContainer.setAttribute('data-custom-section-name', `${genre.Name} Movies`);
+ scrollableContainer.style.order = 200 + renderedSections.size;
+
+ container.appendChild(scrollableContainer);
+ return true;
+
+ } catch (err) {
+ ERR(`Error rendering genre section for ${genre.Name}:`, err);
+ return false;
+ }
+}
+
+/**
+ * Renders a person section (director, writer, actor)
+ * @param {string} personType - Type of person ('director', 'writer', 'actor')
+ * @param {Object} person - Person object with name and itemIds
+ * @param {HTMLElement} container - Container to append section to
+ * @returns {Promise} - Success status
+ */
+async function renderPersonSection(personType, person, container) {
+ try {
+ if (!person.itemIds || person.itemIds.length === 0) return false;
+
+ // Limit to 16 items and randomize order
+ const limitedItemIds = shuffleArray(person.itemIds).slice(0, 16);
+
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCardsFromIds) {
+ WARN("cardBuilder not available, skipping person section");
+ return false;
+ }
+
+ const sectionName = personType === 'actor' ? `Starring ${person.name}` :
+ personType === 'director' ? `Directed by ${person.name}` :
+ `Written by ${person.name}`;
+
+ const scrollableContainer = await window.cardBuilder.renderCardsFromIds(
+ limitedItemIds,
+ sectionName,
+ null
+ );
+
+ const sectionId = `${personType}-${person.name.toLowerCase().replace(/\s+/g, '-')}`;
+ scrollableContainer.setAttribute('data-custom-section-id', sectionId);
+ scrollableContainer.setAttribute('data-custom-section-name', sectionName);
+ scrollableContainer.style.order = 200 + renderedSections.size;
+
+ container.appendChild(scrollableContainer);
+
+ // Track rendered section
+ renderedSections.add(sectionId);
+
+ return true;
+
+ } catch (err) {
+ ERR(`Error rendering ${personType} section for ${person.name}:`, err);
+ return false;
+ }
+}
+
+/**
+ * Renders "Because you watched" section
+ * @param {Object} movie - Movie object
+ * @param {HTMLElement} container - Container to append section to
+ * @returns {Promise} - Success status
+ */
+async function renderBecauseYouWatchedSection(movie, container) {
+ try {
+ const items = await fetchSimilarContent(movie.Id, 'Movie');
+ if (items.length === 0) return false;
+
+ const limitedItems = items.slice(0, 16);
+
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping because you watched section");
+ return false;
+ }
+
+ const sectionName = `Because you watched ${movie.Name} ${movie.ProductionYear ? `(${movie.ProductionYear})` : ''}`;
+
+ const scrollableContainer = window.cardBuilder.renderCards(
+ limitedItems,
+ sectionName,
+ null
+ );
+
+ const sectionId = `watched-${movie.Id}`;
+ scrollableContainer.setAttribute('data-custom-section-id', sectionId);
+ scrollableContainer.setAttribute('data-custom-section-name', sectionName);
+ scrollableContainer.style.order = 200 + renderedSections.size;
+
+ container.appendChild(scrollableContainer);
+
+ // Track rendered section
+ renderedSections.add(sectionId);
+
+ return true;
+
+ } catch (err) {
+ ERR(`Error rendering because you watched section for ${movie.Name}:`, err);
+ return false;
+ }
+}
+
+/**
+ * Renders "Because you liked" section
+ * @param {Object} movie - Movie object
+ * @param {HTMLElement} container - Container to append section to
+ * @returns {Promise} - Success status
+ */
+async function renderBecauseYouLikedSection(movie, container) {
+ try {
+ const items = await fetchSimilarContent(movie.Id, 'Movie');
+ if (items.length === 0) return false;
+
+ const limitedItems = items.slice(0, 16);
+
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping because you liked section");
+ return false;
+ }
+
+ const sectionName = `Because you liked ${movie.Name} ${movie.ProductionYear ? `(${movie.ProductionYear})` : ''}`;
+
+ const scrollableContainer = window.cardBuilder.renderCards(
+ limitedItems,
+ sectionName,
+ null
+ );
+
+ const sectionId = `liked-${movie.Id}`;
+ scrollableContainer.setAttribute('data-custom-section-id', sectionId);
+ scrollableContainer.setAttribute('data-custom-section-name', sectionName);
+ scrollableContainer.style.order = 200 + renderedSections.size;
+
+ container.appendChild(scrollableContainer);
+
+ // Track rendered section
+ renderedSections.add(sectionId);
+
+ return true;
+
+ } catch (err) {
+ ERR(`Error rendering because you liked section for ${movie.Name}:`, err);
+ return false;
+ }
+}
+
+/**
+ * Renders discovery section based on type
+ * @param {string} sectionType - Type of discovery section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+
+/**
+ * Renders Halloween Movies section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+function renderHalloweenMoviesSection(container) {
+ try {
+ if (halloweenMovies.length === 0) return false;
+
+ const shuffled = [...halloweenMovies].sort(() => Math.random() - 0.5);
+ const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT);
+
+ if (limitedItems.length === 0) return false;
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping Halloween Movies section");
+ return false;
+ }
+
+ // Render the scrollable container
+ const scrollableContainer = window.cardBuilder.renderCards(
+ limitedItems,
+ 'Halloween Movies',
+ null
+ );
+
+ scrollableContainer.setAttribute('data-custom-section-id', 'halloween-movies');
+ scrollableContainer.setAttribute('data-custom-section-name', 'Halloween Movies');
+ scrollableContainer.style.order = 50;
+
+ container.appendChild(scrollableContainer);
+ return true;
+
+ } catch (err) {
+ ERR('Error rendering Halloween Movies section:', err);
+ return false;
+ }
+}
+
+/**
+ * Renders Horror Movies section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+function renderHorrorMoviesSection(container) {
+ try {
+ if (horrorMovies.length === 0) return false;
+
+ const shuffled = [...horrorMovies].sort(() => Math.random() - 0.5);
+ const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT);
+
+ if (limitedItems.length === 0) return false;
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping Horror Movies section");
+ return false;
+ }
+
+ // Render the scrollable container
+ const scrollableContainer = window.cardBuilder.renderCards(
+ limitedItems,
+ 'Horror Movies',
+ halloweenViewMoreUrls.horror
+ );
+
+ scrollableContainer.setAttribute('data-custom-section-id', 'horror-movies');
+ scrollableContainer.setAttribute('data-custom-section-name', 'Horror Movies');
+ scrollableContainer.style.order = 51;
+
+ container.appendChild(scrollableContainer);
+ return true;
+
+ } catch (err) {
+ ERR('Error rendering Horror Movies section:', err);
+ return false;
+ }
+}
+
+/**
+ * Renders Thriller Movies section
+ * @param {HTMLElement} container - Container to append the section to
+ * @returns {boolean} - Success status
+ */
+function renderThrillerMoviesSection(container) {
+ try {
+ if (thrillerMovies.length === 0) return false;
+
+ const shuffled = [...thrillerMovies].sort(() => Math.random() - 0.5);
+ const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT);
+
+ if (limitedItems.length === 0) return false;
+
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) {
+ WARN("cardBuilder not available, skipping Thriller Movies section");
+ return false;
+ }
+
+ // Render the scrollable container
+ const scrollableContainer = window.cardBuilder.renderCards(
+ limitedItems,
+ 'Thriller Movies',
+ halloweenViewMoreUrls.thriller
+ );
+
+ scrollableContainer.setAttribute('data-custom-section-id', 'thriller-movies');
+ scrollableContainer.setAttribute('data-custom-section-name', 'Thriller Movies');
+ scrollableContainer.style.order = 52;
+
+ container.appendChild(scrollableContainer);
+ return true;
+
+ } catch (err) {
+ ERR('Error rendering Thriller Movies section:', err);
+ return false;
+ }
+}
+
+
+/**
+ * Renders all Halloween sections
+ * @param {HTMLElement} container - Container to append sections to
+ */
+async function renderAllHalloweenSections(container) {
+ if (!isHalloweenPeriod()) {
+ LOG('Not in Halloween period, skipping Halloween sections');
+ return;
+ }
+
+ try {
+ // Fetch Halloween data if not already loaded
+ if (halloweenMovies.length === 0) {
+ LOG('Fetching Halloween movie data...');
+ const movieData = await fetchHalloweenMovieData();
+ halloweenMovies = movieData.halloweenMovies;
+ horrorMovies = movieData.horrorMovies;
+ thrillerMovies = movieData.thrillerMovies;
+ halloweenViewMoreUrls = movieData.viewMoreUrls;
+
+ }
+
+ const results = [
+ renderHalloweenMoviesSection(container),
+ renderHorrorMoviesSection(container),
+ renderThrillerMoviesSection(container)
+ ];
+
+ const successCount = results.filter(Boolean).length;
+ LOG(`Rendered ${successCount}/${results.length} Halloween sections`);
+ return successCount === results.length;
+
+ } catch (err) {
+ ERR('Error rendering Halloween sections:', err);
+ }
+}
+
+/**
+ * Renders all Christmas sections (stub for future implementation)
+ * @param {HTMLElement} container - Container to append sections to
+ */
+async function renderAllChristmasSections(container) {
+ if (!isChristmasPeriod()) {
+ LOG('Not in Christmas period, skipping Christmas sections');
+ return;
+ }
+
+ // TODO: Implement Christmas sections
+ LOG('Christmas sections not yet implemented');
+}
+
+/**
+ * Renders all New Years sections (stub for future implementation)
+ * @param {HTMLElement} container - Container to append sections to
+ */
+async function renderAllNewYearsSections(container) {
+ if (!isNewYearsPeriod()) {
+ LOG('Not in New Years period, skipping New Years sections');
+ return;
+ }
+
+ // TODO: Implement New Years sections
+ LOG('New Years sections not yet implemented');
+}
+
+/**
+ * Renders all Valentine's Day sections (stub for future implementation)
+ * @param {HTMLElement} container - Container to append sections to
+ */
+async function renderAllValentinesSections(container) {
+ if (!isValentinesPeriod()) {
+ LOG('Not in Valentine\'s Day period, skipping Valentine\'s Day sections');
+ return;
+ }
+
+ // TODO: Implement Valentine's Day sections
+ LOG('Valentine\'s Day sections not yet implemented');
+}
+
+/**
+ * Renders all seasonal sections based on current date
+ * @param {HTMLElement} container - Container to append sections to
+ */
+async function renderAllSeasonalSections(container) {
+ const sectionsToRender = [];
+
+ // Add Halloween sections if in Halloween period
+ if (isHalloweenPeriod()) {
+ sectionsToRender.push(renderAllHalloweenSections(container));
+ }
+
+ // Add Christmas sections if in Christmas period
+ if (isChristmasPeriod()) {
+ sectionsToRender.push(renderAllChristmasSections(container));
+ }
+
+ // Add New Years sections if in New Years period
+ if (isNewYearsPeriod()) {
+ sectionsToRender.push(renderAllNewYearsSections(container));
+ }
+
+ // Add Valentine's Day sections if in Valentine's Day period
+ if (isValentinesPeriod()) {
+ sectionsToRender.push(renderAllValentinesSections(container));
+ }
+
+ if (sectionsToRender.length === 0) {
+ LOG('No seasonal periods active, skipping seasonal sections');
+ return;
+ }
+
+ const results = await Promise.all(sectionsToRender);
+ const successCount = results.filter(Boolean).length;
+ LOG(`Rendered ${successCount}/${sectionsToRender.length} seasonal section groups`);
+}
+
/************ Home Screen Observer ************/
/**
@@ -150,7 +2986,7 @@ async function checkAndRenderCustomSections() {
// Try multiple selectors to find the container
const selectors = [
- '.itemDetailPage:not(.hide) .homeSectionsContainer',
+ '.libraryPage:not(.hide) .homeSectionsContainer',
'.homeSectionsContainer',
'.homeSectionsContainer:not(.hide)',
'[class*="homeSections"]'
@@ -169,11 +3005,19 @@ async function checkAndRenderCustomSections() {
}
// Check if sections are already rendered
- const hasRenderedSections = customHomeSections.some(section =>
+ const hasRenderedCustomSections = customHomeSections.some(section =>
homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`)
);
- if (hasRenderedSections) {
+ const hasRenderedNewAndTrendingSections = (enableNewMovies && homeSectionsContainer.querySelector('[data-custom-section-id="new-movies"]')) ||
+ (enableNewEpisodes && homeSectionsContainer.querySelector('[data-custom-section-id="new-episodes"]')) ||
+ (enableTrending && homeSectionsContainer.querySelector('[data-custom-section-id="trending-movies"]'));
+
+ const hasRenderedDiscoverySections = (enableWatchlist && homeSectionsContainer.querySelector('[data-custom-section-id="watchlist"]')) ||
+ (enableDiscovery && homeSectionsContainer.querySelector('[data-custom-section-id="watched-movie"]')) ||
+ (enableSeasonal && homeSectionsContainer.querySelector('[data-custom-section-id="halloween-movies"]'));
+
+ if (hasRenderedCustomSections || hasRenderedNewAndTrendingSections || hasRenderedDiscoverySections) {
return;
}
@@ -181,8 +3025,36 @@ async function checkAndRenderCustomSections() {
isProcessing = true;
try {
- await renderAllCustomSections(homeSectionsContainer);
- LOG('Custom sections rendered successfully');
+ LOG('Starting parallel initialization of home screen sections...');
+
+ // Run all initialization functions in parallel for faster loading
+ const initPromises = [
+ // Initialize people cache in background (non-blocking)
+ initializePeopleCache(),
+
+ // Render custom sections
+ renderAllCustomSections(homeSectionsContainer),
+
+ // Render new and trending sections
+ renderAllNewAndTrendingSections(homeSectionsContainer),
+
+ // Render seasonal sections
+ renderAllSeasonalSections(homeSectionsContainer),
+
+ // Render watchlist section
+ renderWatchlistSection(homeSectionsContainer),
+
+ // Set up infinite loading for discovery sections (synchronous, wrapped in Promise.resolve)
+ Promise.resolve().then(() => setupInfiniteLoading(homeSectionsContainer)),
+
+ // Start preloading first batch of discovery sections (non-blocking)
+ preloadNextSections()
+ ];
+
+ // Wait for all parallel operations to complete
+ await Promise.all(initPromises);
+
+ LOG('All custom sections rendered successfully');
} catch (error) {
ERR('Error rendering custom sections:', error);
} finally {
@@ -287,11 +3159,14 @@ window.debugHomeScreen = function() {
window.debugCustomSections = function() {
LOG('Custom sections configuration:', customHomeSections);
+ LOG('New and Trending configuration - enableNewAndTrending:', enableNewAndTrending, 'enableNewMovies:', enableNewMovies, 'enableNewEpisodes:', enableNewEpisodes, 'enableTrending:', enableTrending);
+ LOG('Discovery configuration - enableWatchlist:', enableWatchlist, 'enableDiscovery:', enableDiscovery, 'enableSeasonal:', enableSeasonal);
+ LOG('Seasonal configuration - seasonalItemLimit:', SEASONAL_ITEM_LIMIT);
LOG('Processing flag status:', isProcessing);
// Check multiple selectors
const selectors = [
- '.itemDetailPage:not(.hide) .homeSectionsContainer',
+ '.libraryPage:not(.hide) .homeSectionsContainer',
'.homeSectionsContainer',
'.homeSectionsContainer:not(.hide)',
'[class*="homeSections"]'
@@ -318,9 +3193,290 @@ window.debugCustomSections = function() {
// Check if cardBuilder is available
LOG('cardBuilder available:', typeof window.cardBuilder !== 'undefined');
LOG('renderCardsFromIds available:', typeof window.cardBuilder?.renderCardsFromIds === 'function');
+
+ // Check if LocalStorageCache is available
+ LOG('LocalStorageCache available:', typeof window.LocalStorageCache !== 'undefined');
+
+ // Check discovery section data availability
+ if (enableWatchlist) {
+ const watchlistData = getWatchlistData();
+ LOG('Watchlist data available:', watchlistData.length, 'items');
+ }
+
+ if (enableDiscovery) {
+ const movieHistory = getMovieHistoryData();
+ const seriesProgress = getSeriesProgressData();
+ LOG('Movie history available:', movieHistory.length, 'items');
+ LOG('Series progress available:', seriesProgress.length, 'items');
+ }
+};
+
+
+// Debug function for seasonal sections
+window.debugSeasonalSections = function() {
+ LOG('=== Seasonal Sections Debug ===');
+ LOG('Configuration - enableSeasonal:', enableSeasonal, 'seasonalItemLimit:', SEASONAL_ITEM_LIMIT);
+
+ LOG('Date checks:');
+ LOG('- Halloween period:', isHalloweenPeriod());
+ LOG('- Christmas period:', isChristmasPeriod());
+ LOG('- New Years period:', isNewYearsPeriod());
+ LOG('- Valentine\'s Day period:', isValentinesPeriod());
+
+ if (enableSeasonal) {
+ LOG('Halloween data:');
+ LOG('- Halloween movies:', halloweenMovies.length);
+ LOG('- Horror movies:', horrorMovies.length);
+ LOG('- Thriller movies:', thrillerMovies.length);
+ LOG('- Halloween people:', halloweenPeople.length);
+ LOG('- ViewMore URLs:', halloweenViewMoreUrls);
+
+ if (halloweenPeople.length > 0) {
+ const directors = halloweenPeople.filter(p => p.directorCount >= 2);
+ const actors = halloweenPeople.filter(p => p.actorCount >= 2);
+ const writers = halloweenPeople.filter(p => p.writerCount >= 2);
+ LOG('- Directors with 2+ movies:', directors.length);
+ LOG('- Actors with 2+ movies:', actors.length);
+ LOG('- Writers with 2+ movies:', writers.length);
+ }
+ }
+};
+
+// Debug function for movie genres cache
+window.debugMovieGenres = async function() {
+ LOG('=== Movie Genres Cache Debug ===');
+
+ const cache = new window.LocalStorageCache();
+ const movieGenres = cache.get('movieGenres');
+
+ if (movieGenres && movieGenres.length > 0) {
+ LOG(`Cached genres: ${movieGenres.length}`);
+ LOG('Sample genres:', movieGenres.slice(0, 5).map(g => ({ name: g.Name, id: g.Id })));
+
+ // Test genre lookups
+ const horrorId = await getGenreId('Horror');
+ const thrillerId = await getGenreId('Thriller');
+ LOG('Horror genre ID:', horrorId);
+ LOG('Thriller genre ID:', thrillerId);
+ } else {
+ LOG('No genres in cache, fetching...');
+ const genres = await fetchAndCacheMovieGenres();
+ LOG(`Fetched and cached ${genres.length} genres`);
+ }
+
+ // Show cache age
+ const cacheAge = cache.getCacheAge('movieGenres');
+ if (cacheAge !== null) {
+ LOG(`Cache age: ${cacheAge.toFixed(2)} hours`);
+ }
+};
+
+// Debug function for discovery sections
+window.debugDiscoverySections = function() {
+ LOG('=== Discovery Sections Debug ===');
+ LOG('Configuration - enableDiscovery:', enableDiscovery);
+ LOG('Configuration - enableInfiniteScroll:', enableInfiniteScroll);
+ LOG('Configuration - minPeopleAppearances:', minPeopleAppearances);
+ LOG('Configuration - minGenreMovieCount:', minGenreMovieCount);
+ LOG('Movies top people loaded:', moviesTopPeople !== null);
+ LOG('People cache complete:', isPeopleCacheComplete);
+ LOG('Is initializing people cache:', isInitializingPeopleCache);
+ LOG('Rendered sections count:', renderedSections.size);
+ LOG('Rendered sections:', Array.from(renderedSections));
+ LOG('Is generating discovery sections:', isGeneratingDiscoverySections);
+ LOG('Preloaded sections count:', preloadedSections.length);
+ LOG('Preloaded section elements count:', preloadedSectionElements.length);
+ LOG('Is preloading sections:', isPreloadingSections);
+
+ if (moviesTopPeople) {
+ LOG('Top people data:');
+ LOG('- Actors:', moviesTopPeople.actors.length);
+ LOG('- Directors:', moviesTopPeople.directors.length);
+ LOG('- Writers:', moviesTopPeople.writers.length);
+
+ if (moviesTopPeople.actors.length > 0) {
+ LOG('Sample actor:', moviesTopPeople.actors[0]);
+ }
+ if (moviesTopPeople.directors.length > 0) {
+ LOG('Sample director:', moviesTopPeople.directors[0]);
+ }
+ if (moviesTopPeople.writers.length > 0) {
+ LOG('Sample writer:', moviesTopPeople.writers[0]);
+ }
+ }
+
+ // Test genre availability
+ const cache = new window.LocalStorageCache();
+ const movieGenres = cache.get('movieGenres');
+ LOG('Available genres:', movieGenres ? movieGenres.length : 0);
+
+ if (movieGenres && movieGenres.length > 0) {
+ const genresWithEnoughMovies = movieGenres.filter(genre =>
+ (genre.MovieCount || 0) >= minGenreMovieCount
+ );
+ LOG(`Genres with ${minGenreMovieCount}+ movies:`, genresWithEnoughMovies.length);
+
+ if (genresWithEnoughMovies.length > 0) {
+ LOG('Sample qualifying genres:', genresWithEnoughMovies.slice(0, 5).map(g => ({
+ name: g.Name,
+ movieCount: g.MovieCount
+ })));
+ }
+ }
+
+ // Test watchlist data
+ const watchlistData = getWatchlistData();
+ LOG('Watchlist data available:', watchlistData.length, 'items');
+
+ // Test movie history
+ const movieHistory = getMovieHistoryData();
+ LOG('Movie history available:', movieHistory.length, 'items');
+
+ // Test favorites
+ fetchFavoriteItems('Movie').then(favorites => {
+ LOG('Favorite movies available:', favorites.length, 'items');
+ });
+};
+
+// Function to manually trigger discovery sections rendering from buffer
+window.generateDiscoverySections = async function() {
+ LOG('Manually triggering discovery sections rendering from buffer...');
+ const homeSectionsContainer = document.querySelector('.homeSectionsContainer');
+ if (homeSectionsContainer) {
+ const result = await renderNextDiscoveryGroup(homeSectionsContainer);
+ LOG('Discovery sections rendering result:', result);
+ } else {
+ LOG('Home sections container not found');
+ }
+};
+
+// Function to manually trigger preloading
+window.preloadDiscoverySections = async function() {
+ LOG('Manually triggering discovery sections preloading...');
+ await preloadNextSections();
+ LOG('Preloading completed. Preloaded sections:', preloadedSections.length);
+};
+
+// Function to manually continue loading people data
+window.continueLoadingPeopleData = async function() {
+ LOG('Manually continuing people data loading...');
+ if (isInitializingPeopleCache) {
+ LOG('People cache initialization already in progress');
+ return;
+ }
+
+ isInitializingPeopleCache = true;
+ try {
+ const result = await fetchTopPeople();
+ if (result) {
+ moviesTopPeople = result;
+ isPeopleCacheComplete = result.isComplete || false;
+ LOG('People data loading result:', result.isComplete ? 'Complete' : 'Partial');
+ }
+ } finally {
+ isInitializingPeopleCache = false;
+ }
+};
+
+// Function to manually refresh movie genres cache
+window.refreshMovieGenres = async function() {
+ LOG('Manually refreshing movie genres cache...');
+ const cache = new window.LocalStorageCache();
+ cache.clear('movieGenres');
+ const genres = await fetchAndCacheMovieGenres();
+ LOG(`Refreshed cache with ${genres.length} genres`);
+ return genres;
+};
+
+// Function to manually unhide preloaded sections for testing
+window.unhidePreloadedSections = function() {
+ LOG('Manually unhiding preloaded sections...');
+ const hiddenSections = document.querySelectorAll('[data-preloaded="true"]');
+ LOG(`Found ${hiddenSections.length} hidden preloaded sections`);
+
+ let unhiddenCount = 0;
+ hiddenSections.forEach(section => {
+ section.style.display = '';
+ section.removeAttribute('data-preloaded');
+ unhiddenCount++;
+ });
+
+ LOG(`Unhid ${unhiddenCount} preloaded sections`);
+ return unhiddenCount;
+};
+
+// Function to test new movies section
+window.testNewMovies = async function() {
+ LOG('Testing New Movies section...');
+ const movies = await fetchNewMovies();
+ LOG(`Found ${movies.length} new movies:`, movies.map(m => ({ name: m.Name, premiereDate: m.PremiereDate })));
+ return movies;
+};
+
+// Function to test new episodes section
+window.testNewEpisodes = async function() {
+ LOG('Testing New Episodes section...');
+ const episodes = await fetchNewEpisodes();
+ LOG(`Found ${episodes.length} new episodes from last 7 days (after deduplication):`, episodes.map(e => ({
+ name: e.Name,
+ series: e.SeriesName,
+ episode: `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`,
+ premiereDate: e.PremiereDate
+ })));
+
+ // Group by series to show deduplication effect
+ const seriesGroups = {};
+ episodes.forEach(episode => {
+ const series = episode.SeriesName || 'Unknown';
+ if (!seriesGroups[series]) {
+ seriesGroups[series] = [];
+ }
+ seriesGroups[series].push(episode);
+ });
+
+ LOG('Episodes grouped by series:', Object.keys(seriesGroups).map(series => ({
+ series: series,
+ episodeCount: seriesGroups[series].length,
+ episodes: seriesGroups[series].map(e => `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`)
+ })));
+
+ return episodes;
+};
+
+// Function to test deduplication logic with sample data
+window.testDeduplication = function() {
+ LOG('Testing episode deduplication logic...');
+
+ // Create sample episodes for testing
+ const sampleEpisodes = [
+ { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 1, ParentIndexNumber: 1 },
+ { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 2, ParentIndexNumber: 1 },
+ { Name: 'Episode 3', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 3, ParentIndexNumber: 1 },
+ { Name: 'Episode 1', SeriesName: 'Test Series B', PremiereDate: '2024-01-16', IndexNumber: 1, ParentIndexNumber: 1 },
+ { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 1, ParentIndexNumber: 2 },
+ { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 2, ParentIndexNumber: 2 }
+ ];
+
+ LOG('Original episodes:', sampleEpisodes.map(e => ({
+ series: e.SeriesName,
+ episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`,
+ date: e.PremiereDate
+ })));
+
+ const deduplicated = deduplicateEpisodesBySeriesAndDate(sampleEpisodes);
+
+ LOG('After deduplication:', deduplicated.map(e => ({
+ series: e.SeriesName,
+ episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`,
+ date: e.PremiereDate
+ })));
+
+ LOG(`Deduplication result: ${sampleEpisodes.length} episodes → ${deduplicated.length} episodes`);
+
+ return deduplicated;
};
LOG('Home screen functionality initialized');
-LOG('Debug functions available: window.debugHomeScreen(), window.debugCustomSections()');
+LOG('Debug functions available: window.debugHomeScreen(), window.debugCustomSections(), window.debugDiscoverySections(), window.debugSeasonalSections(), window.debugMovieGenres(), window.refreshMovieGenres(), window.generateDiscoverySections(), window.preloadDiscoverySections(), window.continueLoadingPeopleData(), window.unhidePreloadedSections(), window.testNewMovies(), window.testNewEpisodes(), window.testDeduplication()');
})();
diff --git a/infiniteScroll.js b/infiniteScroll.js
new file mode 100644
index 0000000..8ecc11a
--- /dev/null
+++ b/infiniteScroll.js
@@ -0,0 +1,816 @@
+(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);
+
+ // Supported pages (same as headerTabs)
+ const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html'];
+
+ // Media type configurations - Read from centralized config or use defaults
+ const MEDIA_CONFIGS = {
+ 'movies.html': {
+ includeItemTypes: 'Movie',
+ context: 'movies',
+ sortBy: 'SortName,ProductionYear'
+ },
+ 'tv.html': {
+ includeItemTypes: 'Series',
+ context: 'tvshows',
+ sortBy: 'SortName,ProductionYear'
+ },
+ };
+
+ let userId = null;
+ const limit = 100;
+
+ let currentPage = null;
+ let currentTab = 0;
+ let startIndex = 0;
+ let loading = false;
+ let hasMore = true;
+ let totalRecordCount = null; // Track total items available from API
+ let retryCount = 0;
+ // scrollState removed - not needed
+ let scrollTimeout = null;
+ let isScrollTriggered = false; // Prevent multiple triggers
+ let isRequestInProgress = false; // Blocking flag to prevent multiple simultaneous requests
+ let hasInitialized = false; // Prevent multiple initializations
+ let isInitializing = false; // Prevent concurrent initializations
+
+ // Create loading indicator element
+ function createLoadingIndicator() {
+ const loadingDiv = document.createElement('div');
+ loadingDiv.className = 'infinite-scroll-loading';
+ loadingDiv.id = 'infinite-scroll-loading-indicator';
+ loadingDiv.innerHTML = `
+
+ Loading more content...
+ `;
+ return loadingDiv;
+ }
+
+ // Show loading indicator
+ function showLoadingIndicator() {
+ let loadingIndicator = document.getElementById('infinite-scroll-loading-indicator');
+
+ if (!loadingIndicator) {
+ loadingIndicator = createLoadingIndicator();
+
+ // Find the container and append the loading indicator as a sibling
+ const container = getContainer();
+ if (container && container.parentNode) {
+ container.parentNode.insertBefore(loadingIndicator, container.nextSibling);
+ } else {
+ // Fallback: append to body
+ document.body.appendChild(loadingIndicator);
+ }
+ }
+
+ // Show with animation
+ setTimeout(() => {
+ loadingIndicator.classList.add('show');
+ }, 10);
+
+ LOG('Loading indicator shown');
+ }
+
+ // Hide loading indicator
+ function hideLoadingIndicator() {
+ const loadingIndicator = document.getElementById('infinite-scroll-loading-indicator');
+ if (loadingIndicator) {
+ loadingIndicator.classList.remove('show');
+
+ // Remove from DOM after animation
+ setTimeout(() => {
+ if (loadingIndicator.parentNode) {
+ loadingIndicator.parentNode.removeChild(loadingIndicator);
+ }
+ }, 300);
+
+ LOG('Loading indicator hidden');
+ }
+ }
+
+ // Add CSS to hide paging controls when infinite scroll is active
+ function addInfiniteScrollCSS() {
+ // Check if infinite scroll is enabled in configuration
+ const isEnabled = window.KefinTweaksConfig?.scripts?.infiniteScroll !== false;
+ if (!isEnabled) {
+ LOG('Infinite scroll disabled in configuration, skipping CSS addition');
+ return;
+ }
+
+ const styleId = 'infinite-scroll-paging-hide';
+
+ // Check if style already exists
+ if (document.getElementById(styleId)) {
+ return;
+ }
+
+ const style = document.createElement('style');
+ style.id = styleId;
+ style.textContent = `
+ #moviesTab .listPaging>div,
+ #seriesTab .listPaging>div {
+ display: none !important;
+ }
+
+ /* Infinite scroll loading indicator */
+ .infinite-scroll-loading {
+ 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;
+ }
+
+ .infinite-scroll-loading.show {
+ opacity: 1;
+ }
+
+ .infinite-scroll-loading .spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #f3f3f3;
+ border-top: 2px solid #3498db;
+ border-radius: 50%;
+ animation: infinite-scroll-spin 1s linear infinite;
+ margin-right: 10px;
+ }
+
+ @keyframes infinite-scroll-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+ `;
+
+ document.head.appendChild(style);
+ LOG('Added CSS to hide paging controls for infinite scroll');
+ }
+
+ // Wait for ApiClient to be available
+ function waitForApiClient() {
+ return new Promise((resolve) => {
+ if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) {
+ userId = ApiClient._serverInfo.UserId;
+ LOG('ApiClient ready, userId:', userId);
+ resolve();
+ } else {
+ LOG('Waiting for ApiClient...');
+ setTimeout(() => waitForApiClient().then(resolve), 100);
+ }
+ });
+ }
+
+ // Wait for cardBuilder to be available
+ function waitForCardBuilder() {
+ return new Promise((resolve) => {
+ if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) {
+ LOG('cardBuilder ready');
+ resolve();
+ } else {
+ LOG('Waiting for cardBuilder...');
+ setTimeout(() => waitForCardBuilder().then(resolve), 100);
+ }
+ });
+ }
+
+ // Page detection function (same as headerTabs)
+ function getCurrentPage() {
+ const hash = window.location.hash;
+ const hashMatches = hash.match(/#\/([^?]+)/g);
+
+ if (!hashMatches || hashMatches.length === 0) {
+ LOG('No hash matches found for:', hash);
+ return null;
+ }
+
+ for (const match of hashMatches) {
+ const pageFromHash = match.replace('#/', '');
+ if (SUPPORTED_PAGES.includes(pageFromHash)) {
+ LOG('Found supported page:', pageFromHash);
+ return pageFromHash;
+ }
+ }
+ return null;
+ }
+
+ // Get current tab index from URL
+ function getCurrentTab() {
+ const hash = window.location.hash;
+ let searchParams = '';
+
+ if (hash.includes('?')) {
+ searchParams = hash.split('?')[1];
+ } else {
+ searchParams = window.location.search.substring(1);
+ }
+
+ const urlParams = new URLSearchParams(searchParams);
+ const tabParam = urlParams.get('tab');
+ return tabParam ? parseInt(tabParam, 10) : 0;
+ }
+
+ // State persistence functions - REMOVED
+ // We don't need to store scroll state as it can cause issues with infinite scroll behavior
+
+ function getParentId() {
+ const match = window.location.hash.match(/topParentId=([^&]+)/);
+ return match ? match[1] : null;
+ }
+
+ // Get current sort settings from Jellyfin's localStorage keys
+ function getCurrentSortSettings() {
+ const userId = ApiClient.getCurrentUserId();
+ const parentId = getParentId();
+ const page = getCurrentPage();
+
+ if (!userId || !parentId || !page) {
+ LOG('Missing required IDs for sort detection:', { userId, parentId, page });
+ return null;
+ }
+
+ // Map page to media type
+ const mediaTypeMap = {
+ 'movies.html': 'movies',
+ 'tv.html': 'series'
+ };
+
+ const mediaType = mediaTypeMap[page];
+ if (!mediaType) {
+ LOG('Unsupported page for sort detection:', page);
+ return null;
+ }
+
+ // Build localStorage keys
+ const storageKey = `${userId}-${parentId}-${mediaType}`;
+ const filterKey = `${userId}-${parentId}-${mediaType}-filter`;
+
+ LOG('Looking for sort settings in localStorage keys:', { storageKey, filterKey });
+
+ try {
+ // Get main sort settings
+ const sortData = JSON.parse(localStorage.getItem(storageKey) || '{}');
+ const filterData = JSON.parse(localStorage.getItem(filterKey) || '{}');
+
+ LOG('Found sort data:', sortData);
+ LOG('Found filter data:', filterData);
+
+ return {
+ sortBy: sortData.SortBy || 'SortName,ProductionYear',
+ sortOrder: sortData.SortOrder || 'Ascending',
+ filters: filterData.Filters || '',
+ years: filterData.Years || '',
+ genres: filterData.Genres || '',
+ tags: filterData.Tags || '',
+ // Add other filter properties as needed
+ };
+ } catch (error) {
+ ERR('Error parsing sort settings from localStorage:', error);
+ return null;
+ }
+ }
+
+ // Get current alpha picker selection
+ function getCurrentAlphaPicker() {
+ const page = getCurrentPage();
+ if (!page) return null;
+
+ let activeTab = null;
+ if (page === 'tv.html') {
+ activeTab = document.querySelector("#seriesTab.is-active");
+ } else if (page === 'movies.html') {
+ activeTab = document.querySelector("#moviesTab.is-active");
+ }
+
+ if (!activeTab) return null;
+
+ const alphaPickerRow = activeTab.querySelector(".alphaPickerRow");
+ if (!alphaPickerRow) return null;
+
+ const selectedButton = alphaPickerRow.querySelector(".alphaPickerButton-selected");
+ if (!selectedButton) return null;
+
+ const dataValue = selectedButton.getAttribute('data-value');
+ LOG('Found alpha picker selection:', dataValue);
+
+ return dataValue;
+ }
+
+ // Get current start index based on existing items count
+ function getCurrentStartIndex() {
+ const container = getContainer();
+ if (!container) return 0;
+
+ const existingItemsCount = container.children.length;
+ LOG('Current start index based on existing items:', existingItemsCount);
+
+ return existingItemsCount;
+ }
+
+ // Update pagination display to show current range
+ function updatePaginationDisplay() {
+ const page = getCurrentPage();
+ if (!page) return;
+
+ let activeTab = null;
+ if (page === 'tv.html') {
+ activeTab = document.querySelector("#seriesTab.is-active");
+ } else if (page === 'movies.html') {
+ activeTab = document.querySelector("#moviesTab.is-active");
+ }
+
+ if (!activeTab) return;
+
+ const listPagings = activeTab.querySelectorAll(".listPaging");
+ if (!listPagings.length) return;
+
+ listPagings.forEach(listPaging => {
+ const span = listPaging.querySelector("span");
+ if (!span) return;
+
+ const container = getContainer();
+ if (!container) return;
+
+ const currentItemsCount = container.children.length;
+ const totalCount = totalRecordCount || '?';
+
+ // Update the display to show current range
+ span.textContent = `1-${currentItemsCount} of ${totalCount}`;
+ LOG('Updated pagination display:', { currentItemsCount, totalCount });
+ });
+ }
+
+ function getContainer() {
+ const libraryPage = document.querySelector(".libraryPage:not(.hide)");
+ if (!libraryPage) return null;
+
+ // Check if we're on the correct active tab
+ const page = getCurrentPage();
+ if (!page) return null;
+
+ let activeTab = null;
+ if (page === 'tv.html') {
+ activeTab = document.querySelector("#seriesTab.is-active");
+ } else if (page === 'movies.html') {
+ activeTab = document.querySelector("#moviesTab.is-active");
+ }
+
+ if (!activeTab) {
+ 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;
+ }
+
+ return container;
+ }
+
+ async function loadMore() {
+ LOG('LoadMore called:', { loading, hasMore, isRequestInProgress, isScrollTriggered });
+
+ if (loading || !hasMore || isRequestInProgress) {
+ LOG('LoadMore blocked:', { loading, hasMore, isRequestInProgress });
+ return;
+ }
+
+ // Don't load more if there are no items in the container (page still loading)
+ const container = getContainer();
+ if (container && container.children.length === 0) {
+ LOG('LoadMore blocked: No items in container yet (page still loading)');
+ return;
+ }
+
+ LOG('Starting loadMore request...');
+ loading = true;
+ isRequestInProgress = true;
+
+ // Show loading indicator
+ showLoadingIndicator();
+
+ // Wait for ApiClient to be ready
+ if (!userId) {
+ LOG('Waiting for ApiClient before loading...');
+ await waitForApiClient();
+ }
+
+ // Wait for cardBuilder to be ready
+ await waitForCardBuilder();
+
+ const page = getCurrentPage();
+ const tab = getCurrentTab();
+
+ if (!page) {
+ WARN("No supported page detected");
+ loading = false;
+ isRequestInProgress = false;
+ hideLoadingIndicator();
+ return;
+ }
+
+ const parentId = getParentId();
+ if (!parentId) {
+ WARN("No topParentId found in URL");
+ loading = false;
+ isRequestInProgress = false;
+ hideLoadingIndicator();
+ return;
+ }
+
+ const mediaConfig = MEDIA_CONFIGS[page];
+ if (!mediaConfig) {
+ WARN("No media configuration found for page:", page);
+ loading = false;
+ isRequestInProgress = false;
+ hideLoadingIndicator();
+ return;
+ }
+
+ // Double-check ApiClient is available
+ if (typeof ApiClient === 'undefined') {
+ ERR('ApiClient is not available');
+ loading = false;
+ isRequestInProgress = false;
+ hideLoadingIndicator();
+ return;
+ }
+
+ // Get current start index based on existing items
+ startIndex = getCurrentStartIndex();
+
+ // Get current sort settings from localStorage
+ const sortSettings = getCurrentSortSettings();
+ let apiParams = {
+ SortBy: mediaConfig.sortBy, // fallback
+ SortOrder: "Ascending", // fallback
+ IncludeItemTypes: mediaConfig.includeItemTypes,
+ Recursive: true,
+ Fields: "PrimaryImageAspectRatio,MediaSourceCount",
+ ImageTypeLimit: 1,
+ EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
+ ParentId: parentId,
+ StartIndex: startIndex,
+ Limit: limit
+ };
+
+ // Apply detected sort settings if available
+ if (sortSettings) {
+ LOG('Using detected sort settings:', sortSettings);
+ apiParams.SortBy = sortSettings.sortBy;
+ apiParams.SortOrder = sortSettings.sortOrder;
+
+ // Add filter parameters if they exist
+ if (sortSettings.filters) {
+ apiParams.Filters = sortSettings.filters;
+ }
+ if (sortSettings.years) {
+ apiParams.Years = sortSettings.years;
+ }
+ if (sortSettings.genres) {
+ apiParams.Genres = sortSettings.genres;
+ }
+ if (sortSettings.tags) {
+ apiParams.Tags = sortSettings.tags;
+ }
+ } else {
+ LOG('Using fallback sort settings');
+ }
+
+ // Add alpha picker support
+ const alphaPicker = getCurrentAlphaPicker();
+ if (alphaPicker) {
+ apiParams.NameStartsWith = alphaPicker;
+ LOG('Added alpha picker filter:', alphaPicker);
+ }
+
+ const url = ApiClient.getUrl("Users/" + userId + "/Items", apiParams);
+
+ LOG('Loading more items:', { page, tab, startIndex, limit, url });
+
+ try {
+ const result = await ApiClient.getJSON(url);
+
+ // Update total record count from API response
+ if (result.TotalRecordCount !== undefined) {
+ totalRecordCount = result.TotalRecordCount;
+ LOG('Updated total record count from API:', totalRecordCount);
+ }
+
+ if (result.Items && result.Items.length > 0) {
+ const container = getContainer();
+ if (container) {
+ // Check if cardBuilder is available
+ if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.buildCard) {
+ ERR('cardBuilder not available or buildCard method missing');
+ loading = false;
+ isRequestInProgress = false;
+ hideLoadingIndicator();
+ return;
+ }
+
+ LOG('Building cards:', {
+ itemCount: result.Items.length,
+ context: mediaConfig.context,
+ container: container
+ });
+
+ // Build each card individually and append to container
+ result.Items.forEach((item, index) => {
+ const card = window.cardBuilder.buildCard(item);
+ card.setAttribute('data-index', startIndex + index);
+ container.appendChild(card);
+ });
+
+ LOG('Cards built successfully');
+ }
+ startIndex += result.Items.length;
+
+ // Update pagination display
+ updatePaginationDisplay();
+
+ // Update hasMore based on total record count
+ if (totalRecordCount !== null) {
+ hasMore = startIndex < totalRecordCount;
+ LOG('hasMore updated based on total count:', {
+ startIndex,
+ totalRecordCount,
+ hasMore
+ });
+ } else {
+ // Fallback to old logic if no total count available
+ hasMore = result.Items.length >= limit;
+ LOG('hasMore updated using fallback logic:', {
+ itemsLength: result.Items.length,
+ limit,
+ hasMore
+ });
+ }
+
+ // Scroll state persistence removed - not needed
+
+ LOG('Loaded items successfully:', {
+ loaded: result.Items.length,
+ startIndex,
+ totalRecordCount,
+ hasMore
+ });
+ } else {
+ hasMore = false;
+ LOG('No more items to load (empty result)');
+ }
+
+ // Reset retry count on success
+ retryCount = 0;
+
+ } catch (err) {
+ ERR("Infinite scroll load error", err);
+
+ // Retry logic
+ if (retryCount < RETRY_ATTEMPTS) {
+ retryCount++;
+ WARN(`Retrying loadMore (attempt ${retryCount}/${RETRY_ATTEMPTS})`);
+ setTimeout(() => {
+ loading = false;
+ isRequestInProgress = false;
+ loadMore();
+ }, RETRY_DELAY * retryCount);
+ return;
+ } else {
+ ERR("Max retry attempts reached, giving up");
+ hasMore = false;
+ }
+ } finally {
+ loading = false;
+ isRequestInProgress = false;
+
+ // Hide loading indicator
+ hideLoadingIndicator();
+ }
+ }
+
+ function setupScrollWatcher() {
+ // Remove any existing scroll listener
+ window.removeEventListener("scroll", handleScroll);
+
+ // Add throttled scroll listener
+ window.addEventListener("scroll", handleScroll);
+ LOG('Scroll watcher setup complete');
+ }
+
+ function handleScroll() {
+ // Throttle scroll events - only check every 100ms
+ if (scrollTimeout) {
+ clearTimeout(scrollTimeout);
+ }
+
+ scrollTimeout = setTimeout(() => {
+ checkScrollPosition();
+ }, 100);
+ }
+
+ function checkScrollPosition() {
+ // Don't trigger if already loading, no more items, or request in progress
+ if (loading || !hasMore || isScrollTriggered || isRequestInProgress) {
+ LOG('Scroll check blocked:', { loading, hasMore, isScrollTriggered, isRequestInProgress });
+ return;
+ }
+
+ const container = getContainer();
+ if (!container) {
+ LOG('No container found in scroll check');
+ return;
+ }
+
+ // Don't trigger if there are no items in the container (page still loading)
+ const existingItemsCount = container.children.length;
+ if (existingItemsCount === 0) {
+ LOG('Scroll check blocked: No items in container yet (page still loading)');
+ return;
+ }
+
+ const scrollTop = window.scrollY || document.documentElement.scrollTop;
+ const windowHeight = window.innerHeight;
+ const documentHeight = document.documentElement.scrollHeight;
+
+ // Calculate distance from bottom - trigger when one viewport height away
+ const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
+ const triggerThreshold = windowHeight; // One viewport height from bottom
+
+ LOG('Scroll check:', {
+ scrollTop,
+ windowHeight,
+ documentHeight,
+ distanceFromBottom,
+ triggerThreshold,
+ existingItemsCount,
+ loading,
+ hasMore,
+ isScrollTriggered,
+ isRequestInProgress
+ });
+
+ if (distanceFromBottom <= triggerThreshold) {
+ isScrollTriggered = true;
+ LOG('Triggering loadMore from scroll (one viewport height from bottom)');
+ loadMore().finally(() => {
+ // Reset trigger flag after load completes
+ setTimeout(() => {
+ isScrollTriggered = false;
+ LOG('Scroll trigger flag reset');
+ }, 1000); // Wait 1 second before allowing next trigger
+ });
+ }
+ }
+
+ // Reset state when navigating to a new library or tab
+ function reset() {
+ const page = getCurrentPage();
+ const tab = getCurrentTab();
+
+ LOG('Resetting infinite scroll state:', { page, tab });
+
+ // startIndex will be calculated dynamically based on existing items
+ hasMore = true;
+ totalRecordCount = null;
+ loading = false;
+ retryCount = 0;
+ isScrollTriggered = false;
+ isRequestInProgress = false;
+
+ // Scroll state restoration removed - not needed
+
+ // Clear container if switching pages/tabs
+ if (currentPage !== page || currentTab !== tab) {
+ const container = getContainer();
+ if (container) {
+ //container.innerHTML = '';
+ LOG('[REMOVED] Cleared container for new page/tab');
+ }
+ hasInitialized = false; // Reset initialization flag for new page/tab
+ isInitializing = false; // Reset initializing flag for new page/tab
+ }
+
+ currentPage = page;
+ currentTab = tab;
+ }
+
+ // Check if we should initialize infinite scroll
+ function shouldInitialize() {
+ const page = getCurrentPage();
+ const container = getContainer();
+
+ if (!page) {
+ return false;
+ }
+
+ if (!container) {
+ LOG('No active tab container found, waiting...');
+ return false;
+ }
+
+ // Double-check that we have the correct active tab
+ let activeTab = null;
+ if (page === 'tv.html') {
+ activeTab = document.querySelector("#seriesTab.is-active");
+ } else if (page === 'movies.html') {
+ activeTab = document.querySelector("#moviesTab.is-active");
+ }
+
+ if (!activeTab) {
+ LOG('No active tab found for page, skipping initialization:', page);
+ return false;
+ }
+
+ return true;
+ }
+
+ // Initialize infinite scroll
+ async function initialize() {
+ if (!shouldInitialize() || hasInitialized || isInitializing) {
+ LOG('Skipping initialization:', { shouldInit: shouldInitialize(), hasInitialized, isInitializing });
+ return;
+ }
+
+ LOG('Initializing infinite scroll');
+ isInitializing = true;
+ hasInitialized = true;
+
+ try {
+ // Add CSS to hide paging controls
+ addInfiniteScrollCSS();
+
+ // Wait for ApiClient to be ready
+ if (!userId) {
+ LOG('Waiting for ApiClient before initialization...');
+ await waitForApiClient();
+ }
+
+ // Wait for cardBuilder to be ready
+ await waitForCardBuilder();
+
+ reset();
+ //loadMore(); // load first page
+ setupScrollWatcher();
+
+ // Update pagination display with initial state
+ updatePaginationDisplay();
+ } finally {
+ isInitializing = false;
+ }
+ }
+
+ // Scroll state loading removed - not needed
+
+ // Wait for page to mount
+ const observer = new MutationObserver(() => {
+ if (shouldInitialize()) {
+ initialize().catch(err => ERR('Initialization failed:', err));
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ // Also try to initialize immediately if page is already loaded
+ if (document.readyState === 'complete') {
+ setTimeout(() => {
+ initialize().catch(err => ERR('Initial initialization failed:', err));
+ }, 100);
+ }
+
+ // Cleanup function
+ function cleanup() {
+ window.removeEventListener("scroll", handleScroll);
+ if (scrollTimeout) {
+ clearTimeout(scrollTimeout);
+ }
+
+ // Remove the CSS that hides paging controls
+ const styleElement = document.getElementById('infinite-scroll-paging-hide');
+ if (styleElement) {
+ styleElement.remove();
+ LOG('Removed CSS for paging controls');
+ }
+
+ // Hide and remove loading indicator
+ hideLoadingIndicator();
+
+ LOG('Infinite scroll cleanup complete');
+ }
+
+ // Cleanup on page unload
+ window.addEventListener('beforeunload', cleanup);
+})();
diff --git a/injector.js b/injector.js
index 5f79371..e289e6d 100644
--- a/injector.js
+++ b/injector.js
@@ -1,35 +1,40 @@
// KefinTweaks Script Injector
// Dynamically loads scripts and CSS files based on user configuration
-// Usage: Modify the ENABLED_SCRIPTS array below to enable/disable features
+// Usage: Configuration is now managed in kefinTweaks.js - this script reads from window.KefinTweaksConfig
(function() {
'use strict';
- // Configuration: Set which scripts to load
- // Set to true to enable, false to disable
- const ENABLED_SCRIPTS = {
- // Core functionality (required for other scripts)
- cardBuilder: true, // Required by watchlist, homeScreen and search
-
+ // Configuration: Read from centralized config or use defaults
+ const ENABLED_SCRIPTS = window.KefinTweaksConfig?.scripts || {
// UI and functional enhancements
watchlist: true, // Watchlist functionality
homeScreen: true, // Custom home screen sections
search: true, // Enhanced search functionality
-
headerTabs: true, // Header tab enhancements
- customMenu: true, // Custom menu functionality
+ customMenuLinks: true, // Custom menu links functionality
exclusiveElsewhere: true, // Exclusive elsewhere branding
-
- // Scripts with additional backend requirements
- updoot: false, // Upvote functionality from https://github.com/BobHasNoSoul/jellyfin-updoot
-
- // Utility scripts
+ updoot: true, // Upvote functionality
backdropLeakFix: true, // Memory leak fixes
dashboardButtonFix: true, // Dashboard button fix
+ infiniteScroll: true, // Infinite scroll functionality
+ removeContinue: true, // Remove from continue watching functionality
+ subtitleSearch: true, // Subtitle search functionality
+ playlist: true // Playlist view page modifications
+
+ // Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal)
+ // are automatically enabled when needed by other scripts
};
// Script definitions with dependencies and metadata
const SCRIPT_DEFINITIONS = [
+ {
+ name: 'utils',
+ script: 'utils.js',
+ css: null,
+ dependencies: [],
+ description: 'Common utilities for page view management and MutationObserver conversion'
+ },
{
name: 'cardBuilder',
script: 'cardBuilder.js',
@@ -37,25 +42,39 @@
dependencies: [],
description: 'Core card building functionality (required by other scripts)'
},
+ {
+ name: 'localStorageCache',
+ script: 'localStorageCache.js',
+ css: null,
+ dependencies: [],
+ description: 'localStorage-based caching layer with 24-hour TTL and manual refresh'
+ },
+ {
+ name: 'modal',
+ script: 'modal.js',
+ css: null,
+ dependencies: [],
+ description: 'Generic modal system for Jellyfin-style dialogs'
+ },
{
name: 'watchlist',
script: 'watchlist.js',
css: 'watchlist.css',
- dependencies: ['cardBuilder'],
+ dependencies: ['cardBuilder', 'localStorageCache', 'modal'],
description: 'Adds watchlist functionality throughout Jellyfin interface'
},
{
name: 'homeScreen',
script: 'homeScreen.js',
css: 'homeScreen.css',
- dependencies: ['cardBuilder'],
+ dependencies: ['cardBuilder', 'localStorageCache', 'utils'],
description: 'Adds custom home screen sections'
},
{
name: 'search',
script: 'search.js',
css: 'search.css',
- dependencies: [],
+ dependencies: ['cardBuilder', 'utils'],
description: 'Enhanced search functionality'
},
{
@@ -66,11 +85,11 @@
description: 'Header tab improvements'
},
{
- name: 'customMenu',
- script: 'custom-menu.js',
+ name: 'customMenuLinks',
+ script: 'customMenuLinks.js',
css: null,
- dependencies: [],
- description: 'Remove target="_blank" from custom menu links'
+ dependencies: ['utils'],
+ description: 'Load and add custom menu links from configuration'
},
{
name: 'exclusiveElsewhere',
@@ -99,13 +118,73 @@
css: null,
dependencies: [],
description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to'
+ },
+ {
+ name: 'infiniteScroll',
+ script: 'infiniteScroll.js',
+ css: null,
+ dependencies: ['cardBuilder'],
+ description: 'Adds infinite scroll functionality to media library pages'
+ },
+ {
+ name: 'removeContinue',
+ script: 'removeContinue.js',
+ css: null,
+ dependencies: [],
+ description: 'Adds remove from continue watching functionality to cards with data-position-ticks'
+ },
+ {
+ name: 'subtitleSearch',
+ script: 'subtitleSearch.js',
+ css: 'subtitleSearch.css',
+ dependencies: [],
+ description: 'Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources'
+ },
+ {
+ name: 'breadcrumbs',
+ script: 'breadcrumbs.js',
+ css: 'breadcrumbNav.css',
+ dependencies: ['utils'],
+ description: 'Adds breadcrumb navigation to item detail pages for Movies, Series, Seasons, Episodes, Music Artists, and Music Albums'
+ },
+ {
+ name: 'playlist',
+ script: 'playlist.js',
+ css: null,
+ dependencies: ['cardBuilder', 'utils'],
+ description: 'Modifies playlist view page behavior to navigate to item details instead of playing, and adds play button to playlist items'
}
];
+ // Auto-enable dependencies for enabled scripts
+ function autoEnableDependencies() {
+ let hasChanges = false;
+
+ SCRIPT_DEFINITIONS.forEach(script => {
+ if (ENABLED_SCRIPTS[script.name]) {
+ script.dependencies.forEach(dep => {
+ if (!ENABLED_SCRIPTS[dep]) {
+ ENABLED_SCRIPTS[dep] = true;
+ hasChanges = true;
+ console.log(`[KefinTweaks Injector] Auto-enabled dependency '${dep}' for '${script.name}'`);
+ }
+ });
+ }
+ });
+
+ return hasChanges;
+ }
+
// Configuration validation
function validateConfiguration() {
const errors = [];
+ // Auto-enable dependencies first
+ const dependenciesEnabled = autoEnableDependencies();
+ if (dependenciesEnabled) {
+ console.log('[KefinTweaks Injector] Auto-enabled required dependencies');
+ }
+
// Check dependencies - only validate if the script is enabled
SCRIPT_DEFINITIONS.forEach(script => {
if (ENABLED_SCRIPTS[script.name]) {
@@ -125,9 +204,9 @@
return true;
}
- // Get the root path for scripts (adjust this based on your setup)
+ // Get the root path for scripts from configuration
function getScriptRoot() {
- return 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
+ return window.KefinTweaksConfig?.scriptRoot || 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
}
// Load a CSS file
diff --git a/kefinTweaks.js b/kefinTweaks.js
new file mode 100644
index 0000000..af3e0d4
--- /dev/null
+++ b/kefinTweaks.js
@@ -0,0 +1,203 @@
+// KefinTweaks - Central Configuration and Script Loader
+// 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
+//
+// FEATURES OVERVIEW:
+// =================
+// For a detailed feature overview see README.md at https://github.com/ranaldsgift/KefinTweaks
+//
+// 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
+//
+// INSTALLATION:
+// ============
+// 0. Install required prerequisites listed above
+// 1. Add a new script to your JS Injector plugin
+// 2. Copy this entire file into the new script
+// (Optional) Enable/disable features, or customize their configuration by editing the script below
+// 3. Ensure the script is enabled
+// 4. Save your changes
+//
+// CONFIGURATION GUIDE:
+// ===================
+// - Scripts: Enable/disable individual features
+// - Home Screen: Configure custom sections and discovery settings
+// - Exclusive Elsewhere: Only useful if you want to display an icon or logo instead of text
+// - Search: Enable Jellyseerr integration adds an option to search for Jellyseerr results only
+//
+// Considerations for the home screen sections:
+// 1. Custom sections (order 1-29) - Your playlist-based sections
+// 2. New and Trending sections (order 30-49) - Newest movies and episodes
+// 3. Seasonal sections (order 50+) - Holiday-themed content
+// 4. Discovery sections (order 200+) - Intelligent recommendations based on your library
+
+(function() {
+ 'use strict';
+
+ // ============================================================================
+ // CENTRAL CONFIGURATION
+ // ============================================================================
+
+ const KEFIN_TWEAKS_CONFIG = {
+ // ============================================================================
+ // SCRIPT ROOT CONFIGURATION
+ // ============================================================================
+ // Configure where scripts are loaded from
+ scriptRoot: 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/',
+
+ // ============================================================================
+ // SCRIPT ENABLE/DISABLE FLAGS
+ // ============================================================================
+ // Toggle individual features on/off. Dependencies are automatically managed.
+ scripts: {
+ // 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
+ 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
+
+ // Branding and external integrations
+ updoot: false, // Integration with Jellyfin Updoot upvote functionality
+ // REQUIRES: ExclusiveElsewhere requires JellyfinEnhanced plugin https://github.com/n00bcodr/Jellyfin-Enhanced
+ exclusiveElsewhere: false, // Custom branding when items aren't available on streaming services
+
+ // Performance and stability improvements
+ backdropLeakFix: true, // Fixes memory leaks from backdrop images when tab isn't focused
+ dashboardButtonFix: true, // Proper back button behavior on dashboard
+ infiniteScroll: true, // Smooth infinite scrolling for large media libraries
+
+ // Playback and media enhancements
+ 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
+ },
+
+ // ============================================================================
+ // HOME SCREEN CONFIGURATION
+ // ============================================================================
+ // Configure custom home screen sections and discovery features
+ homeScreen: {
+ // NEW AND TRENDING SECTIONS
+ // Automatically curated sections showing latest content
+ enableNewAndTrending: true, // Master toggle for new and trending sections
+ enableNewMovies: true, // Show newest movies section
+ enableNewEpisodes: true, // Show newest episodes from last 7 days
+ enableTrending: false, // Show trending sections (currently stubs, order 32-33)
+
+ // DISCOVERY SECTIONS
+ // Intelligent recommendations based on your library content
+ // Includes:
+ // - [Genre] Movies (random genres with sufficient movie count)
+ // - Directed by [Director] (top directors from your library)
+ // - Written by [Writer] (top writers from your library)
+ // - Starring [Actor] (top actors from your library)
+ // - Because you watched [Movie] (similar content based on watch history)
+ // - Because you liked [Movie] (similar content based on favorites)
+ enableDiscovery: true, // Enables discovery sections on the home screen
+
+ // Discovery section behavior and filtering
+ 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
+
+ // WATCHLIST SECTION
+ // Requires watchlist script to be enabled
+ enableWatchlist: true, // Show watchlist section on home screen
+
+ // SEASONAL SECTIONS
+ // Holiday-themed content sections
+ enableSeasonal: true, // Master toggle for seasonal sections
+ seasonalItemLimit: 16, // Maximum items to show in seasonal sections
+
+ // CUSTOM SECTIONS
+ // Add your own playlist/collection-based sections
+ customSections: [
+ // Add more custom sections here
+ /* Example:
+ {
+ // Optionally provide a name, otherwise the name of the playlist/collection will be used
+ name: 'Your Custom Section',
+
+ // The ID of the playlist/collection to display in the section, get this from Jellyfin playlist/collection URL
+ id: 'acc84f03475eb51602b13cde158d21ad',
+
+ // The maximum number of items to display in the section on the home screen
+ maxItems: 20,
+
+ // The order of the section for it to appear on the home screen
+ order: 5
+ },
+ */
+ ],
+ },
+
+ // ============================================================================
+ // EXCLUSIVE ELSEWHERE CONFIGURATION
+ // ============================================================================
+ // Configure exclusive elsewhere branding behavior
+ exclusiveElsewhere: {
+ hideServerName: false // Set to true to hide server name, useful when you want to only show a logo or icon via CSS
+ },
+
+ // ============================================================================
+ // SEARCH CONFIGURATION
+ // ============================================================================
+ // Configure search functionality
+ search: {
+ enableJellyseerr: false // Enable Jellyseerr integration for request functionality
+ },
+
+ // ============================================================================
+ // CUSTOM MENU LINKS CONFIGURATION
+ // ============================================================================
+ // Configure custom menu links to be added to the custom menu
+ customMenuLinks: [
+ // Add your custom menu links here
+ /* Example:
+ {
+ name: 'My Custom Link', // Display name for the menu item
+ icon: 'link', // Material icon name (optional, defaults to 'link')
+ url: '#/userpluginsettings.html?pageUrl=https://domain.com/custom-page', // URL to navigate to
+ openInNewTab: false // Whether to open in new tab (optional, defaults to false)
+ },
+ {
+ name: 'External Link',
+ icon: 'open_in_new',
+ url: 'https://example.com',
+ openInNewTab: true
+ },
+ */
+ ],
+ };
+
+ // ============================================================================
+ // CONFIGURATION EXPOSURE
+ // ============================================================================
+ // Make configuration available to individual scripts
+ window.KefinTweaksConfig = KEFIN_TWEAKS_CONFIG;
+
+ // ============================================================================
+ // SCRIPT LOADER INITIALIZATION
+ // ============================================================================
+ // 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.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');
+
+})();
+
diff --git a/localStorageCache.js b/localStorageCache.js
new file mode 100644
index 0000000..2b8dbff
--- /dev/null
+++ b/localStorageCache.js
@@ -0,0 +1,269 @@
+// KefinTweaks localStorage Cache Manager
+// Provides 24-hour TTL caching with manual refresh functionality
+// Usage: Integrates with existing cache system to add persistence
+
+(function() {
+ '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);
+
+ // Generic localStorage cache manager with chunked storage support
+ class LocalStorageCache {
+ constructor(prefix = 'kefinTweaks_', ttl = 24 * 60 * 60 * 1000, chunkSize = 10) { // 24 hours, 10 items per chunk
+ this.prefix = prefix;
+ this.ttl = ttl;
+ this.chunkSize = chunkSize;
+ }
+
+ // 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)
+ isCacheValid(cacheName, userId = null) {
+ const key = this.getCacheKey(cacheName, userId);
+ const cached = localStorage.getItem(key);
+ if (!cached) return false;
+
+ try {
+ const data = JSON.parse(cached);
+ const now = Date.now();
+ const ttl = data.ttl || this.ttl; // Use stored TTL or default
+ return (now - data.timestamp) < ttl;
+ } catch {
+ return false;
+ }
+ }
+
+ // Get cached data if valid
+ get(cacheName, userId = null) {
+ if (!this.isCacheValid(cacheName, userId)) {
+ return null;
+ }
+
+ const key = this.getCacheKey(cacheName, userId);
+ try {
+ const cached = localStorage.getItem(key);
+ const data = JSON.parse(cached);
+ return data.payload;
+ } catch {
+ return null;
+ }
+ }
+
+ // Store data with timestamp
+ set(cacheName, data, userId = null, customTtl = null) {
+ const key = this.getCacheKey(cacheName, userId);
+ const ttl = customTtl || this.ttl;
+ const payload = {
+ timestamp: Date.now(),
+ ttl: ttl,
+ payload: data
+ };
+
+ try {
+ localStorage.setItem(key, JSON.stringify(payload));
+ LOG(`Cached data for ${cacheName}: ${Array.isArray(data) ? data.length : 'object'} items (TTL: ${ttl / (1000 * 60 * 60)}h)`);
+ return true;
+ } catch (error) {
+ WARN('Failed to store data:', error);
+ return false;
+ }
+ }
+
+ // Clear specific cache
+ clear(cacheName, userId = null) {
+ const key = this.getCacheKey(cacheName, userId);
+ localStorage.removeItem(key);
+ LOG(`Cleared cache for ${cacheName}`);
+ }
+
+ // Clear all caches for current user
+ clearAll(userId = null) {
+ const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
+ const keys = Object.keys(localStorage).filter(key =>
+ key.startsWith(this.prefix) && key.endsWith(`_${user}`)
+ );
+ keys.forEach(key => localStorage.removeItem(key));
+ LOG(`Cleared all caches for user ${user}: ${keys.length} entries`);
+ }
+
+ // Get cache age in hours
+ getCacheAge(cacheName, userId = null) {
+ const key = this.getCacheKey(cacheName, userId);
+ const cached = localStorage.getItem(key);
+ if (!cached) return null;
+
+ try {
+ const data = JSON.parse(cached);
+ return (Date.now() - data.timestamp) / (1000 * 60 * 60); // hours
+ } catch {
+ return null;
+ }
+ }
+
+ // Get cache size in bytes (approximate)
+ getCacheSize(cacheName, userId = null) {
+ const key = this.getCacheKey(cacheName, userId);
+ const cached = localStorage.getItem(key);
+ return cached ? cached.length : 0;
+ }
+
+ // Get all cache names for current user
+ getAllCacheNames(userId = null) {
+ const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
+ const keys = Object.keys(localStorage).filter(key =>
+ key.startsWith(this.prefix) && key.endsWith(`_${user}`)
+ );
+ return keys.map(key => key.replace(this.prefix, '').replace(`_${user}`, ''));
+ }
+
+ // Chunked storage methods for large datasets
+ setChunked(cacheName, data, userId = null) {
+ if (!Array.isArray(data)) {
+ WARN('Chunked storage requires array data');
+ return false;
+ }
+
+ if (data.length === 0) {
+ LOG(`No data to chunk for ${cacheName}`);
+ return true;
+ }
+
+ try {
+ const chunks = this.chunkArray(data, this.chunkSize);
+ const chunkCount = chunks.length;
+
+ LOG(`Storing ${data.length} items in ${chunkCount} chunks for ${cacheName}`);
+
+ // Store each chunk
+ for (let i = 0; i < chunks.length; i++) {
+ const chunkKey = `${cacheName}_chunk_${i}`;
+ const chunkData = {
+ chunkIndex: i,
+ totalChunks: chunkCount,
+ data: chunks[i],
+ timestamp: Date.now()
+ };
+
+ if (!this.set(chunkKey, chunkData, userId)) {
+ WARN(`Failed to store chunk ${i} for ${cacheName}`);
+ return false;
+ }
+ }
+
+ // Store metadata
+ const metadata = {
+ totalChunks: chunkCount,
+ totalItems: data.length,
+ timestamp: Date.now(),
+ chunkSize: this.chunkSize
+ };
+
+ if (!this.set(`${cacheName}_meta`, metadata, userId)) {
+ WARN(`Failed to store metadata for ${cacheName}`);
+ return false;
+ }
+
+ LOG(`Successfully stored ${data.length} items in ${chunkCount} chunks for ${cacheName}`);
+ return true;
+
+ } catch (error) {
+ ERR(`Error storing chunked data for ${cacheName}:`, error);
+ return false;
+ }
+ }
+
+ // Load chunked data
+ getChunked(cacheName, userId = null) {
+ try {
+ const meta = this.get(`${cacheName}_meta`, userId);
+ if (!meta) {
+ LOG(`No metadata found for chunked cache ${cacheName}`);
+ return null;
+ }
+
+ if (!this.isCacheValid(`${cacheName}_meta`, userId)) {
+ LOG(`Chunked cache ${cacheName} has expired`);
+ return null;
+ }
+
+ const allData = [];
+ let missingChunks = 0;
+
+ for (let i = 0; i < meta.totalChunks; i++) {
+ const chunk = this.get(`${cacheName}_chunk_${i}`, userId);
+ if (chunk && chunk.data) {
+ allData.push(...chunk.data);
+ } else {
+ missingChunks++;
+ WARN(`Missing chunk ${i} for ${cacheName}`);
+ }
+ }
+
+ if (missingChunks > 0) {
+ WARN(`Missing ${missingChunks} chunks for ${cacheName}, returning null`);
+ return null;
+ }
+
+ if (allData.length !== meta.totalItems) {
+ WARN(`Data length mismatch for ${cacheName}: expected ${meta.totalItems}, got ${allData.length}`);
+ return null;
+ }
+
+ LOG(`Successfully loaded ${allData.length} items from ${meta.totalChunks} chunks for ${cacheName}`);
+ return allData;
+
+ } catch (error) {
+ ERR(`Error loading chunked data for ${cacheName}:`, error);
+ return null;
+ }
+ }
+
+ // Clear chunked cache
+ clearChunked(cacheName, userId = null) {
+ try {
+ const meta = this.get(`${cacheName}_meta`, userId);
+ if (meta) {
+ // Clear all chunks
+ for (let i = 0; i < meta.totalChunks; i++) {
+ this.clear(`${cacheName}_chunk_${i}`, userId);
+ }
+ // Clear metadata
+ this.clear(`${cacheName}_meta`, userId);
+ LOG(`Cleared chunked cache ${cacheName} (${meta.totalChunks} chunks)`);
+ } else {
+ LOG(`No chunked cache found for ${cacheName}`);
+ }
+ } catch (error) {
+ ERR(`Error clearing chunked cache ${cacheName}:`, error);
+ }
+ }
+
+ // Utility method to chunk arrays
+ chunkArray(array, size) {
+ const chunks = [];
+ for (let i = 0; i < array.length; i += size) {
+ chunks.push(array.slice(i, i + size));
+ }
+ return chunks;
+ }
+
+ // Check if a cache is chunked
+ isChunked(cacheName, userId = null) {
+ const meta = this.get(`${cacheName}_meta`, userId);
+ return meta && meta.totalChunks > 1;
+ }
+ }
+
+ // Create global instance
+ window.LocalStorageCache = LocalStorageCache;
+
+ LOG('LocalStorageCache manager loaded');
+
+})();
diff --git a/modal.js b/modal.js
new file mode 100644
index 0000000..26f7f99
--- /dev/null
+++ b/modal.js
@@ -0,0 +1,226 @@
+/**
+ * Generic Modal System for Jellyfin-style dialogs
+ * Provides reusable modal functionality similar to cardBuilder.js
+ */
+
+window.ModalSystem = (function() {
+ 'use strict';
+
+ // Store active modals
+ const activeModals = new Map();
+
+ /**
+ * Create a Jellyfin-style modal dialog
+ * @param {Object} options - Modal configuration
+ * @param {string} options.id - Unique modal ID
+ * @param {string} options.title - Modal title
+ * @param {string} options.content - HTML content for the modal body
+ * @param {Function} options.onClose - Callback when modal closes
+ * @param {Function} options.onOpen - Callback when modal opens
+ * @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true)
+ * @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true)
+ * @returns {Object} Modal instance
+ */
+ function createModal(options = {}) {
+ const {
+ id,
+ title,
+ content,
+ onClose,
+ onOpen,
+ closeOnBackdrop = true,
+ closeOnEscape = true
+ } = options;
+
+ if (!id) {
+ throw new Error('Modal ID is required');
+ }
+
+ // Remove existing modal if it exists
+ removeModal(id);
+
+ // Create modal elements
+ const backdrop = document.createElement('div');
+ backdrop.className = 'dialogBackdrop dialogBackdropOpened';
+ backdrop.setAttribute('data-modal-id', id);
+
+ const dialogContainer = document.createElement('div');
+ dialogContainer.className = 'dialogContainer';
+ dialogContainer.setAttribute('data-modal-id', id);
+
+ const dialog = document.createElement('div');
+ dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened';
+ dialog.setAttribute('data-history', 'true');
+ dialog.setAttribute('data-autofocus', 'true');
+ dialog.setAttribute('data-removeonclose', 'true');
+ dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
+
+ const dialogContent = document.createElement('div');
+ dialogContent.style.margin = '0';
+ dialogContent.style.padding = '1.25em 1.5em 1.5em';
+
+ // Add title if provided
+ if (title) {
+ const titleElement = document.createElement('h2');
+ titleElement.style.margin = '0 0 .5em';
+ titleElement.textContent = title;
+ dialogContent.appendChild(titleElement);
+ }
+
+ // Add content
+ if (content) {
+ if (typeof content === 'string') {
+ dialogContent.innerHTML += content;
+ } else if (content instanceof HTMLElement) {
+ dialogContent.appendChild(content);
+ }
+ }
+
+ // Assemble modal
+ dialog.appendChild(dialogContent);
+ dialogContainer.appendChild(dialog);
+
+ // Add to DOM
+ document.body.appendChild(backdrop);
+ document.body.appendChild(dialogContainer);
+
+ // Create modal instance
+ const modalInstance = {
+ id,
+ backdrop,
+ dialogContainer,
+ dialog,
+ dialogContent,
+ isOpen: true,
+ close: () => closeModal(id),
+ updateContent: (newContent) => updateModalContent(id, newContent),
+ addEventListener: (event, handler) => {
+ dialog.addEventListener(event, handler);
+ }
+ };
+
+ // Store modal instance
+ activeModals.set(id, modalInstance);
+
+ // Add event listeners
+ if (closeOnBackdrop) {
+ backdrop.addEventListener('click', () => closeModal(id));
+ dialogContainer.addEventListener('click', (e) => {
+ if (e.target === dialogContainer) {
+ closeModal(id);
+ }
+ });
+ }
+
+ if (closeOnEscape) {
+ const escapeHandler = (e) => {
+ if (e.key === 'Escape') {
+ closeModal(id);
+ }
+ };
+ document.addEventListener('keydown', escapeHandler);
+ modalInstance._escapeHandler = escapeHandler;
+ }
+
+ // Call onOpen callback
+ if (onOpen && typeof onOpen === 'function') {
+ onOpen(modalInstance);
+ }
+
+ return modalInstance;
+ }
+
+ /**
+ * Close a modal by ID
+ * @param {string} id - Modal ID
+ */
+ function closeModal(id) {
+ const modal = activeModals.get(id);
+ if (!modal) return;
+
+ // Remove escape handler if it exists
+ if (modal._escapeHandler) {
+ document.removeEventListener('keydown', modal._escapeHandler);
+ }
+
+ // Remove from DOM
+ if (modal.backdrop && modal.backdrop.parentNode) {
+ modal.backdrop.parentNode.removeChild(modal.backdrop);
+ }
+ if (modal.dialogContainer && modal.dialogContainer.parentNode) {
+ modal.dialogContainer.parentNode.removeChild(modal.dialogContainer);
+ }
+
+ // Remove from active modals
+ activeModals.delete(id);
+
+ // Call onClose callback if modal instance has it
+ if (modal.onClose && typeof modal.onClose === 'function') {
+ modal.onClose();
+ }
+ }
+
+ /**
+ * Remove a modal by ID (force removal)
+ * @param {string} id - Modal ID
+ */
+ function removeModal(id) {
+ const modal = activeModals.get(id);
+ if (modal) {
+ closeModal(id);
+ }
+ }
+
+ /**
+ * Update modal content
+ * @param {string} id - Modal ID
+ * @param {string|HTMLElement} content - New content
+ */
+ function updateModalContent(id, content) {
+ const modal = activeModals.get(id);
+ if (!modal) return;
+
+ if (typeof content === 'string') {
+ modal.dialogContent.innerHTML = content;
+ } else if (content instanceof HTMLElement) {
+ modal.dialogContent.innerHTML = '';
+ modal.dialogContent.appendChild(content);
+ }
+ }
+
+ /**
+ * Check if a modal is open
+ * @param {string} id - Modal ID
+ * @returns {boolean}
+ */
+ function isModalOpen(id) {
+ return activeModals.has(id);
+ }
+
+ /**
+ * Get all active modal IDs
+ * @returns {Array}
+ */
+ function getActiveModalIds() {
+ return Array.from(activeModals.keys());
+ }
+
+ /**
+ * Close all modals
+ */
+ function closeAllModals() {
+ const ids = Array.from(activeModals.keys());
+ ids.forEach(id => closeModal(id));
+ }
+
+ // Public API
+ return {
+ create: createModal,
+ close: closeModal,
+ remove: removeModal,
+ updateContent: updateModalContent,
+ isOpen: isModalOpen,
+ getActiveIds: getActiveModalIds,
+ closeAll: closeAllModals
+ };
+})();
diff --git a/pages/images/avsettings.png b/pages/images/avsettings.png
new file mode 100644
index 0000000..ceee6d5
Binary files /dev/null and b/pages/images/avsettings.png differ
diff --git a/pages/images/breadcrumbs.png b/pages/images/breadcrumbs.png
new file mode 100644
index 0000000..1372861
Binary files /dev/null and b/pages/images/breadcrumbs.png differ
diff --git a/pages/images/continuewatching.png b/pages/images/continuewatching.png
new file mode 100644
index 0000000..cd8a2f7
Binary files /dev/null and b/pages/images/continuewatching.png differ
diff --git a/pages/images/custommenu.png b/pages/images/custommenu.png
new file mode 100644
index 0000000..56d8849
Binary files /dev/null and b/pages/images/custommenu.png differ
diff --git a/pages/images/customtab.png b/pages/images/customtab.png
new file mode 100644
index 0000000..0eaa896
Binary files /dev/null and b/pages/images/customtab.png differ
diff --git a/pages/images/halloween.jpg b/pages/images/halloween.jpg
new file mode 100644
index 0000000..3ce78f0
Binary files /dev/null and b/pages/images/halloween.jpg differ
diff --git a/pages/images/history.png b/pages/images/history.png
new file mode 100644
index 0000000..34b2c48
Binary files /dev/null and b/pages/images/history.png differ
diff --git a/pages/images/homescreen.png b/pages/images/homescreen.png
new file mode 100644
index 0000000..fc4bc0f
Binary files /dev/null and b/pages/images/homescreen.png differ
diff --git a/pages/images/injector.png b/pages/images/injector.png
new file mode 100644
index 0000000..eb0d291
Binary files /dev/null and b/pages/images/injector.png differ
diff --git a/pages/images/introskip1.png b/pages/images/introskip1.png
new file mode 100644
index 0000000..dba234d
Binary files /dev/null and b/pages/images/introskip1.png differ
diff --git a/pages/images/introskip2.png b/pages/images/introskip2.png
new file mode 100644
index 0000000..8c896c9
Binary files /dev/null and b/pages/images/introskip2.png differ
diff --git a/pages/images/jfenhanced1.png b/pages/images/jfenhanced1.png
new file mode 100644
index 0000000..58982d9
Binary files /dev/null and b/pages/images/jfenhanced1.png differ
diff --git a/pages/images/jfenhanced2.png b/pages/images/jfenhanced2.png
new file mode 100644
index 0000000..b86dfb5
Binary files /dev/null and b/pages/images/jfenhanced2.png differ
diff --git a/pages/images/playlist.png b/pages/images/playlist.png
new file mode 100644
index 0000000..58ce8f4
Binary files /dev/null and b/pages/images/playlist.png differ
diff --git a/pages/images/progress.png b/pages/images/progress.png
new file mode 100644
index 0000000..0c776e3
Binary files /dev/null and b/pages/images/progress.png differ
diff --git a/pages/images/search.png b/pages/images/search.png
new file mode 100644
index 0000000..73c238d
Binary files /dev/null and b/pages/images/search.png differ
diff --git a/pages/images/statistics.png b/pages/images/statistics.png
new file mode 100644
index 0000000..b0fb6f3
Binary files /dev/null and b/pages/images/statistics.png differ
diff --git a/pages/images/subtitlesearch.png b/pages/images/subtitlesearch.png
new file mode 100644
index 0000000..7edcc00
Binary files /dev/null and b/pages/images/subtitlesearch.png differ
diff --git a/pages/images/videosettings.png b/pages/images/videosettings.png
new file mode 100644
index 0000000..4c2b939
Binary files /dev/null and b/pages/images/videosettings.png differ
diff --git a/pages/images/watchlist.png b/pages/images/watchlist.png
new file mode 100644
index 0000000..658d5e9
Binary files /dev/null and b/pages/images/watchlist.png differ
diff --git a/pages/images/watchlistoverlay.png b/pages/images/watchlistoverlay.png
new file mode 100644
index 0000000..290ec82
Binary files /dev/null and b/pages/images/watchlistoverlay.png differ
diff --git a/playlist.js b/playlist.js
new file mode 100644
index 0000000..0934c79
--- /dev/null
+++ b/playlist.js
@@ -0,0 +1,309 @@
+// 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
+
+(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);
+
+ LOG('Script loaded - JS Injector mode');
+
+ 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;
+ }
+
+ const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
+ if (!childrenItemsContainer) {
+ 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
+ */
+ function modifyPlaylistItemClicks() {
+ const libraryPage = document.querySelector('.libraryPage:not(.hide)');
+ if (!libraryPage) {
+ WARN('Library page not found');
+ return;
+ }
+
+ const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
+ if (!childrenItemsContainer) {
+ WARN('Children items container not found');
+ return;
+ }
+
+ const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
+ LOG(`Found ${playlistItems.length} playlist items`);
+
+ playlistItems.forEach((item, index) => {
+ const playlistItemId = item.getAttribute('data-playlistitemid');
+ const serverId = ApiClient.serverId();
+
+ if (!playlistItemId) {
+ WARN(`Playlist item ${index} has no data-playlistitemid`);
+ return;
+ }
+
+ // Remove existing click handlers
+ item.removeAttribute('data-action');
+ item.style.cursor = 'pointer';
+
+ // Add new click handler to navigate to details page
+ item.addEventListener('click', (e) => {
+ // Check if the click target is within the listViewUserDataButtons container
+ const buttonsContainer = item.querySelector('.listViewUserDataButtons');
+ if (buttonsContainer && buttonsContainer.contains(e.target)) {
+ LOG(`Click on button detected, not navigating for item: ${playlistItemId}`);
+ return; // Don't navigate when clicking on buttons
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const detailsUrl = `#/details?id=${playlistItemId}&serverId=${serverId}`;
+ LOG(`Navigating to: ${detailsUrl}`);
+
+ // Use Jellyfin's navigation method
+ if (window.Emby && window.Emby.Page) {
+ window.Emby.Page.show(detailsUrl);
+ } else {
+ // Fallback to direct navigation
+ window.location.hash = detailsUrl;
+ }
+ });
+
+ LOG(`Modified click handler for playlist item: ${playlistItemId}`);
+ });
+ }
+
+ /**
+ * Adds play button to each playlist item's listViewUserDataButtons container
+ */
+ function addPlayButtons() {
+ const libraryPage = document.querySelector('.libraryPage:not(.hide)');
+ if (!libraryPage) {
+ WARN('Library page not found for play buttons');
+ return;
+ }
+
+ const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
+ if (!childrenItemsContainer) {
+ WARN('Children items container not found');
+ return;
+ }
+
+ const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
+ LOG(`Found ${playlistItems.length} playlist items to add play buttons to`);
+
+ playlistItems.forEach((item, index) => {
+ const playlistItemId = item.getAttribute('data-playlistitemid');
+ const serverId = ApiClient.serverId();
+
+ if (!playlistItemId) {
+ WARN(`Playlist item ${index} has no data-playlistitemid`);
+ return;
+ }
+
+ const buttonsContainer = item.querySelector('.listViewUserDataButtons');
+ if (!buttonsContainer) {
+ WARN(`No listViewUserDataButtons container found for playlist item ${index}`);
+ return;
+ }
+
+ // Check if play button already exists for this item
+ const existingPlayButton = buttonsContainer.querySelector('.playlist-play-button');
+ if (existingPlayButton) {
+ LOG(`Play button already exists for item ${index}`);
+ return;
+ }
+
+ // Create play button following the same pattern as other buttons
+ // Play button
+ const playButton = document.createElement('button');
+ playButton.setAttribute('is', 'paper-icon-button-light');
+ playButton.className = 'itemAction paper-icon-button-light playBtn';
+ playButton.setAttribute('data-action', 'resume');
+
+ const playIcon = document.createElement('span');
+ playIcon.className = 'material-icons play_arrow';
+ playIcon.setAttribute('aria-hidden', 'true');
+ playButton.appendChild(playIcon);
+
+ // 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`);
+ }
+
+ /**
+ * Main function to modify the playlist page
+ * @param {string} itemId - The playlist item ID
+ */
+ async function modifyPlaylistPage(itemId) {
+ if (isPlaylistPageModified()) {
+ LOG('Playlist page already modified, skipping');
+ return;
+ }
+
+ LOG(`Modifying playlist page for item: ${itemId}`);
+ currentPlaylistId = itemId;
+
+ // Wait a bit for the page to fully load
+ setTimeout(() => {
+ modifyPlaylistItemClicks();
+ addPlayButtons();
+ LOG('Playlist page modifications complete');
+ }, 500);
+ }
+
+ /**
+ * 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
+ */
+ function initializePlaylistHook() {
+ if (!window.KefinTweaksUtils) {
+ WARN('KefinTweaksUtils not available, retrying in 1 second');
+ setTimeout(initializePlaylistHook, 1000);
+ return;
+ }
+
+ 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();
+ }
+ }, {
+ immediate: true,
+ pages: ['details']
+ });
+
+ LOG('Playlist hook initialized successfully');
+ }
+
+ // Initialize the hook when the script loads
+ initializePlaylistHook();
+
+ // Also check current page on script load in case we're already on a playlist 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(`Script loaded on details page with item ID: ${itemId}`);
+ isPlaylistPage(itemId).then(isPlaylist => {
+ if (isPlaylist) {
+ LOG(`Already on playlist page: ${itemId}`);
+ modifyPlaylistPage(itemId);
+ }
+ }).catch(error => {
+ ERR('Error checking current playlist page:', error);
+ });
+ }
+ }
+
+ console.log('[Playlist] Module loaded and ready');
+})();
diff --git a/removeContinue.js b/removeContinue.js
new file mode 100644
index 0000000..5c748be
--- /dev/null
+++ b/removeContinue.js
@@ -0,0 +1,295 @@
+// Remove From Continue Watching
+// Adds functionality to remove items from the continue watchlist list
+//
+// This script adds a "remove from continue watching" button to cards that have
+// data-positionticks attribute (indicating they are in progress)
+//
+// No external dependencies required - self-contained functionality
+
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[RemoveContinue]', ...args);
+ const WARN = (...args) => console.warn('[RemoveContinue]', ...args);
+ const ERROR = (...args) => console.error('[RemoveContinue]', ...args);
+
+ LOG('Remove continue watching functionality initialized');
+
+ // Add CSS for positioning the remove button in top right corner
+ const style = document.createElement('style');
+ style.textContent = `
+ .remove-continue-button {
+ position: absolute !important;
+ top: 8px !important;
+ right: 8px !important;
+ z-index: 10 !important;
+ background: rgba(0, 0, 0, 0.7) !important;
+ border: none !important;
+ border-radius: 50% !important;
+ width: 32px !important;
+ height: 32px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ cursor: pointer !important;
+ transition: all 0.2s ease !important;
+ opacity: 0 !important;
+ transform: scale(0.8) !important;
+ }
+
+ .cardOverlayContainer:hover .remove-continue-button {
+ opacity: 1 !important;
+ transform: scale(1) !important;
+ }
+
+ .remove-continue-button:hover {
+ background: rgba(220, 38, 38, 0.9) !important;
+ transform: scale(1.1) !important;
+ }
+
+ .remove-continue-button:disabled {
+ opacity: 0.6 !important;
+ cursor: not-allowed !important;
+ transform: scale(0.9) !important;
+ }
+
+ .remove-continue-button .material-icons {
+ color: white !important;
+ font-size: 18px !important;
+ }
+ `;
+ document.head.appendChild(style);
+
+ /************ Remove Continue Watching Button Observer ************/
+
+ // Function to add remove continue watching button to a card overlay container
+ function addRemoveContinueButton(overlayContainer) {
+ // Check if remove continue button already exists
+ if (overlayContainer && overlayContainer.querySelector('.remove-continue-button')) {
+ return;
+ }
+
+ // Find the card parent to get the item ID and check for data-positionticks
+ const card = overlayContainer.closest('.card');
+ if (!card) {
+ WARN('Could not find card parent for overlay container');
+ return;
+ }
+
+ // Only add button to cards with data-positionticks (in progress items)
+ const positionTicks = card.getAttribute('data-positionticks');
+ if (!positionTicks) {
+ return; // Not a continue watching item, skip
+ }
+
+ const itemId = card.getAttribute('data-id');
+ if (!itemId) {
+ WARN('Could not find data-id on card element');
+ return;
+ }
+
+ // Find the .cardOverlayButton-br container to position our button before it
+ const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
+ if (!buttonContainer) {
+ WARN('Could not find .cardOverlayButton-br container');
+ return;
+ }
+
+ // Create remove continue watching button
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.className = 'remove-continue-button';
+ removeButton.setAttribute('data-action', 'none');
+ removeButton.setAttribute('data-id', itemId);
+ removeButton.title = 'Remove from Continue Watching';
+
+ // Create the close icon
+ const removeIcon = document.createElement('span');
+ removeIcon.className = 'material-icons';
+ removeIcon.textContent = 'close';
+ removeIcon.setAttribute('aria-hidden', 'true');
+
+ removeButton.appendChild(removeIcon);
+
+ // Add click event listener
+ removeButton.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Show loading state
+ const originalIcon = removeIcon.textContent;
+ removeIcon.textContent = 'hourglass_empty';
+ removeButton.disabled = true;
+
+ try {
+ // Call API to remove from continue watching
+ await removeFromContinueWatching(itemId);
+
+ // Remove the card from the DOM
+ card.remove();
+
+ LOG(`Successfully removed item ${itemId} from continue watching`);
+ } catch (error) {
+ ERROR('Failed to remove from continue watching:', error);
+
+ // Restore original state on error
+ removeIcon.textContent = originalIcon;
+ removeButton.disabled = false;
+
+ // Show error message to user
+ alert('Failed to remove from continue watching. Please try again.');
+ }
+ });
+
+ // Add the button as a sibling before the .cardOverlayButton-br container
+ buttonContainer.parentNode.insertBefore(removeButton, buttonContainer);
+
+ LOG(`Added remove continue watching button for item ${itemId}`);
+ }
+
+ // Function to fetch current user data for an item
+ async function fetchUserData(itemId) {
+ const userId = ApiClient.getCurrentUserId();
+ const serverUrl = ApiClient.serverAddress();
+ const token = ApiClient.accessToken();
+
+ if (!userId || !serverUrl || !token) {
+ throw new Error('Missing required API credentials');
+ }
+
+ const userDataUrl = `${serverUrl}/UserItems/${itemId}/UserData`;
+
+ const response = await fetch(userDataUrl, {
+ method: 'GET',
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`,
+ "Content-Type": "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch user data: ${response.status} ${response.statusText}`);
+ }
+
+ return await response.json();
+ }
+
+ // Function to update user data to reset progress while preserving played status
+ async function updateUserDataProgress(itemId, userData) {
+ const userId = ApiClient.getCurrentUserId();
+ const serverUrl = ApiClient.serverAddress();
+ const token = ApiClient.accessToken();
+
+ if (!userId || !serverUrl || !token) {
+ throw new Error('Missing required API credentials');
+ }
+
+ // Create updated user data with progress reset
+ const updatedUserData = {
+ ...userData,
+ PlayedPercentage: 0,
+ PlaybackPositionTicks: 0
+ };
+
+ const userDataUrl = `${serverUrl}/UserItems/${itemId}/UserData`;
+
+ const response = await fetch(userDataUrl, {
+ method: 'POST',
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(updatedUserData)
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update user data: ${response.status} ${response.statusText}`);
+ }
+
+ return response;
+ }
+
+ // Function to call the API to remove item from continue watching
+ async function removeFromContinueWatching(itemId) {
+ try {
+ // First, fetch the current user data
+ const userData = await fetchUserData(itemId);
+
+ // Then update it to reset progress while preserving played status
+ await updateUserDataProgress(itemId, userData);
+
+ LOG(`Successfully reset progress for item ${itemId} while preserving played status`);
+ } catch (error) {
+ ERROR('Failed to reset progress:', error);
+ throw error;
+ }
+ }
+
+ // Function to process all existing overlay containers
+ function processExistingOverlayContainers() {
+ const overlayContainers = document.querySelectorAll('.cardOverlayContainer');
+
+ overlayContainers.forEach((overlayContainer) => {
+ const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
+ if (buttonContainer) {
+ addRemoveContinueButton(overlayContainer);
+ }
+ });
+ }
+
+ // Set up MutationObserver to watch for new overlay containers
+ function setupRemoveContinueButtonObserver() {
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.type === 'childList') {
+ // Check for added nodes
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ // Check if the added node is an overlay container
+ if (node.classList && node.classList.contains('cardOverlayContainer')) {
+ const buttonContainer = node.querySelector('.cardOverlayButton-br');
+ if (buttonContainer) {
+ addRemoveContinueButton(node);
+ }
+ }
+
+ // Check for overlay containers within the added node
+ const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer');
+ if (overlayContainers && overlayContainers.length > 0) {
+ overlayContainers.forEach((overlayContainer) => {
+ const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
+ if (buttonContainer) {
+ addRemoveContinueButton(overlayContainer);
+ }
+ });
+ }
+ }
+ });
+ }
+ });
+ });
+
+ // Start observing
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+
+ // Process any existing overlay containers
+ processExistingOverlayContainers();
+ }
+
+ // Initialize the observer
+ setupRemoveContinueButtonObserver();
+
+ // Debug function for troubleshooting (available in console)
+ window.debugRemoveContinueButtons = function() {
+ LOG('Manual debug trigger called');
+ LOG('Current overlay containers:', document.querySelectorAll('.cardOverlayContainer').length);
+ LOG('Current remove continue buttons:', document.querySelectorAll('.remove-continue-button').length);
+ LOG('Cards with data-positionticks:', document.querySelectorAll('.card[data-positionticks]').length);
+ processExistingOverlayContainers();
+ };
+
+ LOG('Remove continue watching functionality loaded successfully');
+})();
\ No newline at end of file
diff --git a/search.css b/search.css
index 3f0f193..c9d1d0f 100644
--- a/search.css
+++ b/search.css
@@ -28,4 +28,23 @@ div#searchPage {
div#searchPage {
padding-top: 5rem !important;
}
+}
+
+div#searchPage {
+ display: flex;
+ flex-direction: column;
+}
+
+div#searchPage>* {
+ order: 1;
+}
+
+div#searchPage>.jellyseerr-section {
+ order: 2;
+ padding-left: max(env(safe-area-inset-left), 3.3%);
+}
+
+div#smart-search-results {
+ padding-bottom: 0 !important;
+ padding-right: 0 !important;
}
\ No newline at end of file
diff --git a/search.js b/search.js
index 15c89f8..eda4447 100644
--- a/search.js
+++ b/search.js
@@ -5,28 +5,18 @@
const WARN = (...args) => console.warn('[SmartSearch]', ...args);
const ERR = (...args) => console.error('[SmartSearch]', ...args);
- // Configuration
- const CONFIG = {
- enableJellyseerr: false, // Toggle for Jellyseerr integration
- };
-
LOG('script load start');
- // Monitor network requests to see what's happening
- const originalFetch = window.fetch;
- const originalXHR = window.XMLHttpRequest;
-
- // Track if we should block XHR requests (only for the initial URL query)
+ // Configuration
+ 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 fetch to log our requests
- window.fetch = function(...args) {
- LOG('FETCH request:', args[0]);
- return originalFetch.apply(this, args);
- };
-
- // Override XHR to log and potentially block default search requests
+ // Override XHR to block automatic search requests from direct URL navigation
const OriginalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new OriginalXHR();
@@ -37,12 +27,12 @@
LOG('XHR request:', method, url);
// Check if this is a search request we want to block
- // Our smart search uses fetch(), so any XHR search request should be blocked
+ // 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='));
if (isSearchRequest && shouldBlockXHR && !hasBlockedInitialSearch) {
- LOG('Blocking initial default search XHR request:', url);
+ LOG('Blocking initial automatic search XHR request:', url);
hasBlockedInitialSearch = true;
// Disable blocking after this first request
shouldBlockXHR = false;
@@ -50,7 +40,7 @@
// Override send to do nothing
xhr.send = function() {
- LOG('Initial default search XHR blocked');
+ LOG('Initial automatic search XHR blocked');
// Simulate a successful response
setTimeout(() => {
xhr.readyState = 4;
@@ -61,16 +51,16 @@
}
}, 100);
};
+ } else {
+ // Use original send for non-blocked requests
+ xhr.send = function(...args) {
+ return originalSend.apply(this, args);
+ };
}
return originalOpen.apply(this, [method, url, ...args]);
};
- xhr.send = function(...args) {
- LOG('XHR sending:', args);
- return originalSend.apply(this, args);
- };
-
return xhr;
};
@@ -90,14 +80,13 @@
LOG('Current hash:', window.location.hash);
if (queryFromUrl && (window.location.pathname.includes('search.html') || window.location.hash.includes('search'))) {
- LOG('URL query detected, setting up protection');
+ LOG('URL query detected, enabling XHR blocking for automatic search prevention');
shouldBlockXHR = true; // Enable XHR blocking for initial URL query
-
- // Just enable XHR blocking - don't touch the input
- LOG('XHR blocking enabled for initial search');
}
})();
+
+
// config
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist'];
@@ -127,6 +116,30 @@
LOG('Cached results:', { searchTerm, searchType, cacheKey, resultCount: results.total });
}
+ // Function to clear search results except jellyseerr-section
+ function clearSearchResultsExceptJellyseerr() {
+ LOG('Clearing search results except jellyseerr-section');
+
+ // Clear the smart search results container
+ const resultsContainer = document.getElementById('smart-search-results');
+ if (resultsContainer) {
+ resultsContainer.innerHTML = '';
+ }
+ }
+
+ // Function to wait for jellyseerrAPI to be available
+ async function waitForJellyseerrAPI(maxAttempts = 50, interval = 200) {
+ for (let i = 0; i < maxAttempts; i++) {
+ if (window.JellyfinEnhanced?.jellyseerrAPI) {
+ LOG('jellyseerrAPI found after', i * interval, 'ms');
+ return true;
+ }
+ await new Promise(resolve => setTimeout(resolve, interval));
+ }
+ WARN('jellyseerrAPI not found after', maxAttempts * interval, 'ms');
+ return false;
+ }
+
// styles (dedupe by id)
function addCustomStyles() {
if (document.getElementById('smart-search-styles')) return;
@@ -137,9 +150,8 @@
.smart-search-wrapper { margin: 12px 0 20px 0; }
.smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; }
.smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; }
- .smart-search-primary, .smart-search-secondary { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; }
- .smart-search-primary { background:#00a4dc; color:#fff; }
- .smart-search-secondary { background:#444; color:#fff; }
+ .smart-search-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; }
+ .smart-search-btn.active { background:var(--btnSubmitColor); }
.smart-search-results { margin-top:12px; }
.smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; }
#persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; }
@@ -150,6 +162,7 @@
position: absolute;
z-index: 1;
right: 0;
+ cursor: pointer;
}
`;
document.head.appendChild(style);
@@ -214,57 +227,38 @@
}
}
- // Fetch Jellyseerr results
- async function fetchJellyseerrResults(searchTerm) {
- try {
- const jellyseerrUrl = `${ApiClient.serverAddress()}/JellyfinEnhanced/jellyseerr/search?query=${encodeURIComponent(searchTerm)}`;
- LOG('Fetching Jellyseerr results:', jellyseerrUrl);
-
- const response = await ApiClient.fetch({
- url: jellyseerrUrl,
- method: 'GET',
- headers: {
- 'Accept': 'application/json',
- 'X-Jellyfin-User-Id': ApiClient.getCurrentUserId()
- }
- });
- const data = await response.json();
-
- LOG('Jellyseerr response:', data);
- return data;
- } catch (err) {
- WARN('Jellyseerr fetch failed', err);
- return { movies: [], tv: [] };
- }
- }
-
- // Convert Jellyseerr item to our format
- function convertJellyseerrItem(item, type) {
- return {
- Id: `jellyseerr_${item.id}`,
- Name: item.title || item.name,
- Type: type === 'movie' ? 'Movie' : 'Series',
- MediaType: 'Video',
- ImageTags: item.posterPath ? { Primary: item.posterPath } : null,
- ProductionYear: item.releaseDate ? new Date(item.releaseDate).getFullYear() : null,
- Overview: item.overview,
- // Jellyseerr specific data
- JellyseerrId: item.id,
- JellyseerrType: type,
- ExternalId: item.tmdbId || item.tvdbId,
- IsJellyseerr: true
- };
- }
-
// perform smart search
async function performSmartSearch(searchTerm, searchType = 'core') {
LOG('performSmartSearch()', { searchTerm, searchType });
- const results = { groupedItems: {}, total: 0 };
+ let results = { groupedItems: {}, total: 0 };
+
+ const searchPage = document.getElementById('searchPage');
+ if (!searchPage) {
+ ERR('searchPage not found');
+ return results;
+ }
// Hide search suggestions
- const searchSuggestions = document.querySelector('.searchSuggestions');
+ const searchSuggestions = searchPage.querySelector('.searchSuggestions');
if (searchSuggestions) {
- searchSuggestions.style.display = 'none';
+ searchSuggestions.remove();
+ }
+
+ const noItemsMessage = searchPage.querySelector('.noItemsMessage.dummy-section');
+ if (!noItemsMessage) {
+ const noItemsMessage = document.createElement('div');
+ noItemsMessage.className = 'noItemsMessage dummy-section';
+ noItemsMessage.style.display = 'none';
+ searchPage.appendChild(noItemsMessage);
+ }
+
+ // Update URL with search query and type
+ const urlType = searchType === 'core' ? 'videos' : searchType;
+ updateSearchUrl(searchTerm, urlType);
+
+ // Handle Jellyseerr-only search
+ if (searchType === 'request') {
+ return results;
}
// Show loading spinner
@@ -296,19 +290,12 @@
});
});
- // Add Jellyseerr search for core types (if enabled)
- if (CONFIG.enableJellyseerr && (searchType === 'core' || searchType === 'all')) {
- promises.push(fetchJellyseerrResults(searchTerm));
- } else {
- promises.push(Promise.resolve({ movies: [], tv: [] }));
- }
-
const start = performance.now();
const responses = await Promise.all(promises);
const end = performance.now();
// Group results by type
- responses.forEach((r, idx) => {
+ responses.forEach(async (r, idx) => {
if (idx < typeGroups.length) {
// Regular Jellyfin results
const type = typeGroups[idx];
@@ -320,38 +307,17 @@
// Artists: show all items, they often don't have primary images
filteredItems = r.Items;
} else {
- // Other types: filter to show only those with images
- filteredItems = r.Items.filter(item => item.ImageTags?.Primary);
+ // Other types: sort so items with images appear first
+ filteredItems = r.Items.sort((a, b) => {
+ const aHasImage = a.ImageTags?.Primary ? 1 : 0;
+ const bHasImage = b.ImageTags?.Primary ? 1 : 0;
+ return bHasImage - aHasImage; // Items with images first
+ });
}
results.groupedItems[type] = filteredItems;
results.total += filteredItems.length;
}
- } else {
- // Jellyseerr results
- if (r && (r.movies || r.tv)) {
- // Process Jellyseerr movies
- if (r.movies && r.movies.length > 0) {
- const jellyseerrMovies = r.movies.map(item => convertJellyseerrItem(item, 'movie'));
- if (results.groupedItems['Movie']) {
- results.groupedItems['Movie'] = [...results.groupedItems['Movie'], ...jellyseerrMovies];
- } else {
- results.groupedItems['Movie'] = jellyseerrMovies;
- }
- results.total += jellyseerrMovies.length;
- }
-
- // Process Jellyseerr TV shows
- if (r.tv && r.tv.length > 0) {
- const jellyseerrTv = r.tv.map(item => convertJellyseerrItem(item, 'tv'));
- if (results.groupedItems['Series']) {
- results.groupedItems['Series'] = [...results.groupedItems['Series'], ...jellyseerrTv];
- } else {
- results.groupedItems['Series'] = jellyseerrTv;
- }
- results.total += jellyseerrTv.length;
- }
- }
}
});
@@ -360,10 +326,6 @@
// Cache the results
cacheResults(searchTerm, searchType, results);
- // Update URL with search query and type
- const urlType = searchType === 'core' ? 'videos' : searchType;
- updateSearchUrl(searchTerm, urlType);
-
// Hide loading spinner
setLoadingSpinner(false);
@@ -376,40 +338,6 @@
}
}
-
- function createSmartItemElement(item) {
- const serverId = ApiClient.serverId();
- const div = document.createElement('div');
- div.className = 'smart-search-item';
-
- const img = document.createElement('img');
- img.src = item.ImageTags?.Primary
- ? `${ApiClient.serverAddress()}/Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}&maxWidth=300`
- : '/web/assets/img/icon-transparent.png';
- img.alt = item.Name || 'Unknown';
- img.onerror = () => { img.src = '/web/assets/img/icon-transparent.png'; };
-
- const h = document.createElement('h3');
- h.textContent = item.Name || 'Unknown';
- h.title = h.textContent;
-
- const p = document.createElement('p');
- p.textContent = `${item.Type}${item.ProductionYear ? ` (${item.ProductionYear})` : ''}`;
- p.title = p.textContent;
-
- div.appendChild(img);
- div.appendChild(h);
- div.appendChild(p);
-
- div.addEventListener('click', () => {
- LOG('item click', { id: item.Id, name: item.Name });
- try { Dashboard.navigate(`#!/details?id=${item.Id}&serverId=${serverId}`); } catch(e){ ERR(e); }
- });
-
- return div;
- }
-
-
function getTypeDisplayName(itemType) {
const typeMap = {
'Movie': 'Movies',
@@ -438,7 +366,7 @@
LOG('Creating smart-search-results container');
smartResults = document.createElement('div');
smartResults.id = 'smart-search-results';
- smartResults.className = 'smart-search-results emby-scroller';
+ smartResults.className = 'smart-search-results emby-scroller searchResults, padded-top, padded-bottom-page';
const searchPage = document.getElementById('searchPage');
if (searchPage) {
searchPage.appendChild(smartResults);
@@ -489,7 +417,7 @@
if (results.groupedItems[itemType] && results.groupedItems[itemType].length > 0) {
const items = results.groupedItems[itemType];
const title = getTypeDisplayName(itemType);
- const section = window.cardBuilder.renderCards(items, title, index);
+ const section = window.cardBuilder.renderCards(items, title, null, index);
frag.appendChild(section);
totalShown += items.length;
}
@@ -500,11 +428,11 @@
}
// main init
- function initDualSearch() {
- if (typeof ApiClient === 'undefined') { setTimeout(initDualSearch, 500); return; }
+ function initSmartSearch() {
+ if (typeof ApiClient === 'undefined') { setTimeout(initSmartSearch, 500); return; }
const originalInput = document.getElementById('searchTextInput');
- if (!originalInput) { setTimeout(initDualSearch, 500); return; }
+ if (!originalInput) { setTimeout(initSmartSearch, 500); return; }
addCustomStyles();
@@ -517,53 +445,65 @@
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
}
}
- LOG('initDualSearch - URL query:', queryFromUrl);
- LOG('initDualSearch - originalInput exists:', !!originalInput);
+ LOG('initSmartSearch - URL query:', queryFromUrl);
+ LOG('initSmartSearch - originalInput exists:', !!originalInput);
if (originalInput) {
- LOG('initDualSearch - originalInput value:', originalInput.value);
+ LOG('initSmartSearch - originalInput value:', originalInput.value);
}
if (queryFromUrl) {
- LOG('initDualSearch - URL query detected, leaving input alone');
+ LOG('initSmartSearch - URL query detected, leaving input alone');
}
- // create Smart search UI if not exist
+ // Replace original search input with our smart search input
let wrapper = document.getElementById('smart-search-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.id = 'smart-search-wrapper';
wrapper.className = 'smart-search-wrapper';
- const input = document.createElement('input');
- input.id = 'smart-search-input';
+ // Replace the original input with our smart search input
+ const input = originalInput.cloneNode(true);
+ input.id = 'searchTextInput'; // Use the same ID as original
input.className = 'smart-search-input emby-input searchfields-txtSearch';
input.type = 'text';
- input.placeholder = 'Smart Search...';
+ input.placeholder = 'Search...';
+ input.value = ''; // Clear any existing value
+ input.removeAttribute('data-jellyseerr-listener');
const btnRow = document.createElement('div');
btnRow.className = 'smart-search-buttons';
const btnAll = document.createElement('button');
btnAll.id = 'smart-search-all';
- btnAll.className = 'smart-search-secondary emby-button';
+ btnAll.className = 'smart-search-btn emby-button';
btnAll.textContent = 'All';
const btnCore = document.createElement('button');
btnCore.id = 'smart-search-core';
- btnCore.className = 'smart-search-primary emby-button';
+ btnCore.className = 'smart-search-btn emby-button active';
btnCore.textContent = 'Movies/TV';
const btnMusic = document.createElement('button');
btnMusic.id = 'smart-search-music';
- btnMusic.className = 'smart-search-secondary emby-button';
+ btnMusic.className = 'smart-search-btn emby-button';
btnMusic.textContent = 'Music';
const btnBooks = document.createElement('button');
btnBooks.id = 'smart-search-books';
- btnBooks.className = 'smart-search-secondary emby-button';
+ btnBooks.className = 'smart-search-btn emby-button';
btnBooks.textContent = 'Books';
btnRow.appendChild(btnAll);
btnRow.appendChild(btnCore);
btnRow.appendChild(btnMusic);
btnRow.appendChild(btnBooks);
+
+ if (CONFIG.enableJellyseerr) {
+ const btnRequest = document.createElement('button');
+ btnRequest.id = 'smart-search-request';
+ btnRequest.className = 'smart-search-btn emby-button';
+ btnRequest.textContent = 'Request';
+
+ btnRow.appendChild(btnRequest);
+ }
const stats = document.createElement('div');
stats.id = 'smart-search-stats';
@@ -573,16 +513,20 @@
wrapper.appendChild(btnRow);
wrapper.appendChild(stats);
+ // Replace the original input with our wrapper
originalInput.parentElement.insertBefore(wrapper, originalInput);
+ originalInput.remove(); // Remove the original input
+ input.focus();
}
// Smart results container will be created lazily when needed
- const smartInput = document.getElementById('smart-search-input');
+ const smartInput = document.getElementById('searchTextInput');
const smartCoreBtn = document.getElementById('smart-search-core');
const smartMusicBtn = document.getElementById('smart-search-music');
const smartBooksBtn = document.getElementById('smart-search-books');
const smartAllBtn = document.getElementById('smart-search-all');
+ const smartRequestBtn = document.getElementById('smart-search-request');
// Track current search type
let currentSearchType = 'videos';
@@ -590,31 +534,27 @@
// Function to update button states
function updateButtonStates(activeType) {
// Remove active class from all buttons
- smartAllBtn.classList.remove('smart-search-primary');
- smartAllBtn.classList.add('smart-search-secondary');
- smartCoreBtn.classList.remove('smart-search-primary');
- smartCoreBtn.classList.add('smart-search-secondary');
- smartMusicBtn.classList.remove('smart-search-primary');
- smartMusicBtn.classList.add('smart-search-secondary');
- smartBooksBtn.classList.remove('smart-search-primary');
- smartBooksBtn.classList.add('smart-search-secondary');
+ smartAllBtn.classList.remove('active');
+ smartCoreBtn.classList.remove('active');
+ smartMusicBtn.classList.remove('active');
+ smartBooksBtn.classList.remove('active');
+ smartRequestBtn.classList.remove('active');
// Add active class to the selected button
if (activeType === 'all') {
- smartAllBtn.classList.remove('smart-search-secondary');
- smartAllBtn.classList.add('smart-search-primary');
+ smartAllBtn.classList.add('active');
} else if (activeType === 'videos') {
- smartCoreBtn.classList.remove('smart-search-secondary');
- smartCoreBtn.classList.add('smart-search-primary');
+ smartCoreBtn.classList.add('active');
} else if (activeType === 'music') {
- smartMusicBtn.classList.remove('smart-search-secondary');
- smartMusicBtn.classList.add('smart-search-primary');
+ smartMusicBtn.classList.add('active');
} else if (activeType === 'books') {
- smartBooksBtn.classList.remove('smart-search-secondary');
- smartBooksBtn.classList.add('smart-search-primary');
+ smartBooksBtn.classList.add('active');
+ } else if (activeType === 'request') {
+ smartRequestBtn.classList.add('active');
}
}
+
// Function to handle search type toggle
function setSearchType(type) {
LOG('setSearchType called:', { type, currentSearchType });
@@ -629,6 +569,15 @@
const internalType = type === 'videos' ? 'core' : type;
LOG('Internal type mapping:', { type, internalType });
+ // For request type, don't use cache and always perform fresh search
+ if (type === 'request') {
+ LOG('Request type - clearing results and performing fresh Jellyseerr search');
+ // Clear existing search results except jellyseerr-section
+ clearSearchResultsExceptJellyseerr();
+ performSmartSearch(searchTerm, 'request');
+ return;
+ }
+
// Check if we have cached results for this search term and type
const cachedResults = getCachedResults(searchTerm, internalType);
if (cachedResults) {
@@ -664,11 +613,18 @@
};
smartInput.addEventListener('input', doSmartSearchDebounced);
- smartInput.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ e.preventDefault(); const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType; performSmartSearch((smartInput.value||'').trim(), internalType); }});
+ smartInput.addEventListener('keydown', (e)=>{
+ if(e.key==='Enter'){
+ e.preventDefault();
+ const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
+ performSmartSearch((smartInput.value||'').trim(), internalType);
+ }
+ });
smartCoreBtn.addEventListener('click', ()=>setSearchType('videos'));
smartMusicBtn.addEventListener('click', ()=>setSearchType('music'));
smartBooksBtn.addEventListener('click', ()=>setSearchType('books'));
smartAllBtn.addEventListener('click', ()=>setSearchType('all'));
+ smartRequestBtn.addEventListener('click', ()=>setSearchType('request'));
// Parse type parameter from URL
let typeFromUrl = 'videos'; // default
@@ -713,33 +669,26 @@
LOG('setMode', smartMode);
if (smartMode) {
wrapper.style.display = '';
- originalInput.style.display = 'none';
- // Hide all search results containers
- const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
+ // Hide search results containers (be more specific to avoid affecting other pages)
+ const searchResults = document.querySelectorAll('#searchPage .searchResults, #searchPage .padded-top, #searchPage .padded-bottom-page');
searchResults.forEach(sr => sr.style.display = 'none');
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = '';
toggleBtn.textContent='Switch to Default Search';
- // Keep XHR blocking enabled for smart mode
- shouldBlockXHR = true;
// Add smart-search-mode class to body for CSS targeting
document.body.classList.add('smart-search-mode');
} else {
wrapper.style.display = 'none';
- originalInput.style.display = '';
- // Show all search results containers
- const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
+ // Show search results containers (be more specific to avoid affecting other pages)
+ const searchResults = document.querySelectorAll('#searchPage .searchResults, #searchPage .padded-top, #searchPage .padded-bottom-page');
searchResults.forEach(sr => sr.style.display = '');
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = 'none';
toggleBtn.textContent='Switch to Smart Search';
- // Disable XHR blocking for default search mode
- shouldBlockXHR = false;
- LOG('Disabled XHR blocking for default search mode');
// Remove smart-search-mode class from body
document.body.classList.remove('smart-search-mode');
- // Input is left alone - let it keep its value
+ // Note: We don't restore the original input since we replaced it entirely
}
};
@@ -768,86 +717,50 @@
}
LOG('URL query in init:', queryFromUrl);
- if (queryFromUrl) {
- LOG('Query detected in init, enabling XHR blocking');
- shouldBlockXHR = true;
- // Also try to prevent search form submission
- const searchForm = document.querySelector('form[action*="search"]');
- LOG('Search form found:', !!searchForm);
- if (searchForm) {
- searchForm.addEventListener('submit', (e) => {
- LOG('Search form submission prevented');
- e.preventDefault();
- e.stopPropagation();
- return false;
- });
- }
-
- // Try to hide search results containers immediately
- const hideSearchResults = () => {
- const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
- LOG('Found search results containers:', searchResults.length);
- searchResults.forEach(sr => {
- sr.style.display = 'none';
- LOG('Hidden search result container');
- });
- };
-
- // Hide immediately and on DOM changes
- hideSearchResults();
- document.addEventListener('DOMContentLoaded', hideSearchResults);
-
- // Use mutation observer to hide any new search results
- const hideObserver = new MutationObserver(() => {
- hideSearchResults();
- });
- hideObserver.observe(document.body, { childList: true, subtree: true });
-
- // Stop observing after 10 seconds
+ LOG('Calling initSmartSearch');
+ initSmartSearch();
+ }
+ }
+
+ // Initialize search hook using utils
+ function initializeSearchHook() {
+ if (!window.KefinTweaksUtils) {
+ WARN('KefinTweaksUtils not available, retrying in 1 second');
+ setTimeout(initializeSearchHook, 1000);
+ return;
+ }
+
+ LOG('Registering search page handler with KefinTweaksUtils');
+
+ // Register handler for search page
+ window.KefinTweaksUtils.onViewPage((view, element) => {
+ LOG('Search page detected via utils');
+ // Small delay to ensure DOM is ready
setTimeout(() => {
- LOG('Stopping hide observer after 10 seconds');
- hideObserver.disconnect();
- }, 10000);
- }
-
- LOG('Calling initDualSearch');
- initDualSearch();
- }
- }
-
- // Mutation observer to detect when search page content is loaded dynamically
- function setupMutationObserver() {
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.type === 'childList') {
- // Check if search input appears in the DOM
- const searchInput = document.getElementById('searchTextInput');
- if (searchInput && !document.getElementById('smart-search-wrapper')) {
- LOG('Search input detected via mutation observer');
- initDualSearch();
- }
+ const searchInput = document.getElementById('searchTextInput');
+ if (searchInput && !document.getElementById('smart-search-wrapper')) {
+ LOG('Search input found, initializing smart search');
+ initSmartSearch();
}
- });
+ }, 100);
+ }, {
+ immediate: true,
+ pages: ['search']
});
-
- // Observe the entire document for changes
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
-
- LOG('Mutation observer setup complete');
+
+ LOG('Search hook initialized successfully');
}
+ // Initialize the hook when the script loads
+ initializeSearchHook();
+
if (document.readyState==='loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
- setupMutationObserver();
});
} else {
init();
- setupMutationObserver();
}
LOG('script load end');
})();
diff --git a/subtitleSearch.css b/subtitleSearch.css
new file mode 100644
index 0000000..d100b37
--- /dev/null
+++ b/subtitleSearch.css
@@ -0,0 +1,19 @@
+div#subtitleSearchResults {
+ overflow-y: scroll;
+ height: auto !important;
+ max-height: unset;
+ display: grid;
+ grid-auto-rows: min-content;
+}
+
+div#subtitleSearchResults button.btnDownload {
+ margin-right: 20px;
+}
+
+#videoOsdPage:not(.hide) button.btnSubtitles.hide {
+ display: block !important;
+}
+
+.dialogContainer [data-id="secondarysubtitle"] {
+ display: none;
+}
\ No newline at end of file
diff --git a/subtitleSearch.js b/subtitleSearch.js
new file mode 100644
index 0000000..decd3b1
--- /dev/null
+++ b/subtitleSearch.js
@@ -0,0 +1,1263 @@
+// Jellyfin Subtitle Search Script
+// Adds subtitle search functionality to the video OSD
+// Allows users to search and download subtitles from remote sources
+
+(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);
+
+ LOG('Script loaded - Subtitle Search functionality');
+
+ // State management
+ let isInitialized = false;
+ let isAddingCCButton = false;
+ let subtitleDialogObserver = null;
+
+ // Configuration
+ const CONFIG = {
+ searchButtonText: 'Search Subtitles',
+ searchIcon: 'search',
+ downloadIcon: 'file_download',
+ ccIcon: 'closed_caption'
+ };
+
+
+ /**
+ * Check if ApiClient is available
+ */
+ function isApiClientAvailable() {
+ return typeof ApiClient !== 'undefined' && ApiClient && typeof ApiClient.getCurrentUser === 'function';
+ }
+
+ /**
+ * Check if user has subtitle management permissions
+ */
+ async function checkSubtitlePermissions() {
+ if (!isApiClientAvailable()) {
+ WARN('ApiClient not available, cannot check subtitle permissions');
+ return false;
+ }
+
+ try {
+ const user = await ApiClient.getCurrentUser();
+ if (!user || !user.Policy) {
+ WARN('User or Policy not available');
+ return false;
+ }
+
+ const hasPermission = user.Policy.EnableSubtitleManagement;
+ LOG('Subtitle management permission:', hasPermission);
+ return hasPermission;
+ } catch (error) {
+ ERR('Error checking subtitle permissions:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Get the current item ID from the OSD
+ */
+ function getCurrentItemId() {
+ const ratingButton = document.querySelector('#videoOsdPage:not(.hide) .btnUserRating');
+ if (!ratingButton) {
+ WARN('Rating button not found, cannot get item ID');
+ return null;
+ }
+
+ const itemId = ratingButton.getAttribute('data-id');
+ LOG('Current item ID:', itemId);
+ return itemId;
+ }
+
+ /**
+ * Get the preferred subtitle language
+ */
+ async function getPreferredLanguage() {
+ if (!isApiClientAvailable()) {
+ WARN('ApiClient not available, using default language: eng');
+ return 'eng';
+ }
+
+ try {
+ const user = await ApiClient.getCurrentUser();
+ if (!user || !user.Configuration) {
+ return 'eng';
+ }
+
+ // Try subtitle language preference first
+ if (user.Configuration.SubtitleLanguagePreference) {
+ LOG('Using subtitle language preference:', user.Configuration.SubtitleLanguagePreference);
+ return user.Configuration.SubtitleLanguagePreference;
+ }
+
+ // Fall back to audio language preference
+ if (user.Configuration.AudioLanguagePreference) {
+ LOG('Using audio language preference:', user.Configuration.AudioLanguagePreference);
+ return user.Configuration.AudioLanguagePreference;
+ }
+
+ // Default to English
+ LOG('Using default language: eng');
+ return 'eng';
+ } catch (error) {
+ ERR('Error getting preferred language:', error);
+ return 'eng';
+ }
+ }
+
+ /**
+ * Check if CC button already exists
+ */
+ function ccButtonExists() {
+ const existingButton = document.querySelector('#videoOsdPage:not(.hide) .btnSubtitles');
+ return existingButton !== null;
+ }
+
+ /**
+ * Create the CC button element
+ */
+ function createCCButton() {
+ const button = document.createElement('button');
+ button.setAttribute('is', 'paper-icon-button-light');
+ button.className = 'btnSubtitles autoSize paper-icon-button-light';
+ button.title = 'Subtitles';
+
+ const icon = document.createElement('span');
+ icon.className = 'xlargePaperIconButton material-icons closed_caption';
+ icon.setAttribute('aria-hidden', 'true');
+
+ button.appendChild(icon);
+ return button;
+ }
+
+ /**
+ * Add CC button to OSD controls
+ */
+ function addCCButton() {
+ if (isAddingCCButton) {
+ LOG('Already in process of adding CC button, skipping');
+ return;
+ }
+
+ if (ccButtonExists()) {
+ LOG('CC button already exists, skipping');
+ return;
+ }
+
+ isAddingCCButton = true;
+
+ try {
+ const buttonsContainer = document.querySelector('#videoOsdPage:not(.hide) .osdControls > .buttons');
+ if (!buttonsContainer) {
+ WARN('OSD buttons container not found');
+ return;
+ }
+
+ const ratingButton = buttonsContainer.querySelector('.btnUserRating');
+ if (!ratingButton) {
+ WARN('Rating button not found in buttons container');
+ return;
+ }
+
+ const ccButton = createCCButton();
+
+ // Insert CC button before the rating button
+ buttonsContainer.insertBefore(ccButton, ratingButton);
+
+ // Add click handler to open subtitle dialog
+ ccButton.addEventListener('click', () => {
+ LOG('CC button clicked, opening subtitle dialog');
+ openSubtitleDialog();
+ });
+
+ LOG('CC button added successfully');
+ } catch (error) {
+ ERR('Error adding CC button:', error);
+ } finally {
+ isAddingCCButton = false;
+ }
+ }
+
+ /**
+ * Create subtitle dialog with search functionality
+ */
+ function createSubtitleDialog() {
+ const dialog = document.createElement('div');
+ dialog.className = 'dialogContainer';
+
+ dialog.innerHTML = `
+
+ `;
+
+ return dialog;
+ }
+
+ /**
+ * Open subtitle dialog
+ */
+ function openSubtitleDialog() {
+ // Remove any existing subtitle dialogs
+ const existingDialogs = document.querySelectorAll('.dialogContainer');
+ existingDialogs.forEach(dialog => {
+ if (dialog.querySelector('.actionSheetTitle')?.textContent === 'Subtitles') {
+ dialog.remove();
+ }
+ });
+
+ const dialog = createSubtitleDialog();
+ document.body.appendChild(dialog);
+
+ // Add search button click handler
+ const searchButton = dialog.querySelector('#subtitleSearchButton');
+ if (searchButton) {
+ searchButton.addEventListener('click', () => {
+ LOG('Search button clicked');
+ searchSubtitles();
+ });
+ }
+
+ // Add click handler to close dialog when clicking outside
+ dialog.addEventListener('click', (e) => {
+ if (e.target === dialog) {
+ dialog.remove();
+ }
+ });
+
+ LOG('Subtitle dialog opened');
+ }
+
+ /**
+ * Create subtitle search results dialog
+ */
+ function createSearchResultsDialog() {
+ const dialog = document.createElement('div');
+ dialog.className = 'dialogContainer';
+ dialog.id = 'subtitleSearchResultsDialog';
+ let serverName = ApiClient.serverName() ?? 'Jellyfin';
+
+ dialog.innerHTML = `
+
+
+
If you have ${serverName} open in multiple tabs the subtitle will be downloaded, but the player will not switch to the subtitle and it will appear as if the download is hanging.
+
Subtitle Search Results
+
+
+
+ `;
+
+ return dialog;
+ }
+
+ /**
+ * Search for subtitles
+ */
+ async function searchSubtitles() {
+ const itemId = getCurrentItemId();
+ if (!itemId) {
+ ERR('Cannot search subtitles without item ID');
+ return;
+ }
+
+ const language = await getPreferredLanguage();
+
+ // Remove any existing search results dialog
+ const existingResultsDialog = document.querySelector('#subtitleSearchResultsDialog');
+ if (existingResultsDialog) {
+ existingResultsDialog.remove();
+ }
+
+ // Create and show search results dialog
+ const resultsDialog = createSearchResultsDialog();
+ document.body.appendChild(resultsDialog);
+
+ const resultsContainer = resultsDialog.querySelector('#subtitleSearchResults');
+
+ // Add click handler to close dialog when clicking outside
+ resultsDialog.addEventListener('click', (e) => {
+ if (e.target === resultsDialog) {
+ resultsDialog.remove();
+ }
+ });
+
+ try {
+ LOG('Searching subtitles for item:', itemId, 'language:', language);
+
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available for authorization');
+ }
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+
+ const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${language}`, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Authorization': `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const subtitles = await response.json();
+ LOG('Found subtitles:', subtitles.length);
+
+ displaySubtitleResults(subtitles, resultsContainer, itemId);
+
+ } catch (error) {
+ ERR('Error searching subtitles:', error);
+ resultsContainer.innerHTML = 'Error searching subtitles
';
+ }
+ }
+
+ /**
+ * Display subtitle search results
+ */
+ function displaySubtitleResults(subtitles, container, itemId) {
+ if (subtitles.length === 0) {
+ container.innerHTML = '';
+ return;
+ }
+
+ const resultsHTML = subtitles.map(subtitle => {
+ const matchType = subtitle.IsHashMatch ? 'Perfect match' : 'Partial match';
+ const downloads = subtitle.DownloadCount?.toLocaleString() || '0';
+
+ return `
+
+
+
+
${subtitle.Name}
+
+ Format: ${subtitle.Format}
+ ${downloads} downloads
+ Framerate: ${subtitle.FrameRate}
+
+
+ ${matchType}
+
+
+
+
+
+
+ `;
+ }).join('');
+
+ container.innerHTML = resultsHTML;
+
+ // Add download button handlers
+ container.querySelectorAll('.btnDownload').forEach(button => {
+ button.addEventListener('click', (e) => {
+ const subId = e.currentTarget.getAttribute('data-subid');
+ const subtitleName = e.currentTarget.closest('.listItem').querySelector('.listItemBody>div:first-child').textContent;
+
+ // Hide dialog immediately and show progress
+ hideDialogAndShowProgress();
+
+ downloadSubtitle(itemId, subId, subtitleName);
+ });
+ });
+ }
+
+ /**
+ * Get existing subtitle streams from item
+ */
+ async function getExistingSubtitleStreams(itemId) {
+ try {
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available');
+ }
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+
+ const response = await fetch(`${serverUrl}/Items/${itemId}`, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Authorization': `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const itemData = await response.json();
+ const existingSubripStreams = [];
+
+ if (itemData.MediaStreams) {
+ itemData.MediaStreams.forEach((stream, index) => {
+ if (stream.Type === 'Subtitle' && stream.Codec === 'subrip') {
+ existingSubripStreams.push({
+ index: index,
+ codec: stream.Codec,
+ language: stream.Language,
+ displayTitle: stream.DisplayTitle
+ });
+ }
+ });
+ }
+
+ LOG('Existing subrip streams:', existingSubripStreams);
+ return existingSubripStreams;
+
+ } catch (error) {
+ ERR('Error getting existing subtitle streams:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Poll for newly added subtitle stream with retry mechanism
+ */
+ async function pollForNewSubtitleStream(itemId, existingStreams) {
+ const maxAttempts = 20;
+ const pollInterval = 5000; // 3 seconds
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ LOG(`Polling attempt ${attempt}/${maxAttempts} for new subtitle stream`);
+
+ try {
+ const newStreamIndex = await findNewSubtitleStream(itemId, existingStreams);
+
+ if (newStreamIndex !== null) {
+ LOG(`New subtitle stream found on attempt ${attempt}`);
+ return newStreamIndex;
+ }
+
+ // If not the last attempt, wait before trying again
+ if (attempt < maxAttempts) {
+ LOG(`No new subtitle found, waiting ${pollInterval}ms before next attempt`);
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
+ }
+
+ } catch (error) {
+ ERR(`Error on polling attempt ${attempt}:`, error);
+
+ // If not the last attempt, wait before trying again
+ if (attempt < maxAttempts) {
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
+ }
+ }
+ }
+
+ LOG('Max polling attempts reached, no new subtitle stream found');
+ return null;
+ }
+
+ /**
+ * Find newly added subtitle stream
+ */
+ async function findNewSubtitleStream(itemId, existingStreams) {
+ try {
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available');
+ }
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+
+ const response = await fetch(`${serverUrl}/Items/${itemId}`, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Authorization': `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const itemData = await response.json();
+ const currentSubripStreams = [];
+
+ if (itemData.MediaStreams) {
+ itemData.MediaStreams.forEach((stream, index) => {
+ if (stream.Type === 'Subtitle' && stream.Codec === 'subrip') {
+ currentSubripStreams.push({
+ index: index,
+ codec: stream.Codec,
+ language: stream.Language,
+ displayTitle: stream.DisplayTitle
+ });
+ }
+ });
+ }
+
+ LOG('Current subrip streams:', currentSubripStreams);
+
+ // Find the new stream by comparing with existing ones
+ const newStream = currentSubripStreams.find(currentStream => {
+ return !existingStreams.some(existingStream =>
+ existingStream.language === currentStream.language &&
+ existingStream.displayTitle === currentStream.displayTitle
+ );
+ });
+
+ if (newStream) {
+ LOG('Found new subtitle stream:', newStream);
+ return newStream.index;
+ }
+
+ LOG('No new subtitle stream found');
+ return null;
+
+ } catch (error) {
+ ERR('Error finding new subtitle stream:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Get session data by matching PlayState.MediaSourceId
+ */
+ async function getSession() {
+ try {
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available');
+ }
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+
+ // Get current item ID
+ const itemId = getCurrentItemId();
+ if (!itemId) {
+ throw new Error('No current item ID found');
+ }
+
+ // Get item details to find MediaSourceId
+ const itemResponse = await fetch(`${serverUrl}/Items/${itemId}`, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Authorization': `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!itemResponse.ok) {
+ throw new Error(`HTTP ${itemResponse.status}: ${itemResponse.statusText}`);
+ }
+
+ const itemData = await itemResponse.json();
+ const mediaSourceId = itemData.MediaSources?.[0]?.Id;
+
+ if (!mediaSourceId) {
+ throw new Error('No MediaSourceId found for current item');
+ }
+
+ 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;
+ }
+ }
+
+ /**
+ * Send SetSubtitleStreamIndex command as fallback
+ */
+ async function setSubtitleStreamIndex(sessionId, subtitleStreamIndex) {
+ try {
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available');
+ }
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+ const userId = ApiClient.getCurrentUserId();
+
+ LOG('Sending SetSubtitleStreamIndex command:', {
+ sessionId,
+ subtitleStreamIndex,
+ userId
+ });
+
+ const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Command`, {
+ method: 'POST',
+ headers: {
+ 'X-Emby-Token': token,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ Name: "SetSubtitleStreamIndex",
+ ControllingUserId: userId,
+ Arguments: { Index: subtitleStreamIndex }
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const result = await response.text();
+ LOG('SetSubtitleStreamIndex command result:', result);
+
+ return true;
+ } catch (error) {
+ ERR('Error sending SetSubtitleStreamIndex command:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Trigger custom playback progress event (to be called by external progress monitoring)
+ */
+ function triggerPlaybackProgressEvent(progressData) {
+ const event = new CustomEvent('playbackprogress', {
+ detail: progressData
+ });
+ document.dispatchEvent(event);
+ }
+
+ /**
+ * Listen for progress updates to verify subtitle stream change
+ */
+ function listenForSubtitleStreamChange(sessionId, expectedSubtitleIndex, timeout = 10000) {
+ return new Promise((resolve) => {
+ let timeoutId;
+ let progressListener;
+ let checkInterval;
+
+ const cleanup = () => {
+ if (timeoutId) clearTimeout(timeoutId);
+ if (progressListener) {
+ document.removeEventListener('playbackprogress', progressListener);
+ }
+ if (checkInterval) {
+ clearInterval(checkInterval);
+ }
+ };
+
+ // Set timeout
+ timeoutId = setTimeout(() => {
+ LOG('Timeout waiting for subtitle stream verification');
+ cleanup();
+ resolve(false);
+ }, timeout);
+
+ // Listen for progress updates
+ progressListener = (event) => {
+ try {
+ const progressData = event.detail;
+ LOG('Progress update received:', progressData);
+
+ if (progressData && progressData.SubtitleStreamIndex !== undefined) {
+ const currentSubtitleIndex = progressData.SubtitleStreamIndex;
+ LOG('Current subtitle stream index:', currentSubtitleIndex, 'Expected:', expectedSubtitleIndex);
+
+ if (currentSubtitleIndex === expectedSubtitleIndex) {
+ LOG('Subtitle stream verification successful');
+ cleanup();
+ resolve(true);
+ }
+ }
+ } catch (error) {
+ ERR('Error processing progress update:', error);
+ }
+ };
+
+ // Listen for custom progress events
+ document.addEventListener('playbackprogress', progressListener);
+
+ // Also check current session state periodically as backup
+ checkInterval = setInterval(async () => {
+ try {
+ const session = await getSession();
+ if (session && session.PlayState && session.PlayState.SubtitleStreamIndex === expectedSubtitleIndex) {
+ LOG('Subtitle stream verified via session check');
+ cleanup();
+ resolve(true);
+ }
+ } catch (error) {
+ ERR('Error checking session state:', error);
+ }
+ }, 1000);
+ });
+ }
+
+ /**
+ * Update playback progress with new subtitle stream using PlayNow command
+ */
+ async function updatePlaybackProgress(subtitleStreamIndex, subtitleName) {
+ try {
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available');
+ }
+
+ LOG('Changing subtitle stream to index:', subtitleStreamIndex);
+
+ // Get session data
+ const session = await getSession();
+ const sessionId = session.Id;
+ const playState = session.PlayState;
+
+ if (!playState) {
+ throw new Error('No play state found in session');
+ }
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+
+ // Get current item ID
+ const itemId = getCurrentItemId();
+ if (!itemId) {
+ throw new Error('No current item ID found');
+ }
+
+ // Prepare PlayNow command parameters
+ const params = new URLSearchParams({
+ playCommand: "PlayNow",
+ itemIds: itemId,
+ startPositionTicks: playState.PositionTicks?.toString() || "0",
+ subtitleStreamIndex: subtitleStreamIndex.toString(),
+ audioStreamIndex: playState.AudioStreamIndex?.toString() || "0"
+ });
+
+ LOG('PlayNow command parameters:', {
+ sessionId,
+ itemId,
+ startPositionTicks: playState.PositionTicks,
+ subtitleStreamIndex,
+ audioStreamIndex: playState.AudioStreamIndex
+ });
+
+ // Use PlayNow command to restart playback with new subtitle
+ const commandResponse = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
+ method: 'POST',
+ headers: {
+ 'X-Emby-Token': token
+ }
+ });
+
+ if (!commandResponse.ok) {
+ throw new Error(`HTTP ${commandResponse.status}: ${commandResponse.statusText}`);
+ }
+
+ const commandResult = await commandResponse.text();
+ LOG('PlayNow command result:', commandResult);
+
+ // Wait a moment for the playback to restart
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // Verify that the subtitle stream was actually changed
+ LOG('Verifying subtitle stream change...');
+ const verificationSuccess = await listenForSubtitleStreamChange(sessionId, subtitleStreamIndex, 10000);
+
+ if (verificationSuccess) {
+ LOG('Subtitle stream changed successfully using PlayNow command');
+ } else {
+ LOG('PlayNow command did not change subtitle stream, trying SetSubtitleStreamIndex fallback');
+
+ // Try the direct command as fallback
+ const fallbackSuccess = await setSubtitleStreamIndex(sessionId, subtitleStreamIndex);
+
+ if (fallbackSuccess) {
+ LOG('Subtitle stream changed successfully using SetSubtitleStreamIndex fallback');
+ } else {
+ throw new Error('Both PlayNow and SetSubtitleStreamIndex commands failed');
+ }
+ }
+
+ } catch (error) {
+ ERR('Error changing subtitle stream:', error);
+ throw error;
+ }
+ }
+
+
+
+
+
+ /**
+ * Hide search results dialog and show download progress in top-right
+ */
+ function hideDialogAndShowProgress() {
+ // Hide the search results dialog
+ const resultsDialog = document.querySelector('#subtitleSearchResultsDialog');
+ if (resultsDialog) {
+ resultsDialog.style.display = 'none';
+ }
+
+ // Show download progress indicator
+ showDownloadProgress();
+ }
+
+ /**
+ * Show download progress UI in top-right corner
+ */
+ 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);
+ }
+
+ document.body.appendChild(progressToast);
+ }
+
+ /**
+ * Show download success message
+ */
+ 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);
+ }
+
+ document.body.appendChild(successToast);
+
+ // Auto-remove after 3 seconds
+ setTimeout(() => {
+ if (successToast.parentNode) {
+ successToast.remove();
+ }
+ }, 3000);
+ }
+
+ /**
+ * Show download error message
+ */
+ 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);
+ }
+
+ // Auto-remove after 4 seconds
+ setTimeout(() => {
+ if (errorToast.parentNode) {
+ errorToast.remove();
+ }
+ }, 4000);
+ }
+
+ /**
+ * Hide download progress UI
+ */
+ function hideDownloadProgress() {
+ const progressToast = document.querySelector('#subtitleDownloadProgress');
+ if (progressToast) {
+ progressToast.remove();
+ }
+ }
+
+ /**
+ * Download subtitle
+ */
+ async function downloadSubtitle(itemId, subId, subtitleName) {
+ try {
+ LOG('Downloading subtitle:', subId);
+
+ if (!isApiClientAvailable()) {
+ throw new Error('ApiClient not available for authorization');
+ }
+
+ // Get existing subtitle streams before download
+ const existingStreams = await getExistingSubtitleStreams(itemId);
+
+ const token = ApiClient.accessToken();
+ const serverUrl = ApiClient.serverAddress();
+
+ const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${subId}`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ LOG('Subtitle downloaded successfully');
+
+ // Poll for new subtitle stream with retry mechanism
+ const newSubtitleIndex = await pollForNewSubtitleStream(itemId, existingStreams);
+
+ if (newSubtitleIndex !== null) {
+ LOG('New subtitle stream found at index:', newSubtitleIndex);
+
+ // Update playback progress to use the new subtitle
+ await updatePlaybackProgress(newSubtitleIndex, subtitleName);
+
+ // Hide progress and show success
+ hideDownloadProgress();
+ showDownloadSuccess();
+
+ // Remove the search results dialog on success
+ const resultsDialog = document.querySelector('#subtitleSearchResultsDialog');
+ if (resultsDialog) {
+ resultsDialog.remove();
+ }
+ } else {
+ LOG('Could not determine new subtitle stream index after polling');
+
+ // Hide progress and show error
+ hideDownloadProgress();
+ showDownloadError('Could not detect new subtitle');
+
+ // Show the dialog again on error
+ const resultsDialog = document.querySelector('#subtitleSearchResultsDialog');
+ if (resultsDialog) {
+ resultsDialog.style.display = 'block';
+ }
+ }
+
+ // Show success message (optional)
+ // You could add a toast notification here
+
+ } catch (error) {
+ ERR('Error downloading subtitle:', error);
+
+ // Hide progress and show error
+ hideDownloadProgress();
+ showDownloadError('Failed to download subtitle');
+
+ // Show the dialog again on error
+ const resultsDialog = document.querySelector('#subtitleSearchResultsDialog');
+ if (resultsDialog) {
+ resultsDialog.style.display = 'block';
+ }
+ }
+ }
+
+ /**
+ * Add search button to existing subtitle dialogs
+ */
+ function addSearchButtonToExistingDialogs() {
+ const subtitleDialogs = document.querySelectorAll('.dialogContainer:not(.hide)');
+
+ subtitleDialogs.forEach(dialog => {
+ const title = dialog.querySelector('.actionSheetTitle');
+ if (title && title.innerHTML === 'Subtitles') {
+ // Check if search button already exists
+ if (dialog.querySelector('#subtitleSearchButton')) {
+ return;
+ }
+
+ const scroller = dialog.querySelector('.actionSheetScroller');
+ if (!scroller) {
+ return;
+ }
+
+ // Create search button
+ const searchButton = document.createElement('button');
+ searchButton.setAttribute('is', 'emby-button');
+ searchButton.type = 'button';
+ searchButton.className = 'listItem listItem-button actionSheetMenuItem emby-button';
+ searchButton.id = 'subtitleSearchButton';
+
+ searchButton.innerHTML = `
+
+
+
${CONFIG.searchButtonText}
+
+ `;
+
+ // Insert before the title
+ title.parentNode.insertBefore(searchButton, title);
+
+ // Add CSS offset to the dialog container
+ const dialogContainer = dialog.querySelector('.focuscontainer.dialog');
+ if (dialogContainer) {
+ const currentTop = dialogContainer.style.top;
+ if (currentTop) {
+ // Parse existing top value and add 33px
+ const currentTopValue = parseInt(currentTop.replace('px', ''));
+ dialogContainer.style.top = `${currentTopValue - 33}px`;
+ } else {
+ // If no existing top value, set to 33px
+ dialogContainer.style.top = '-33px';
+ }
+ }
+
+ // Add click handler
+ searchButton.addEventListener('click', () => {
+ LOG('Search button clicked in existing dialog');
+ searchSubtitles();
+ });
+
+
+ LOG('Search button added to existing subtitle dialog');
+ }
+ });
+ }
+
+
+ /**
+ * Initialize subtitle search functionality
+ */
+ async function initialize() {
+ if (isInitialized) {
+ LOG('Already initialized, skipping');
+ return;
+ }
+
+ try {
+ // Check if ApiClient is available first
+ if (!isApiClientAvailable()) {
+ LOG('ApiClient not available, skipping initialization');
+ return;
+ }
+
+ // Check permissions first
+ const hasPermission = await checkSubtitlePermissions();
+ if (!hasPermission) {
+ LOG('User does not have subtitle management permission, skipping initialization');
+ return;
+ }
+
+ LOG('User has subtitle management permission, initializing...');
+
+ // Add CC button if it doesn't exist
+ addCCButton();
+
+ // Set up observer for subtitle dialogs
+ if (subtitleDialogObserver) {
+ subtitleDialogObserver.disconnect();
+ }
+
+ subtitleDialogObserver = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.type === 'childList') {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ // Check if a subtitle dialog was added
+ if (node.classList && node.classList.contains('dialogContainer')) {
+ setTimeout(() => addSearchButtonToExistingDialogs(), 100);
+ }
+ }
+ });
+ }
+ });
+ });
+
+ subtitleDialogObserver.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+
+ isInitialized = true;
+ LOG('Subtitle search functionality initialized successfully');
+
+ } catch (error) {
+ ERR('Error initializing subtitle search:', error);
+ }
+ }
+
+ /**
+ * Clean up resources
+ */
+ function cleanup() {
+ if (subtitleDialogObserver) {
+ subtitleDialogObserver.disconnect();
+ subtitleDialogObserver = null;
+ }
+ isInitialized = false;
+ LOG('Subtitle search functionality cleaned up');
+ }
+
+ // Initialize when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initialize);
+ } else {
+ initialize();
+ }
+
+ // Re-initialize when navigating (for SPA behavior)
+ let lastUrl = location.href;
+ new MutationObserver(() => {
+ const url = location.href;
+ if (url !== lastUrl) {
+ lastUrl = url;
+ setTimeout(() => {
+ cleanup();
+ initialize();
+ }, 1000);
+ }
+ }).observe(document, { subtree: true, childList: true });
+
+ // Expose functions globally for debugging
+ window.subtitleSearchCleanup = cleanup;
+ window.getSession = getSession;
+ window.setSubtitleStreamIndex = setSubtitleStreamIndex;
+ window.listenForSubtitleStreamChange = listenForSubtitleStreamChange;
+ window.triggerPlaybackProgressEvent = triggerPlaybackProgressEvent;
+
+})();
diff --git a/utils.js b/utils.js
new file mode 100644
index 0000000..c3a90cb
--- /dev/null
+++ b/utils.js
@@ -0,0 +1,305 @@
+// KefinTweaks Utilities
+// Provides standardized functionality for hooking into Emby.Page.onViewShow
+// while maintaining original Jellyfin functionality
+
+(function() {
+ 'use strict';
+
+ // Common logging function
+ const LOG = (...args) => console.log('[KefinTweaks Utils]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks Utils]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks Utils]', ...args);
+
+ LOG('Utils script loaded');
+
+ // Store the original onViewShow function
+ let originalOnViewShow = null;
+
+ // Initialize the utils by hooking into Emby.Page.onViewShow
+ function initialize() {
+ // Store the original onViewShow if it exists
+ if (window.Emby && window.Emby.Page && window.Emby.Page.onViewShow) {
+ originalOnViewShow = window.Emby.Page.onViewShow;
+ LOG('Stored original Emby.Page.onViewShow');
+ }
+
+ // Override onViewShow to maintain original functionality
+ if (window.Emby && window.Emby.Page) {
+ window.Emby.Page.onViewShow = function (...args) {
+ // Call original handler if it exists
+ if (originalOnViewShow) {
+ try {
+ originalOnViewShow.apply(this, ...args);
+ } catch (err) {
+ ERR('Error in original onViewShow handler:', err);
+ }
+ }
+
+ // Call our registered handlers
+ notifyHandlers(args[0], args[1]);
+ };
+
+ LOG('Hooked into Emby.Page.onViewShow');
+ } else {
+ WARN('Emby.Page.onViewShow not found - utils may not work correctly');
+ }
+ }
+
+ // Array to store registered handlers
+ const handlers = [];
+
+ /**
+ * Register a callback to be called when page view changes
+ * @param {Function} callback - Function to call when page view changes
+ * @param {Object} options - Options for the handler
+ */
+ function onViewPage(callback, options = {}) {
+ if (typeof callback !== 'function') {
+ ERR('Callback must be a function');
+ return;
+ }
+
+ const handlerConfig = {
+ callback,
+ options: {
+ immediate: false, // Call immediately if on matching page
+ pages: [], // Specific pages to watch (empty = all pages)
+ ...options
+ }
+ };
+
+ handlers.push(handlerConfig);
+ LOG(`Registered onViewPage handler (total: ${handlers.length})`);
+
+ // Call immediately if requested and we're on a matching page
+ if (handlerConfig.options.immediate) {
+ const currentView = getCurrentView();
+ if (currentView && shouldCallHandler(handlerConfig, currentView)) {
+ try {
+ callback(currentView, document);
+ } catch (err) {
+ ERR('Error in immediate handler call:', err);
+ }
+ }
+ }
+
+ // Return a function to unregister this handler
+ return () => {
+ const index = handlers.indexOf(handlerConfig);
+ if (index !== -1) {
+ handlers.splice(index, 1);
+ LOG(`Unregistered onViewPage handler (remaining: ${handlers.length})`);
+ }
+ };
+ }
+
+ /**
+ * Notify all registered handlers of a view change
+ * @param {string} view - The view name
+ * @param {Element} element - The view element
+ */
+ function notifyHandlers(view, element) {
+ handlers.forEach((config) => {
+ if (shouldCallHandler(config, view)) {
+ try {
+ config.callback(view, element);
+ } catch (err) {
+ ERR('Error in onViewPage handler:', err);
+ }
+ }
+ });
+ }
+
+ /**
+ * Check if a handler should be called for a given view
+ * @param {Object} config - Handler configuration
+ * @param {string} view - The view name
+ * @returns {boolean} Whether the handler should be called
+ */
+ function shouldCallHandler(config, view) {
+ const { pages } = config.options;
+
+ // If no specific pages are specified, call for all pages
+ if (pages.length === 0) return true;
+
+ // Get current URL hash to determine actual page
+ const currentUrl = window.location.hash;
+
+ // Check if the current URL matches any of the specified pages
+ return pages.some(page => {
+ if (typeof page === 'string') {
+ // Check if URL hash contains the page pattern
+ return currentUrl.includes(`/${page}.html`) || currentUrl.includes(`/${page}`) || currentUrl.includes(page);
+ } else if (page instanceof RegExp) {
+ return page.test(currentUrl);
+ }
+ return false;
+ });
+ }
+
+ /**
+ * Get the current view name
+ * @returns {string|null} Current view name or null if not available
+ */
+ function getCurrentView() {
+ // Try to get view from URL hash (primary method)
+ const hash = window.location.hash;
+ if (hash) {
+ // Extract page name from hash patterns like #/home.html, #/search.html, etc.
+ const pageMatch = hash.match(/#\/([^\/\?]+)\.html/);
+ if (pageMatch) {
+ return pageMatch[1];
+ }
+
+ // Fallback: look for /view/ pattern
+ const viewMatch = hash.match(/\/view\/([^\/\?]+)/);
+ if (viewMatch) {
+ return viewMatch[1];
+ }
+ }
+
+ // Try to get view from active page element (fallback)
+ const activePage = document.querySelector('.page:not(.hide)');
+ if (activePage) {
+ return activePage.getAttribute('data-page') || activePage.className.split(' ').find(cls => cls.includes('Page'));
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the number of registered handlers
+ * @returns {number} Number of registered handlers
+ */
+ function getHandlerCount() {
+ return handlers.length;
+ }
+
+ /**
+ * Clear all handlers
+ */
+ function clearHandlers() {
+ handlers.length = 0;
+ LOG('Cleared all onViewPage handlers');
+ }
+
+ // Initialize when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initialize);
+ } else {
+ initialize();
+ }
+
+ /**
+ * Add a custom menu link to the custom menu options
+ * @param {string} name - Display name for the menu item
+ * @param {string} icon - Material icon name
+ * @param {string} url - URL to navigate to
+ * @param {boolean} openInNewTab - Whether to open in new tab (default: false)
+ * @returns {Promise} - True if successfully added, false otherwise
+ */
+ function addCustomMenuLink(name, icon, url, openInNewTab = false) {
+ return new Promise((resolve) => {
+ // Check if menu container already exists
+ const existingContainer = document.querySelector('.customMenuOptions');
+ if (existingContainer) {
+ const success = addLinkToContainer(existingContainer, name, icon, url, openInNewTab);
+ resolve(success);
+ return;
+ }
+
+ LOG(`Custom menu container not found, waiting for it to appear for link: ${name}`);
+
+ // Use MutationObserver to watch for the menu container
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ mutation.addedNodes.forEach(node => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ // Check if the added node is the menu or contains it
+ if (node.matches?.('.customMenuOptions') || node.querySelector?.('.customMenuOptions')) {
+ const container = node.matches?.('.customMenuOptions') ? node : node.querySelector('.customMenuOptions');
+ LOG(`Custom menu container detected, adding link: ${name}`);
+ observer.disconnect();
+
+ const success = addLinkToContainer(container, name, icon, url, openInNewTab);
+ resolve(success);
+ return;
+ }
+ }
+ });
+ }
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+
+ // Fallback timeout after 10 seconds
+ setTimeout(() => {
+ observer.disconnect();
+ WARN(`Menu not found after 10 seconds for link: ${name}`);
+ resolve(false);
+ }, 10000);
+ });
+ }
+
+ /**
+ * Add the link to the existing container
+ * @param {Element} container - The custom menu options container
+ * @param {string} name - Display name for the menu item
+ * @param {string} icon - Material icon name
+ * @param {string} url - URL to navigate to
+ * @param {boolean} openInNewTab - Whether to open in new tab
+ * @returns {boolean} - True if successfully added
+ */
+ function addLinkToContainer(container, name, icon, url, openInNewTab) {
+ try {
+ // Create the menu link element
+ const link = document.createElement('a');
+ link.setAttribute('is', 'emby-linkbutton');
+ link.className = 'emby-button navMenuOption lnkMediaFolder';
+ link.href = url;
+
+ if (openInNewTab) {
+ link.setAttribute('rel', 'noopener noreferrer');
+ link.setAttribute('target', '_blank');
+ }
+
+ // Create the icon span
+ const iconSpan = document.createElement('span');
+ iconSpan.className = `material-icons navMenuOptionIcon ${icon}`;
+ iconSpan.setAttribute('aria-hidden', 'true');
+
+ // Create the text span
+ const textSpan = document.createElement('span');
+ textSpan.className = 'navMenuOptionText';
+ textSpan.textContent = name;
+
+ // Append icon and text to link
+ link.appendChild(iconSpan);
+ link.appendChild(textSpan);
+
+ // Append link to container
+ container.appendChild(link);
+
+ LOG(`Successfully added custom menu link: ${name}`);
+ return true;
+ } catch (err) {
+ ERR(`Error adding custom menu link ${name}:`, err);
+ return false;
+ }
+ }
+
+ // Expose utilities to global scope
+ window.KefinTweaksUtils = {
+ onViewPage,
+ getCurrentView,
+ getHandlerCount,
+ clearHandlers,
+ addCustomMenuLink
+ };
+
+ LOG('KefinTweaks Utils loaded and available at window.KefinTweaksUtils');
+
+})();
diff --git a/watchlist.css b/watchlist.css
index dceb78c..1709776 100644
--- a/watchlist.css
+++ b/watchlist.css
@@ -1,3 +1,1806 @@
.watchlist-icon[data-active='true'] .detailButton-icon:before {
font-variation-settings: 'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 20;
-}
\ No newline at end of file
+}
+.custom-scroller-container h2.sectionTitle.sectionTitle-cards {
+ display: flex;
+ align-items: center;
+}
+
+/* Watchlist Tab Styling */
+.sections.watchlist {
+ padding-bottom: 20px;
+}
+
+.sections.watchlist .watchlist-tabs {
+ display: flex;
+ gap: 8px;
+ padding: 0 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ justify-content: center;
+}
+
+.sections.watchlist .watchlist-tabs button {
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.7);
+ padding: 12px 16px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s ease;
+ position: relative;
+}
+
+.sections.watchlist .watchlist-tabs button:hover {
+ color: rgba(255, 255, 255, 0.9);
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.sections.watchlist .watchlist-tabs button.active {
+ color: #00a4dc;
+ border-bottom-color: #00a4dc;
+ background: rgba(0, 164, 220, 0.1);
+}
+
+/* Tab content styling - hide content by default, show only active */
+.sections.watchlist div[data-tab]:not(.watchlist-tabs) {
+ display: none;
+ position: relative;
+}
+
+.sections.watchlist div[data-tab]:not(.watchlist-tabs).active {
+ display: block;
+}
+
+.watchlist > div:not([data-tab="watchlist"]) > div {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+/* Progress Tab Styling */
+.progress-series {
+ padding: 0 16px;
+}
+
+.progress-card {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ margin-bottom: 16px;
+ overflow: hidden;
+ transition: background-color 0.2s ease;
+}
+
+.progress-card:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.progress-card-completed {
+ border-left: 4px solid #4caf50;
+ background: rgba(76, 175, 80, 0.05);
+}
+
+.progress-card-completed:hover {
+ background: rgba(76, 175, 80, 0.08);
+}
+
+.progress-card-content {
+ display: flex;
+ padding: 16px;
+ gap: 16px;
+}
+
+.progress-poster {
+ flex-shrink: 0;
+ width: 140px;
+ height: 200px;
+ border-radius: 6px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.progress-poster .poster-link {
+ display: block;
+ width: 100%;
+ height: 100%;
+ text-decoration: none;
+ transition: opacity 0.2s ease;
+}
+
+.progress-poster .poster-link:hover {
+ opacity: 0.8;
+}
+
+.progress-poster img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.progress-details {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.progress-tab-header, .movie-history-header {
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.progress-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.progress-title .title-link {
+ color: #fff;
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.progress-title .title-link:hover {
+ color: #00a4dc;
+ text-decoration: underline;
+}
+
+.completion-badge {
+ background: #4caf50;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 500;
+ padding: 4px 8px;
+ border-radius: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.progress-navigation {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.nav-button {
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-button:hover {
+ color: rgba(255, 255, 255, 0.9);
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.nav-button .material-icons {
+ font-size: 20px;
+}
+
+.progress-percentage {
+ font-size: 24px;
+ font-weight: 600;
+ color: #00a4dc;
+ min-width: 60px;
+ text-align: right;
+}
+
+.progress-bar-container {
+ margin: 8px 0;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 8px;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #00a4dc 0%, #0078d4 100%);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+.progress-bar-completed .progress-bar-fill {
+ background: linear-gradient(90deg, #4caf50 0%, #2e7d32 100%);
+}
+
+.progress-stats {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 14px;
+ line-height: 1.5;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.progress-header-stats-container, .movie-history-stats-container {
+ display: flex;
+ gap: 10px;
+}
+
+.progress-header-stats {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.7);
+ background: rgba(255, 255, 255, 0.1);
+ padding: 6px 12px;
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ display: flex;
+ align-items: center;
+}
+
+.progress-stats-separator {
+ color: rgba(255, 255, 255, 0.5);
+ font-weight: 300;
+}
+
+.progress-stats strong {
+ color: #fff;
+ font-weight: 600;
+}
+
+.progress-last-watched {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.progress-last-watched strong {
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 500;
+}
+
+.progress-actions {
+ display: flex;
+ gap: 16px;
+ margin-top: 8px;
+}
+
+.action-link {
+ background: none;
+ border: none;
+ color: #00a4dc;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0;
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.action-link:hover {
+ color: #0078d4;
+ text-decoration: underline;
+}
+
+/* Empty State Messages */
+.watchlist-empty-message,
+.progress-empty-message,
+.movie-history-empty-message {
+ text-align: center;
+ padding: 60px 20px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.empty-message-icon {
+ margin-bottom: 20px;
+ opacity: 0.6;
+}
+
+.empty-message-icon .material-icons {
+ font-size: 64px;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.empty-message-title {
+ color: #fff;
+ font-size: 20px;
+ font-weight: 500;
+ margin: 0 0 12px 0;
+}
+
+.empty-message-subtitle {
+ margin: 0;
+ font-size: 16px;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+/* Pagination Controls */
+.pagination {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px 16px;
+ margin-top: 20px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ grid-column: 1 / -1;
+}
+
+.pagination-top {
+ margin-top: 0;
+ margin-bottom: 10px;
+ padding-top: 0;
+ border-top: none;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.pagination-bottom {
+ margin-top: 10px;
+ margin-bottom: 0;
+}
+
+.pagination-info {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.pagination-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.pagination-btn {
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: rgba(255, 255, 255, 0.8);
+ padding: 8px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 40px;
+ height: 40px;
+}
+
+.pagination-btn:hover:not(.disabled) {
+ background: rgba(255, 255, 255, 0.2);
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.pagination-btn.active {
+ background: #00a4dc;
+ color: #fff;
+ border-color: #00a4dc;
+}
+
+.pagination-btn.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pagination-pages {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin: 0 8px;
+}
+
+.pagination-ellipsis {
+ color: rgba(255, 255, 255, 0.5);
+ padding: 0 8px;
+ font-size: 14px;
+}
+
+.pagination-btn .material-icons {
+ font-size: 18px;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .progress-card-content {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .progress-poster {
+ width: 250px;
+ height: 46.25px;
+ align-self: center;
+ max-width: 95%;
+ }
+
+ .progress-poster img {
+ object-fit: contain;
+ }
+
+ .progress-tab-header {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ }
+
+ .progress-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .progress-title {
+ font-size: 20px;
+ }
+
+ .progress-percentage {
+ font-size: 20px;
+ text-align: left;
+ }
+
+ .progress-navigation {
+ align-self: flex-end;
+ }
+
+ .progress-pagination {
+ flex-direction: column;
+ gap: 16px;
+ text-align: center;
+ }
+
+ .pagination-controls {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .pagination-pages {
+ margin: 0 4px;
+ }
+
+ .pagination-pages>*:not(.pagination-btn.active) {
+ display: none;
+ }
+
+ .progress-tab-header h2 {
+ display: none;
+ }
+}
+
+/* Progress Statistics Container */
+.progress-stats-container {
+ margin: 20px auto !important;
+ padding: 0 20px;
+}
+
+.progress-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.stat-card {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 12px;
+ padding: 20px;
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ transition: all 0.3s ease;
+ backdrop-filter: blur(10px);
+}
+
+.stat-card:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.25);
+ transform: translateY(-2px);
+}
+
+.stat-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 50px;
+ height: 50px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.stat-icon .material-icons {
+ font-size: 24px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.stat-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.stat-value {
+ font-size: 28px;
+ font-weight: 700;
+ color: #fff;
+ line-height: 1;
+ margin-bottom: 4px;
+}
+
+.stat-label {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.7);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.top-shows-container {
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 12px;
+ padding: 20px;
+ backdrop-filter: blur(10px);
+}
+
+.top-shows-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 15px;
+ font-size: 16px;
+ font-weight: 600;
+ color: #fff;
+}
+
+.top-shows-header .material-icons {
+ font-size: 20px;
+ color: #ffd700;
+}
+
+.top-shows-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.top-show-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 15px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ transition: all 0.2s ease;
+}
+
+.top-show-item:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.15);
+}
+
+.top-show-item:first-child {
+ background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05));
+ border-color: rgba(255, 215, 0, 0.3);
+}
+
+.top-show-item:first-child .show-rank {
+ color: #ffd700;
+ font-weight: 700;
+}
+
+.top-show-name {
+ flex: 1;
+ font-size: 14px;
+ font-weight: 500;
+ color: #fff;
+ margin-right: 10px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.top-show-episodes {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.6);
+ font-weight: 500;
+ background: rgba(255, 255, 255, 0.1);
+ padding: 4px 8px;
+ border-radius: 12px;
+ white-space: nowrap;
+}
+
+.show-rank {
+ font-size: 16px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ margin-right: 12px;
+ min-width: 20px;
+ text-align: center;
+}
+
+/* Responsive Design for Stats */
+@media (max-width: 768px) {
+ .progress-stats-grid {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ }
+
+ .stat-card {
+ padding: 15px;
+ gap: 12px;
+ }
+
+ .stat-icon {
+ width: 40px;
+ height: 40px;
+ }
+
+ .stat-icon .material-icons {
+ font-size: 20px;
+ }
+
+ .stat-value {
+ font-size: 24px;
+ }
+
+ .stat-label {
+ font-size: 12px;
+ }
+
+ .top-shows-container {
+ padding: 15px;
+ }
+
+ .top-show-item {
+ padding: 10px 12px;
+ }
+
+ .top-show-name {
+ font-size: 13px;
+ }
+
+ .top-show-episodes {
+ font-size: 11px;
+ padding: 3px 6px;
+ }
+}
+
+/* Movie History Styles */
+.movie-history-container {
+ padding: 0 20px;
+ margin-bottom: 0 auto;
+ max-width: 1200px;
+}
+
+
+/* Paginated Container */
+.paginated-container {
+ padding: 0 20px;
+ margin: 0 auto;
+ max-width: 1200px;
+}
+
+/* Pagination positioning */
+.pagination-top {
+ margin-bottom: 20px;
+}
+
+.pagination-bottom {
+ margin-top: 20px;
+}
+
+.pagination-controls>button:first-child, .pagination-controls>button:last-child {
+ display: none;
+}
+
+.movie-history-stats {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.7);
+ background: rgba(255, 255, 255, 0.1);
+ padding: 6px 12px;
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ display: flex;
+ align-items: center;
+}
+
+.movie-history-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.movie-card {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 12px;
+ overflow: hidden;
+ transition: all 0.3s ease;
+ backdrop-filter: blur(10px);
+ position: relative;
+ display: grid;
+ grid-template-rows: auto 1fr;
+}
+
+.movie-card:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.25);
+ transform: translateY(-4px);
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
+}
+
+.movie-poster {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ overflow: hidden;
+ transition: transform 0.3s ease;
+}
+
+.movie-poster img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.movie-poster-placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.movie-poster-placeholder .material-icons {
+ font-size: 48px;
+}
+
+.movie-card:hover .movie-poster {
+ transform: scale(1.05);
+}
+
+.movie-poster-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%);
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ padding: 15px;
+}
+
+.movie-watched-badge {
+ background: rgba(76, 175, 80, 0.9);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.movie-watched-badge .material-icons {
+ font-size: 14px;
+}
+
+/* Movie poster overlay favorite button */
+.movie-poster-overlay .cardOverlayButton {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: rgba(255, 255, 255, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+ backdrop-filter: blur(10px);
+ z-index: 10;
+}
+
+.movie-poster-overlay .cardOverlayButton:hover {
+ background: rgba(0, 0, 0, 0.8);
+ border-color: rgba(255, 255, 255, 0.5);
+ color: #fff;
+ transform: scale(1.1);
+}
+
+.movie-poster-overlay .cardOverlayButton.favorited {
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: #ff0000;
+}
+
+.movie-poster-overlay .cardOverlayButton.favorited:hover {
+ background: rgba(0, 0, 0, 0.9);
+ transform: scale(1.1);
+}
+
+.movie-poster-overlay .cardOverlayButton .material-icons {
+ font-size: 20px;
+}
+
+.movie-poster-overlay:hover .cardOverlayButton, .cardOverlayButton.favorited {
+ opacity: 1;
+}
+
+.movie-details {
+ padding: 20px;
+ display: grid;
+ grid-template-rows: auto auto auto 1fr;
+}
+
+.movie-details a:not(:hover) {
+ text-decoration: none;
+}
+
+.movie-title {
+ font-size: 18px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0 0 8px 0;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ justify-self: start;
+}
+
+.movie-meta {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.movie-year {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.7);
+ font-weight: 500;
+}
+
+.movie-runtime {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.6);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.movie-runtime .material-icons {
+ font-size: 14px;
+}
+
+.movie-watched-date {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.5);
+ font-style: italic;
+ margin-top: auto;
+}
+
+.movie-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 15px;
+ align-self: end;
+ justify-content: center;
+}
+
+.movie-action-btn {
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: #fff;
+ padding: 8px 12px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ text-decoration: none;
+ text-align: center;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ cursor: pointer;
+}
+
+.movie-action-btn:hover {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: rgba(255, 255, 255, 0.4);
+ text-decoration: none;
+ color: #fff;
+}
+
+.movie-action-btn .material-icons {
+ font-size: 14px;
+}
+
+.movie-action-btn.favorited {
+ background: rgba(255, 193, 7, 0.2);
+ border-color: rgba(255, 193, 7, 0.4);
+ color: #ffc107;
+}
+
+.movie-action-btn.favorited:hover {
+ background: rgba(255, 193, 7, 0.3);
+ border-color: rgba(255, 193, 7, 0.6);
+ color: #ffc107;
+}
+
+/* Responsive Design for Movie History */
+@media (max-width: 768px) {
+ .movie-history-grid {
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 15px;
+ }
+
+ .movie-history-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ align-items: center;
+ padding: 20px;
+ }
+
+ .movie-history-header h2 {
+ display: none;
+ }
+
+ .movie-poster {
+ aspect-ratio: 16 / 9;
+ }
+
+ .movie-details {
+ padding: 15px;
+ }
+
+ .movie-title {
+ font-size: 16px;
+ }
+
+ .movie-poster-overlay .cardOverlayButton {
+ width: 36px;
+ height: 36px;
+ top: 12px;
+ right: 12px;
+ }
+
+ .movie-poster-overlay .cardOverlayButton .material-icons {
+ font-size: 18px;
+ }
+}
+
+/* Search Container */
+.search-container {
+ margin: 20px 0 !important;
+ padding: 0 20px;
+ justify-self: center;
+}
+
+.search-input-wrapper {
+ position: relative;
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.search-input-wrapper .search-icon {
+ position: absolute;
+ left: -26px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 20px;
+ pointer-events: none;
+}
+
+.search-input {
+ width: 100%;
+ height: 40px;
+ padding: 0 40px 0 40px;
+ transform: translateX(-40px);
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 20px;
+ color: #fff;
+ font-size: 14px;
+ outline: none;
+ transition: all 0.3s ease;
+}
+
+.search-input::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.search-input:focus {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.4);
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
+}
+
+.search-clear-btn {
+ position: absolute;
+ right: -32px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 50%;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-clear-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.search-clear-btn .material-icons {
+ font-size: 18px;
+}
+
+/* Unwatched Episodes Container */
+.unwatched-episodes-container {
+ margin-top: 15px;
+ padding: 15px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ animation: slideDown 0.3s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.unwatched-episodes-list {
+ max-height: 300px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.unwatched-episode-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ transition: background-color 0.2s ease, opacity 0.3s ease;
+}
+
+.unwatched-episode-item:last-child {
+ border-bottom: none;
+}
+
+.unwatched-episode-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+}
+
+.episode-image {
+ flex-shrink: 0;
+ margin-right: 12px;
+ max-width: 60px;
+}
+
+.episode-image img {
+ width: 60px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+}
+
+.episode-placeholder {
+ width: 60px;
+ height: 40px;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.episode-placeholder .material-icons {
+ font-size: 20px;
+ margin-bottom: 2px;
+}
+
+.episode-placeholder .episode-number {
+ font-size: 10px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.episode-details {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Sort Button */
+.sort-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ color: white;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 14px;
+ margin-left: 8px;
+}
+
+.sort-button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.sort-button .material-icons {
+ font-size: 18px;
+}
+
+.sort-button .sort-label {
+ font-weight: 500;
+}
+
+/* Episode Watched Toggle Button */
+.episode-watched-toggle {
+ background: none;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ margin-right: 12px;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+}
+
+.episode-watched-toggle:hover {
+ border-color: rgba(255, 255, 255, 0.6);
+ background: rgba(255, 255, 255, 0.1);
+ transform: scale(1.05);
+}
+
+.episode-watched-toggle:active {
+ transform: scale(0.95);
+}
+
+.episode-watched-toggle .material-icons {
+ font-size: 18px;
+ color: rgba(255, 255, 255, 0.7);
+ transition: color 0.2s ease;
+}
+
+.episode-watched-toggle:hover .material-icons {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.episode-title {
+ margin-bottom: 4px;
+}
+
+.episode-link {
+ color: #fff;
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 14px;
+ transition: color 0.2s ease;
+}
+
+.episode-link:hover {
+ color: #4fc3f7;
+ text-decoration: underline;
+}
+
+.episode-date {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 12px;
+}
+
+.unwatched-loading,
+.unwatched-empty,
+.unwatched-error {
+ text-align: center;
+ padding: 20px;
+ color: rgba(255, 255, 255, 0.7);
+ font-style: italic;
+}
+
+.unwatched-error {
+ color: #ff6b6b;
+}
+
+/* Statistics tab loading states */
+div[data-tab="statistics"]>* {
+ display: none;
+}
+
+div[data-tab="statistics"][data-ready="true"]>*:not(.loading-container) {
+ display: block;
+}
+
+div[data-tab="statistics"][data-ready="false"]>.loading-container {
+ display: block;
+}
+
+div[data-tab="watchlist"] .loading-message {
+ padding-top: 85px;
+}
+
+
+.progress-bar {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(1px, 1fr));
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 1px;
+}
+
+
+.progress-chunk:first-child {
+ border-radius: 2px 0 0 2px;
+}
+
+.progress-chunk:last-child {
+ border-radius: 0 2px 2px 0;
+}
+
+.progress-chunk {
+ height: 8px;
+ transition: all 0.2s ease;
+ position: relative;
+}
+
+.progress-chunk.watched {
+ background: #00a4dc;
+}
+
+.progress-chunk.unwatched {
+ background: rgba(255, 255, 255, 0.1);
+ cursor: pointer;
+}
+
+.progress-chunk.unwatched:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: scaleY(1.5);
+ z-index: 1;
+ position: relative;
+}
+
+.progress-chunk.watched:hover {
+ background: #0078d4;
+ transform: scaleY(1.2);
+ z-index: 1;
+ position: relative;
+}
+
+.progress-bar-completed .progress-chunk.watched {
+ background: #4caf50;
+}
+
+.progress-bar-completed .progress-chunk.watched:hover {
+ background: #2e7d32;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .progress-chunk {
+ height: 6px;
+ }
+}
+
+/* Show All Button */
+.show-all-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 12px;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ color: white;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-left: auto;
+}
+
+.show-all-btn:hover {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.show-all-btn .material-icons {
+ font-size: 16px;
+}
+
+/* Top Shows Header */
+.top-shows-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+/* All Shows Modal */
+.all-shows-modal-content {
+ max-width: 600px;
+ max-height: 80vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.all-shows-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ transition: background-color 0.2s ease;
+}
+
+.close-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.close-btn .material-icons {
+ font-size: 20px;
+}
+
+.all-shows-list {
+ max-height: 60vh;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-right: 5px;
+}
+
+.all-show-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ transition: background-color 0.2s ease;
+}
+
+.all-show-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.show-rank {
+ font-weight: 600;
+ font-size: 14px;
+ color: #00a4dc;
+ min-width: 24px;
+ text-align: center;
+}
+
+.show-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.show-name {
+ font-weight: 500;
+ font-size: 14px;
+ color: white;
+ margin-bottom: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.show-episodes {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.no-shows-message {
+ text-align: center;
+ color: rgba(255, 255, 255, 0.7);
+ font-style: italic;
+ padding: 40px 20px;
+}
+
+/* Episode Confirmation Modal */
+.episode-confirmation-content {
+ max-width: 400px;
+ min-width: 350px;
+}
+
+.confirmation-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.confirmation-header h3 {
+ margin: 0;
+ color: white;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.confirmation-body {
+ margin-bottom: 24px;
+}
+
+.episode-info {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 6px;
+ padding: 12px;
+ margin-bottom: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.episode-title {
+ font-weight: 500;
+ font-size: 14px;
+ color: white;
+ margin-bottom: 4px;
+}
+
+.episode-details {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.confirmation-message {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.confirmation-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+}
+
+.confirm-btn,
+.cancel-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 16px;
+ border-radius: 4px;
+ border: none;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.confirm-btn {
+ background: #4caf50;
+ color: white;
+}
+
+.confirm-btn:hover {
+ background: #45a049;
+ transform: translateY(-1px);
+}
+
+.cancel-btn {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.cancel-btn:hover {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.confirm-btn .material-icons,
+.cancel-btn .material-icons {
+ font-size: 18px;
+}
+
+.view-episode-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 16px;
+ border-radius: 4px;
+ border: none;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ background: rgba(0, 164, 220, 0.1);
+ color: #00a4dc;
+ border: 1px solid rgba(0, 164, 220, 0.3);
+}
+
+.view-episode-btn:hover {
+ background: rgba(0, 164, 220, 0.2);
+ border-color: rgba(0, 164, 220, 0.5);
+ color: #00a4dc;
+ transform: translateY(-1px);
+}
+
+.view-episode-btn .material-icons {
+ font-size: 18px;
+}
+
+/* Mark All Episodes Confirmation Modal */
+.series-info {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 6px;
+ padding: 12px;
+ margin-bottom: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.series-title {
+ font-weight: 500;
+ font-size: 14px;
+ color: white;
+ margin-bottom: 4px;
+}
+
+.series-details {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.episode-count-info {
+ background: rgba(255, 193, 7, 0.1);
+ border: 1px solid rgba(255, 193, 7, 0.3);
+ border-radius: 6px;
+ padding: 12px;
+ margin-bottom: 16px;
+ text-align: center;
+}
+
+.episode-count-info .episode-count {
+ font-weight: 600;
+ font-size: 16px;
+ color: #ffc107;
+}
+
+.episode-count-info {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 14px;
+}
+
+/* Mobile responsive for Show All */
+@media (max-width: 768px) {
+ .all-shows-modal-content {
+ max-width: calc(100vw - 40px);
+ max-height: 90vh;
+ }
+
+ .show-all-btn {
+ padding: 4px 8px;
+ font-size: 11px;
+ }
+
+ .show-all-btn .material-icons {
+ font-size: 14px;
+ }
+
+ .episode-confirmation-content {
+ max-width: calc(100vw - 40px);
+ min-width: auto;
+ }
+
+ .confirmation-actions {
+ flex-direction: column;
+ }
+
+ .confirm-btn,
+ .cancel-btn {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+.mdl-radio {
+ box-sizing: border-box;
+ display: inline-block;
+ line-height: 24px;
+ margin: 0;
+ position: relative
+}
+
+.radio-label-block {
+ -webkit-align-items: center;
+ align-items: center;
+ display: -webkit-flex;
+ display: flex;
+ margin-bottom: .5em;
+ margin-top: .5em
+}
+
+.mdl-radio__button {
+ -webkit-appearance: none;
+ appearance: none;
+ border: none;
+ height: 1px;
+ line-height: 24px;
+ margin: 0;
+ opacity: 0;
+ padding: 0;
+ position: absolute;
+ width: 1px
+}
+
+.mdl-radio__circles {
+ border-radius: 50%;
+ cursor: pointer;
+ height: 1.08em;
+ position: relative;
+ width: 1.08em
+}
+
+[dir=ltr] .mdl-radio__circles {
+ margin-right: .54em
+}
+
+[dir=rtl] .mdl-radio__circles {
+ margin-left: .54em
+}
+
+.mdl-radio__circles svg {
+ height: 100%;
+ overflow: visible;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ z-index: 1
+}
+
+[dir=ltr] .mdl-radio__circles svg {
+ left: 0
+}
+
+[dir=rtl] .mdl-radio__circles svg {
+ right: 0
+}
+
+.mdl-radio__button:disabled+.mdl-radio__circles {
+ cursor: auto
+}
+
+.mdl-radio__button:disabled+.mdl-radio__circles .mdl-radio__outer-circle {
+ color: rgba(0,0,0,.26)
+}
+
+.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__outer-circle {
+ color: #00a4dc
+}
+
+.mdl-radio__inner-circle {
+ -webkit-transform: scale(0);
+ transform: scale(0);
+ -webkit-transform-origin: 50% 50%;
+ transform-origin: 50% 50%;
+ transition-duration: .2s;
+ transition-property: -webkit-transform;
+ transition-property: transform;
+ transition-property: transform,-webkit-transform
+}
+
+.mdl-radio__button:checked+.mdl-radio__circles .mdl-radio__inner-circle {
+ -webkit-transform: scale(1);
+ transform: scale(1)
+}
+
+.mdl-radio__button:disabled+.mdl-radio__circles .mdl-radio__inner-circle {
+ color: rgba(0,0,0,.26)
+}
+
+.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__inner-circle {
+ color: #00a4dc
+}
+
+.mdl-radio__focus-circle {
+ background: #00a4dc;
+ border-radius: 50%;
+ box-sizing: border-box;
+ height: 100%;
+ left: 0;
+ margin: 0;
+ opacity: .26;
+ position: absolute;
+ top: 0;
+ -webkit-transform: scale(0);
+ transform: scale(0);
+ transition-duration: .2s;
+ transition-property: -webkit-transform;
+ transition-property: transform;
+ transition-property: transform,-webkit-transform;
+ width: 100%
+}
+
+.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__focus-circle {
+ -webkit-transform: scale(1.75);
+ transform: scale(1.75)
+}
+
+.mdl-radio__label {
+ cursor: pointer
+}
+
+.mdl-radio__button:disabled+.mdl-radio__label {
+ color: rgba(0,0,0,.26);
+ cursor: auto
+}
diff --git a/watchlist.js b/watchlist.js
index 88dc667..d6d38c2 100644
--- a/watchlist.js
+++ b/watchlist.js
@@ -21,459 +21,5570 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
const WARN = (...args) => console.warn('[Watchlist]', ...args);
const ERR = (...args) => console.error('[Watchlist]', ...args);
- LOG('Script loaded - JS Injector mode');
+ LOG('Script loaded - JS Injector mode');
+
+ // Initialize localStorage cache manager
+ const localStorageCache = new LocalStorageCache('kefinTweaks_', 24 * 60 * 60 * 1000);
+
+ // Playback monitoring for watchlist cleanup
+ let originalFetch = window.fetch;
+ let playbackMonitorInitialized = false;
+
+ // Data optimization functions for localStorage storage
+ function optimizeProgressDataForStorage(progressDataArray) {
+ return progressDataArray.map(progress => ({
+ // Series info (minimal)
+ series: {
+ Id: progress.series.Id,
+ Name: progress.series.Name,
+ ImageTags: progress.series.ImageTags,
+ Status: progress.series.Status,
+ ProductionYear: progress.series.ProductionYear
+ },
+
+ // Progress metrics (pre-calculated statistics only)
+ watchedCount: progress.watchedCount,
+ totalEpisodes: progress.totalEpisodes,
+ remainingCount: progress.remainingCount,
+ percentage: progress.percentage,
+ totalRuntime: progress.totalRuntime,
+ watchedRuntime: progress.watchedRuntime,
+ remainingRuntime: progress.remainingRuntime,
+
+ // Last watched episode (minimal)
+ lastWatchedEpisode: progress.lastWatchedEpisode ? {
+ Id: progress.lastWatchedEpisode.Id,
+ Name: progress.lastWatchedEpisode.Name,
+ IndexNumber: progress.lastWatchedEpisode.IndexNumber,
+ ParentIndexNumber: progress.lastWatchedEpisode.ParentIndexNumber,
+ UserData: progress.lastWatchedEpisode.UserData,
+ ImageTags: progress.lastWatchedEpisode.ImageTags
+ } : null,
+
+ // Binary progress data for instant rendering (compact storage)
+ binaryProgress: progress.binaryProgress || {}
+
+ // Episodes array completely removed - will be fetched on-demand
+ }));
+ }
+
+ function optimizeMovieDataForStorage(movieDataArray) {
+ // Collect all unique people data
+ const peopleCache = new Map();
+
+ // First pass: collect all people data
+ movieDataArray.forEach(movie => {
+ if (movie.People) {
+ movie.People.forEach(person => {
+ if (person.Id && person.Name && person.Type) {
+ peopleCache.set(person.Id, {
+ Id: person.Id,
+ Name: person.Name,
+ Type: person.Type
+ });
+ }
+ });
+ }
+ });
+
+ // Store people cache separately
+ if (peopleCache.size > 0) {
+ const peopleArray = Array.from(peopleCache.values());
+ localStorageCache.set('people', peopleArray);
+ LOG(`Stored ${peopleArray.length} unique people in people cache`);
+ }
+
+ // Return optimized movie data with only people IDs
+ return movieDataArray.map(movie => ({
+ Id: movie.Id,
+ Name: movie.Name,
+ BackdropImageTags: movie.BackdropImageTags,
+ ImageTags: movie.ImageTags,
+ PremiereDate: movie.PremiereDate,
+ RunTimeTicks: movie.RunTimeTicks,
+ ProductionYear: movie.ProductionYear,
+ PeopleIds: movie.People ? movie.People.map(person => person.Id).filter(id => id) : [],
+ UserData: {
+ IsFavorite: movie.UserData.IsFavorite,
+ LastPlayedDate: movie.UserData.LastPlayedDate,
+ PlayCount: movie.UserData.PlayCount,
+ Played: movie.UserData.Played
+ }
+ }));
+ }
+
+ // Reconstruct people data from IDs and people cache
+ function reconstructPeopleData(movieDataArray) {
+ // Get people cache
+ const peopleCache = localStorageCache.get('people') || [];
+ const peopleMap = new Map(peopleCache.map(person => [person.Id, person]));
+
+ // Reconstruct people data for each movie
+ return movieDataArray.map(movie => {
+ const reconstructedMovie = { ...movie };
+
+ // Reconstruct People array from PeopleIds
+ if (movie.PeopleIds && Array.isArray(movie.PeopleIds)) {
+ reconstructedMovie.People = movie.PeopleIds
+ .map(id => peopleMap.get(id))
+ .filter(person => person); // Remove any undefined entries
+ delete reconstructedMovie.PeopleIds; // Remove the IDs array
+ }
+
+ return reconstructedMovie;
+ });
+ }
+
+ // Optimize watchlist data for localStorage storage
+ function optimizeWatchlistDataForStorage(watchlistDataArray) {
+ return watchlistDataArray.map(item => ({
+ Id: item.Id,
+ Type: item.Type,
+ MediaType: item.MediaType,
+ Name: item.Name,
+ SeriesId: item.SeriesId,
+ SeriesName: item.SeriesName,
+ IndexNumber: item.IndexNumber,
+ ParentIndexNumber: item.ParentIndexNumber,
+ ImageTags: item.ImageTags,
+ BackdropImageTags: item.BackdropImageTags,
+ PremiereDate: item.PremiereDate,
+ ProductionYear: item.ProductionYear,
+ UserData: {
+ IsFavorite: item.UserData.IsFavorite,
+ Played: item.UserData.Played,
+ LastPlayedDate: item.UserData.LastPlayedDate,
+ PlayCount: item.UserData.PlayCount
+ }
+ }));
+ }
+
+ // Show loading state for watchlist tab
+ function showWatchlistLoading() {
+ const watchlistTab = document.querySelector('div[data-tab="watchlist"]');
+ if (!watchlistTab) return;
+
+ // Hide all watchlist sections
+ const sections = ['movies', 'series', 'seasons', 'episodes'];
+ sections.forEach(section => {
+ const sectionEl = watchlistTab.querySelector(`.watchlist-${section}`);
+ if (sectionEl) sectionEl.style.display = 'none';
+ });
+
+ // Show loading message
+ const loadingEl = watchlistTab.querySelector('.watchlist-loading') || createWatchlistLoadingElement();
+ loadingEl.style.display = 'block';
+ }
+
+ // Create loading element if it doesn't exist
+ function createWatchlistLoadingElement() {
+ const watchlistTab = document.querySelector('div[data-tab="watchlist"]');
+ if (!watchlistTab) return null;
+
+ const loadingEl = document.createElement('div');
+ loadingEl.className = 'watchlist-loading';
+ loadingEl.innerHTML = `
+
+
+
Loading watchlist...
+
+ `;
+ loadingEl.style.display = 'none';
+ watchlistTab.appendChild(loadingEl);
+ return loadingEl;
+ }
+
+ // Hide loading state and show watchlist sections
+ function hideWatchlistLoading() {
+ const watchlistTab = document.querySelector('div[data-tab="watchlist"]');
+ if (!watchlistTab) return;
+
+ // Hide loading message
+ const loadingEl = watchlistTab.querySelector('.watchlist-loading');
+ if (loadingEl) loadingEl.style.display = 'none';
+
+ // Show all watchlist sections
+ const sections = ['movies', 'series', 'seasons', 'episodes'];
+ sections.forEach(section => {
+ const sectionEl = watchlistTab.querySelector(`.watchlist-${section}`);
+ if (sectionEl) sectionEl.style.display = 'block';
+ });
+ }
+
+ // Add refresh button to tab headers
+ function addRefreshButtons() {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ // Progress tab refresh button and sort button
+ const progressStats = watchlistSection.querySelector('.progress-header-stats-container');
+ if (progressStats) {
+ const refreshBtn = createRefreshButton('progress', 'Series Progress');
+
+ // Add sort button next to refresh button
+ const sortBtn = createSortButton();
+ progressStats.appendChild(sortBtn);
+ progressStats.appendChild(refreshBtn);
+ }
+
+ // History tab refresh button
+ const historyStats = watchlistSection.querySelector('.movie-history-stats-container');
+ if (historyStats) {
+ // Add sort button first
+ const sortBtn = createMovieSortButton();
+ historyStats.appendChild(sortBtn);
+
+ const refreshBtn = createRefreshButton('movies', 'Movie History');
+ historyStats.appendChild(refreshBtn);
+ }
+
+ // Watchlist tab refresh button
+ const watchlistTab = watchlistSection.querySelector('div[data-tab="watchlist"]');
+ if (watchlistTab) {
+ const refreshBtn = createRefreshButton('watchlist', 'Watchlist');
+ refreshBtn.style.position = 'absolute';
+ refreshBtn.style.left = '50%';
+ refreshBtn.style.top = '1rem';
+ refreshBtn.style.transform = 'translate(-50%, 0%)';
+ watchlistTab.appendChild(refreshBtn);
+ }
+ }
+
+ // Create a refresh button element
+ function createRefreshButton(cacheName, label) {
+ const refreshBtn = document.createElement('button');
+ refreshBtn.className = 'paper-icon-button-light emby-button ';
+ refreshBtn.innerHTML = 'refresh ';
+ refreshBtn.title = `Refresh ${label} data from server`;
+
+ refreshBtn.onclick = async () => {
+ refreshBtn.disabled = true;
+ refreshBtn.innerHTML = 'refresh ';
+
+ try {
+ // Clear localStorage cache
+ if (cacheName === 'progress') {
+ localStorageCache.clear(`progress`);
+ // Clear in-memory cache
+ progressCache.data = [];
+ progressCache.allDataLoaded = false;
+ progressCache.totalPages = 0;
+ tabStates.progress.isDataFetched = false;
+ } else if (cacheName === 'movies') {
+ localStorageCache.clear(`movies`);
+ // Clear in-memory cache
+ movieCache.data = [];
+ movieCache.allDataLoaded = false;
+ movieCache.totalPages = 0;
+ tabStates.history.isDataFetched = false;
+ } else if (cacheName === 'watchlist') {
+ // Clear all watchlist sections
+ const sections = ['movies', 'series', 'seasons', 'episodes'];
+ sections.forEach(section => {
+ localStorageCache.clear(`watchlist_${section}`);
+ watchlistCache[section].data = [];
+ });
+ }
+
+ // Re-fetch data
+ if (cacheName === 'progress') {
+ await initProgressTab();
+ } else if (cacheName === 'movies') {
+ await initHistoryTab();
+ } else if (cacheName === 'watchlist') {
+ await initWatchlistTab();
+ }
+
+ // Show success feedback with checkmark
+ refreshBtn.innerHTML = 'check ';
+ setTimeout(() => {
+ refreshBtn.innerHTML = 'refresh ';
+ refreshBtn.disabled = false;
+ }, 2000);
+
+ } catch (error) {
+ ERR('Failed to refresh cache:', error);
+ refreshBtn.innerHTML = 'error ';
+ setTimeout(() => {
+ refreshBtn.innerHTML = 'refresh ';
+ refreshBtn.disabled = false;
+ }, 3000);
+ }
+ };
+
+ return refreshBtn;
+ }
+
+ // Create sort button element
+ function createSortButton() {
+ const sortBtn = document.createElement('button');
+ sortBtn.className = 'sort-button';
+ sortBtn.id = 'progress-sort-btn';
+
+ // Get current sort order
+ const currentSort = getCurrentSortOrder();
+ const currentLabel = SORT_OPTIONS[currentSort]?.label || 'Last Watched';
+
+ sortBtn.innerHTML = `
+ sort
+ ${currentLabel}
+ arrow_drop_down
+ `;
+ sortBtn.title = 'Sort series by different criteria';
+
+ sortBtn.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ showSortModal();
+ };
+
+ // Update the label with direction indicator after creation
+ updateSortButtonLabel(currentSort);
+
+ return sortBtn;
+ }
+
+ // Create movie sort button element
+ function createMovieSortButton() {
+ const sortBtn = document.createElement('button');
+ sortBtn.className = 'sort-button';
+ sortBtn.id = 'movie-sort-btn';
+
+ // Get current sort order
+ const currentSort = getCurrentMovieSortOrder();
+ const currentLabel = MOVIE_SORT_OPTIONS[currentSort]?.label || 'Last Watched';
+
+ sortBtn.innerHTML = `
+ sort
+ ${currentLabel}
+ arrow_drop_down
+ `;
+ sortBtn.title = 'Sort movies by different criteria';
+
+ sortBtn.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ showMovieSortModal();
+ };
+
+ // Update the label with direction indicator after creation
+ updateMovieSortButtonLabel(currentSort);
+
+ return sortBtn;
+ }
+
+ // Show sort options modal
+ function showSortModal() {
+ // Create modal content
+ const sortOptionsContent = `
+
+ ${Object.entries(SORT_OPTIONS).map(([key, option]) => `
+
+
+
+ ${option.label}
+
+ `).join('')}
+
+ Sort Order
+
+
+
+
+ Ascending
+
+
+
+
+ Descending
+
+
+ `;
+
+ // Create modal using the generic modal system
+ const modal = window.ModalSystem.create({
+ id: 'sort-modal',
+ title: 'Sort By',
+ content: sortOptionsContent,
+ onOpen: (modalInstance) => {
+ // Set current selection
+ const currentSort = getCurrentSortOrder();
+ const currentDirection = getCurrentSortDirection();
+ const currentRadio = modalInstance.dialog.querySelector(`input[value="${currentSort}"]`);
+ const currentDirectionRadio = modalInstance.dialog.querySelector(`input[value="${currentDirection}"]`);
+ if (currentRadio) {
+ currentRadio.checked = true;
+ }
+ if (currentDirectionRadio) {
+ currentDirectionRadio.checked = true;
+ }
+
+ // Add event listeners for radio button changes
+ const sortOptions = modalInstance.dialog.querySelectorAll('input[name="sortOption"]');
+ const sortDirections = modalInstance.dialog.querySelectorAll('input[name="sortDirection"]');
+
+ // Apply changes immediately when radio buttons change
+ const applyChanges = () => {
+ const selectedOption = modalInstance.dialog.querySelector('input[name="sortOption"]:checked');
+ const selectedDirection = modalInstance.dialog.querySelector('input[name="sortDirection"]:checked');
+ if (selectedOption) {
+ const direction = selectedDirection ? selectedDirection.value : 'desc';
+ setSortOrder(selectedOption.value, direction);
+ updateSortButtonLabel(selectedOption.value, direction);
+ }
+ };
+
+ sortOptions.forEach(option => {
+ option.addEventListener('change', applyChanges);
+ });
+
+ sortDirections.forEach(direction => {
+ direction.addEventListener('change', applyChanges);
+ });
+ }
+ });
+ }
+
+ // Update sort button label
+ function updateSortButtonLabel(sortKey, direction = null) {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ const sortBtn = watchlistSection.querySelector('#progress-sort-btn');
+ if (sortBtn) {
+ const currentDirection = direction || getCurrentSortDirection();
+ const directionText = currentDirection === 'asc' ? '↑' : '↓';
+ const label = SORT_OPTIONS[sortKey]?.label || 'Last Watched';
+ const labelSpan = sortBtn.querySelector('.sort-label');
+ if (labelSpan) {
+ labelSpan.textContent = `${label} ${directionText}`;
+ }
+ }
+ }
+
+ // Show movie sort options modal
+ function showMovieSortModal() {
+ // Create modal content
+ const movieSortOptionsContent = `
+
+ ${Object.entries(MOVIE_SORT_OPTIONS).map(([key, option]) => `
+
+
+
+ ${option.label}
+
+ `).join('')}
+
+ Sort Order
+
+
+
+
+ Ascending
+
+
+
+
+ Descending
+
+
+ `;
+
+ // Create modal using the generic modal system
+ const modal = window.ModalSystem.create({
+ id: 'movie-sort-modal',
+ title: 'Sort By',
+ content: movieSortOptionsContent,
+ onOpen: (modalInstance) => {
+ // Set current selection
+ const currentSort = getCurrentMovieSortOrder();
+ const currentDirection = getCurrentMovieSortDirection();
+ const currentRadio = modalInstance.dialog.querySelector(`input[value="${currentSort}"]`);
+ const currentDirectionRadio = modalInstance.dialog.querySelector(`input[value="${currentDirection}"]`);
+ if (currentRadio) {
+ currentRadio.checked = true;
+ }
+ if (currentDirectionRadio) {
+ currentDirectionRadio.checked = true;
+ }
+
+ // Add event listeners for radio button changes
+ const sortOptions = modalInstance.dialog.querySelectorAll('input[name="movieSortOption"]');
+ const sortDirections = modalInstance.dialog.querySelectorAll('input[name="movieSortDirection"]');
+
+ // Apply changes immediately when radio buttons change
+ const applyChanges = () => {
+ const selectedOption = modalInstance.dialog.querySelector('input[name="movieSortOption"]:checked');
+ const selectedDirection = modalInstance.dialog.querySelector('input[name="movieSortDirection"]:checked');
+ if (selectedOption) {
+ const direction = selectedDirection ? selectedDirection.value : 'desc';
+ setMovieSortOrder(selectedOption.value, direction);
+ updateMovieSortButtonLabel(selectedOption.value, direction);
+ renderHistoryContent();
+ }
+ };
+
+ sortOptions.forEach(option => {
+ option.addEventListener('change', applyChanges);
+ });
+
+ sortDirections.forEach(direction => {
+ direction.addEventListener('change', applyChanges);
+ });
+ }
+ });
+ }
+
+ // Update movie sort button label
+ function updateMovieSortButtonLabel(sortKey, direction = null) {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ const sortBtn = watchlistSection.querySelector('#movie-sort-btn');
+ if (sortBtn) {
+ const currentDirection = direction || getCurrentMovieSortDirection();
+ const directionText = currentDirection === 'asc' ? '↑' : '↓';
+ const label = MOVIE_SORT_OPTIONS[sortKey]?.label || 'Last Watched';
+ const labelSpan = sortBtn.querySelector('.sort-label');
+ if (labelSpan) {
+ labelSpan.textContent = `${label} ${directionText}`;
+ }
+ }
+ }
+
+ // Show episode watched confirmation modal
+ function showEpisodeWatchedConfirmation(episodeId, element, episodeInfo = null, seriesId = null) {
+ // Create modal content
+ const episodeTitle = episodeInfo ? episodeInfo.title : 'Episode Title';
+ const episodeDetails = episodeInfo ? `Season ${episodeInfo.season}, Episode ${episodeInfo.episode}` : 'Season X, Episode Y';
+
+ const confirmationContent = `
+
+
+
${episodeTitle}
+
${episodeDetails}
+
+
+ Are you sure you want to mark this episode as watched?
+
+
+
+
+ info
+ Go To Episode
+
+
+ check
+ Mark as Watched
+
+
+ close
+ Cancel
+
+
+ `;
+
+ // Create modal using the generic modal system
+ const modal = window.ModalSystem.create({
+ id: 'episode-confirmation-modal',
+ title: 'Mark Episode as Watched',
+ content: confirmationContent,
+ onOpen: (modalInstance) => {
+ // Store the episode ID and element for the confirmation
+ modalInstance.dialog.dataset.episodeId = episodeId;
+ modalInstance.dialog.dataset.elementType = element.classList.contains('progress-chunk') ? 'chunk' : 'button';
+ modalInstance.dialog.dataset.seriesId = seriesId;
+
+ // Store season/episode for binary mode
+ if (episodeInfo) {
+ modalInstance.dialog.dataset.season = episodeInfo.season;
+ modalInstance.dialog.dataset.episode = episodeInfo.episode;
+ }
+
+ // Add event listeners
+ const confirmBtn = modalInstance.dialog.querySelector('#confirm-mark-watched');
+ const cancelBtn = modalInstance.dialog.querySelector('#cancel-mark-watched');
+ const viewEpisodeBtn = modalInstance.dialog.querySelector('#view-episode-btn');
+
+ viewEpisodeBtn.onclick = () => {
+ // Navigate to the episode's item page
+ const episodeId = modalInstance.dialog.dataset.episodeId;
+ if (episodeId) {
+ Dashboard.navigate(`#/details?id=${episodeId}`);
+ }
+
+ window.ModalSystem.close('episode-confirmation-modal');
+ };
+
+ confirmBtn.onclick = async () => {
+ const episodeId = modalInstance.dialog.dataset.episodeId;
+ const elementType = modalInstance.dialog.dataset.elementType;
+ const seriesId = modalInstance.dialog.dataset.seriesId;
+
+ // Find the original element
+ let element = null;
+ if (elementType === 'chunk') {
+ // Find progress bar by series ID, then find chunk by season/episode
+ const progressBar = document.querySelector(`.progress-bar[data-series-id="${seriesId}"]`);
+ if (progressBar) {
+ const season = modalInstance.dialog.dataset.season;
+ const episode = modalInstance.dialog.dataset.episode;
+ element = progressBar.querySelector(`[data-season="${season}"][data-episode="${episode}"]`);
+ }
+ } else {
+ element = document.querySelector(`[data-episode-id="${episodeId}"].episode-watched-toggle`);
+ }
+
+ if (element) {
+ await toggleEpisodeWatchedStatus(episodeId, element);
+ }
+
+ window.ModalSystem.close('episode-confirmation-modal');
+ };
+
+ cancelBtn.onclick = () => {
+ window.ModalSystem.close('episode-confirmation-modal');
+ };
+ }
+ });
+ }
+
+ // Show mark all episodes confirmation modal
+ function showMarkAllEpisodesConfirmation(seriesId, buttonElement, seriesInfo) {
+ // Create modal content
+ const confirmationContent = `
+
+
+
${seriesInfo.name}
+
${seriesInfo.watchedCount} of ${seriesInfo.totalEpisodes} episodes watched (${seriesInfo.percentage}%)
+
+
+ ${seriesInfo.unwatchedCount} unwatched episodes will be marked as watched
+
+
+ Are you sure you want to mark all remaining episodes as watched?
+
+
+
+
+ check_circle
+ Mark All as Watched
+
+
+ close
+ Cancel
+
+
+ `;
+
+ // Create modal using the generic modal system
+ const modal = window.ModalSystem.create({
+ id: 'mark-all-confirmation-modal',
+ title: 'Mark All Episodes as Watched',
+ content: confirmationContent,
+ onOpen: (modalInstance) => {
+ // Store the series ID and button for the confirmation
+ modalInstance.dialog.dataset.seriesId = seriesId;
+ modalInstance.dialog.dataset.buttonElement = 'true';
+
+ // Add event listeners
+ const confirmBtn = modalInstance.dialog.querySelector('#confirm-mark-all-watched');
+ const cancelBtn = modalInstance.dialog.querySelector('#cancel-mark-all-watched');
+
+ confirmBtn.onclick = async () => {
+ const seriesId = modalInstance.dialog.dataset.seriesId;
+
+ // Find the mark all button
+ const markAllBtn = document.querySelector(`[data-series-id="${seriesId}"].mark-all-watched-btn`);
+ if (markAllBtn) {
+ await markAllEpisodesAsWatched(seriesId, markAllBtn);
+ }
+
+ window.ModalSystem.close('mark-all-confirmation-modal');
+ };
+
+ cancelBtn.onclick = () => {
+ window.ModalSystem.close('mark-all-confirmation-modal');
+ };
+ }
+ });
+ }
+
+ // Show all shows modal
+ function showAllShowsModal() {
+ // Get all progress data and sort it the same way as top 5
+ const progressData = progressCache.data;
+ const allShows = progressData
+ .map(progress => ({
+ name: progress.series.Name,
+ episodesWatched: progress.watchedCount,
+ totalEpisodes: progress.totalEpisodes,
+ percentage: progress.percentage,
+ seriesId: progress.series.Id
+ }))
+ .sort((a, b) => {
+ // Primary sort: completion percentage (highest first)
+ const percentageDiff = b.percentage - a.percentage;
+ if (percentageDiff !== 0) {
+ return percentageDiff;
+ }
+ // Tiebreaker: episode count (most episodes first)
+ return b.totalEpisodes - a.totalEpisodes;
+ });
+
+ let showsHtml = '';
+ if (allShows.length === 0) {
+ showsHtml = 'No shows watched yet
';
+ } else {
+ showsHtml = allShows.map((show, index) => {
+ const rank = index + 1;
+ const episodesText = show.episodesWatched === 1 ? 'episode' : 'episodes';
+
+ return `
+
+
${rank}
+
+
${show.name}
+
${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
+
+
+ `;
+ }).join('');
+ }
+
+ // Create modal content
+ const modalContent = `
+
+ ${showsHtml}
+
+ `;
+
+ // Create modal using the generic modal system
+ window.ModalSystem.create({
+ id: 'all-shows-modal',
+ title: 'All Watched Shows',
+ content: modalContent,
+ onOpen: (modalInstance) => {
+ // Modal is ready and content is populated
+ LOG('All shows modal opened');
+ }
+ });
+ }
- // Add custom CSS for watchlist icon
- const style = document.createElement('style');
- style.textContent = `
- .material-icons.watchlist:before {
- content: "\\e866";
- }
- `;
- document.head.appendChild(style);
- LOG('Custom watchlist icon CSS added');
-
-/************ Helpers ************/
-
-async function fetchLikedItems(type) {
- const apiClient = window.ApiClient;
- const userId = apiClient.getCurrentUserId();
- const serverUrl = apiClient.serverAddress();
- const token = apiClient.accessToken();
-
- const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true`;
-
- try {
- const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
- const data = await res.json();
- return data.Items || [];
- } catch (err) {
- ERR("Failed to fetch liked items for type:", type, err);
- return [];
+ // Centralized selector function for visible library page
+ function getVisibleLibraryPage() {
+ return document.querySelector('.homePage:not(.hide)');
}
-}
-async function renderCards(containerSelector, type) {
- const container = document.querySelector(containerSelector);
- if (!container) {
- WARN("Container not found:", containerSelector);
- 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 };
- }
-
- // 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(items, getTypeDisplayName(type));
- container.innerHTML = '';
- container.appendChild(scrollableContainer);
- LOG(`Rendered ${items.length} ${type} items using cardBuilder`);
- } else {
- ERR("cardBuilder not available - this should not happen with proper dependency management");
- container.style.display = 'none';
- return { type, itemCount: 0 };
- }
-
- return { type, itemCount: items.length };
-}
-
-
-function getTypeDisplayName(itemType) {
- const typeMap = {
- 'Movie': 'Movies',
- 'Series': 'Shows',
- 'Season': 'Seasons',
- 'Episode': 'Episodes',
- 'Person': 'People',
- 'MusicAlbum': 'Albums',
- 'Audio': 'Songs',
- 'Artist': 'Artists',
- 'Playlist': 'Playlists',
- 'Book': 'Books',
- 'AudioBook': 'Audiobooks',
- 'Photo': 'Photos',
- 'PhotoAlbum': 'Photo Albums',
- 'TvChannel': 'TV Channels',
- 'LiveTvProgram': 'Live TV',
- 'BoxSet': 'Collections'
+ // Tab state tracking to avoid unnecessary re-rendering
+ const tabStates = {
+ progress: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false },
+ history: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false },
+ watchlist: { currentPage: 1, currentSearch: '', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false },
+ statistics: { currentPage: 1, currentSearch: '', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false }
};
- return typeMap[itemType] || itemType;
-}
-/************ Watchlist Section Observer ************/
+ // Check if we should skip rendering based on current state
+ function shouldSkipRendering(tabName, currentPage, currentSearch, container, data) {
+ const state = tabStates[tabName];
+ const isSamePage = state.currentPage === currentPage;
+ const isSameSearch = state.currentSearch === currentSearch;
+
+ // For progress tab, also check if sort order or direction has changed
+ let isSameSort = true;
+ if (tabName === 'progress') {
+ const currentSort = getCurrentSortOrder();
+ const currentDirection = getCurrentSortDirection();
+ isSameSort = state.currentSort === currentSort && state.currentDirection === currentDirection;
+ } else if (tabName === 'history') {
+ // For history tab, check if movie sort order or direction has changed
+ const currentSort = getCurrentMovieSortOrder();
+ const currentDirection = getCurrentMovieSortDirection();
+ isSameSort = state.currentSort === currentSort && state.currentDirection === currentDirection;
+ }
+
+ // Check if container has the right number of children
+ const hasCorrectContent = container &&
+ container.children.length === data.length;
+
+ // Skip if we have content and it's the same page/search/sort/data count
+ return state.hasContent && isSamePage && isSameSearch && isSameSort && hasCorrectContent;
+ }
-// Function to render watchlist content when .sections.watchlist is found
-async function renderWatchlistContent() {
- try {
- const results = await Promise.all([
- renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-movies", "Movie"),
- renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-series", "Series"),
- renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-seasons", "Season"),
- renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-episodes", "Episode")
- ]);
-
- // Check if all sections are empty
- const totalItems = results.reduce((sum, result) => sum + result.itemCount, 0);
-
- if (totalItems === 0) {
- // Show empty state message
- showEmptyWatchlistMessage();
- } else {
- // Hide empty state message if it exists
- hideEmptyWatchlistMessage();
- }
- } catch (err) {
- ERR('Error rendering watchlist cards:', err);
- }
-}
+ // Update tab state after successful render
+ function updateTabState(tabName, currentPage, currentSearch) {
+ const state = {
+ currentPage,
+ currentSearch,
+ hasContent: true,
+ lastDataHash: '', // No longer used
+ isFetching: false,
+ isDataFetched: true
+ };
+
+ // For progress tab, also track current sort order and direction
+ if (tabName === 'progress') {
+ state.currentSort = getCurrentSortOrder();
+ state.currentDirection = getCurrentSortDirection();
+ } else if (tabName === 'history') {
+ // For history tab, track current movie sort order and direction
+ state.currentSort = getCurrentMovieSortOrder();
+ state.currentDirection = getCurrentMovieSortDirection();
+ }
+
+ tabStates[tabName] = state;
+ }
-// Function to check if watchlist section exists and render content
-function checkAndRenderWatchlist() {
- const watchlistSection = document.querySelector('#indexPage:not(.hide) .sections.watchlist');
- if (watchlistSection && !watchlistSection.dataset.watchlistRendered) {
- watchlistSection.dataset.watchlistRendered = 'true';
- renderWatchlistContent();
- }
-}
+ // Clear tab state when content is cleared
+ function clearTabState(tabName) {
+ tabStates[tabName].hasContent = false;
+ tabStates[tabName].lastDataHash = '';
+ tabStates[tabName].isDataFetched = false;
+ tabStates[tabName].isFetching = false;
+ }
-// Set up observer to watch for .sections.watchlist element
-function setupWatchlistSectionObserver() {
- // Check immediately in case the element already exists
- checkAndRenderWatchlist();
-
- // Set up MutationObserver to watch for .sections.watchlist
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.type === 'childList') {
- mutation.addedNodes.forEach((node) => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- // Check if the added node is the watchlist section
- if (node.classList && node.classList.contains('sections') && node.classList.contains('watchlist')) {
- checkAndRenderWatchlist();
- }
-
- // Check for watchlist section within the added node
- const watchlistSection = node.querySelector && node.querySelector('.sections.watchlist');
- if (watchlistSection) {
- checkAndRenderWatchlist();
- }
- }
- });
- }
- });
- });
-
- // Start observing
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
-}
+ function getWatchlistSection() {
+ const visibleWatchlistSections = document.querySelectorAll('.libraryPage:not(.hide) .sections.watchlist');
-// Initialize the watchlist section observer
-setupWatchlistSectionObserver();
+ if (!visibleWatchlistSections || visibleWatchlistSections.length === 0) {
+ return null;
+ }
-function showEmptyWatchlistMessage() {
- // Check if message already exists
- let emptyMessage = document.querySelector('.watchlist-empty-message');
- if (emptyMessage) {
- emptyMessage.style.display = 'block';
- return;
- }
-
- // Create empty state message
- emptyMessage = document.createElement('div');
- emptyMessage.className = 'watchlist-empty-message';
- emptyMessage.style.cssText = `
- text-align: center;
- padding: 40px 20px;
- color: #999;
- font-size: 16px;
- line-height: 1.5;
- `;
-
- emptyMessage.innerHTML = `
-
- bookmark_border
-
- Your Watchlist is Empty
-
- Press the bookmark_border
- button to add an item to your Watchlist
-
- `;
-
- // Find the watchlist container and add the message
- const watchlistContainer = document.querySelector('.sections.watchlist');
- if (watchlistContainer) {
- watchlistContainer.appendChild(emptyMessage);
- } else {
- // Fallback to body if watchlist container not found
- document.body.appendChild(emptyMessage);
- }
-}
+ if (visibleWatchlistSections.length > 1) {
+ return visibleWatchlistSections[visibleWatchlistSections.length - 1];
+ }
-function hideEmptyWatchlistMessage() {
- const emptyMessage = document.querySelector('.watchlist-empty-message');
- if (emptyMessage) {
- emptyMessage.style.display = 'none';
- }
-}
-
-LOG('Watchlist functionality initialized');
-
-/************ Watchlist Button Observer ************/
-
-// Function to add watchlist button to a card overlay container
-function addWatchlistButton(overlayContainer) {
- // Check if watchlist button already exists
- if (overlayContainer && overlayContainer.querySelector('.watchlist-button')) {
- return;
+ return visibleWatchlistSections[0];
}
- // Find the card parent to get the item ID
- const card = overlayContainer.closest('.card');
- if (!card) {
- WARN('Could not find card parent for overlay container');
- return;
+ // Safe element getter that targets the visible library page
+ function getElementByIdSafe(elementId) {
+ const libraryPage = getVisibleLibraryPage();
+ return libraryPage ? libraryPage.querySelector(`#${elementId}`) : document.getElementById(elementId);
}
- const itemId = card.getAttribute('data-id');
- if (!itemId) {
- WARN('Could not find data-id on card element');
- return;
+ // Add custom CSS for watchlist icon
+ const style = document.createElement('style');
+ style.textContent = `
+ .material-icons.watchlist:before {
+ content: "\\e866";
+ }
+
+ .loading-message {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ text-align: center;
+ color: #888;
+ grid-column: 1 / -1;
+ }
+
+ .loading-spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 1rem;
+ }
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+ `;
+ document.head.appendChild(style);
+ LOG('Custom watchlist icon CSS added');
+
+ /************ Helpers ************/
+
+ async function fetchLikedItems(type) {
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb`;
+
+ try {
+ const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const data = await res.json();
+ return data.Items || [];
+ } catch (err) {
+ ERR("Failed to fetch liked items for type:", type, err);
+ return [];
+ }
}
-
- // Check if card has data-type attribute - if not, don't add watchlist button
- const itemType = card.getAttribute('data-type');
- if (!itemType) {
- LOG('Card has no data-type, skipping watchlist button');
- return;
+
+ /************ Progress Tab Functions ************/
+
+ // Generic cache factory function
+ function createGenericCache(pageSize = 20) {
+ return {
+ data: [], // Main data array
+ filteredData: [], // Filtered data array
+ searchTerm: '', // Current search term
+ filteredPages: 0, // Pages for filtered data
+ totalPages: 0, // Pages for all data
+ pageSize, // Items per page
+ allDataLoaded: false, // Data loading state
+ sorted: false, // Data sorting state
+ currentPage: 1 // Current page for this tab
+ };
}
-
- // Find the .cardOverlayButton-br container
- const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
- if (!buttonContainer) {
- WARN('Could not find .cardOverlayButton-br container');
- return;
+
+ // Progress caching and pagination configuration
+ const progressCache = createGenericCache(20);
+
+ // Movie history caching and pagination configuration
+ const movieCache = createGenericCache(20);
+
+ // Cache management functions
+ function isCacheValid() {
+ return progressCache.data.length > 0 && progressCache.allDataLoaded;
}
-
- // Create watchlist button
- const watchlistButton = document.createElement('button');
- watchlistButton.type = 'button';
- watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
- watchlistButton.setAttribute('data-action', 'none');
- watchlistButton.setAttribute('data-id', itemId);
- watchlistButton.setAttribute('data-active', 'false');
- watchlistButton.title = 'Add to Watchlist';
-
- // Create the bookmark icon
- const watchlistIcon = document.createElement('span');
- watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
- watchlistIcon.setAttribute('aria-hidden', 'true');
-
- watchlistButton.appendChild(watchlistIcon);
-
+
+
+ // Search functionality
+ function filterProgressDataBySearch(progressData, searchTerm) {
+ if (!searchTerm || searchTerm.trim() === '') {
+ return progressData;
+ }
+
+ const term = searchTerm.toLowerCase().trim();
+ return progressData.filter(progress =>
+ progress.series.Name.toLowerCase().includes(term)
+ );
+ }
+
+ function updateSearchResults(searchTerm) {
+ LOG(`updateSearchResults called with: "${searchTerm}"`);
+ progressCache.searchTerm = searchTerm;
+
+ // Filter the preloaded progress data using correct property name
+ if (progressCache.allDataLoaded && progressCache.data.length > 0) {
+ progressCache.filteredData = filterProgressDataBySearch(progressCache.data, searchTerm);
+ progressCache.filteredPages = Math.ceil(progressCache.filteredData.length / progressCache.pageSize);
+ LOG(`Progress search: ${progressCache.filteredData.length} results from ${progressCache.data.length} total`);
+ } else {
+ // Fallback - this shouldn't happen with new system, but handle gracefully
+ progressCache.filteredData = [];
+ progressCache.filteredPages = 0;
+ WARN('Progress data not loaded yet, search will be empty');
+ }
+
+ LOG(`Search updated: "${searchTerm}" -> ${progressCache.filteredData.length} results, ${progressCache.filteredPages} pages`);
+
+ // Update pagination immediately after search results change
+ const params = getUrlParams();
+ if (params.pageTab === 'progress') {
+ const currentPage = params.page || 1;
+ const totalPages = progressPagination.getCurrentTotalPages();
+
+ // If current page exceeds total pages after filtering, reset to page 1
+ if (currentPage > totalPages && totalPages > 0) {
+ LOG(`Current page ${currentPage} exceeds total pages ${totalPages} after search, resetting to page 1`);
+ updateUrlParams('progress', 1);
+ // Update pagination controls with page 1
+ renderPaginationControls(1, totalPages, 'progress');
+ } else {
+ // Update pagination controls with current page
+ renderPaginationControls(currentPage, totalPages, 'progress');
+ }
+ }
+ }
+
+ // Movie search functionality
+ function filterMoviesBySearch(movies, searchTerm) {
+ if (!searchTerm || searchTerm.trim() === '') {
+ return movies;
+ }
+
+ const term = searchTerm.toLowerCase().trim();
+ return movies.filter(movie => {
+ const title = (movie.Name || '').toLowerCase();
+ const year = movie.ProductionYear || '';
+ const genres = (movie.Genres || []).join(' ').toLowerCase();
+
+ return title.includes(term) ||
+ year.toString().includes(term) ||
+ genres.includes(term);
+ });
+ }
+
+ function updateMovieSearchResults(searchTerm) {
+ LOG(`updateMovieSearchResults called with: "${searchTerm}"`);
+ movieCache.searchTerm = searchTerm;
+
+ // Filter the cached movie data
+ if (movieCache.data.length > 0) {
+ movieCache.filteredData = filterMoviesBySearch(movieCache.data, searchTerm);
+ movieCache.filteredPages = Math.ceil(movieCache.filteredData.length / movieCache.pageSize);
+ LOG(`Movie search: ${movieCache.filteredData.length} results from ${movieCache.data.length} total`);
+ LOG(`Movie cache state: searchTerm="${movieCache.searchTerm}", filteredData.length=${movieCache.filteredData.length}, filteredPages=${movieCache.filteredPages}`);
+ } else {
+ WARN('No movie data available for search');
+ }
+
+ LOG(`Movie search updated: "${searchTerm}" -> ${movieCache.filteredData.length} results, ${movieCache.filteredPages} pages`);
+
+ // Update pagination immediately after search results change
+ const params = getUrlParams();
+ if (params.pageTab === 'history') {
+ const currentPage = params.page || 1;
+ const totalPages = moviePagination.getCurrentTotalPages();
+
+ // If current page exceeds total pages after filtering, reset to page 1
+ if (currentPage > totalPages && totalPages > 0) {
+ LOG(`Current page ${currentPage} exceeds total pages ${totalPages} after movie search, resetting to page 1`);
+ updateUrlParams('history', 1);
+ // Update pagination controls with page 1
+ renderPaginationControls(1, totalPages, 'history');
+ } else {
+ // Update pagination controls with current page
+ renderPaginationControls(currentPage, totalPages, 'history');
+ }
+ }
+ }
+
+ function getCurrentTotalPages() {
+ // Use the new pagination system
+ return progressPagination.getCurrentTotalPages();
+ }
+
+ // Individual series progress cache management
+ function cacheSeriesProgress(seriesId, progressData) {
+ // Find existing entry in data array
+ const existingIndex = progressCache.data.findIndex(item => item.series.Id === seriesId);
+
+ if (existingIndex !== -1) {
+ // Update existing entry
+ progressCache.data[existingIndex] = progressData;
+ } else {
+ // Add new entry
+ progressCache.data.push(progressData);
+ }
+
+ LOG(`Cached progress data for series: ${progressData.series.Name}`);
+ }
+
+ function getCachedSeriesProgress(seriesId) {
+ const cachedProgress = progressCache.data.find(item =>
+ item && item.series && item.series.Id === seriesId
+ );
+ return cachedProgress || null;
+ }
+
+ // Helper function to get series ID from episode ID
+ async function getSeriesIdFromEpisode(episodeId) {
+ // Query API to get episode details and find series ID
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ try {
+ const episodeUrl = `${serverUrl}/Items/${episodeId}?UserId=${userId}&Fields=ParentId`;
+ const episodeRes = await fetch(episodeUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const episodeData = await episodeRes.json();
+
+ // Get the series ID from the episode's parent (season) parent
+ if (episodeData.ParentId) {
+ const seasonUrl = `${serverUrl}/Items/${episodeData.ParentId}?UserId=${userId}&Fields=ParentId`;
+ const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const seasonData = await seasonRes.json();
+
+ return seasonData.ParentId || null;
+ }
+
+ return null;
+ } catch (err) {
+ ERR(`Failed to get series ID for episode ${episodeId}:`, err);
+ return null;
+ }
+ }
+
+ // Helper function to recalculate series progress metrics
+ function recalculateSeriesProgressMetrics(progressData) {
+ // Only recalculate if we have episodes data in memory
+ if (!progressData.episodes || !Array.isArray(progressData.episodes)) {
+ LOG('No episodes data available for recalculation');
+ return progressData;
+ }
+
+ const episodes = progressData.episodes;
+ const today = new Date();
+ today.setHours(23, 59, 59, 999);
+
+ // Filter to aired episodes only
+ const airedEpisodes = episodes.filter(ep =>
+ ep.PremiereDate && new Date(ep.PremiereDate) <= today
+ );
+
+ // Count watched episodes
+ const watchedEpisodes = airedEpisodes.filter(ep =>
+ ep.UserData && ep.UserData.Played === true
+ );
+
+ // Update metrics
+ progressData.totalEpisodes = airedEpisodes.length;
+ progressData.watchedCount = watchedEpisodes.length;
+ progressData.remainingCount = progressData.totalEpisodes - progressData.watchedCount;
+ progressData.percentage = progressData.totalEpisodes > 0
+ ? Math.round((progressData.watchedCount / progressData.totalEpisodes) * 100)
+ : 0;
+
+ // Update last watched episode
+ const lastWatched = watchedEpisodes
+ .sort((a, b) => new Date(b.UserData.LastPlayedDate || 0) - new Date(a.UserData.LastPlayedDate || 0))[0];
+ progressData.lastWatchedEpisode = lastWatched ? {
+ Id: lastWatched.Id,
+ Name: lastWatched.Name,
+ IndexNumber: lastWatched.IndexNumber,
+ ParentIndexNumber: lastWatched.ParentIndexNumber,
+ UserData: lastWatched.UserData,
+ ImageTags: lastWatched.ImageTags
+ } : null;
+
+ LOG(`Recalculated metrics for series: ${progressData.series.Name} - ${progressData.watchedCount}/${progressData.totalEpisodes} (${progressData.percentage}%)`);
+ return progressData;
+ }
+
+ // Main function to update episode watched status in cache
+ function updateEpisodeWatchedStatusInCache(seriesId, episodeId, isWatched = true) {
+ const cachedProgress = getCachedSeriesProgress(seriesId);
+ if (!cachedProgress) {
+ LOG(`No cached progress found for series: ${seriesId}`);
+ return false;
+ }
+
+ // Update episode data if available in memory
+ if (cachedProgress.episodes) {
+ const episode = cachedProgress.episodes.find(ep => ep.Id === episodeId);
+ if (episode) {
+ episode.UserData = episode.UserData || {};
+ episode.UserData.Played = isWatched;
+ episode.UserData.IsPlayed = isWatched;
+
+ // Set LastPlayedDate if marking as watched
+ if (isWatched) {
+ episode.UserData.LastPlayedDate = new Date().toISOString();
+ }
+
+ LOG(`Updated episode ${episodeId} watched status to ${isWatched} in memory cache`);
+ }
+ }
+
+ // Update basic statistics
+ if (isWatched) {
+ cachedProgress.watchedCount = Math.min(cachedProgress.watchedCount + 1, cachedProgress.totalEpisodes);
+ cachedProgress.remainingCount = Math.max(cachedProgress.remainingCount - 1, 0);
+ } else {
+ cachedProgress.watchedCount = Math.max(cachedProgress.watchedCount - 1, 0);
+ cachedProgress.remainingCount = Math.min(cachedProgress.remainingCount + 1, cachedProgress.totalEpisodes);
+ }
+
+ // Recalculate percentage
+ cachedProgress.percentage = cachedProgress.totalEpisodes > 0
+ ? Math.round((cachedProgress.watchedCount / cachedProgress.totalEpisodes) * 100)
+ : 0;
+
+ // Regenerate binaryProgress from episodes if available
+ if (Array.isArray(cachedProgress.episodes) && cachedProgress.episodes.length > 0) {
+ cachedProgress.binaryProgress = generateBinaryProgressData(cachedProgress.episodes);
+ }
+
+ // Update the cache
+ cacheSeriesProgress(seriesId, cachedProgress);
+
+ // Update localStorage to persist changes
+ updateProgressDataInLocalStorage();
+
+ LOG(`Updated episode ${episodeId} watched status to ${isWatched} in cache for series: ${cachedProgress.series.Name}`);
+ return true;
+ }
+
+ // Helper function to update episode watched status in cache without localStorage update
+ // Used for bulk operations to avoid multiple localStorage writes
+ function updateEpisodeWatchedStatusInCacheWithoutLocalStorage(seriesId, episodeId, isWatched = true) {
+ const cachedProgress = getCachedSeriesProgress(seriesId);
+ if (!cachedProgress) {
+ LOG(`No cached progress found for series: ${seriesId}`);
+ return false;
+ }
+
+ // Find and update the specific episode
+ const episode = cachedProgress.episodes.find(ep => ep.Id === episodeId);
+ if (!episode) {
+ LOG(`Episode ${episodeId} not found in cached progress for series: ${seriesId}`);
+ return false;
+ }
+
+ // Update the episode's watched status
+ episode.UserData = episode.UserData || {};
+ episode.UserData.Played = isWatched;
+ episode.UserData.IsPlayed = isWatched;
+
+ // Set LastPlayedDate if marking as watched
+ if (isWatched) {
+ episode.UserData.LastPlayedDate = new Date().toISOString();
+ }
+
+ // Recalculate series metrics
+ recalculateSeriesProgressMetrics(cachedProgress);
+
+ // Regenerate binaryProgress from episodes if available
+ if (Array.isArray(cachedProgress.episodes) && cachedProgress.episodes.length > 0) {
+ cachedProgress.binaryProgress = generateBinaryProgressData(cachedProgress.episodes);
+ }
+
+ // Update the cache (without localStorage)
+ cacheSeriesProgress(seriesId, cachedProgress);
+
+ LOG(`Updated episode ${episodeId} watched status to ${isWatched} in cache for series: ${cachedProgress.series.Name} (no localStorage update)`);
+ return true;
+ }
+
+ // Function to update progress card UI after cache changes
+ async function updateProgressCardUI(seriesId) {
+ // Find the progress card for this series by looking for buttons with data-series-id
+ const seriesButton = document.querySelector(`[data-series-id="${seriesId}"]`);
+ if (!seriesButton) {
+ LOG(`Progress card not found for series: ${seriesId}`);
+ return;
+ }
+
+ // Find the parent progress card
+ const progressCard = seriesButton.closest('.progress-card');
+ if (!progressCard) {
+ LOG(`Progress card container not found for series: ${seriesId}`);
+ return;
+ }
+
+ // Get updated progress data
+ const updatedProgress = getCachedSeriesProgress(seriesId);
+ if (!updatedProgress) {
+ LOG(`No updated progress data found for series: ${seriesId}`);
+ return;
+ }
+
+ // Update chunked progress bar (preserve enhanced chunks if possible)
+ await updateProgressBarInPlace(progressCard, updatedProgress);
+
+ // Update percentage text
+ const percentageText = progressCard.querySelector('.progress-percentage');
+ if (percentageText) {
+ percentageText.textContent = `${updatedProgress.percentage}%`;
+ }
+
+ // Update stats text
+ const statsText = progressCard.querySelector('.progress-stats');
+ if (statsText) {
+ statsText.innerHTML = updatedProgress.percentage === 100
+ ? `Watched ${updatedProgress.watchedCount} of ${updatedProgress.totalEpisodes} episodes - Series Complete! `
+ : `Watched ${updatedProgress.watchedCount} of ${updatedProgress.totalEpisodes} episodes which leaves ${updatedProgress.remainingCount} episodes left to watch.`;
+ }
+
+ // Update completion badge
+ const completionBadge = progressCard.querySelector('.completion-badge');
+ if (updatedProgress.percentage === 100) {
+ if (!completionBadge) {
+ const titleElement = progressCard.querySelector('.progress-title');
+ if (titleElement) {
+ const badge = document.createElement('span');
+ badge.className = 'completion-badge';
+ badge.textContent = '✓ Complete';
+ titleElement.appendChild(badge);
+ }
+ }
+ } else if (completionBadge) {
+ completionBadge.remove();
+ }
+
+ // Update progress bar class for completed series
+ const progressBarContainer = progressCard.querySelector('.progress-bar');
+ if (progressBarContainer) {
+ if (updatedProgress.percentage === 100) {
+ progressBarContainer.classList.add('progress-bar-completed');
+ } else {
+ progressBarContainer.classList.remove('progress-bar-completed');
+ }
+ }
+
+ // Update mark all as watched button visibility
+ const markAllButton = progressCard.querySelector('.mark-all-watched');
+ if (markAllButton) {
+ if (updatedProgress.remainingCount === 0) {
+ markAllButton.style.display = 'none';
+ } else {
+ markAllButton.style.display = '';
+ }
+ }
+
+ // Update unwatched toggle button visibility
+ const unwatchedToggle = progressCard.querySelector('.unwatched-toggle');
+ if (unwatchedToggle) {
+ if (updatedProgress.remainingCount === 0) {
+ unwatchedToggle.classList.add('hide');
+ } else {
+ unwatchedToggle.classList.remove('hide');
+ }
+ }
+
+ LOG(`Updated UI for series: ${updatedProgress.series.Name} (${updatedProgress.percentage}% complete)`);
+ }
+
+ // Function to create chunked progress bar showing individual episode status
+ async function createChunkedProgressBar(card, progressData) {
+ const progressBar = card.querySelector('.progress-bar');
+ if (!progressBar) return;
+
+ const { totalEpisodes, watchedCount } = progressData;
+ const seriesId = progressData.series.Id;
+
+ // Check if we have binary progress data
+ if (progressData.binaryProgress) {
+ // Create progress bar from binary data (instant)
+ createBinaryProgressBar(progressBar, progressData.binaryProgress, seriesId);
+ LOG(`Created binary progress bar for series: ${progressData.series.Name}`);
+
+ // Enhance with episode details in background
+ setTimeout(async () => {
+ try {
+ const episodes = await fetchEpisodesForSeries(seriesId);
+ if (episodes && episodes.length > 0) {
+ enhanceProgressBarWithEpisodes(progressBar, episodes);
+ LOG(`Enhanced progress bar with episode details for series: ${progressData.series.Name}`);
+ }
+ } catch (err) {
+ ERR(`Failed to enhance progress bar for series ${seriesId}:`, err);
+ }
+ }, 100); // Small delay to let initial render complete
+
+ } else {
+ // Fallback to simple progress bar
+ progressBar.innerHTML = `
`;
+ }
+ }
+
+ // Function to update progress data in localStorage
+ function updateProgressDataInLocalStorage() {
+ if (progressCache.data.length === 0) {
+ LOG('No progress data to update in localStorage');
+ return;
+ }
+
+ try {
+ // Optimize the data for storage (same as in fetchAllProgressData)
+ const optimizedData = optimizeProgressDataForStorage(progressCache.data);
+
+ // Update localStorage with the new data
+ const success = localStorageCache.set('progress', optimizedData);
+ if (success) {
+ LOG('Updated progress data in localStorage');
+ } else {
+ WARN('Failed to update progress data in localStorage');
+ }
+ } catch (err) {
+ ERR('Error updating progress data in localStorage:', err);
+ }
+ }
+
+ // URL parameter handling functions (using Jellyfin's hash format)
+ function getUrlParams() {
+ // Parse hash parameters from the query string part of the hash
+ const hash = window.location.hash;
+
+ // Check if there are query parameters in the hash
+ if (hash.includes('?')) {
+ const [, queryString] = hash.split('?');
+ const params = new URLSearchParams(queryString);
+
+ return {
+ pageTab: params.get('pageTab') || 'watchlist',
+ page: parseInt(params.get('page')) || 1,
+ search: params.get('search') || '',
+ movieSearch: params.get('movieSearch') || '',
+ sort: params.get('sort') || 'lastWatched',
+ sortDirection: params.get('sortDirection') || 'desc',
+ movieSort: params.get('movieSort') || 'lastWatched',
+ movieSortDirection: params.get('movieSortDirection') || 'desc'
+ };
+ }
+
+ // No query parameters found, return defaults
+ return {
+ pageTab: 'watchlist',
+ page: 1,
+ search: '',
+ movieSearch: '',
+ sort: 'lastWatched',
+ sortDirection: 'desc',
+ movieSort: 'lastWatched',
+ movieSortDirection: 'desc'
+ };
+ }
+
+ function updateUrlParams(pageTab, page, sort = null, sortDirection = null, movieSort = null, movieSortDirection = null) {
+ const currentHash = window.location.hash;
+
+ let newHash = currentHash;
+
+ // Get search terms from respective caches
+ const searchTerm = pageTab === 'progress' ? progressCache.searchTerm : '';
+ const movieSearchTerm = pageTab === 'history' ? movieCache.searchTerm : '';
+
+ // Get current sort order and direction or use provided values
+ const currentSort = sort || (pageTab === 'progress' ? getCurrentSortOrder() : null);
+ const currentDirection = sortDirection || (pageTab === 'progress' ? getCurrentSortDirection() : null);
+ const currentMovieSort = movieSort || (pageTab === 'history' ? getCurrentMovieSortOrder() : null);
+ const currentMovieDirection = movieSortDirection || (pageTab === 'history' ? getCurrentMovieSortDirection() : null);
+
+ if (currentHash.includes('?')) {
+ // Replace existing parameters
+ const [hashPath, hashSearch] = currentHash.split('?');
+ const params = new URLSearchParams(hashSearch);
+
+ // Set our parameters
+ params.set('pageTab', pageTab);
+
+ // Only set page parameter for paginated tabs (progress and history)
+ if (pageTab === 'progress' || pageTab === 'history') {
+ params.set('page', page.toString());
+ } else {
+ // Remove page parameter for non-paginated tabs (watchlist and statistics)
+ params.delete('page');
+ }
+
+ // Update sort parameters for progress tab
+ if (currentSort && pageTab === 'progress') {
+ params.set('sort', currentSort);
+ if (currentDirection) {
+ params.set('sortDirection', currentDirection);
+ }
+ } else if (pageTab !== 'progress') {
+ params.delete('sort');
+ params.delete('sortDirection');
+ }
+
+ // Update movie sort parameters for history tab
+ if (currentMovieSort && pageTab === 'history') {
+ params.set('movieSort', currentMovieSort);
+ if (currentMovieDirection) {
+ params.set('movieSortDirection', currentMovieDirection);
+ }
+ } else if (pageTab !== 'history') {
+ params.delete('movieSort');
+ params.delete('movieSortDirection');
+ }
+
+ // Update search parameters based on tab
+ if (searchTerm && searchTerm.trim() !== '') {
+ params.set('search', searchTerm);
+ } else {
+ params.delete('search');
+ }
+
+ if (movieSearchTerm && movieSearchTerm.trim() !== '') {
+ params.set('movieSearch', movieSearchTerm);
+ } else {
+ params.delete('movieSearch');
+ }
+
+ const paramString = params.toString();
+ newHash = paramString ? `${hashPath}?${paramString}` : hashPath;
+ } else {
+ // Add parameters
+ let paramString = `pageTab=${pageTab}`;
+
+ // Only add page parameter for paginated tabs (progress and history)
+ if (pageTab === 'progress' || pageTab === 'history') {
+ paramString += `&page=${page}`;
+ }
+
+ // Add sort parameters for progress tab
+ if (currentSort && pageTab === 'progress') {
+ paramString += `&sort=${currentSort}`;
+ if (currentDirection) {
+ paramString += `&sortDirection=${currentDirection}`;
+ }
+ }
+
+ // Add movie sort parameters for history tab
+ if (currentMovieSort && pageTab === 'history') {
+ paramString += `&movieSort=${currentMovieSort}`;
+ if (currentMovieDirection) {
+ paramString += `&movieSortDirection=${currentMovieDirection}`;
+ }
+ }
+
+ if (searchTerm && searchTerm.trim() !== '') {
+ paramString += `&search=${encodeURIComponent(searchTerm)}`;
+ }
+ if (movieSearchTerm && movieSearchTerm.trim() !== '') {
+ paramString += `&movieSearch=${encodeURIComponent(movieSearchTerm)}`;
+ }
+ newHash = `${currentHash}?${paramString}`;
+ }
+
+ // Update cache currentPage for the appropriate tab
+ if (pageTab === 'progress') {
+ progressCache.currentPage = page;
+ } else if (pageTab === 'history') {
+ movieCache.currentPage = page;
+ }
+
+ const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
+
+ // Replace history entry instead of adding new one
+ window.history.replaceState(null, '', newUrl);
+ LOG(`Hash updated: pageTab=${pageTab}, page=${page}, search="${searchTerm}", movieSearch="${movieSearchTerm}"`, newUrl);
+ }
+
+ // Sorting configuration and functions
+ const SORT_OPTIONS = {
+ lastWatched: {
+ label: 'Last Watched',
+ default: true,
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortByLastWatched(a, b, direction)
+ },
+ name: {
+ label: 'Name',
+ defaultDirection: 'asc',
+ sortFn: (a, b, direction) => sortByName(a, b, direction)
+ },
+ progress: {
+ label: 'Progress',
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortByProgress(a, b, direction)
+ },
+ episodeCount: {
+ label: 'Episode Count',
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortByEpisodeCount(a, b, direction)
+ },
+ releaseDate: {
+ label: 'Release Date',
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortByReleaseDate(a, b, direction)
+ }
+ };
+
+ // Movie-specific sorting configuration
+ const MOVIE_SORT_OPTIONS = {
+ lastWatched: {
+ label: 'Last Watched',
+ default: true,
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortMovieByLastWatched(a, b, direction)
+ },
+ name: {
+ label: 'Name',
+ defaultDirection: 'asc',
+ sortFn: (a, b, direction) => sortMovieByName(a, b, direction)
+ },
+ premiereDate: {
+ label: 'Premiere Date',
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortMovieByPremiereDate(a, b, direction)
+ },
+ runtime: {
+ label: 'Runtime',
+ defaultDirection: 'desc',
+ sortFn: (a, b, direction) => sortMovieByRuntime(a, b, direction)
+ }
+ };
+
+ // Get current sort order from URL or localStorage
+ function getCurrentSortOrder() {
+ const params = getUrlParams();
+ if (params.sort && SORT_OPTIONS[params.sort]) {
+ return params.sort;
+ }
+
+ // Fallback to localStorage
+ const savedSort = localStorage.getItem('kefinTweaks_progressSort');
+ if (savedSort && SORT_OPTIONS[savedSort]) {
+ return savedSort;
+ }
+
+ return 'lastWatched';
+ }
+
+ // Get current sort direction from URL or localStorage
+ function getCurrentSortDirection() {
+ const params = getUrlParams();
+ if (params.sortDirection && (params.sortDirection === 'asc' || params.sortDirection === 'desc')) {
+ return params.sortDirection;
+ }
+
+ // Fallback to localStorage
+ const savedDirection = localStorage.getItem('kefinTweaks_progressSortDirection');
+ if (savedDirection && (savedDirection === 'asc' || savedDirection === 'desc')) {
+ return savedDirection;
+ }
+
+ // Default to the sort option's default direction
+ const currentSort = getCurrentSortOrder();
+ return SORT_OPTIONS[currentSort]?.defaultDirection || 'desc';
+ }
+
+ // Movie-specific sort functions
+ function getCurrentMovieSortOrder() {
+ const params = getUrlParams();
+ if (params.movieSort && MOVIE_SORT_OPTIONS[params.movieSort]) {
+ return params.movieSort;
+ }
+
+ // Fallback to localStorage
+ const savedSort = localStorage.getItem('kefinTweaks_movieSort');
+ if (savedSort && MOVIE_SORT_OPTIONS[savedSort]) {
+ return savedSort;
+ }
+
+ return 'lastWatched';
+ }
+
+ function getCurrentMovieSortDirection() {
+ const params = getUrlParams();
+ if (params.movieSortDirection && (params.movieSortDirection === 'asc' || params.movieSortDirection === 'desc')) {
+ return params.movieSortDirection;
+ }
+
+ // Fallback to localStorage
+ const savedDirection = localStorage.getItem('kefinTweaks_movieSortDirection');
+ if (savedDirection && (savedDirection === 'asc' || savedDirection === 'desc')) {
+ return savedDirection;
+ }
+
+ // Default to the sort option's default direction
+ const currentSort = getCurrentMovieSortOrder();
+ return MOVIE_SORT_OPTIONS[currentSort]?.defaultDirection || 'desc';
+ }
+
+ // Set sort order and update URL
+ function setSortOrder(sortKey, direction = null) {
+ if (!SORT_OPTIONS[sortKey]) {
+ LOG(`Invalid sort key: ${sortKey}`);
+ return;
+ }
+
+ // Use provided direction or default for the sort option
+ const sortDirection = direction || SORT_OPTIONS[sortKey].defaultDirection;
+
+ // Save to localStorage
+ localStorage.setItem('kefinTweaks_progressSort', sortKey);
+ localStorage.setItem('kefinTweaks_progressSortDirection', sortDirection);
+
+ // Update URL - reset to page 1 when sort changes
+ const params = getUrlParams();
+ updateUrlParams(params.pageTab, 1, sortKey, sortDirection);
+
+ // Update tab state to reflect page reset
+ if (tabStates.progress) {
+ tabStates.progress.currentPage = 1;
+ }
+
+ // Re-render content
+ renderProgressContent();
+ }
+
+ // Sort function for last watched (individual items)
+ function sortByLastWatched(a, b, direction = 'desc') {
+ // Get last watched dates from progress data
+ const dateA = a.lastWatchedEpisode && a.lastWatchedEpisode.UserData && a.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(a.lastWatchedEpisode.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last watched date
+
+ const dateB = b.lastWatchedEpisode && b.lastWatchedEpisode.UserData && b.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(b.lastWatchedEpisode.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last watched date
+
+ // Sort by direction
+ return direction === 'asc' ? dateA - dateB : dateB - dateA;
+ }
+
+ // Sort function for name
+ function sortByName(a, b, direction = 'asc') {
+ const result = a.series.Name.localeCompare(b.series.Name);
+ return direction === 'asc' ? result : -result;
+ }
+
+ // Sort function for progress percentage
+ function sortByProgress(a, b, direction = 'desc') {
+ // Primary sort: progress percentage
+ const progressResult = b.percentage - a.percentage;
+
+ // If progress is the same, use last watched date as tiebreaker
+ if (progressResult === 0) {
+ const dateA = a.lastWatchedEpisode && a.lastWatchedEpisode.UserData && a.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(a.lastWatchedEpisode.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last watched date
+
+ const dateB = b.lastWatchedEpisode && b.lastWatchedEpisode.UserData && b.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(b.lastWatchedEpisode.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last watched date
+
+ // For tiebreaker, always use descending (most recent first) regardless of main direction
+ const tiebreakerResult = dateB - dateA;
+ return direction === 'asc' ? -progressResult : progressResult + (tiebreakerResult * 0.0001);
+ }
+
+ return direction === 'asc' ? -progressResult : progressResult;
+ }
+
+ // Sort function for episode count
+ function sortByEpisodeCount(a, b, direction = 'desc') {
+ const result = b.totalEpisodes - a.totalEpisodes;
+ return direction === 'asc' ? -result : result;
+ }
+
+ // Sort function for release date
+ function sortByReleaseDate(a, b, direction = 'desc') {
+ const dateA = new Date(a.series.ProductionYear);
+ const dateB = new Date(b.series.ProductionYear);
+ const result = dateB - dateA;
+ return direction === 'asc' ? -result : result;
+ }
+
+ // Movie-specific sort functions
+ function sortMovieByLastWatched(a, b, direction = 'desc') {
+ const dateA = a.UserData && a.UserData.LastPlayedDate
+ ? new Date(a.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last played date
+
+ const dateB = b.UserData && b.UserData.LastPlayedDate
+ ? new Date(b.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last played date
+
+ // Sort by direction
+ return direction === 'asc' ? dateA - dateB : dateB - dateA;
+ }
+
+ function sortMovieByName(a, b, direction = 'asc') {
+ const result = a.Name.localeCompare(b.Name);
+ return direction === 'asc' ? result : -result;
+ }
+
+ function sortMovieByPremiereDate(a, b, direction = 'desc') {
+ const dateA = new Date(a.PremiereDate || a.ProductionYear);
+ const dateB = new Date(b.PremiereDate || b.ProductionYear);
+ const result = dateB - dateA;
+ return direction === 'asc' ? -result : result;
+ }
+
+ function sortMovieByRuntime(a, b, direction = 'desc') {
+ const runtimeA = a.RunTimeTicks ? Math.floor(a.RunTimeTicks / 10000000) : 0; // Convert ticks to seconds
+ const runtimeB = b.RunTimeTicks ? Math.floor(b.RunTimeTicks / 10000000) : 0;
+ const result = runtimeB - runtimeA;
+ return direction === 'asc' ? -result : result;
+ }
+
+ // Set movie sort order and update URL
+ function setMovieSortOrder(sortKey, direction = null) {
+ if (!MOVIE_SORT_OPTIONS[sortKey]) {
+ LOG(`Invalid movie sort key: ${sortKey}`);
+ return;
+ }
+
+ // Use provided direction or default for the sort option
+ const sortDirection = direction || MOVIE_SORT_OPTIONS[sortKey].defaultDirection;
+
+ // Save to localStorage
+ localStorage.setItem('kefinTweaks_movieSort', sortKey);
+ localStorage.setItem('kefinTweaks_movieSortDirection', sortDirection);
+
+ // Update URL - reset to page 1 when sort changes
+ const params = getUrlParams();
+ updateUrlParams(params.pageTab, 1, null, null, sortKey, sortDirection);
+
+ // Update tab state to reflect page reset
+ if (tabStates.history) {
+ tabStates.history.currentPage = 1;
+ }
+
+ // Re-render content
+ renderHistoryContent();
+ }
+
+ // Apply sorting to data
+ function applySorting(data, sortKey) {
+ if (!SORT_OPTIONS[sortKey]) {
+ LOG(`Invalid sort key: ${sortKey}`);
+ return data;
+ }
+
+ const sortDirection = getCurrentSortDirection();
+ const sortFn = SORT_OPTIONS[sortKey].sortFn;
+ const sortedData = [...data].sort((a, b) => sortFn(a, b, sortDirection));
+ LOG(`Applied sorting: ${SORT_OPTIONS[sortKey].label} (${sortDirection})`);
+ return sortedData;
+ }
+
+ // Apply sorting to movie data
+ function applyMovieSorting(data, sortKey) {
+ if (!MOVIE_SORT_OPTIONS[sortKey]) {
+ LOG(`Invalid movie sort key: ${sortKey}`);
+ return data;
+ }
+
+ const sortDirection = getCurrentMovieSortDirection();
+ const sortFn = MOVIE_SORT_OPTIONS[sortKey].sortFn;
+ const sortedData = [...data].sort((a, b) => sortFn(a, b, sortDirection));
+ LOG(`Applied movie sorting: ${MOVIE_SORT_OPTIONS[sortKey].label} (${sortDirection})`);
+ return sortedData;
+ }
+
+ function updateUrlWithSearch(pageTab, page, search = '', movieSearch = '') {
+ const currentHash = window.location.hash;
+
+ let newHash = currentHash;
+
+ if (currentHash.includes('?')) {
+ // Replace existing parameters
+ const [hashPath, hashSearch] = currentHash.split('?');
+ const params = new URLSearchParams(hashSearch);
+
+ // Set our parameters
+ params.set('pageTab', pageTab);
+ params.set('page', page.toString());
+
+ // Add or remove search parameter
+ if (search && search.trim() !== '') {
+ params.set('search', search);
+ } else {
+ params.delete('search');
+ }
+
+ // Add or remove movie search parameter
+ if (movieSearch && movieSearch.trim() !== '') {
+ params.set('movieSearch', movieSearch);
+ } else {
+ params.delete('movieSearch');
+ }
+
+ const paramString = params.toString();
+ newHash = paramString ? `${hashPath}?${paramString}` : hashPath;
+ } else {
+ // Add parameters
+ let paramString = `pageTab=${pageTab}&page=${page}`;
+ if (search && search.trim() !== '') {
+ paramString += `&search=${encodeURIComponent(search)}`;
+ }
+ if (movieSearch && movieSearch.trim() !== '') {
+ paramString += `&movieSearch=${encodeURIComponent(movieSearch)}`;
+ }
+ newHash = `${currentHash}?${paramString}`;
+ }
+
+ // Update cache currentPage for the appropriate tab
+ if (pageTab === 'progress') {
+ progressCache.currentPage = page;
+ } else if (pageTab === 'history') {
+ movieCache.currentPage = page;
+ }
+
+ const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
+
+ // Replace history entry instead of adding new one
+ window.history.replaceState(null, '', newUrl);
+ LOG(`Hash updated: pageTab=${pageTab}, page=${page}, search="${search}", movieSearch="${movieSearch}"`, newUrl);
+ }
+
+ function isProgressTabActive() {
+ const params = getUrlParams();
+ return params.pageTab === 'progress';
+ }
+
+ // Hash change listener for browser navigation
+ function setupHashChangeListener() {
+ window.addEventListener('hashchange', () => {
+ if (!window.location.hash.includes('home.html')) {
+ return;
+ }
+
+ LOG('Hash changed, checking watchlist tab');
+ const params = getUrlParams();
+
+ // Check if we're on any watchlist tab and render accordingly
+ if (params.pageTab === 'progress' || params.pageTab === 'history' || params.pageTab === 'watchlist' || params.pageTab === 'statistics') {
+ // Find the watchlist section and switch to the appropriate tab
+ const watchlistSection = getWatchlistSection();
+ if (watchlistSection) {
+ // Update tab UI
+ const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button');
+ buttons.forEach(button => button.classList.remove('active'));
+
+ const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)');
+ tabContents.forEach(content => content.style.display = 'none');
+
+ const activeButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${params.pageTab}"]`);
+ const activeContent = watchlistSection.querySelector(`div[data-tab="${params.pageTab}"]:not(.watchlist-tabs)`);
+
+ if (activeButton) activeButton.classList.add('active');
+ if (activeContent) activeContent.style.display = 'block';
+
+ // Render content based on active tab
+ if (params.pageTab === 'progress') {
+ renderProgressContent();
+ } else if (params.pageTab === 'watchlist') {
+ renderWatchlistContent();
+ } else if (params.pageTab === 'history') {
+ renderHistoryContent();
+ } else if (params.pageTab === 'statistics') {
+ renderStatisticsContent();
+ }
+ }
+ }
+ });
+ }
+
+ // Tab initialization functions
+ async function initProgressTab() {
+ const watchlistSection = getWatchlistSection();
+ const progressContent = watchlistSection ? watchlistSection.querySelector('div[data-tab="progress"] .progress-series') : null;
+
+ // Only skip if we're currently fetching OR if data is fetched AND we have cached data
+ if (tabStates.progress.isFetching || (tabStates.progress.isDataFetched && progressCache.allDataLoaded)) {
+ LOG('Progress tab already initialized or fetching');
+ return;
+ }
+
+ const urlParams = getUrlParams();
+ if (urlParams.search) {
+ const searchInput = getElementByIdSafe('progress-search');
+ if (searchInput) {
+ searchInput.value = urlParams.search;
+ }
+ }
+
+ LOG('Initializing progress tab');
+ renderProgressContent();
+
+ try {
+ await fetchAllProgressData(true);
+ tabStates.progress.isDataFetched = true;
+ tabStates.progress.isFetching = false;
+
+ if (urlParams.search) {
+ updateSearchResults(urlParams.search);
+ }
+
+ if (movieCache && movieCache.allDataLoaded) {
+ renderStatisticsContent();
+ }
+
+ renderProgressContent();
+ LOG('Progress tab initialization complete');
+ } catch (err) {
+ ERR('Error initializing progress tab:', err);
+ tabStates.progress.isFetching = false;
+ }
+ }
+
+ async function initHistoryTab() {
+ if (tabStates.history.isFetching) {
+ LOG('History tab is currently fetching, skipping');
+ return;
+ }
+
+ const urlParams = getUrlParams();
+ if (urlParams.movieSearch) {
+ const searchInput = getElementByIdSafe('movie-search');
+ if (searchInput) {
+ searchInput.value = urlParams.movieSearch;
+ }
+ }
+
+ LOG('Initializing history tab');
+ renderHistoryContent();
+
+ try {
+ // Always fetch data (will use cache if available)
+ await fetchWatchedMovies(true);
+ tabStates.history.isDataFetched = true;
+ tabStates.history.isFetching = false;
+
+ if (urlParams.movieSearch) {
+ updateMovieSearchResults(urlParams.movieSearch);
+ }
+
+ if (progressCache && progressCache.allDataLoaded) {
+ renderStatisticsContent();
+ }
+
+ // Always render content, even if data was already fetched
+ // This ensures content is rendered when tab is reinitialized
+ renderHistoryContent();
+ LOG('History tab initialization complete');
+ } catch (err) {
+ ERR('Error initializing history tab:', err);
+ tabStates.history.isFetching = false;
+ }
+ }
+
+ // Initialize a specific watchlist section
+ async function initWatchlistSection(section, type) {
+ LOG(`Initializing ${section} section`);
+
+ // Check localStorage cache first
+ const userId = window.ApiClient.getCurrentUserId();
+ const cachedData = localStorageCache.get(`watchlist_${section}`);
+ if (cachedData) {
+ LOG(`Using localStorage cache for watchlist ${section}`);
+ watchlistCache[section].data = cachedData;
+ return;
+ }
+
+ try {
+ const items = await fetchLikedItems(type);
+
+ // Sort items by release date descending (newest first)
+ const sortedItems = items.sort((a, b) => {
+ const dateA = new Date(a.PremiereDate || a.ProductionYear || 0);
+ const dateB = new Date(b.PremiereDate || b.ProductionYear || 0);
+ return dateB - dateA; // Descending order (newest first)
+ });
+
+ // Store in cache
+ watchlistCache[section].data = sortedItems;
+
+ // Store in localStorage for next time (optimized)
+ const optimizedData = optimizeWatchlistDataForStorage(sortedItems);
+ localStorageCache.set(`watchlist_${section}`, optimizedData);
+ LOG(`Stored optimized watchlist ${section} data in localStorage`);
+
+ // Sync watched status for this section (remove played items)
+ const playedItems = sortedItems.filter(item => item.UserData && item.UserData.Played);
+ if (playedItems.length > 0) {
+ LOG(`Found ${playedItems.length} played items in ${section} section, removing from watchlist`);
+ for (const item of playedItems) {
+ await removeItemFromWatchlist(item.Id, item.Type);
+ }
+ }
+
+ LOG(`${section} section initialization complete: ${sortedItems.length} items`);
+ } catch (err) {
+ ERR(`Error initializing ${section} section:`, err);
+ watchlistCache[section].data = [];
+ }
+ }
+
+ async function initWatchlistTab() {
+ if (tabStates.watchlist.isFetching) {
+ LOG('Watchlist tab is fetching');
+ return;
+ }
+
+ LOG('Initializing watchlist tab');
+ tabStates.watchlist.isFetching = true;
+ tabStates.watchlist.isDataFetched = false;
+
+ renderWatchlistContent();
+
+ try {
+ // Initialize all watchlist sections in parallel
+ await Promise.all([
+ initWatchlistSection('movies', 'Movie'),
+ initWatchlistSection('series', 'Series'),
+ initWatchlistSection('seasons', 'Season'),
+ initWatchlistSection('episodes', 'Episode')
+ ]);
+
+ tabStates.watchlist.isDataFetched = true;
+ tabStates.watchlist.isFetching = false;
+
+ // Sync watched status to remove played items
+ await syncWatchedStatusToWatchlist();
+
+ renderWatchlistContent();
+
+ LOG('Watchlist tab initialization complete');
+ } catch (err) {
+ ERR('Error initializing watchlist tab:', err);
+ tabStates.watchlist.isFetching = false;
+ // Hide loading state on error too
+ hideWatchlistLoading();
+ }
+ }
+
+ async function initStatisticsTab() {
+ if (tabStates.statistics.isFetching || tabStates.statistics.isDataFetched) {
+ LOG('Statistics tab already initialized or fetching');
+ return;
+ }
+
+ // Statistics depends on progress and history data
+ if (!tabStates.progress.isDataFetched || !tabStates.history.isDataFetched) {
+ LOG('Statistics tab waiting for progress and history data');
+ return;
+ }
+
+ LOG('Initializing statistics tab');
+ tabStates.statistics.isFetching = true;
+
+ try {
+ // Statistics are calculated from existing cache data
+ tabStates.statistics.isDataFetched = true;
+ tabStates.statistics.isFetching = false;
+
+ await renderStatisticsContent();
+ LOG('Statistics tab initialization complete');
+ } catch (err) {
+ ERR('Error initializing statistics tab:', err);
+ tabStates.statistics.isFetching = false;
+ }
+ }
+
+ // Initialize all tabs with proper dependencies
+ async function initializeAllTabs() {
+ LOG('Starting tab initialization');
+
+ // Independent tabs can run in parallel
+ const watchlistPromise = initWatchlistTab();
+
+ // Dependent tabs: progress and history first, then statistics
+ const progressPromise = initProgressTab();
+ const historyPromise = initHistoryTab();
+
+ // Wait for progress and history, then init statistics
+ await Promise.all([watchlistPromise, progressPromise, historyPromise]);
+ await initStatisticsTab();
+
+ LOG('All tabs initialized');
+ }
+
+ function sortInProgressSeries(series) {
+ LOG('Starting to sort series by Status and date');
+
+ const sorted = series.sort((a, b) => {
+ // First, separate Continuing vs Ended
+ if (a.Status === 'Continuing' && b.Status === 'Ended') return -1;
+ if (a.Status === 'Ended' && b.Status === 'Continuing') return 1;
+
+ // Within same status, sort by appropriate date
+ if (a.Status === 'Continuing') {
+ const dateA = new Date(a.PremiereDate || 0);
+ const dateB = new Date(b.PremiereDate || 0);
+ return dateB - dateA; // Newest first
+ } else { // Ended
+ const dateA = new Date(a.EndDate || 0);
+ const dateB = new Date(b.EndDate || 0);
+ return dateB - dateA; // Most recently ended first
+ }
+ });
+
+ // Debug logging for sorting results
+ const continuingCount = sorted.filter(s => s.Status === 'Continuing').length;
+ const endedCount = sorted.filter(s => s.Status === 'Ended').length;
+ LOG(`Sorting complete: ${continuingCount} Continuing, ${endedCount} Ended series`);
+
+ return sorted;
+ }
+
+ function sortProgressDataByLastWatched(progressDataArray) {
+ LOG('Starting to sort progress data by most recently watched episode');
+
+ const sorted = progressDataArray.sort((a, b) => {
+ // Get last watched dates from progress data
+ const dateA = a.lastWatchedEpisode && a.lastWatchedEpisode.UserData && a.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(a.lastWatchedEpisode.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last watched date
+
+ const dateB = b.lastWatchedEpisode && b.lastWatchedEpisode.UserData && b.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(b.lastWatchedEpisode.UserData.LastPlayedDate)
+ : new Date(0); // Fallback to epoch if no last watched date
+
+ // Sort by most recently watched first (descending order)
+ return dateB - dateA;
+ });
+
+ // Debug logging for sorting results
+ const withLastWatched = sorted.filter(p => p.lastWatchedEpisode && p.lastWatchedEpisode.UserData && p.lastWatchedEpisode.UserData.LastPlayedDate).length;
+ const withoutLastWatched = sorted.length - withLastWatched;
+ LOG(`Progress sorting complete: ${withLastWatched} with last watched date, ${withoutLastWatched} without`);
+
+ return sorted;
+ }
+
+
+ async function fetchAllProgressData(useCache = true) {
+ // Check if we're already fetching to prevent duplicate calls
+ if (tabStates.progress.isFetching) {
+ LOG('Progress data fetch already in progress, skipping duplicate call');
+ return progressCache.data; // Return existing data or empty array
+ }
+
+ // Check if we already have all progress data loaded
+ if (useCache && progressCache.allDataLoaded) {
+ LOG('Using cached all progress data');
+ return progressCache.data;
+ }
+
+ // Check localStorage cache if in-memory cache is empty
+ if (useCache && !progressCache.allDataLoaded) {
+ const cachedData = localStorageCache.get(`progress`);
+ if (cachedData) {
+ LOG('Using localStorage cache for progress data');
+ progressCache.data = cachedData;
+ progressCache.allDataLoaded = true;
+ progressCache.totalPages = Math.ceil(cachedData.length / progressCache.pageSize);
+ return cachedData;
+ }
+ }
+
+ // Set fetching flag to prevent duplicate calls
+ tabStates.progress.isFetching = true;
+ LOG('Starting progress data fetch...');
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ // Fetch series from API directly
+ const seriesUrl = `${serverUrl}/Items?IncludeItemTypes=Series&UserId=${userId}&Recursive=true&Fields=UserData,RecursiveItemCount&EnableImageTypes=Primary,Banner`;
+
+ try {
+ const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const seriesData = await seriesRes.json();
+ const series = seriesData.Items || [];
+
+ if (series.length === 0) {
+ LOG('No series found, returning empty progress data');
+ progressCache.data = [];
+ progressCache.allDataLoaded = true;
+ return [];
+ }
+
+ // Filter series that have been started (UserData.PlayedPercentage > 0)
+ const inProgressSeries = series.filter(series => {
+ return series.UserData &&
+ series.UserData.PlayedPercentage > 0;
+ });
+ LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`);
+
+
+ LOG(`Fetching progress data for all ${inProgressSeries.length} series`);
+
+ // Fetch progress data for all series in parallel
+ const progressData = await Promise.all(
+ inProgressSeries.map(async (seriesItem) => {
+ return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token);
+ })
+ );
+
+ // Filter out series with no valid progress data
+ const validProgressData = progressData.filter(data => data !== null);
+ LOG(`Successfully processed ${validProgressData.length} progress entries`);
+
+ // Sort by most recently watched episode
+ const sortedProgressData = sortProgressDataByLastWatched(validProgressData);
+
+ // Update cache with all progress data
+ progressCache.data = sortedProgressData;
+ progressCache.allDataLoaded = true;
+ progressCache.totalPages = Math.ceil(sortedProgressData.length / progressCache.pageSize);
+
+ // Store in localStorage for next time (optimized)
+ const optimizedData = optimizeProgressDataForStorage(sortedProgressData);
+ localStorageCache.set(`progress`, optimizedData);
+ LOG('Stored optimized progress data in localStorage');
+
+ LOG(`All progress data loaded and sorted: ${sortedProgressData.length} items, ${progressCache.totalPages} pages`);
+
+ return sortedProgressData;
+ } catch (err) {
+ ERR("Failed to fetch all progress data:", err);
+ return [];
+ } finally {
+ // Always clear the fetching flag, even on error
+ tabStates.progress.isFetching = false;
+ LOG('Progress data fetch completed');
+ renderStatisticsContent();
+ }
+ }
+
+ async function fetchProgressForPage(page = 1) {
+ LOG(`Getting progress data for page ${page} from preloaded data`);
+
+ // Ensure we have all progress data loaded
+ if (!progressCache.allDataLoaded || progressCache.data.length === 0) {
+ await fetchAllProgressData(true);
+ }
+
+ // Get current sort order and apply sorting
+ const currentSort = getCurrentSortOrder();
+ const sortedData = applySorting(progressCache.data, currentSort);
+
+ // Update pagination with sorted data
+ progressPagination.updateCache(sortedData);
+
+ // Get the progress data for this page from sorted data
+ const { pageData } = progressPagination.getCachedPage(page);
+
+ return pageData;
+ }
+
+ async function fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token) {
+ try {
+ // Check if we already have progress data cached for this series
+ const cachedProgress = getCachedSeriesProgress(series.Id);
+ if (cachedProgress) {
+ LOG(`Using cached progress for series: ${series.Name}`);
+ return cachedProgress;
+ }
+
+ LOG(`Fetching progress for series: ${series.Name} (${series.Status})`);
+
+ // Get all episodes including missing ones to get accurate TotalRecordCount
+ const episodesUrl = `${serverUrl}/Shows/${series.Id}/Episodes?UserId=${userId}&Fields=UserData&EnableImageTypes=Primary`;
+ const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const episodesData = await episodesRes.json();
+ let episodes = episodesData.Items || [];
+
+ episodes = episodes
+ .filter(ep => ep.ParentIndexNumber !== 0)
+ .filter((ep, index, self) =>
+ index === self.findIndex(e =>
+ e.ParentIndexNumber === ep.ParentIndexNumber &&
+ e.IndexNumber === ep.IndexNumber
+ )
+ );
+
+ // Count only episodes that have aired (have PremiereDate and it's before today)
+ const today = new Date();
+ today.setHours(23, 59, 59, 999); // End of today
+
+ const airedEpisodes = episodes.filter(ep =>
+ ep.PremiereDate && new Date(ep.PremiereDate) <= today
+ );
+ const totalEpisodes = airedEpisodes.length;
+
+ // Count watched episodes from aired episodes only
+ const watchedEpisodes = airedEpisodes.filter(ep => ep.UserData && ep.UserData.Played === true);
+ const watchedCount = watchedEpisodes.length;
+ const remainingCount = totalEpisodes - watchedCount;
+
+ // Debug logging to verify we're getting the right data
+ LOG(`Series: ${series.Name}, Aired Episodes: ${totalEpisodes}, Watched: ${watchedCount}, Remaining: ${remainingCount}`);
+
+ if (totalEpisodes === 0) return null;
+
+ // Calculate total runtime from available episodes (only those with actual files)
+ const availableEpisodes = episodes.filter(ep => ep.RunTimeTicks && ep.RunTimeTicks > 0);
+ const totalRuntime = availableEpisodes.reduce((total, ep) => total + (ep.RunTimeTicks / 10000000), 0);
+
+ // Calculate watched runtime from actually watched episodes
+ const watchedRuntime = watchedEpisodes.reduce((total, ep) => total + (ep.RunTimeTicks ? ep.RunTimeTicks / 10000000 : 0), 0);
+
+ // Estimate remaining runtime (this is approximate since we don't have runtime for missing episodes)
+ const remainingRuntime = totalRuntime - watchedRuntime;
+
+ // Find last watched episode
+ let lastWatchedEpisode = null;
+ if (watchedEpisodes.length > 0) {
+ lastWatchedEpisode = watchedEpisodes
+ .filter(ep => ep.UserData && ep.UserData.LastPlayedDate)
+ .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate))[0];
+ }
+
+ // Calculate percentage based on total episodes (including missing ones)
+ const percentage = totalEpisodes > 0 ? Math.round((watchedCount / totalEpisodes) * 100) : 0;
+
+ // Filter unwatched episodes from aired episodes only
+ const unwatchedEpisodes = airedEpisodes.filter(episode =>
+ !episode.UserData.Played || episode.UserData.Played === false
+ );
+
+ // Generate binary progress data for instant rendering
+ const binaryProgress = generateBinaryProgressData(episodes);
+
+ const progressData = {
+ series: series,
+ watchedCount: watchedCount,
+ totalEpisodes: totalEpisodes,
+ remainingCount: remainingCount,
+ percentage: percentage,
+ totalRuntime: totalRuntime,
+ watchedRuntime: watchedRuntime,
+ remainingRuntime: remainingRuntime,
+ lastWatchedEpisode: lastWatchedEpisode,
+ // Binary progress data for instant rendering (stored in localStorage)
+ binaryProgress: binaryProgress,
+ // Episodes cached in memory only (not stored in localStorage)
+ episodes: episodes
+ };
+
+ // Cache the progress data for this series
+ cacheSeriesProgress(series.Id, progressData);
+
+ return progressData;
+ } catch (err) {
+ ERR(`Failed to fetch progress for series ${series.Name}:`, err);
+ return null;
+ }
+ }
+
+ function formatTimeAgo(dateString) {
+ const now = new Date();
+ const date = new Date(dateString);
+ const diffInSeconds = Math.floor((now - date) / 1000);
+
+ if (diffInSeconds < 60) return 'just now';
+ if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`;
+ if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
+ if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
+ if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`;
+ return `${Math.floor(diffInSeconds / 31536000)} years ago`;
+ }
+
+ function formatLastWatchedDate(dateString) {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ });
+ }
+
+ // Play movie function
+ function playMovie(movieId) {
+ LOG(`Playing movie: ${movieId}`);
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+
+ // Use Jellyfin's built-in play functionality
+ apiClient.play({
+ Ids: [movieId],
+ UserId: userId
+ });
+ }
+
+ async function toggleMovieFavorite(movieId, buttonElement) {
+ LOG(`Toggling favorite for movie: ${movieId}`);
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ try {
+ // Determine current favorite status from button state
+ const isCurrentlyFavorite = buttonElement.classList.contains('favorited');
+ const method = isCurrentlyFavorite ? 'DELETE' : 'POST';
+
+ // Make the appropriate API call
+ const toggleUrl = `${serverUrl}/Users/${userId}/FavoriteItems/${movieId}`;
+ const toggleRes = await fetch(toggleUrl, {
+ method: method,
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`
+ }
+ });
+
+ if (!toggleRes.ok) {
+ throw new Error(`Failed to ${isCurrentlyFavorite ? 'remove from' : 'add to'} favorites: ${toggleRes.status}`);
+ }
+
+ // Update button appearance based on the new state (opposite of current)
+ const icon = buttonElement.querySelector('.material-icons');
+ const text = buttonElement.querySelector('span:last-child');
+ const newFavoriteStatus = !isCurrentlyFavorite;
+
+ if (newFavoriteStatus) {
+ buttonElement.classList.add('favorited');
+ } else {
+ buttonElement.classList.remove('favorited');
+ }
+
+ LOG(`Successfully ${newFavoriteStatus ? 'added to' : 'removed from'} favorites: ${movieId}`);
+
+ } catch (err) {
+ ERR('Error toggling movie favorite:', err);
+ // You could add a toast notification here if desired
+ }
+ }
+
+ // Statistics calculation functions
+ async function calculateProgressStatistics(progressData) {
+ if (!progressData || progressData.length === 0) {
+ // Still try to get movie count even if no series data
+ await fetchWatchedMovies(true); // Use cached data
+ return {
+ seriesStarted: 0,
+ seriesWatched: 0,
+ episodesWatched: 0,
+ moviesWatched: movieCache.data.length,
+ topShows: []
+ };
+ }
+
+ // Calculate basic statistics
+ const seriesStarted = progressData.length;
+ const seriesWatched = progressData.filter(progress => progress.percentage === 100).length;
+ const episodesWatched = progressData.reduce((total, progress) => total + progress.watchedCount, 0);
+
+ // Get movie count from cache
+ await fetchWatchedMovies(true); // Use cached data
+ const moviesWatched = movieCache.data.length;
+
+ // Calculate top 5 shows by completion percentage, then by episode count
+ const topShows = progressData
+ .map(progress => ({
+ name: progress.series.Name,
+ episodesWatched: progress.watchedCount,
+ totalEpisodes: progress.totalEpisodes,
+ percentage: progress.percentage,
+ seriesId: progress.series.Id
+ }))
+ .sort((a, b) => {
+ // Primary sort: completion percentage (highest first)
+ const percentageDiff = b.percentage - a.percentage;
+ if (percentageDiff !== 0) {
+ return percentageDiff;
+ }
+ // Tiebreaker: episode count (most episodes first)
+ return b.totalEpisodes - a.totalEpisodes;
+ })
+ .slice(0, 5);
+
+ return {
+ seriesStarted,
+ seriesWatched,
+ episodesWatched,
+ moviesWatched,
+ topShows
+ };
+ }
+
+ async function updateProgressStatistics(progressData) {
+ const stats = await calculateProgressStatistics(progressData);
+
+ // Update stat cards
+ const seriesStartedEl = getElementByIdSafe('stat-series-started');
+ const seriesWatchedEl = getElementByIdSafe('stat-series-watched');
+ const episodesWatchedEl = getElementByIdSafe('stat-episodes-watched');
+ const moviesWatchedEl = getElementByIdSafe('stat-movies-watched');
+
+ if (seriesStartedEl) seriesStartedEl.textContent = stats.seriesStarted.toLocaleString();
+ if (seriesWatchedEl) seriesWatchedEl.textContent = stats.seriesWatched.toLocaleString();
+ if (episodesWatchedEl) episodesWatchedEl.textContent = stats.episodesWatched.toLocaleString();
+ if (moviesWatchedEl) moviesWatchedEl.textContent = stats.moviesWatched.toLocaleString();
+
+ // Update top shows list
+ updateTopShowsList(stats.topShows);
+
+ LOG(`Statistics updated: ${stats.seriesStarted} started, ${stats.seriesWatched} watched, ${stats.episodesWatched} episodes, ${stats.moviesWatched} movies`);
+ }
+
+ function updateTopShowsList(topShows) {
+ const topShowsList = getElementByIdSafe('top-shows-list');
+ if (!topShowsList) return;
+
+ if (topShows.length === 0) {
+ topShowsList.innerHTML = '';
+ return;
+ }
+
+ const showsHtml = topShows.map((show, index) => {
+ const rank = index + 1;
+ const episodesText = show.episodesWatched === 1 ? 'episode' : 'episodes';
+
+ return `
+
+
${rank}
+
${show.name}
+
${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
+
+ `;
+ }).join('');
+
+ topShowsList.innerHTML = showsHtml;
+ }
+
+ function updateProgressStats() {
+ const progressCountEl = getElementByIdSafe('progress-count');
+ const completedCountEl = getElementByIdSafe('completed-count');
+
+ if (!progressCountEl || !completedCountEl) return;
+
+ // Always use full, non-filtered progress data for stats
+ const fullData = progressCache.data;
+
+ // Calculate counts
+ let inProgressCount = 0;
+ let completedCount = 0;
+
+ fullData.forEach(progress => {
+ if (progress.percentage >= 100) {
+ completedCount++;
+ } else if (progress.percentage > 0) {
+ inProgressCount++;
+ }
+ });
+
+ // Update the display
+ progressCountEl.textContent = `${inProgressCount} In Progress`;
+ completedCountEl.textContent = `${completedCount} Watched`;
+
+ LOG(`Progress stats updated: ${inProgressCount} in progress, ${completedCount} completed (from full dataset)`);
+ }
+
+ async function renderProgressContent() {
+ const watchlistSection = getWatchlistSection();
+ const progressTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="progress"]') : null;
+ const paginatedContainer = progressTab ? progressTab.querySelector('.paginated-container') : null;
+ const container = paginatedContainer ? paginatedContainer.querySelector('.progress-series') : null;
+
+ if (!container) {
+ WARN("Progress container not found");
+ return;
+ }
+
+ try {
+ // Check if we're currently fetching data or data hasn't been fetched yet
+ if (tabStates.progress.isFetching || !tabStates.progress.isDataFetched) {
+ hideEmptyProgressMessage();
+ container.innerHTML = '';
+ return;
+ }
+
+ // Get current page and search term from URL
+ const params = getUrlParams();
+ const currentPage = (params.pageTab === 'progress') ?
+ (params.page || 1) :
+ (progressCache.currentPage || 1);
+ const searchTerm = params.search || '';
+
+ // Get progress data for current page from preloaded data
+ const pageProgress = await fetchProgressForPage(currentPage);
+
+ const searchInput = getElementByIdSafe('progress-search');
+ if (searchInput && params.pageTab === 'progress') {
+ searchInput.value = searchTerm;
+ searchInput.focus();
+ }
+
+ // Check if we should skip rendering
+ if (shouldSkipRendering('progress', currentPage, searchTerm, container, pageProgress)) {
+ LOG(`Skipping progress render - same page (${currentPage}), search ("${searchTerm}"), and content`);
+ // Still need to ensure pagination controls are rendered
+ const totalPages = progressCache.searchTerm ? progressCache.filteredPages : progressCache.totalPages;
+ if (totalPages > 1) {
+ renderPaginationControls(currentPage, totalPages, 'progress');
+ }
+ return;
+ }
+
+ // Restore search state if URL has search parameter
+ if (searchTerm && searchTerm !== progressCache.searchTerm) {
+ const clearBtn = getElementByIdSafe('progress-search-clear');
+
+ if (searchInput) {
+ searchInput.value = searchTerm;
+ updateSearchResults(searchTerm);
+
+ if (clearBtn) {
+ clearBtn.style.display = 'flex';
+ }
+ }
+ }
+
+ LOG(`Rendering progress content for page ${currentPage}`);
+
+ // Ensure we have all progress data loaded
+ await fetchAllProgressData(true);
+
+ // Get total pages from current data (filtered if searching)
+ const totalPages = getCurrentTotalPages();
+
+ // Update statistics with all progress data (not just current page)
+ updateProgressStatistics(progressCache.data);
+
+ if (!pageProgress || pageProgress.length === 0) {
+ // Hide any existing content and show appropriate empty message
+ container.innerHTML = '';
+ if (progressCache.searchTerm && progressCache.searchTerm.trim() !== '') {
+ showEmptySearchMessage();
+ } else {
+ showEmptyProgressMessage();
+ }
+ return;
+ }
+
+ // Hide empty message and clear container
+ hideEmptyProgressMessage();
+ container.innerHTML = '';
+
+ // Create progress cards asynchronously
+ for (const progress of pageProgress) {
+ const progressCard = await createProgressCard(progress);
+ container.appendChild(progressCard);
+ }
+
+ // Update progress stats
+ updateProgressStats();
+
+ // Add pagination controls to both top and bottom
+ renderPaginationControls(currentPage, totalPages, 'progress');
+
+ // Update cache currentPage
+ progressCache.currentPage = currentPage;
+
+ // Update tab state after successful render
+ updateTabState('progress', currentPage, searchTerm);
+
+ LOG(`Rendered ${pageProgress.length} progress items for page ${currentPage} of ${totalPages}`);
+ } catch (err) {
+ ERR('Error rendering progress content:', err);
+ // Clear container and show empty message on error
+ container.innerHTML = '';
+ clearTabState('progress');
+ showEmptyProgressMessage();
+ }
+ }
+
+ // Centralized pagination system
+ function createPaginationSystem(cache) {
+ return {
+ isCacheValid: () => cache.data.length > 0,
+
+ clearCache: () => {
+ cache.data = [];
+ cache.filteredData = [];
+ cache.totalPages = 0;
+ cache.sorted = false;
+ cache.searchTerm = '';
+ cache.filteredPages = 0;
+ cache.allDataLoaded = false;
+ LOG('Cache cleared');
+ },
+
+ updateCache: (data) => {
+ cache.data = data;
+ cache.totalPages = Math.ceil(data.length / cache.pageSize);
+ cache.sorted = true;
+ cache.allDataLoaded = true;
+ LOG(`Cache updated: ${data.length} items, ${cache.totalPages} pages`);
+ },
+
+ getCurrentData: () => {
+ return cache.searchTerm ? cache.filteredData : cache.data;
+ },
+
+ getCurrentTotalPages: () => {
+ return cache.searchTerm ? cache.filteredPages : cache.totalPages;
+ },
+
+ getCachedPage: (page = 1) => {
+ const currentData = cache.searchTerm ? cache.filteredData : cache.data;
+ const totalPages = cache.searchTerm ? cache.filteredPages : cache.totalPages;
+
+ const startIndex = (page - 1) * cache.pageSize;
+ const endIndex = startIndex + cache.pageSize;
+ const pageData = currentData.slice(startIndex, endIndex);
+
+ LOG(`Retrieved page ${page}: ${pageData.length} items (${startIndex}-${endIndex}) from ${currentData.length} total`);
+ return { pageData, totalPages, currentPage: page };
+ }
+ };
+ }
+
+ // Create pagination systems
+ const progressPagination = createPaginationSystem(progressCache);
+ const moviePagination = createPaginationSystem(movieCache);
+
+ // Watchlist cache for different sections
+ const watchlistCache = {
+ movies: { data: [] },
+ series: { data: [] },
+ seasons: { data: [] },
+ episodes: { data: [] }
+ };
+
+ // Function to render the complete watchlist HTML structure
+ function renderWatchlistHtml() {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) {
+ WARN('Watchlist section not found, cannot render HTML');
+ return;
+ }
+
+ // Check if HTML is already rendered
+ if (watchlistSection.dataset.htmlRendered === 'true') {
+ LOG('Watchlist HTML already rendered, skipping');
+ return;
+ }
+
+ LOG('Rendering watchlist HTML structure');
+
+ watchlistSection.innerHTML = `
+
+ Watchlist
+ Series Progress
+ Movie History
+ Statistics
+
+
+
+
+
+
+
+
+
+ bookmark_border
+
+
Your Watchlist is Empty
+
Click the bookmark_border to add movies and shows to your watchlist
+
+
+
+
+
+
+
+ search
+
+
+ close
+
+
+
+
+
+
+ acute
+
+
No Progress to Show
+
Start watching some shows to track your progress here
+
+
+
+
+
+
+
+ search
+
+
+ close
+
+
+
+
+
+
+ movie
+
+
No Movies Watched
+
Start watching some movies to see your history here
+
+
+
+
+
+
+
+
Loading statistics data...
+
+
+
+
+
+
+
+ check_circle_outline
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Mark as rendered to prevent duplicate rendering
+ watchlistSection.dataset.htmlRendered = 'true';
+ // Reset listeners setup flag since we have new DOM elements
+ watchlistSection.dataset.listenersSetup = 'false';
+
+ // Add refresh buttons after HTML is rendered
+ addRefreshButtons();
+
+ LOG('Watchlist HTML structure rendered successfully');
+ }
+
+ // Clear watchlist cache
+ function clearWatchlistCache() {
+ Object.keys(watchlistCache).forEach(section => {
+ watchlistCache[section].data = [];
+ });
+ LOG('Watchlist cache cleared');
+ }
+
+ // Update watchlist cache when an item is toggled
+ async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) {
+ try {
+ // Determine which cache section this item belongs to
+ let sectionName;
+ switch (itemType) {
+ case 'Movie':
+ sectionName = 'movies';
+ break;
+ case 'Series':
+ sectionName = 'series';
+ break;
+ case 'Season':
+ sectionName = 'seasons';
+ break;
+ case 'Episode':
+ sectionName = 'episodes';
+ break;
+ default:
+ LOG('Unknown item type for cache update:', itemType);
+ return;
+ }
+
+ if (isAdded) {
+ // Item was added to watchlist - fetch the item and add to cache
+ const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId);
+ if (item) {
+ // Add to cache if not already present
+ const existingIndex = watchlistCache[sectionName].data.findIndex(cachedItem => cachedItem.Id === itemId);
+ if (existingIndex === -1) {
+ watchlistCache[sectionName].data.push(item);
+
+ // Sort by release date descending (newest first)
+ watchlistCache[sectionName].data.sort((a, b) => {
+ const dateA = new Date(a.PremiereDate || a.ProductionYear || 0);
+ const dateB = new Date(b.PremiereDate || b.ProductionYear || 0);
+ return dateB - dateA;
+ });
+
+ // Update localStorage cache
+ const optimizedData = optimizeWatchlistDataForStorage(watchlistCache[sectionName].data);
+ localStorageCache.set(`watchlist_${sectionName}`, optimizedData);
+
+ LOG(`Added ${itemType} to watchlist cache: ${item.Name}`);
+ }
+ }
+ } else {
+ // Item was removed from watchlist - remove from cache
+ const existingIndex = watchlistCache[sectionName].data.findIndex(cachedItem => cachedItem.Id === itemId);
+ if (existingIndex !== -1) {
+ const removedItem = watchlistCache[sectionName].data.splice(existingIndex, 1)[0];
+
+ // Update localStorage cache
+ const optimizedData = optimizeWatchlistDataForStorage(watchlistCache[sectionName].data);
+ localStorageCache.set(`watchlist_${sectionName}`, optimizedData);
+
+ LOG(`Removed ${itemType} from watchlist cache: ${removedItem.Name}`);
+ }
+ }
+ } catch (err) {
+ ERR('Error updating watchlist cache:', err);
+ }
+ }
+
+ // Initialize monitoring to remove watched items from watchlist (playback + manual)
+ function initializePlaybackMonitoring() {
+ if (playbackMonitorInitialized) {
+ return;
+ }
+
+ // Override fetch to intercept playback stopped and watch status update requests
+ window.fetch = async function(...args) {
+ const response = await originalFetch.apply(this, args);
+
+ // Check if this is a playback stopped request
+ if (args[0] && typeof args[0] === 'string' && args[0].includes('/Sessions/Playing/Stopped')) {
+ try {
+ // Clone the response to avoid consuming it
+ const clonedResponse = response.clone();
+
+ // Get the request body if it exists
+ if (args[1] && args[1].body) {
+ const body = args[1].body;
+ let payload;
+
+ // Handle different body types
+ if (typeof body === 'string') {
+ payload = JSON.parse(body);
+ } else if (body instanceof FormData) {
+ // FormData - extract JSON if available
+ const jsonData = body.get('data');
+ if (jsonData) {
+ payload = JSON.parse(jsonData);
+ }
+ } else {
+ payload = body;
+ }
+
+ if (payload && payload.ItemId) {
+ await handlePlaybackStopped(payload.ItemId);
+ }
+ }
+ } catch (err) {
+ ERR('Error processing playback stopped request:', err);
+ }
+ }
+
+ // Check if this is a watch status update request (PlayedItems) - both POST and DELETE
+ if (args[0] && typeof args[0] === 'string' && args[0].includes('/PlayedItems/') && args[1] && (args[1].method === 'POST' || args[1].method === 'DELETE')) {
+ try {
+ // Extract item ID from URL: /Users/{userId}/PlayedItems/{itemId}
+ const urlMatch = args[0].match(/\/PlayedItems\/([^/?]+)/);
+ if (urlMatch && urlMatch[1]) {
+ const itemId = urlMatch[1];
+ const method = args[1].method;
+ LOG(`Detected watch status ${method} request for item:`, itemId);
+ await handleWatchStatusUpdate(itemId);
+ }
+ } catch (err) {
+ ERR('Error processing watch status update request:', err);
+ }
+ }
+
+ return response;
+ };
+
+ playbackMonitorInitialized = true;
+ LOG('Playback and watch status monitoring initialized');
+ }
+
+ // Shared handler for when items' watched status changes (played or unplayed)
+ async function handleItemWatchedStatusChange(itemId) {
+ try {
+ LOG('Item watched status changed:', itemId);
+
+ // Get the item data to check current played status
+ const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId);
+ if (!item) {
+ LOG('Could not fetch item data for:', itemId);
+ return;
+ }
+
+ const isPlayed = item.UserData && item.UserData.Played;
+ LOG(`Item is now ${isPlayed ? 'played' : 'unplayed'}:`, item.Name);
+
+ if (isPlayed) {
+ // Item is now played - remove from watchlist and update caches
+ LOG('Item is now played, removing from watchlist:', item.Name);
+ await removeItemFromWatchlist(itemId, item.Type);
+
+ // Update relevant caches based on item type
+ if (item.Type === 'Episode') {
+ // Update series progress cache for episodes
+ if (item.SeriesId) {
+ if (progressCache.allDataLoaded) {
+ LOG('Updating series progress cache for episode:', item.Name);
+ updateEpisodeWatchedStatusInCache(item.SeriesId, itemId, true);
+ } else {
+ LOG('Progress cache not fully loaded, updating localStorage only for episode:', item.Name);
+ await updateEpisodeInLocalStorage(item);
+ }
+ }
+
+ // Check parent Season and Series
+ await checkAndRemoveParentItems(item);
+ } else if (item.Type === 'Movie') {
+ // Update movie history cache for movies
+ if (movieCache.allDataLoaded) {
+ LOG('Updating movie history cache for movie:', item.Name);
+ await updateMovieHistoryCache(item);
+ } else {
+ LOG('Movie cache not fully loaded, updating localStorage only for movie:', item.Name);
+ await updateMovieInLocalStorage(item);
+ }
+ }
+
+ LOG('Successfully removed played item from watchlist and updated caches:', item.Name);
+ } else {
+ // Item is now unplayed - update caches to reflect unwatched status
+ LOG('Item is now unplayed, updating caches:', item.Name);
+
+ // Update relevant caches based on item type
+ if (item.Type === 'Episode') {
+ // Update series progress cache for episodes (mark as unwatched)
+ if (item.SeriesId) {
+ if (progressCache.allDataLoaded) {
+ LOG('Updating series progress cache for episode (unwatched):', item.Name);
+ updateEpisodeWatchedStatusInCache(item.SeriesId, itemId, false);
+ } else {
+ LOG('Progress cache not fully loaded, updating localStorage only for episode (unwatched):', item.Name);
+ await updateEpisodeInLocalStorageUnwatched(item);
+ }
+ }
+ } else if (item.Type === 'Movie') {
+ // Remove from movie history cache for movies
+ if (movieCache.allDataLoaded) {
+ LOG('Removing movie from history cache (unwatched):', item.Name);
+ await removeMovieFromHistoryCache(item);
+ } else {
+ LOG('Movie cache not fully loaded, updating localStorage only for movie (unwatched):', item.Name);
+ await removeMovieFromLocalStorage(item);
+ }
+ }
+
+ LOG('Successfully updated caches for unplayed item:', item.Name);
+ }
+ } catch (err) {
+ ERR('Error handling item watched status change:', err);
+ }
+ }
+
+ // Update episode in localStorage when progress cache is not fully loaded
+ async function updateEpisodeInLocalStorage(episode) {
+ try {
+ const cachedProgress = localStorageCache.get('progress');
+ if (!cachedProgress) {
+ LOG('No progress data in localStorage to update');
+ return;
+ }
+
+ const seriesIndex = cachedProgress.findIndex(p => p.series.Id === episode.SeriesId);
+ if (seriesIndex === -1) {
+ LOG('Series not found in localStorage progress data:', episode.SeriesId);
+ return;
+ }
+
+ const seriesProgress = cachedProgress[seriesIndex];
+ const season = String(episode.ParentIndexNumber);
+ const episodeIndex = episode.IndexNumber - 1; // Convert to 0-based index
+
+ // Update binaryProgress for the specific episode
+ if (seriesProgress.binaryProgress && seriesProgress.binaryProgress[season]) {
+ const seasonProgress = seriesProgress.binaryProgress[season];
+ if (episodeIndex >= 0 && episodeIndex < seasonProgress.length) {
+ // Mark episode as watched (set to '1')
+ seriesProgress.binaryProgress[season] =
+ seasonProgress.substring(0, episodeIndex) + '1' + seasonProgress.substring(episodeIndex + 1);
+
+ LOG(`Updated episode ${episode.IndexNumber} in season ${season} as watched in localStorage`);
+
+ // Save back to localStorage
+ localStorageCache.set('progress', cachedProgress);
+ }
+ }
+ } catch (err) {
+ ERR('Error updating episode in localStorage:', err);
+ }
+ }
+
+ // Update movie in localStorage when movie cache is not fully loaded
+ async function updateMovieInLocalStorage(movie) {
+ try {
+ const cachedMovies = localStorageCache.get('movies');
+ if (!cachedMovies) {
+ LOG('No movie data in localStorage to update');
+ return;
+ }
+
+ // Check if movie already exists
+ const existingIndex = cachedMovies.findIndex(m => m.Id === movie.Id);
+ if (existingIndex !== -1) {
+ // Update existing movie
+ cachedMovies[existingIndex] = movie;
+ LOG('Updated existing movie in localStorage:', movie.Name);
+ } else {
+ // Add new movie to beginning (most recent first)
+ cachedMovies.unshift(movie);
+ LOG('Added new movie to localStorage:', movie.Name);
+ }
+
+ // Save back to localStorage
+ localStorageCache.set('movies', cachedMovies);
+ } catch (err) {
+ ERR('Error updating movie in localStorage:', err);
+ }
+ }
+
+ // Update episode as unwatched in localStorage when progress cache is not fully loaded
+ async function updateEpisodeInLocalStorageUnwatched(episode) {
+ try {
+ const cachedProgress = localStorageCache.get('progress');
+ if (!cachedProgress) {
+ LOG('No progress data in localStorage to update');
+ return;
+ }
+
+ const seriesIndex = cachedProgress.findIndex(p => p.series.Id === episode.SeriesId);
+ if (seriesIndex === -1) {
+ LOG('Series not found in localStorage progress data:', episode.SeriesId);
+ return;
+ }
+
+ const seriesProgress = cachedProgress[seriesIndex];
+ const season = String(episode.ParentIndexNumber);
+ const episodeIndex = episode.IndexNumber - 1; // Convert to 0-based index
+
+ // Update binaryProgress for the specific episode
+ if (seriesProgress.binaryProgress && seriesProgress.binaryProgress[season]) {
+ const seasonProgress = seriesProgress.binaryProgress[season];
+ if (episodeIndex >= 0 && episodeIndex < seasonProgress.length) {
+ // Mark episode as unwatched (set to '0')
+ seriesProgress.binaryProgress[season] =
+ seasonProgress.substring(0, episodeIndex) + '0' + seasonProgress.substring(episodeIndex + 1);
+
+ LOG(`Updated episode ${episode.IndexNumber} in season ${season} as unwatched in localStorage`);
+
+ // Save back to localStorage
+ localStorageCache.set('progress', cachedProgress);
+ }
+ }
+ } catch (err) {
+ ERR('Error updating episode as unwatched in localStorage:', err);
+ }
+ }
+
+ // Remove movie from history cache when marked as unwatched
+ async function removeMovieFromHistoryCache(movie) {
+ try {
+ // Find and remove movie from cache
+ const existingIndex = movieCache.data.findIndex(cachedMovie => cachedMovie.Id === movie.Id);
+ if (existingIndex !== -1) {
+ movieCache.data.splice(existingIndex, 1);
+ LOG('Removed movie from history cache:', movie.Name);
+
+ // Update cache metadata
+ movieCache.totalPages = Math.ceil(movieCache.data.length / movieCache.pageSize);
+
+ // Update localStorage cache
+ await localStorageCache.set('movies', movieCache.data);
+
+ // Update filtered data if search is active
+ if (movieCache.searchTerm) {
+ updateMovieSearchResults(movieCache.searchTerm);
+ }
+
+ LOG('Movie removed from history cache successfully');
+ } else {
+ LOG('Movie not found in history cache:', movie.Name);
+ }
+ } catch (err) {
+ ERR('Error removing movie from history cache:', err);
+ }
+ }
+
+ // Remove movie from localStorage when movie cache is not fully loaded
+ async function removeMovieFromLocalStorage(movie) {
+ try {
+ const cachedMovies = localStorageCache.get('movies');
+ if (!cachedMovies) {
+ LOG('No movie data in localStorage to update');
+ return;
+ }
+
+ // Find and remove movie
+ const existingIndex = cachedMovies.findIndex(m => m.Id === movie.Id);
+ if (existingIndex !== -1) {
+ cachedMovies.splice(existingIndex, 1);
+ LOG('Removed movie from localStorage:', movie.Name);
+
+ // Save back to localStorage
+ localStorageCache.set('movies', cachedMovies);
+ } else {
+ LOG('Movie not found in localStorage:', movie.Name);
+ }
+ } catch (err) {
+ ERR('Error removing movie from localStorage:', err);
+ }
+ }
+
+ // Update movie history cache when a movie is marked as played
+ async function updateMovieHistoryCache(movie) {
+ try {
+ // Check if movie is already in cache
+ const existingIndex = movieCache.data.findIndex(cachedMovie => cachedMovie.Id === movie.Id);
+
+ if (existingIndex !== -1) {
+ // Update existing movie in cache
+ movieCache.data[existingIndex] = movie;
+ LOG('Updated existing movie in history cache:', movie.Name);
+ } else {
+ // Add new movie to cache
+ movieCache.data.unshift(movie); // Add to beginning (most recent first)
+ LOG('Added new movie to history cache:', movie.Name);
+ }
+
+ // Update cache metadata
+ movieCache.totalPages = Math.ceil(movieCache.data.length / movieCache.pageSize);
+ movieCache.allDataLoaded = true;
+
+ // Update localStorage cache
+ await localStorageCache.set(`movies`, movieCache.data);
+
+ // Update filtered data if search is active
+ if (movieCache.searchTerm) {
+ updateMovieSearchResults(movieCache.searchTerm);
+ }
+
+ LOG('Movie history cache updated successfully');
+ } catch (err) {
+ ERR('Error updating movie history cache:', err);
+ }
+ }
+
+ // Handle playback stopped event
+ async function handlePlaybackStopped(itemId) {
+ await handleItemWatchedStatusChange(itemId);
+ }
+
+ // Handle watch status update (manual "Mark as Watched" or "Mark as Unwatched")
+ async function handleWatchStatusUpdate(itemId) {
+ await handleItemWatchedStatusChange(itemId);
+ }
+
+ // Remove item from watchlist (API + cache + UI)
+ async function removeItemFromWatchlist(itemId, itemType) {
+ // Remove from watchlist via API
+ await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, 'false');
+
+ // Remove from cache
+ await updateWatchlistCacheOnToggle(itemId, itemType, false);
+
+ // Update watchlist button on item detail page if user is viewing this item
+ await updateWatchlistButtonOnDetailPage();
+ }
+
+ // Check and remove parent Season/Series if they're played
+ async function checkAndRemoveParentItems(episode) {
+ try {
+ // Check Season
+ if (episode.SeasonId) {
+ const seasonInWatchlist = await checkIfItemInWatchlist(episode.SeasonId);
+ if (seasonInWatchlist) {
+ const seasonItem = await ApiClient.getItem(ApiClient.getCurrentUserId(), episode.SeasonId);
+ if (seasonItem && seasonItem.UserData && seasonItem.UserData.Played) {
+ LOG('Season is now played, removing from watchlist:', seasonItem.Name);
+ await removeItemFromWatchlist(episode.SeasonId, 'Season');
+ }
+ }
+ }
+
+ // Check Series
+ if (episode.SeriesId) {
+ const seriesInWatchlist = await checkIfItemInWatchlist(episode.SeriesId);
+ if (seriesInWatchlist) {
+ const seriesItem = await ApiClient.getItem(ApiClient.getCurrentUserId(), episode.SeriesId);
+ if (seriesItem && seriesItem.UserData && seriesItem.UserData.Played) {
+ LOG('Series is now played, removing from watchlist:', seriesItem.Name);
+ await removeItemFromWatchlist(episode.SeriesId, 'Series');
+ }
+ }
+ }
+ } catch (err) {
+ ERR('Error checking parent items:', err);
+ }
+ }
+
+ // Sync watched status to watchlist - remove all played items
+ async function syncWatchedStatusToWatchlist() {
+ try {
+ LOG('Syncing watched status to watchlist...');
+ let removedCount = 0;
+
+ // Check all watchlist sections
+ const sections = ['movies', 'series', 'seasons', 'episodes'];
+ for (const section of sections) {
+ const items = watchlistCache[section].data;
+ const playedItems = items.filter(item => item.UserData && item.UserData.Played);
+
+ if (playedItems.length > 0) {
+ LOG(`Found ${playedItems.length} played items in ${section} section`);
+
+ // Remove each played item
+ for (const item of playedItems) {
+ await removeItemFromWatchlist(item.Id, item.Type);
+ removedCount++;
+ }
+ }
+ }
+
+ if (removedCount > 0) {
+ LOG(`Sync complete: removed ${removedCount} played items from watchlist`);
+ } else {
+ LOG('Sync complete: no played items found in watchlist');
+ }
+ } catch (err) {
+ ERR('Error syncing watched status to watchlist:', err);
+ }
+ }
+
+ // Update watchlist button on item detail page based on current item's watchlist status
+ async function updateWatchlistButtonOnDetailPage() {
+ try {
+ // Check if we're on an item detail page
+ const itemDetailPage = document.querySelector('#itemDetailPage:not(.hide)');
+ if (!itemDetailPage) {
+ LOG('Not on item detail page, skipping watchlist button update');
+ return;
+ }
+
+ // Check if there's a watchlist button
+ const watchlistButton = itemDetailPage.querySelector('.mainDetailButtons .watchlist-icon.detailButton');
+ if (!watchlistButton) {
+ LOG('No watchlist button found on detail page, skipping update');
+ return;
+ }
+
+ // Extract item ID from current URL
+ const currentUrl = window.location.hash;
+ const urlMatch = currentUrl.match(/[?&]id=([^&]+)/);
+ const currentItemId = urlMatch ? urlMatch[1] : null;
+
+ if (!currentItemId) {
+ LOG('Could not extract item ID from URL, skipping update');
+ return;
+ }
+
+ // Fetch current item data to get watchlist status
+ const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), currentItemId);
+ if (!item || !item.UserData) {
+ LOG('Could not fetch item data, skipping watchlist button update');
+ return;
+ }
+
+ // Update button based on current watchlist status
+ const isInWatchlist = item.UserData.Likes;
+ watchlistButton.setAttribute('data-active', isInWatchlist.toString());
+ watchlistButton.title = isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist';
+
+ LOG(`Updated watchlist button on detail page for item ${currentItemId}: ${isInWatchlist ? 'in watchlist' : 'not in watchlist'}`);
+ } catch (err) {
+ ERR('Error updating watchlist button on detail page:', err);
+ }
+ }
+
+ // Check if an item is in the user's watchlist via API
+ async function checkIfItemInWatchlist(itemId) {
+ try {
+ // First check if we have the item in our cache (faster)
+ const sections = ['movies', 'series', 'seasons', 'episodes'];
+ for (const section of sections) {
+ if (watchlistCache[section].data.some(item => item.Id === itemId)) {
+ return true;
+ }
+ }
+
+ // If not in cache, check via API
+ const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId);
+ if (item && item.UserData && item.UserData.Likes) {
+ LOG(`Item ${itemId} is in watchlist (via API check)`);
+ return true;
+ }
+
+ LOG(`Item ${itemId} is not in watchlist (via API check)`);
+ return false;
+ } catch (err) {
+ ERR('Error checking if item is in watchlist:', err);
+ return false;
+ }
+ }
+
+ async function fetchWatchedMovies(useCache = true) {
+ // Check if we're already fetching to prevent duplicate calls
+ if (tabStates.history.isFetching) {
+ LOG('Movie data fetch already in progress, skipping duplicate call');
+ return movieCache.data; // Return existing data or empty array
+ }
+
+ // Check if we have valid cached movie data
+ if (useCache && moviePagination.isCacheValid() && movieCache.data.length > 0) {
+ LOG('Using cached movie data');
+ return movieCache.data;
+ }
+
+ // Check localStorage cache if in-memory cache is empty
+ if (useCache && movieCache.data.length === 0) {
+ const cachedData = localStorageCache.get(`movies`);
+ if (cachedData) {
+ LOG('Using localStorage cache for movies');
+ // Reconstruct people data from IDs and people cache
+ const reconstructedData = reconstructPeopleData(cachedData);
+ moviePagination.updateCache(reconstructedData);
+ return reconstructedData;
+ }
+ }
+
+ // Set fetching flag to prevent duplicate calls
+ tabStates.history.isFetching = true;
+ LOG('Starting movie data fetch...');
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ // Fetch movies that have been played
+ const url = `${serverUrl}/Items?IncludeItemTypes=Movie&UserId=${userId}&Recursive=true&Filters=IsPlayed&Fields=UserData,ProviderIds,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
+
+ try {
+ const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const data = await res.json();
+ const movies = data.Items || [];
+
+ // Filter out movie duplicates
+ const seenImdbIds = new Set();
+ const filteredMovies = movies.filter(movie => {
+ if (!movie.ProviderIds || !movie.ProviderIds.Imdb) return true;
+ if (seenImdbIds.has(movie.ProviderIds.Imdb)) return false;
+ seenImdbIds.add(movie.ProviderIds.Imdb);
+ return true;
+ });
+
+ // Sort movies by most recently watched
+ const sortedMovies = filteredMovies.sort((a, b) => {
+ const dateA = a.UserData && a.UserData.LastPlayedDate ? new Date(a.UserData.LastPlayedDate) : new Date(0);
+ const dateB = b.UserData && b.UserData.LastPlayedDate ? new Date(b.UserData.LastPlayedDate) : new Date(0);
+ return dateB - dateA; // Most recent first
+ });
+
+ // Update cache
+ moviePagination.updateCache(sortedMovies);
+
+ // Store in localStorage for next time (optimized)
+ const optimizedMovies = optimizeMovieDataForStorage(sortedMovies);
+ localStorageCache.set(`movies`, optimizedMovies);
+ LOG('Stored optimized movie data in localStorage');
+
+ LOG(`Found and cached ${sortedMovies.length} watched movies`);
+ return sortedMovies;
+ } catch (err) {
+ ERR("Failed to fetch watched movies:", err);
+ return [];
+ } finally {
+ // Always clear the fetching flag, even on error
+ tabStates.history.isFetching = false;
+ LOG('Movie data fetch completed');
+ renderStatisticsContent();
+ }
+ }
+
+ function createMovieCard(movie) {
+ const card = document.createElement('div');
+ card.className = 'movie-card';
+
+ // Get movie image URL
+ const imageUrl = movie.ImageTags.Thumb && movie.ImageTags.Thumb
+ ? `/Items/${movie.Id}/Images/Thumb?maxHeight=400&maxWidth=400&tag=${movie.ImageTags.Thumb}`
+ : movie.BackdropImageTags && movie.BackdropImageTags[0]
+ ? `/Items/${movie.Id}/Images/Backdrop?maxHeight=400&maxWidth=400&tag=${movie.BackdropImageTags[0]}`
+ : movie.ImageTags.Primary
+ ? `/Items/${movie.Id}/Images/Primary?maxHeight=300&maxWidth=200&tag=${movie.ImageTags.Primary}`
+ : '';
+
+ // Format watched date
+ const watchedDate = movie.UserData && movie.UserData.LastPlayedDate
+ ? formatLastWatchedDate(movie.UserData.LastPlayedDate)
+ : 'Unknown date';
+
+ // Format runtime
+ const runtime = movie.RunTimeTicks
+ ? Math.round(movie.RunTimeTicks / 10000000 / 60) + ' min'
+ : 'Unknown runtime';
+
+ // Format year
+ const year = movie.ProductionYear || movie.PremiereDate
+ ? new Date(movie.PremiereDate || movie.ProductionYear).getFullYear()
+ : 'Unknown year';
+
+ // Check if movie is favorited
+ const isFavorite = movie.UserData && movie.UserData.IsFavorite;
+ const favoriteIcon = isFavorite ? 'favorite' : 'favorite_border';
+ const favoriteText = isFavorite ? 'Favorited' : 'Favorite';
+ const favoriteClass = isFavorite ? 'favorited' : '';
+
+ card.innerHTML = `
+
+
+ ${imageUrl ? `
` : `
movie
`}
+
+
+ check_circle
+ Watched
+
+
+ ${favoriteIcon}
+
+
+
+
+
+ `;
+
+ return card;
+ }
+
+ async function renderHistoryContent() {
+ LOG('Rendering Movie History content');
+
+ const watchlistSection = getWatchlistSection();
+ const historyTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="history"]') : null;
+ const paginatedContainer = historyTab ? historyTab.querySelector('.paginated-container') : null;
+ const container = paginatedContainer ? paginatedContainer.querySelector('.movie-history-grid') : null;
+ const emptyMessage = historyTab ? historyTab.querySelector('.movie-history-empty-message') : null;
+ const movieCount = getElementByIdSafe('movie-count');
+
+ if (!container) {
+ WARN("Movie history container not found");
+ return;
+ }
+
+ try {
+ // Check if we're currently fetching data or data hasn't been fetched yet
+ if (tabStates.history.isFetching || !tabStates.history.isDataFetched) {
+ hideEmptyHistoryMessage();
+ container.innerHTML = '';
+ return;
+ }
+
+ // Get current page and search terms from URL
+ const params = getUrlParams();
+ const currentPage = (params.pageTab === 'history') ?
+ (params.page || 1) :
+ (movieCache.currentPage || 1);
+ const movieSearchTerm = params.movieSearch || '';
+
+ // Get current sort order and apply sorting to movie data
+ const currentSort = getCurrentMovieSortOrder();
+ const sortedMovies = applyMovieSorting(movieCache.data, currentSort);
+
+ // Update pagination with sorted data
+ moviePagination.updateCache(sortedMovies);
+
+ // Get paginated movie data using the new pagination system
+ const { pageData: movies, totalPages } = moviePagination.getCachedPage(currentPage);
+
+ const searchInput = getElementByIdSafe('movie-search');
+ if (searchInput && params.pageTab === 'history') {
+ searchInput.value = movieSearchTerm;
+ searchInput.focus();
+ }
+
+ if (totalPages > 1) {
+ renderPaginationControls(currentPage, totalPages, 'history');
+ }
+
+ // Check if we should skip rendering
+ if (shouldSkipRendering('history', currentPage, movieSearchTerm, container, movies)) {
+ LOG(`Skipping history render - same page (${currentPage}), search ("${movieSearchTerm}"), and content`);
+ // Still need to ensure pagination controls are rendered
+ if (totalPages > 1) {
+ renderPaginationControls(currentPage, totalPages, 'history');
+ }
+ return;
+ }
+
+ // Restore search state if URL has search parameter
+ if (movieSearchTerm && movieSearchTerm !== movieCache.searchTerm) {
+ const searchInput = getElementByIdSafe('movie-search');
+ const clearBtn = getElementByIdSafe('movie-search-clear');
+
+ if (searchInput) {
+ searchInput.value = movieSearchTerm;
+ updateMovieSearchResults(movieSearchTerm);
+ if (clearBtn) clearBtn.style.display = 'flex';
+ }
+ }
+
+ // Always use full, non-filtered movie data for stats
+ const totalMovies = movieCache.data.length;
+
+ // Update movie count
+ if (movieCount) {
+ movieCount.textContent = `${totalMovies} Watched`;
+ }
+
+ if (totalMovies === 0) {
+ // Show empty message
+ container.innerHTML = '';
+ if (emptyMessage) {
+ emptyMessage.style.display = 'block';
+ }
+ LOG('No watched movies found, showing empty state');
+ return;
+ }
+
+ // Hide empty message and clear container
+ if (emptyMessage) {
+ emptyMessage.style.display = 'none';
+ }
+ container.innerHTML = '';
+
+ // Create movie cards
+ movies.forEach(movie => {
+ const movieCard = createMovieCard(movie);
+ container.appendChild(movieCard);
+ });
+
+ // Update cache currentPage
+ movieCache.currentPage = currentPage;
+
+ // Update tab state after successful render
+ updateTabState('history', currentPage, movieSearchTerm);
+
+ LOG(`Rendered ${movies.length} watched movies for page ${currentPage} of ${totalPages}`);
+ } catch (err) {
+ ERR('Error rendering movie history content:', err);
+ // Clear container and show empty message on error
+ container.innerHTML = '';
+ clearTabState('history');
+ if (emptyMessage) {
+ emptyMessage.style.display = 'block';
+ }
+ }
+ }
+
+ async function renderStatisticsContent() {
+ LOG('Rendering Statistics content');
+
+ try {
+ // Check if both data sources are fully loaded
+ const progressDataReady = progressCache.allDataLoaded;
+ const historyDataReady = movieCache.allDataLoaded;
+
+ const watchlistSection = getWatchlistSection();
+ const statisticsTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="statistics"]') : null;
+
+ if (!statisticsTab) return;
+
+ if (!progressDataReady || !historyDataReady) {
+ // Set data-ready="false" to show loading container
+ statisticsTab.setAttribute('data-ready', 'false');
+ return;
+ }
+
+ // Both data sources are ready, set data-ready="true" to show content
+ statisticsTab.setAttribute('data-ready', 'true');
+
+ // Both data sources are ready, proceed with statistics rendering
+ // Ensure we have all progress data loaded
+ await fetchAllProgressData(true);
+
+ // Update statistics with all progress data
+ await updateProgressStatistics(progressCache.data);
+
+ LOG('Statistics content rendered successfully');
+ } catch (err) {
+ ERR('Error rendering statistics content:', err);
+ // Set data-ready="false" on error
+ if (statisticsTab) {
+ statisticsTab.setAttribute('data-ready', 'false');
+ }
+ }
+ }
+
+ function renderPaginationControls(currentPage, totalPages, tabName = 'progress') {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ // Check if pagination already exists for this tab
+ const tabContent = watchlistSection.querySelector(`div[data-tab="${tabName}"]`);
+ const paginatedContainer = tabContent ? tabContent.querySelector('.paginated-container') : null;
+
+ if (!paginatedContainer) return;
+
+ // Check for existing pagination controls
+ const existingTopPagination = paginatedContainer.querySelector('.pagination-top');
+ const existingBottomPagination = paginatedContainer.querySelector('.pagination-bottom');
+
+ // If totalPages <= 1, remove existing pagination and return
+ if (totalPages <= 1) {
+ if (existingTopPagination) existingTopPagination.remove();
+ if (existingBottomPagination) existingBottomPagination.remove();
+ return; // Don't show pagination for single page
+ }
+
+ // Check if pagination already exists and is correct
+
+ // If pagination exists and shows the same page, just update active states
+ if (existingTopPagination && existingBottomPagination) {
+ const currentPageFromPagination = existingTopPagination.querySelector('.pagination-info')?.textContent;
+ const expectedPageInfo = `Page ${currentPage} of ${totalPages}`;
+
+ if (currentPageFromPagination === expectedPageInfo) {
+ // Just update active states without recreating
+ updatePaginationActiveStates(existingTopPagination, currentPage, totalPages);
+ updatePaginationActiveStates(existingBottomPagination, currentPage, totalPages);
+ return;
+ }
+ }
+
+ // Remove existing pagination for this specific tab only
+ if (existingTopPagination) existingTopPagination.remove();
+ if (existingBottomPagination) existingBottomPagination.remove();
+
+ // Create pagination container
+ const paginationContainer = document.createElement('div');
+ paginationContainer.className = 'pagination';
+ paginationContainer.innerHTML = `
+
+
+ `;
+
+ // Function to add event listeners to pagination container
+ const addPaginationListeners = (container) => {
+ container.addEventListener('click', (e) => {
+ const button = e.target.closest('.pagination-btn');
+ if (!button || button.classList.contains('disabled')) return;
+
+ const page = parseInt(button.dataset.page);
+ LOG(`Pagination clicked: page ${page}, current page ${currentPage}, tab ${tabName}`);
+
+ if (page && page !== currentPage) {
+ // Get search terms based on tab
+ const searchTerm = tabName === 'progress' ? progressCache.searchTerm : '';
+ const movieSearchTerm = tabName === 'history' ? movieCache.searchTerm : '';
+
+ LOG(`Navigating to page ${page} with search terms: progress="${searchTerm}", movie="${movieSearchTerm}"`);
+
+ updateUrlWithSearch(tabName, page, searchTerm, movieSearchTerm);
+
+ if (tabName === 'history') {
+ renderHistoryContent();
+ } else {
+ renderProgressContent();
+ }
+ }
+ });
+ };
+
+ // Add event listeners to the original container
+ addPaginationListeners(paginationContainer);
+
+ // Insert pagination controls at both top and bottom of paginated container
+ if (paginatedContainer) {
+ // Top pagination
+ const topPagination = paginationContainer.cloneNode(true);
+ topPagination.classList.add('pagination-top');
+ addPaginationListeners(topPagination); // Add event listeners to cloned container
+ paginatedContainer.insertBefore(topPagination, paginatedContainer.firstChild);
+
+ // Bottom pagination
+ const bottomPagination = paginationContainer.cloneNode(true);
+ bottomPagination.classList.add('pagination-bottom');
+ addPaginationListeners(bottomPagination); // Add event listeners to cloned container
+ paginatedContainer.appendChild(bottomPagination);
+ }
+ }
+
+ function updatePaginationActiveStates(paginationElement, currentPage, totalPages) {
+ // Update page info
+ const pageInfo = paginationElement.querySelector('.pagination-info');
+ if (pageInfo) {
+ pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
+ }
+
+ // Update button states
+ const buttons = paginationElement.querySelectorAll('.pagination-btn');
+ buttons.forEach(button => {
+ const page = parseInt(button.dataset.page);
+ button.classList.remove('active', 'disabled');
+
+ if (page === currentPage) {
+ button.classList.add('active');
+ } else if ((page === 1 || page === currentPage - 1) && currentPage === 1) {
+ button.classList.add('disabled');
+ } else if ((page === totalPages || page === currentPage + 1) && currentPage === totalPages) {
+ button.classList.add('disabled');
+ }
+ });
+ }
+
+ function generatePageNumbers(currentPage, totalPages) {
+ const maxVisible = 5;
+ let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
+ let endPage = Math.min(totalPages, startPage + maxVisible - 1);
+
+ // Adjust start if we're near the end
+ if (endPage - startPage + 1 < maxVisible) {
+ startPage = Math.max(1, endPage - maxVisible + 1);
+ }
+
+ let pages = '';
+
+ // Add first page and ellipsis if needed
+ if (startPage > 1) {
+ pages += ``;
+ if (startPage > 2) {
+ pages += '';
+ }
+ }
+
+ // Add visible pages
+ for (let i = startPage; i <= endPage; i++) {
+ pages += ``;
+ }
+
+ // Add last page and ellipsis if needed
+ if (endPage < totalPages) {
+ if (endPage < totalPages - 1) {
+ pages += '';
+ }
+ pages += ``;
+ }
+
+ return pages;
+ }
+
+ async function createProgressCard(progress) {
+ const { series, watchedCount, totalEpisodes, remainingCount, percentage, watchedRuntime, remainingRuntime, lastWatchedEpisode } = progress;
+
+ const card = document.createElement('div');
+ card.className = 'progress-card';
+
+ // Add completed class for 100% shows
+ if (percentage === 100) {
+ card.classList.add('progress-card-completed');
+ }
+
+ // Get series image URL
+ const imageUrl = window.innerWidth > 768 ?
+ series.ImageTags && series.ImageTags.Primary
+ ? `/Items/${series.Id}/Images/Primary?maxHeight=200&maxWidth=140&tag=${series.ImageTags.Primary}`
+ : ''
+ : series.ImageTags && series.ImageTags.Banner
+ ? `/Items/${series.Id}/Images/Banner?maxWidth=300&height=60&tag=${series.ImageTags.Banner}`
+ : '';
+
+ // Format last watched info (only if we have episode data)
+ let lastWatchedInfo = '';
+ if (lastWatchedEpisode) {
+ const timeAgo = formatTimeAgo(lastWatchedEpisode.UserData.LastPlayedDate);
+ const formattedDate = formatLastWatchedDate(lastWatchedEpisode.UserData.LastPlayedDate);
+ const episodeInfo = `${lastWatchedEpisode.ParentIndexNumber}x${lastWatchedEpisode.IndexNumber.toString().padStart(2, '0')} "${lastWatchedEpisode.Name}"`;
+ lastWatchedInfo = `Last watched ${episodeInfo} ${timeAgo} on ${formattedDate}.`;
+ }
+
+ card.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ ${percentage === 100
+ ? `Watched ${watchedCount} of ${totalEpisodes} episodes - Series Complete! `
+ : `Watched ${watchedCount} of ${totalEpisodes} episodes which leaves ${remainingCount} episodes left to watch.`
+ }
+
+
+ ${lastWatchedInfo ? `
${lastWatchedInfo}
` : ''}
+
+ + View Unwatched Episodes
+ + Mark All As Watched
+
+
+
+
+ `;
+
+ // Add click handler for unwatched episodes toggle
+ const unwatchedToggle = card.querySelector('.unwatched-toggle');
+ const unwatchedContainer = card.querySelector('.unwatched-episodes-container');
+
+ unwatchedToggle.addEventListener('click', async () => {
+ if (unwatchedContainer.style.display === 'none') {
+ // Show unwatched episodes
+ await loadUnwatchedEpisodes(series.Id, unwatchedContainer);
+ unwatchedContainer.style.display = 'block';
+ unwatchedToggle.textContent = '- Hide Unwatched Episodes';
+ } else {
+ // Hide unwatched episodes
+ unwatchedContainer.style.display = 'none';
+ unwatchedToggle.textContent = '+ View Unwatched Episodes';
+ }
+ });
+
+ // Add click handler for mark all as watched button
+ const markAllWatchedBtn = card.querySelector('.mark-all-watched');
+ if (markAllWatchedBtn) {
+ markAllWatchedBtn.addEventListener('click', async () => {
+ // Get series info for confirmation modal
+ const seriesInfo = {
+ name: series.Name,
+ watchedCount: progress.watchedCount,
+ totalEpisodes: progress.totalEpisodes,
+ unwatchedCount: progress.remainingCount,
+ percentage: progress.percentage
+ };
+
+ showMarkAllEpisodesConfirmation(series.Id, markAllWatchedBtn, seriesInfo);
+ });
+ }
+
+ // Create chunked progress bar
+ await createChunkedProgressBar(card, progress);
+
+ return card;
+ }
+
+ // Function to generate binary progress data from episodes
+ function generateBinaryProgressData(episodes) {
+ const today = new Date();
+ today.setHours(23, 59, 59, 999);
+
+ // Filter to aired episodes only and group by season
+ const airedEpisodes = episodes.filter(ep =>
+ ep.PremiereDate && new Date(ep.PremiereDate) <= today
+ );
+
+ const seasonProgress = {};
+
+ // Group episodes by season
+ airedEpisodes.forEach(episode => {
+ const season = episode.ParentIndexNumber;
+ if (!seasonProgress[season]) {
+ seasonProgress[season] = [];
+ }
+ seasonProgress[season].push(episode);
+ });
+
+ // Generate binary strings for each season
+ const binaryData = {};
+ Object.keys(seasonProgress).forEach(season => {
+ const seasonEpisodes = seasonProgress[season].sort((a, b) => a.IndexNumber - b.IndexNumber);
+ const binaryString = seasonEpisodes.map(ep =>
+ (ep.UserData && ep.UserData.Played === true) ? '1' : '0'
+ ).join('');
+ binaryData[season] = binaryString;
+ });
+
+ return binaryData;
+ }
+
+ // Function to create progress bar from binary data
+ function createBinaryProgressBar(progressBar, binaryData, seriesId) {
+ progressBar.innerHTML = '';
+ progressBar.setAttribute('data-series-id', seriesId);
+
+ Object.keys(binaryData).sort((a, b) => parseInt(a) - parseInt(b)).forEach(season => {
+ const binaryString = binaryData[season];
+
+ // Create chunks for this season
+ for (let i = 0; i < binaryString.length; i++) {
+ const isWatched = binaryString[i] === '1';
+ const chunk = document.createElement('div');
+ chunk.className = `progress-chunk ${isWatched ? 'watched' : 'unwatched'} binary-chunk`;
+ chunk.setAttribute('data-season', season);
+ chunk.setAttribute('data-episode', i + 1);
+ chunk.title = `S${season}:E${i + 1} (${isWatched ? 'Watched' : 'Unwatched'})`;
+
+ // Add click handler for all episodes
+ chunk.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!isWatched) {
+ // Unwatched episode - show confirmation modal (episode name will be enhanced later)
+ const episodeInfo = {
+ title: `Episode ${i + 1}`, // Placeholder name
+ season: parseInt(season),
+ episode: i + 1
+ };
+
+ const episodeId = chunk.getAttribute('data-episode-id');
+ showEpisodeWatchedConfirmation(episodeId, chunk, episodeInfo, seriesId);
+ } else {
+ // Watched episode - we'll need to get the episode ID from the enhanced data
+ // For now, we'll let the enhancement process handle this
+ LOG('Watched episode clicked, waiting for enhancement to get episode ID');
+ }
+ });
+
+ progressBar.appendChild(chunk);
+ }
+ });
+ }
+
+ // Function to enhance progress bar with episode details
+ function enhanceProgressBarWithEpisodes(progressBar, episodes) {
+ const today = new Date();
+ today.setHours(23, 59, 59, 999);
+
+ // Filter to aired episodes only
+ const airedEpisodes = episodes.filter(ep =>
+ ep.PremiereDate && new Date(ep.PremiereDate) <= today
+ );
+
+ // Group episodes by season
+ const seasonEpisodes = {};
+ airedEpisodes.forEach(episode => {
+ const season = episode.ParentIndexNumber;
+ if (!seasonEpisodes[season]) {
+ seasonEpisodes[season] = [];
+ }
+ seasonEpisodes[season].push(episode);
+ });
+
+ // Enhance each chunk with episode details
+ const chunks = progressBar.querySelectorAll('.binary-chunk');
+ chunks.forEach(chunk => {
+ const season = parseInt(chunk.getAttribute('data-season'));
+ const episodeNum = parseInt(chunk.getAttribute('data-episode'));
+
+ const seasonEpisodesList = seasonEpisodes[season];
+ if (seasonEpisodesList) {
+ const episode = seasonEpisodesList.find(ep => ep.IndexNumber === episodeNum);
+ if (episode) {
+ // Update chunk with episode details
+ chunk.setAttribute('data-episode-id', episode.Id);
+ chunk.setAttribute('data-episode-title', episode.Name);
+ chunk.title = `S${season}:E${episodeNum} - ${episode.Name} (${episode.UserData && episode.UserData.Played === true ? 'Watched' : 'Unwatched'})`;
+ chunk.classList.remove('binary-chunk'); // Remove binary class
+
+ // Add click handler for all episodes
+ chunk.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!episode.UserData || !episode.UserData.Played) {
+ // Unwatched episode - show confirmation modal
+ const episodeInfo = {
+ title: episode.Name,
+ season: season,
+ episode: episodeNum
+ };
+ showEpisodeWatchedConfirmation(episode.Id, chunk, episodeInfo, episode.SeriesId);
+ } else {
+ // Watched episode - navigate to episode details page
+ const episodeDetailsUrl = `/web/#/details?id=${episode.Id}&serverId=${ApiClient.serverId()}`;
+ window.location.href = episodeDetailsUrl;
+ }
+ });
+ }
+ }
+ });
+ }
+
+ // Function to update progress bar in place without recreating enhanced chunks
+ async function updateProgressBarInPlace(progressCard, updatedProgress) {
+ const progressBar = progressCard.querySelector('.progress-bar');
+ if (!progressBar) return;
+
+ const seriesId = updatedProgress.series.Id;
+
+ // Check if we have binary progress data
+ if (updatedProgress.binaryProgress) {
+ // Update existing chunks based on binary data
+ const existingChunks = progressBar.querySelectorAll('.progress-chunk');
+ existingChunks.forEach(chunk => {
+ const season = chunk.getAttribute('data-season');
+ const episode = parseInt(chunk.getAttribute('data-episode'));
+ const isWatched = updatedProgress.binaryProgress[season] &&
+ updatedProgress.binaryProgress[season][episode - 1] === '1';
+
+ // Update chunk state
+ chunk.classList.toggle('watched', isWatched);
+ chunk.classList.toggle('unwatched', !isWatched);
+
+ // Update title
+ if (chunk.title) {
+ chunk.title = chunk.title.replace(/\((Watched|Unwatched)\)/, isWatched ? '(Watched)' : '(Unwatched)');
+ }
+
+ // Remove click handler if watched
+ if (isWatched) {
+ const clone = chunk.cloneNode(true);
+ chunk.replaceWith(clone);
+ }
+ });
+
+ // If we have episodes data, enhance any remaining binary chunks
+ if (updatedProgress.episodes && updatedProgress.episodes.length > 0) {
+ setTimeout(async () => {
+ try {
+ enhanceProgressBarWithEpisodes(progressBar, updatedProgress.episodes);
+ } catch (err) {
+ ERR(`Failed to enhance progress bar for series ${seriesId}:`, err);
+ }
+ }, 100);
+ }
+ } else {
+ // Fallback to full recreation if no binary data
+ await createChunkedProgressBar(progressCard, updatedProgress);
+ }
+ }
+
+ // Function to fetch episodes on-demand from API with in-memory caching
+ async function fetchEpisodesForSeries(seriesId) {
+ // First check if we have episodes in memory cache
+ const cachedProgress = getCachedSeriesProgress(seriesId);
+ if (cachedProgress && cachedProgress.episodes) {
+ LOG(`Using in-memory episodes cache for series: ${seriesId} (${cachedProgress.episodes.length} episodes)`);
+ return cachedProgress.episodes;
+ }
+
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ try {
+ LOG(`Fetching episodes from API 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}\"` } });
+ const episodesData = await episodesRes.json();
+ let episodes = episodesData.Items || [];
+
+ // Filter and deduplicate episodes
+ episodes = episodes
+ .filter(ep => ep.ParentIndexNumber !== 0)
+ .filter((ep, index, self) =>
+ index === self.findIndex(e =>
+ e.ParentIndexNumber === ep.ParentIndexNumber &&
+ e.IndexNumber === ep.IndexNumber
+ )
+ );
+
+ // Cache episodes in memory (not localStorage)
+ if (cachedProgress) {
+ cachedProgress.episodes = episodes;
+ LOG(`Cached ${episodes.length} episodes in memory for series: ${seriesId}`);
+ }
+
+ LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}`);
+ return episodes;
+ } catch (err) {
+ ERR(`Failed to fetch episodes for series ${seriesId}:`, err);
+ return [];
+ }
+ }
+
+ // Function to load and display unwatched episodes
+ async function loadUnwatchedEpisodes(seriesId, container) {
+ const listContainer = container.querySelector('.unwatched-episodes-list');
+
+ // Show loading state
+ listContainer.innerHTML = 'Loading unwatched episodes...
';
+
+ try {
+ // Fetch episodes on-demand from API
+ const episodes = await fetchEpisodesForSeries(seriesId);
+
+ // Filter unwatched episodes
+ const unwatchedEpisodes = episodes.filter(ep =>
+ ep.ParentIndexNumber !== 0 &&
+ (!ep.UserData.Played || ep.UserData.Played === false)
+ );
+
+ if (unwatchedEpisodes.length === 0) {
+ listContainer.innerHTML = 'No unwatched episodes found
';
+ return;
+ }
+
+ // Sort episodes by season and episode number
+ const sortedEpisodes = [...unwatchedEpisodes].sort((a, b) => {
+ if (a.ParentIndexNumber !== b.ParentIndexNumber) {
+ return a.ParentIndexNumber - b.ParentIndexNumber;
+ }
+ return a.IndexNumber - b.IndexNumber;
+ });
+
+ // Create episode list HTML
+ const apiClient = window.ApiClient;
+ const episodesHtml = sortedEpisodes.map(episode => {
+ const seasonEpisode = `${episode.ParentIndexNumber}x${episode.IndexNumber.toString().padStart(2, '0')}`;
+ const episodeTitle = episode.Name || 'Untitled Episode';
+ const episodeDate = episode.PremiereDate ? new Date(episode.PremiereDate).toLocaleDateString() : '';
+
+ // Check if episode has a valid image
+ const hasImage = episode.ImageTags && episode.ImageTags.Primary;
+
+ const episodeImageHtml = hasImage
+ ? ` `
+ : `
+ tv
+
`;
+
+ return `
+
+
+ check
+
+
+ ${episodeImageHtml}
+
+
+
+ ${episodeDate ? `
${episodeDate}
` : ''}
+
+
+ `;
+ }).join('');
+
+ listContainer.innerHTML = episodesHtml;
+
+ // Add event listeners for watched toggle buttons
+ const watchedToggleButtons = listContainer.querySelectorAll('.episode-watched-toggle');
+ watchedToggleButtons.forEach(button => {
+ button.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const episodeId = button.getAttribute('data-episode-id');
+ const seriesId = button.getAttribute('data-series-id');
+ const season = button.getAttribute('data-season');
+ const episode = button.getAttribute('data-episode');
+
+ // Get episode info from the episode item
+ const episodeItem = button.closest('.unwatched-episode-item');
+ const episodeTitle = episodeItem?.querySelector('.episode-title a')?.textContent?.trim() || 'Unknown Episode';
+ const seasonEpisode = episodeItem?.querySelector('.episode-title a')?.textContent?.match(/S(\d+):E(\d+)/);
+
+ const episodeInfo = {
+ title: episodeTitle.replace(/^S\d+:E\d+\s*-\s*/, ''), // Remove season/episode prefix
+ season: season,
+ episode: episode,
+ seriesId: seriesId
+ };
+
+ showEpisodeWatchedConfirmation(episodeId, button, episodeInfo);
+ });
+ });
+
+ } catch (err) {
+ ERR('Error loading unwatched episodes:', err);
+ listContainer.innerHTML = 'Error loading episodes
';
+ }
+ }
+
+ // Function to toggle watched status of an individual episode
+ async function toggleEpisodeWatchedStatus(episodeId, buttonElement) {
+ LOG(`Toggling watched status for episode: ${episodeId || 'binary mode'}`);
+
+ // If we don't have an episode ID (binary mode), we need to fetch episodes first
+ if (!episodeId) {
+ const seriesId = buttonElement.closest('.progress-card')?.querySelector('[data-series-id]')?.getAttribute('data-series-id');
+ if (!seriesId) {
+ ERR('Cannot find series ID for binary mode episode');
+ return;
+ }
+
+ // Fetch episodes to get the actual episode ID
+ const episodes = await fetchEpisodesForSeries(seriesId);
+ const season = parseInt(buttonElement.getAttribute('data-season'));
+ const episodeNum = parseInt(buttonElement.getAttribute('data-episode'));
+
+ const seasonEpisodes = episodes.filter(ep => ep.ParentIndexNumber === season);
+ const episode = seasonEpisodes.find(ep => ep.IndexNumber === episodeNum);
+
+ if (!episode) {
+ ERR(`Episode not found: S${season}:E${episodeNum}`);
+ return;
+ }
+
+ episodeId = episode.Id;
+ }
+
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ try {
+ // Mark episode as played using Jellyfin API
+ const markPlayedUrl = `${serverUrl}/Users/${userId}/PlayedItems/${episodeId}`;
+ const response = await fetch(markPlayedUrl, {
+ method: 'POST',
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`,
+ "Content-Type": "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to mark episode as watched: ${response.status}`);
+ }
+
+ // Update cache immediately after successful API call
+ const seriesId = episodeId ? await getSeriesIdFromEpisode(episodeId) :
+ buttonElement.closest('.progress-card')?.querySelector('[data-series-id]')?.getAttribute('data-series-id');
+
+ if (seriesId) {
+ const cacheUpdated = updateEpisodeWatchedStatusInCache(seriesId, episodeId, true);
+ if (cacheUpdated) {
+ // If episodes weren't cached, attempt a direct binaryProgress flip for the specific season/episode
+ const cached = getCachedSeriesProgress(seriesId);
+ if (cached && !Array.isArray(cached.episodes) && cached.binaryProgress && buttonElement && buttonElement.classList.contains('progress-chunk')) {
+ const season = String(buttonElement.getAttribute('data-season'));
+ const epNum = parseInt(buttonElement.getAttribute('data-episode')); // 1-based index
+ if (season && Number.isFinite(epNum) && cached.binaryProgress[season]) {
+ const idx = epNum - 1;
+ const bin = cached.binaryProgress[season];
+ if (idx >= 0 && idx < bin.length && bin[idx] === '0') {
+ cached.binaryProgress[season] = bin.substring(0, idx) + '1' + bin.substring(idx + 1);
+ cacheSeriesProgress(seriesId, cached);
+ }
+ }
+ }
+ // If the action came from a progress chunk, update that chunk in-place
+ if (buttonElement && buttonElement.classList.contains('progress-chunk')) {
+ const chunkEl = buttonElement;
+
+ chunkEl.classList.add('watched');
+ if (chunkEl.title) {
+ chunkEl.title = chunkEl.title.replace(/\((Watched|Unwatched)\)/, '(Watched)');
+ }
+ // Remove click handler by cloning to a fresh node
+ const clone = chunkEl.cloneNode(true);
+ chunkEl.replaceWith(clone);
+ }
+
+ // Update the progress card UI (percentages, counts, etc.)
+ await updateProgressCardUI(seriesId);
+ }
+ }
+
+ // Update UI - remove the episode from the list
+ const episodeItem = buttonElement.closest('.unwatched-episode-item');
+ if (episodeItem) {
+ episodeItem.style.opacity = '0.5';
+ episodeItem.style.transition = 'opacity 0.3s ease';
+
+ // Remove the episode after a short delay
+ setTimeout(() => {
+ episodeItem.remove();
+
+ // Check if no more episodes remain
+ const listContainer = episodeItem.closest('.unwatched-episodes-list');
+ if (listContainer) {
+ const remainingEpisodes = listContainer.querySelectorAll('.unwatched-episode-item');
+ if (remainingEpisodes.length === 0) {
+ listContainer.innerHTML = 'No unwatched episodes found
';
+ }
+ }
+ }, 300);
+ }
+
+ LOG(`Successfully marked episode as watched: ${episodeId}`);
+
+ } catch (err) {
+ ERR('Error toggling episode watched status:', err);
+ // You could add a toast notification here if desired
+ }
+ }
+
+ // Function to mark all unwatched episodes as watched
+ async function markAllEpisodesAsWatched(seriesId, buttonElement) {
+ LOG(`Marking all episodes as watched for series: ${seriesId}`);
+
+ // Get cached progress data for this series
+ const cachedProgress = getCachedSeriesProgress(seriesId);
+ if (!cachedProgress) {
+ ERR('Series progress data not found in cache');
+ return;
+ }
+
+ // Fetch unwatched episodes on-demand from API
+ const episodes = await fetchEpisodesForSeries(seriesId);
+ const unwatchedEpisodes = episodes.filter(ep =>
+ ep.ParentIndexNumber !== 0 &&
+ (!ep.UserData.Played || ep.UserData.Played === false)
+ );
+
+ if (unwatchedEpisodes.length === 0) {
+ LOG('No unwatched episodes to mark as watched');
+ return;
+ }
+
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ // Disable button and show loading state
+ buttonElement.disabled = true;
+ buttonElement.textContent = 'Marking...';
+
+ try {
+ // Mark all episodes as played in parallel
+ const markPromises = unwatchedEpisodes.map(async (episode) => {
+ const markPlayedUrl = `${serverUrl}/Users/${userId}/PlayedItems/${episode.Id}`;
+ const response = await fetch(markPlayedUrl, {
+ method: 'POST',
+ headers: {
+ "Authorization": `MediaBrowser Token="${token}"`,
+ "Content-Type": "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to mark episode ${episode.Id} as watched: ${response.status}`);
+ }
+
+ return episode.Id;
+ });
+
+ // Wait for all requests to complete
+ const completedEpisodes = await Promise.all(markPromises);
+
+ LOG(`Successfully marked ${completedEpisodes.length} episodes as watched`);
+
+ // Update cache for all episodes (without localStorage updates)
+ completedEpisodes.forEach(episodeId => {
+ updateEpisodeWatchedStatusInCacheWithoutLocalStorage(seriesId, episodeId, true);
+ });
+
+ // Update localStorage once for all changes
+ updateProgressDataInLocalStorage();
+
+ // Update the progress card UI
+ await updateProgressCardUI(seriesId);
+
+ // Update UI - clear the unwatched episodes list
+ const unwatchedContainer = buttonElement.closest('.progress-card').querySelector('.unwatched-episodes-container');
+ const listContainer = unwatchedContainer.querySelector('.unwatched-episodes-list');
+ listContainer.innerHTML = 'No unwatched episodes found
';
+
+ // Hide the unwatched episodes container
+ unwatchedContainer.style.display = 'none';
+
+ // Update the toggle button text
+ const unwatchedToggle = buttonElement.closest('.progress-card').querySelector('.unwatched-toggle');
+ unwatchedToggle.textContent = '+ View Unwatched Episodes';
+
+ // Hide the mark all button since there are no more unwatched episodes
+ buttonElement.style.display = 'none';
+
+ } catch (err) {
+ ERR('Error marking all episodes as watched:', err);
+ // Re-enable button on error
+ buttonElement.disabled = false;
+ buttonElement.textContent = '+ Mark All As Watched';
+ }
+ }
+
+
+ // Compare existing DOM data-ids with new data-ids
+ function compareDataIds(existingContainer, newItems) {
+ if (!existingContainer || !newItems) return false;
+
+ // Get existing data-ids from DOM
+ const existingIds = Array.from(existingContainer.querySelectorAll('.itemsContainer > div.card[data-id]'))
+ .map(el => el.getAttribute('data-id'))
+ .sort();
+
+ // Get new data-ids from items
+ const newIds = newItems.map(item => item.Id).sort();
+
+ // Compare arrays
+ if (existingIds.length !== newIds.length) return false;
+
+ return existingIds.every((id, index) => id === newIds[index]);
+ }
+
+ // Render cards from cached data
+ async function renderCardsFromCache(container, sectionName, type) {
+ if (!container) {
+ WARN("Container not found for type:", type);
+ return { type, itemCount: 0 };
+ }
+
+ const items = watchlistCache[sectionName].data;
+
+ // Hide section if no items
+ if (!items || items.length === 0) {
+ container.style.display = 'none';
+ return { type, itemCount: 0 };
+ }
+
+ // Check if content has changed by comparing data-ids
+ if (compareDataIds(container, items)) {
+ LOG(`Skipping ${type} render - content unchanged (${items.length} items)`);
+ container.style.display = '';
+ return { type, itemCount: items.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(items, getTypeDisplayName(type));
+ container.innerHTML = '';
+ container.appendChild(scrollableContainer);
+
+ LOG(`Rendered ${items.length} ${type} items using cardBuilder from cache`);
+ } else {
+ ERR("cardBuilder not available - this should not happen with proper dependency management");
+ container.style.display = 'none';
+ return { type, itemCount: 0 };
+ }
+
+ 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',
+ 'Series': 'Shows',
+ 'Season': 'Seasons',
+ 'Episode': 'Episodes'
+ };
+ return typeMap[itemType] || itemType;
+ }
+
+ /************ Tab Functionality ************/
+
+ // Function to switch between tabs
+ function switchTab(activeTab) {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ // Remove active class from all buttons
+ const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button');
+ buttons.forEach(button => button.classList.remove('active'));
+
+ // Hide all tab content (only the content divs, not the tab buttons)
+ const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)');
+ tabContents.forEach(content => {
+ content.style.display = 'none';
+ });
+
+ // Show selected tab content and activate button
+ const activeButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${activeTab}"]`);
+ const activeContent = watchlistSection.querySelector(`div[data-tab="${activeTab}"]:not(.watchlist-tabs)`);
+
+ if (activeButton) activeButton.classList.add('active');
+ if (activeContent) activeContent.style.display = 'block';
+
+ // Update URL parameters - preserve current page and sort state for this tab
+ // Only preserve page for paginated tabs (progress and history)
+ const currentPage = (activeTab === 'progress' || activeTab === 'history') ? (tabStates[activeTab]?.currentPage || 1) : 1;
+
+ // Preserve current sort state when switching tabs
+ let currentSort = null;
+ let currentSortDirection = null;
+ let currentMovieSort = null;
+ let currentMovieSortDirection = null;
+
+ // Read sort state from the tab we're switching to (preserve its state)
+ if (activeTab === 'progress') {
+ currentSort = tabStates[activeTab]?.currentSort || 'lastWatched';
+ currentSortDirection = tabStates[activeTab]?.currentDirection || 'desc';
+ } else if (activeTab === 'history') {
+ currentMovieSort = tabStates[activeTab]?.currentSort || 'lastWatched';
+ currentMovieSortDirection = tabStates[activeTab]?.currentDirection || 'desc';
+ }
+
+ updateUrlParams(activeTab, currentPage, currentSort, currentSortDirection, currentMovieSort, currentMovieSortDirection);
+
+ // Render content based on active tab
+ if (activeTab === 'progress') {
+ renderProgressContent();
+ } else if (activeTab === 'watchlist') {
+ renderWatchlistContent();
+ } else if (activeTab === 'history') {
+ renderHistoryContent();
+ } else if (activeTab === 'statistics') {
+ renderStatisticsContent();
+ }
+
+ LOG(`Switched to ${activeTab} tab`);
+ }
+
+ // Function to set active tab based on URL params or default to watchlist
+ function setActiveTab() {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ // Check for active tab
+ const activeTab = watchlistSection.querySelector('.watchlist-tabs button.active');
+
+ // Get tab from URL parameters
+ const params = getUrlParams();
+ const targetTab = params.pageTab || 'watchlist';
+
+ if (activeTab && activeTab.getAttribute('data-tab') === targetTab) {
+ LOG('Active tab found, skipping');
+ return;
+ }
+
+ // Validate that the tab exists
+ const validTabs = ['watchlist', 'progress', 'history', 'statistics'];
+ const finalTab = validTabs.includes(targetTab) ? targetTab : 'watchlist';
+
+ // Remove active class from all buttons
+ const buttons = watchlistSection.querySelectorAll('.watchlist-tabs button');
+ buttons.forEach(button => button.classList.remove('active'));
+
+ // Hide all tab content
+ const tabContents = watchlistSection.querySelectorAll('div[data-tab]:not(.watchlist-tabs)');
+ tabContents.forEach(content => {
+ content.style.display = 'none';
+ });
+
+ // Show target tab and activate button
+ const targetButton = watchlistSection.querySelector(`.watchlist-tabs button[data-tab="${finalTab}"]`);
+ const targetContent = watchlistSection.querySelector(`div[data-tab="${finalTab}"]:not(.watchlist-tabs)`);
+
+ if (targetButton) targetButton.classList.add('active');
+ if (targetContent) targetContent.style.display = 'block';
+
+ LOG(`Active tab set to ${finalTab} (from ${params.pageTab ? 'URL param' : 'default'})`);
+ }
+
+ // Function to setup tab event listeners
+ function setupTabListeners() {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) return;
+
+ // Check if listeners are already setup to prevent duplicates
+ if (watchlistSection.dataset.listenersSetup === 'true') {
+ LOG('Tab listeners already setup, skipping');
+ return;
+ }
+
+ const tabButtons = watchlistSection.querySelectorAll('.watchlist-tabs button');
+ tabButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const tabName = button.getAttribute('data-tab');
+ switchTab(tabName);
+ });
+ });
+
+ // Setup search functionality for Progress tab
+ setupProgressSearch();
+ setupMovieSearch();
+
+ // Setup "Show All" button for top shows
+ const showAllBtn = getElementByIdSafe('show-all-shows-btn');
+ if (showAllBtn) {
+ showAllBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ showAllShowsModal();
+ });
+ }
+
+ // Mark as setup to prevent duplicates
+ watchlistSection.dataset.listenersSetup = 'true';
+
+ LOG('Tab event listeners setup complete');
+ }
+
+ // Function to setup progress search functionality
+ function setupProgressSearch() {
+ const searchInput = getElementByIdSafe('progress-search');
+ const clearBtn = getElementByIdSafe('progress-search-clear');
+
+ if (!searchInput || !clearBtn) {
+ WARN('Progress search elements not found:', { searchInput: !!searchInput, clearBtn: !!clearBtn });
+ return;
+ }
+
+ // Check if already setup to prevent duplicates
+ if (searchInput.dataset.searchSetup === 'true') {
+ LOG('Progress search already setup, skipping');
+ return;
+ }
+
+ // Debounced search function
+ let searchTimeout;
+ const debouncedSearch = (searchTerm) => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ updateSearchResults(searchTerm);
+ // Update URL with search term
+ const params = getUrlParams();
+ updateUrlWithSearch(params.pageTab, 1, searchTerm); // Reset to page 1 when searching
+ renderProgressContent();
+ }, 300); // 300ms delay
+ };
+
+ // Search input event
+ searchInput.addEventListener('input', (e) => {
+ const searchTerm = e.target.value;
+
+ // Show/hide clear button
+ if (searchTerm.trim() === '') {
+ clearBtn.style.display = 'none';
+ } else {
+ clearBtn.style.display = 'flex';
+ }
+
+ // Update search results
+ debouncedSearch(searchTerm);
+ });
+
+ // Clear button event
+ clearBtn.addEventListener('click', () => {
+ searchInput.value = '';
+ clearBtn.style.display = 'none';
+ updateSearchResults('');
+ // Update URL to remove search parameter
+ const params = getUrlParams();
+ updateUrlWithSearch(params.pageTab, 1, ''); // Reset to page 1 when clearing
+ renderProgressContent();
+ });
+
+ // Mark as setup to prevent duplicates
+ searchInput.dataset.searchSetup = 'true';
+
+ LOG('Progress search functionality setup complete');
+ }
+
+ // Function to setup movie search functionality
+ function setupMovieSearch() {
+ const searchInput = getElementByIdSafe('movie-search');
+ const clearBtn = getElementByIdSafe('movie-search-clear');
+
+ if (!searchInput || !clearBtn) {
+ WARN('Movie search elements not found:', { searchInput: !!searchInput, clearBtn: !!clearBtn });
+ return;
+ }
+
+ // Check if already setup to prevent duplicates
+ if (searchInput.dataset.searchSetup === 'true') {
+ LOG('Movie search already setup, skipping');
+ return;
+ }
+
+ // Debounced search function
+ let searchTimeout;
+ const debouncedSearch = (searchTerm) => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ updateMovieSearchResults(searchTerm);
+ // Update URL with search term
+ const params = getUrlParams();
+ updateUrlWithSearch(params.pageTab, 1, '', searchTerm); // Reset to page 1 when searching
+ renderHistoryContent();
+ }, 300); // 300ms delay
+ };
+
+ // Search input event
+ searchInput.addEventListener('input', (e) => {
+ const searchTerm = e.target.value;
+
+ // Show/hide clear button
+ if (searchTerm.trim() === '') {
+ clearBtn.style.display = 'none';
+ } else {
+ clearBtn.style.display = 'flex';
+ }
+
+ // Update search results
+ debouncedSearch(searchTerm);
+ });
+
+ // Clear button event
+ clearBtn.addEventListener('click', () => {
+ searchInput.value = '';
+ clearBtn.style.display = 'none';
+ updateMovieSearchResults('');
+ // Update URL to remove search parameter
+ const params = getUrlParams();
+ updateUrlWithSearch(params.pageTab, 1, '', ''); // Reset to page 1 when clearing
+ renderHistoryContent();
+ });
+
+ // Mark as setup to prevent duplicates
+ searchInput.dataset.searchSetup = 'true';
+
+ LOG('Movie search functionality setup complete');
+ }
+
+
+ /************ Watchlist Section Observer ************/
+
+ // Function to render watchlist content when .sections.watchlist is found
+ async function renderWatchlistContent() {
+ try {
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) {
+ ERR('Watchlist section not found');
+ return;
+ }
+
+ const watchlistSections = ['.watchlist-movies', '.watchlist-series', '.watchlist-seasons', '.watchlist-episodes'];
+
+ // Check if we're currently fetching data or data hasn't been fetched yet
+ if (!tabStates.watchlist.isDataFetched) {
+ //Hide empty message
+ hideEmptyWatchlistMessage();
+
+ watchlistSections.forEach(section => {
+ const container = watchlistSection.querySelector(section);
+ if (container) {
+ container.innerHTML = '';
+ }
+ });
+
+ // Show loading state for watchlist tab
+ const container = watchlistSection.querySelector('.watchlist-movies');
+
+ if (container) {
+ container.innerHTML = '';
+ container.style.display = '';
+ }
+ return;
+ }
+
+ // Render all sections using cached data
+ const results = await Promise.all([
+ renderCardsFromCache(watchlistSection.querySelector('.watchlist-movies'), 'movies', 'Movie'),
+ renderCardsFromCache(watchlistSection.querySelector('.watchlist-series'), 'series', 'Series'),
+ renderCardsFromCache(watchlistSection.querySelector('.watchlist-seasons'), 'seasons', 'Season'),
+ renderCardsFromCache(watchlistSection.querySelector('.watchlist-episodes'), 'episodes', 'Episode')
+ ]);
+
+ // Check if all sections are empty
+ const totalItems = results.reduce((sum, result) => sum + result.itemCount, 0);
+
+ if (totalItems === 0) {
+ // Show empty state message
+ showEmptyWatchlistMessage();
+ } else {
+ // Hide empty state message if it exists
+ hideEmptyWatchlistMessage();
+ }
+ } catch (err) {
+ ERR('Error rendering watchlist cards:', err);
+ }
+ }
+
+
+ // Function to check if watchlist section exists and render content
+ async function renderWatchlist() {
+ const watchlistSection = getWatchlistSection();
+
+ if (watchlistSection) {
+ // Check if this is a new watchlist section (no HTML structure)
+ const hasTabs = watchlistSection.querySelector('.watchlist-tabs');
+ const isNewSection = !hasTabs;
+
+ // Check if this is a recreated section by comparing content to cache
+ const isRecreatedSection = hasTabs && !isWatchlistContentValid(watchlistSection);
+
+ if (isNewSection || isRecreatedSection) {
+ LOG(`Watchlist section ${isNewSection ? 'found' : 'recreated'}, initializing...`);
+
+ // Mark as rendered to prevent duplicate initialization
+ watchlistSection.dataset.watchlistRendered = 'true';
+
+ // Render the complete HTML structure first
+ renderWatchlistHtml();
+
+ // Setup tab functionality
+ setupTabListeners();
+
+ // Set active tab based on URL params or default to watchlist
+ setActiveTab();
+
+ // Initialize all tabs in parallel with proper dependencies
+ await initializeAllTabs();
+
+ // Render the default active tab content
+ //renderWatchlistContent();
+
+ LOG('Watchlist section initialized with all tabs');
+ }
+ }
+ }
+
+ // Function to validate if watchlist content matches cache expectations
+ function isWatchlistContentValid(watchlistSection) {
+ try {
+ // Check if all expected containers exist
+ const moviesContainer = watchlistSection.querySelector('.watchlist-movies');
+ const seriesContainer = watchlistSection.querySelector('.watchlist-series');
+ const seasonsContainer = watchlistSection.querySelector('.watchlist-seasons');
+ const episodesContainer = watchlistSection.querySelector('.watchlist-episodes');
+
+ if (!moviesContainer || !seriesContainer || !seasonsContainer || !episodesContainer) {
+ LOG('Watchlist containers missing, content invalid');
+ return false;
+ }
+
+ // Check if content matches cache expectations
+ const expectedMovies = watchlistCache.movies.data.length;
+ const expectedSeries = watchlistCache.series.data.length;
+ const expectedSeasons = watchlistCache.seasons.data.length;
+ const expectedEpisodes = watchlistCache.episodes.data.length;
+
+ const actualMovies = moviesContainer.children.length;
+ const actualSeries = seriesContainer.children.length;
+ const actualSeasons = seasonsContainer.children.length;
+ const actualEpisodes = episodesContainer.children.length;
+
+ // If we have cached data, check if content matches
+ const hasCachedData = expectedMovies > 0 || expectedSeries > 0 || expectedSeasons > 0 || expectedEpisodes > 0;
+
+ if (hasCachedData) {
+ const contentMatches = actualMovies === expectedMovies &&
+ actualSeries === expectedSeries &&
+ actualSeasons === expectedSeasons &&
+ actualEpisodes === expectedEpisodes;
+
+ if (!contentMatches) {
+ LOG(`Watchlist content mismatch - Expected: ${expectedMovies}M/${expectedSeries}S/${expectedSeasons}Se/${expectedEpisodes}E, Actual: ${actualMovies}M/${actualSeries}S/${actualSeasons}Se/${actualEpisodes}E`);
+ return false;
+ }
+ }
+
+ LOG('Watchlist content is valid');
+ return true;
+
+ } catch (err) {
+ ERR('Error validating watchlist content:', err);
+ return false;
+ }
+ }
+
+ // Set up observer to watch for home page navigation
+ function setupWatchlistSectionObserver() {
+ // Check immediately in case we're already on home page
+ if (window.location.href.includes('home.html')) {
+ renderWatchlist();
+ }
+
+ // Create a MutationObserver to watch for empty watchlist containers
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ // Check for added nodes
+ if (mutation.type === 'childList') {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ // Check if this is a library page that became visible
+ if (node.classList && node.classList.contains('homePage') && !node.classList.contains('hide')) {
+ checkForEmptyWatchlist();
+ }
+
+ // Also check any library pages within the added node
+ const libraryPages = node.querySelectorAll ? node.querySelectorAll('.homePage:not(.hide)') : [];
+ libraryPages.forEach(libraryPage => {
+ checkForEmptyWatchlist();
+ });
+ }
+ });
+ }
+
+ // Check for attribute changes (like removing 'hide' class)
+ if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
+ const target = mutation.target;
+ if (target.classList && target.classList.contains('homePage') && !target.classList.contains('hide')) {
+ checkForEmptyWatchlist();
+ }
+ }
+ });
+ });
+
+ // Start observing
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['class']
+ });
+
+ // Function to check for empty watchlist container
+ function checkForEmptyWatchlist() {
+ const visibleWatchlistSections = document.querySelectorAll('.libraryPage:not(.hide) .sections.watchlist');
+
+ visibleWatchlistSections.forEach(section => {
+ if (section.children.length === 0) {
+ LOG('Empty watchlist container found, initializing...');
+ renderWatchlist();
+ }
+ });
+ }
+ }
+
+
+ // Initialize the watchlist section observer
+ setupWatchlistSectionObserver();
+
+ // Initialize hash change listener for browser navigation
+ setupHashChangeListener();
+
+ function showEmptyWatchlistMessage() {
+ // Find the empty message in the watchlist tab
+ const emptyMessage = document.querySelector('div[data-tab="watchlist"] .watchlist-empty-message');
+ if (emptyMessage) {
+ emptyMessage.style.display = 'block';
+ }
+ }
+
+ function hideEmptyWatchlistMessage() {
+ const emptyMessage = document.querySelector('div[data-tab="watchlist"] .watchlist-empty-message');
+ if (emptyMessage) {
+ emptyMessage.style.display = 'none';
+ }
+ }
+
+ function showEmptyProgressMessage() {
+ const emptyMessage = document.querySelector('div[data-tab="progress"] .progress-empty-message');
+ if (emptyMessage) {
+ emptyMessage.style.display = 'block';
+ }
+ }
+
+ function showEmptySearchMessage() {
+ const emptyMessage = document.querySelector('div[data-tab="progress"] .progress-empty-message');
+ if (emptyMessage) {
+ // Update the message for search results
+ const title = emptyMessage.querySelector('.empty-message-title');
+ const subtitle = emptyMessage.querySelector('.empty-message-subtitle');
+
+ if (title) title.textContent = 'No Series Found';
+ if (subtitle) subtitle.textContent = `No series match "${progressCache.searchTerm}". Try a different search term.`;
+
+ emptyMessage.style.display = 'block';
+ }
+ }
+
+ function hideEmptyProgressMessage() {
+ const emptyMessage = document.querySelector('div[data-tab="progress"] .progress-empty-message');
+ if (emptyMessage) {
+ // Reset message to default
+ const title = emptyMessage.querySelector('.empty-message-title');
+ const subtitle = emptyMessage.querySelector('.empty-message-subtitle');
+
+ if (title) title.textContent = 'No Progress to Show';
+ if (subtitle) subtitle.textContent = 'Start watching some shows to track your progress here';
+
+ emptyMessage.style.display = 'none';
+ }
+ }
+
+ function showEmptyHistoryMessage() {
+ const emptyMessage = document.querySelector('div[data-tab="history"] .movie-history-empty-message');
+ if (emptyMessage) {
+ emptyMessage.style.display = 'block';
+ }
+ }
+
+ function hideEmptyHistoryMessage() {
+ const emptyMessage = document.querySelector('div[data-tab="history"] .movie-history-empty-message');
+ if (emptyMessage) {
+ emptyMessage.style.display = 'none';
+ }
+ }
+
+ LOG('Watchlist functionality initialized');
+
+ // Register onViewPage handler to call setActiveTab
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) {
+ window.KefinTweaksUtils.onViewPage((view, element) => {
+ LOG('onViewPage handler triggered for view:', view);
+ // Call setActiveTab when page view changes
+ setActiveTab();
+ }, {
+ pages: ['home.html'], // Only trigger for home page where watchlist tabs are
+ immediate: true
+ });
+ LOG('Registered onViewPage handler for setActiveTab');
+ } else {
+ WARN('KefinTweaksUtils.onViewPage not available');
+ }
+
+ // Initialize playback monitoring
+ initializePlaybackMonitoring();
+
+ /************ Watchlist Button Observer ************/
+
+ // Function to add watchlist button to a card overlay container
+ function addWatchlistButton(overlayContainer) {
+ // Check if watchlist button already exists
+ if (overlayContainer && overlayContainer.querySelector('.watchlist-button')) {
+ return;
+ }
+
+ // Find the card parent to get the item ID
+ const card = overlayContainer.closest('.card');
+ if (!card) {
+ WARN('Could not find card parent for overlay container');
+ return;
+ }
+
+ const itemId = card.getAttribute('data-id');
+ if (!itemId) {
+ WARN('Could not find data-id on card element');
+ return;
+ }
+
+ // Check if card has data-type attribute - if not, don't add watchlist button
+ const itemType = card.getAttribute('data-type');
+ if (!itemType) {
+ LOG('Card has no data-type, skipping watchlist button');
+ return;
+ }
+
+ // Find the .cardOverlayButton-br container
+ const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
+ if (!buttonContainer) {
+ WARN('Could not find .cardOverlayButton-br container');
+ return;
+ }
+
+ // Create watchlist button
+ const watchlistButton = document.createElement('button');
+ watchlistButton.type = 'button';
+ watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
+ watchlistButton.setAttribute('data-action', 'none');
+ watchlistButton.setAttribute('data-id', itemId);
+ watchlistButton.setAttribute('data-active', 'false');
+ watchlistButton.title = 'Add to Watchlist';
+
+ // Create the bookmark icon
+ const watchlistIcon = document.createElement('span');
+ watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
+ watchlistIcon.setAttribute('aria-hidden', 'true');
+
+ watchlistButton.appendChild(watchlistIcon);
+
// Add click event listener
- watchlistButton.addEventListener('click', (e) => {
+ watchlistButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Toggle watchlist status
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
- ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
+ 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';
- });
-
- // 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;
- }
-
- // Check item's current watchlist status and set initial state
- ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
- if (item.UserData && item.UserData.Likes) {
- watchlistButton.dataset.active = 'true';
- // Icon state is handled by CSS class, no need to change textContent
- watchlistButton.title = 'Remove from Watchlist';
- }
- }).catch(err => {
- ERR('Error fetching item data for watchlist button:', err);
- });
-
- // Add the watchlist button to the button container
- buttonContainer.appendChild(watchlistButton);
-}
-
-// Function to process all existing overlay containers
-function processExistingOverlayContainers() {
- const overlayContainers = document.querySelectorAll('.cardOverlayContainer');
-
- overlayContainers.forEach((overlayContainer) => {
- const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
- if (buttonContainer) {
- addWatchlistButton(overlayContainer);
- }
- });
-}
-
-// Set up MutationObserver to watch for new overlay containers
-function setupWatchlistButtonObserver() {
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.type === 'childList') {
- // Check for added nodes
- mutation.addedNodes.forEach((node) => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- // Check if the added node is an overlay container
- if (node.classList && node.classList.contains('cardOverlayContainer')) {
- const buttonContainer = node.querySelector('.cardOverlayButton-br');
- if (buttonContainer) {
- addWatchlistButton(node);
- }
- }
-
- // Check for overlay containers within the added node
- const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer');
- if (overlayContainers && overlayContainers.length > 0) {
- overlayContainers.forEach((overlayContainer) => {
- const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
- if (buttonContainer) {
- addWatchlistButton(overlayContainer);
- }
- });
- }
- }
- });
- }
- });
- });
-
- // Start observing
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
-
- // Process any existing overlay containers
- processExistingOverlayContainers();
-}
-
-// Initialize the observer
-setupWatchlistButtonObserver();
-
-/************ Item Detail Page Observer ************/
-
-// Function to add watchlist button to item detail page
-function addDetailPageWatchlistButton() {
- const trailerButton = document.querySelector('.itemDetailPage:not(.hide) .btnPlayTrailer');
-
- if (!trailerButton) {
- return;
- }
-
- // Check if watchlist button already exists
- const existingButton = document.querySelector('.itemDetailPage:not(.hide) .watchlist-icon');
- if (existingButton) {
- return;
- }
-
- const watchlistIcon = document.createElement('button');
- watchlistIcon.setAttribute("is", "emby-button");
- watchlistIcon.className = 'watchlist-icon detailButton emby-button hide';
- watchlistIcon.title = "Add to Watchlist";
- watchlistIcon.dataset.active = 'false';
-
- // Add the content wrapper and icon span inside the button
- const contentWrapper = document.createElement('div');
- contentWrapper.className = 'detailButton-content';
-
- const iconSpan = document.createElement('span');
- iconSpan.className = 'material-icons detailButton-icon watchlist';
- iconSpan.setAttribute('aria-hidden', 'true');
-
- contentWrapper.appendChild(iconSpan);
- watchlistIcon.appendChild(contentWrapper);
-
- // Get item ID from URL
- const itemId = window.location.href.substring(window.location.href.indexOf("id=") + 3, window.location.href.indexOf("id=") + 35);
-
- watchlistIcon.addEventListener('click', () => {
- const newRating = watchlistIcon.dataset.active === 'false' ? 'true' : 'false';
- ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
- watchlistIcon.dataset.active = newRating;
- // Update title based on state
- const isActive = watchlistIcon.dataset.active === 'true';
- watchlistIcon.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
- });
-
- // 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";
+ // 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;
}
- // 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);
-
- // Set up observer to show button when other buttons become visible
- const target = document.querySelector('.mainDetailButtons');
- if (target) {
- const observer = new MutationObserver(mutations => {
- mutations.forEach(mutation => {
- if (mutation.type === "attributes" &&
- mutation.attributeName === "class" &&
- mutation.target.classList.contains("emby-button") &&
- !mutation.target.classList.contains("watchlist-icon") &&
- !mutation.target.classList.contains("hide")) {
+ // Check item's current watchlist status and set initial state
+ ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
+ if (item.UserData && item.UserData.Likes) {
+ watchlistButton.dataset.active = 'true';
+ // Icon state is handled by CSS class, no need to change textContent
+ watchlistButton.title = 'Remove from Watchlist';
+ }
+ }).catch(err => {
+ ERR('Error fetching item data for watchlist button:', err);
+ });
+
+ // Add the watchlist button to the button container
+ buttonContainer.appendChild(watchlistButton);
+ }
- if (watchlistIcon && watchlistIcon.classList.contains("hide")) {
- watchlistIcon.classList.remove('hide');
- observer.disconnect();
- }
+ // Function to process all existing overlay containers
+ function processExistingOverlayContainers() {
+ const overlayContainers = document.querySelectorAll('.cardOverlayContainer');
+
+ overlayContainers.forEach((overlayContainer) => {
+ const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
+ if (buttonContainer) {
+ addWatchlistButton(overlayContainer);
+ }
+ });
+ }
+
+ // Set up MutationObserver to watch for new overlay containers
+ function setupWatchlistButtonObserver() {
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.type === 'childList') {
+ // Check for added nodes
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ // Check if the added node is an overlay container
+ if (node.classList && node.classList.contains('cardOverlayContainer')) {
+ const buttonContainer = node.querySelector('.cardOverlayButton-br');
+ if (buttonContainer) {
+ addWatchlistButton(node);
+ }
+ }
+
+ // Check for overlay containers within the added node
+ const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer');
+ if (overlayContainers && overlayContainers.length > 0) {
+ overlayContainers.forEach((overlayContainer) => {
+ const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
+ if (buttonContainer) {
+ addWatchlistButton(overlayContainer);
+ }
+ });
+ }
+ }
+ });
}
});
});
-
- observer.observe(target, {
- attributes: true,
- subtree: true,
- attributeFilter: ["class"]
+
+ // Start observing
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true
});
+
+ // Process any existing overlay containers
+ processExistingOverlayContainers();
}
-}
-// Function to monitor item detail pages
-function monitorItemDetailPage() {
- const observer = new MutationObserver(() => {
- const visibleItemDetailPage = document.querySelector('.itemDetailPage:not(.hide)');
- if (!visibleItemDetailPage) {
+ // Initialize the observer
+ setupWatchlistButtonObserver();
+
+ // Add watchlist to custom menu
+ window.KefinTweaksUtils.addCustomMenuLink(
+ 'Watchlist',
+ 'bookmark',
+ '#/home.html?tab=2'
+ );
+
+ /************ Item Detail Page Observer ************/
+
+ // Function to add watchlist button to item detail page
+ function addDetailPageWatchlistButton() {
+ const trailerButton = document.querySelector('.itemDetailPage:not(.hide) .btnPlayTrailer');
+
+ if (!trailerButton) {
return;
}
@@ -483,33 +5594,131 @@ function monitorItemDetailPage() {
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;
- }
+ const watchlistIcon = document.createElement('button');
+ watchlistIcon.setAttribute("is", "emby-button");
+ watchlistIcon.className = 'watchlist-icon detailButton emby-button hide';
+ watchlistIcon.title = "Add to Watchlist";
+ watchlistIcon.dataset.active = 'false';
- // Check if item type is supported for watchlist
+ // Add the content wrapper and icon span inside the button
+ const contentWrapper = document.createElement('div');
+ contentWrapper.className = 'detailButton-content';
+
+ const iconSpan = document.createElement('span');
+ iconSpan.className = 'material-icons detailButton-icon watchlist';
+ iconSpan.setAttribute('aria-hidden', 'true');
+
+ contentWrapper.appendChild(iconSpan);
+ watchlistIcon.appendChild(contentWrapper);
+
+ // Get item ID from URL
+ const itemId = window.location.href.substring(window.location.href.indexOf("id=") + 3, window.location.href.indexOf("id=") + 35);
+
+ watchlistIcon.addEventListener('click', async () => {
+ const newRating = watchlistIcon.dataset.active === 'false' ? 'true' : 'false';
+ await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
+ watchlistIcon.dataset.active = newRating;
+
+ // Update title based on state
+ const isActive = watchlistIcon.dataset.active === 'true';
+ watchlistIcon.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
+
+ // Update watchlist cache immediately
+ // Get item type from the item data we already have
+ const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId);
+ if (item) {
+ await updateWatchlistCacheOnToggle(itemId, item.Type, isActive);
+ }
+ });
+
+ // Get item data to check if it should be shown and current state
ApiClient.getItem(ApiClient.getCurrentUserId(), itemId).then((item) => {
- if (item.Type === "Movie" || item.Type === "Series" || item.Type === "Season" || item.Type === "Episode") {
- addDetailPageWatchlistButton();
+ // 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";
+ return;
+ }
+
+ // 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 for detail page type check:', err);
+ ERR('Error fetching item data:', err);
});
- });
+
+ // Add button after trailer button
+ trailerButton.after(watchlistIcon);
+
+ // Set up observer to show button when other buttons become visible
+ const target = document.querySelector('.mainDetailButtons');
+ if (target) {
+ const observer = new MutationObserver(mutations => {
+ mutations.forEach(mutation => {
+ if (mutation.type === "attributes" &&
+ mutation.attributeName === "class" &&
+ mutation.target.classList.contains("emby-button") &&
+ !mutation.target.classList.contains("watchlist-icon") &&
+ !mutation.target.classList.contains("hide")) {
- // Start observing the entire document body
- observer.observe(document.body, {
- childList: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['class']
- });
-}
+ if (watchlistIcon && watchlistIcon.classList.contains("hide")) {
+ watchlistIcon.classList.remove('hide');
+ observer.disconnect();
+ }
+ }
+ });
+ });
-// Initialize the item detail page observer
-monitorItemDetailPage();
+ observer.observe(target, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ["class"]
+ });
+ }
+ }
+
+ // Function to monitor item detail pages
+ function monitorItemDetailPage() {
+ const observer = new MutationObserver(() => {
+ const visibleItemDetailPage = document.querySelector('.itemDetailPage:not(.hide)');
+ if (!visibleItemDetailPage) {
+ return;
+ }
+
+ // 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();
+ }
+ }).catch(err => {
+ ERR('Error fetching item data for detail page type check:', err);
+ });
+ });
+
+ // Start observing the entire document body
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['class']
+ });
+ }
+
+ // Initialize the item detail page observer
+ monitorItemDetailPage();
// Debug functions for troubleshooting (available in console)
window.debugWatchlistButtons = function() {
@@ -521,7 +5730,35 @@ monitorItemDetailPage();
window.debugWatchlistRendering = function() {
LOG('Manual watchlist rendering trigger called');
- checkAndRenderWatchlist();
+ renderWatchlist();
+ };
+
+ window.debugWatchlistOptimization = function() {
+ LOG('Watchlist optimization debug');
+ const watchlistSection = getWatchlistSection();
+ if (!watchlistSection) {
+ LOG('No watchlist section found');
+ return;
+ }
+
+ const containers = [
+ { name: 'Movies', el: watchlistSection.querySelector('.watchlist-movies') },
+ { name: 'Series', el: watchlistSection.querySelector('.watchlist-series') },
+ { name: 'Seasons', el: watchlistSection.querySelector('.watchlist-seasons') },
+ { name: 'Episodes', el: watchlistSection.querySelector('.watchlist-episodes') }
+ ];
+
+ containers.forEach(({ name, el }) => {
+ if (el) {
+ const dataIds = Array.from(el.querySelectorAll('[data-id]'))
+ .map(card => card.getAttribute('data-id'))
+ .sort();
+ const childCount = el.children.length;
+ LOG(`${name}: ${childCount} children, data-ids=[${dataIds.join(', ')}]`);
+ } else {
+ LOG(`${name}: container not found`);
+ }
+ });
};
window.debugDetailPageWatchlist = function() {
@@ -529,5 +5766,238 @@ monitorItemDetailPage();
addDetailPageWatchlistButton();
};
- LOG('Debug functions available: window.debugWatchlistButtons(), window.debugWatchlistRendering(), window.debugDetailPageWatchlist()');
-})();
\ No newline at end of file
+ window.debugProgressTab = function() {
+ LOG('Manual progress tab trigger called');
+ renderProgressContent();
+ };
+
+ window.debugFetchProgress = async function() {
+ LOG('Manual fetch progress trigger called');
+ const progress = await fetchAllProgressData();
+ LOG('Progress data:', progress);
+ return progress;
+ };
+
+ window.debugFetchProgressWithMissing = async function() {
+ LOG('Manual fetch progress with missing episodes trigger called');
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ const url = `${serverUrl}/Items?IncludeItemTypes=Series&UserId=${userId}&Recursive=true&Fields=UserData,RecursiveItemCount`;
+ const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
+ const data = await res.json();
+ const series = data.Items || [];
+
+ const inProgressSeries = series.filter(series => {
+ return series.UserData && series.UserData.PlayedPercentage > 0;
+ });
+
+ const sortedSeries = sortInProgressSeries(inProgressSeries);
+
+ const progressData = await Promise.all(
+ sortedSeries.map(async (series) => {
+ return await fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token);
+ })
+ );
+
+ LOG('Progress data with missing episodes:', progressData);
+ return progressData;
+ };
+
+ window.debugSorting = function() {
+ LOG('Testing sorting functionality');
+ const apiClient = window.ApiClient;
+ const userId = apiClient.getCurrentUserId();
+ const serverUrl = apiClient.serverAddress();
+ const token = apiClient.accessToken();
+
+ fetch(`${serverUrl}/Items?IncludeItemTypes=Series&UserId=${userId}&Recursive=true&Fields=UserData,ChildCount,RecursiveItemCount`, {
+ headers: { "Authorization": `MediaBrowser Token=\"${token}\"` }
+ })
+ .then(res => res.json())
+ .then(data => {
+ const series = data.Items || [];
+ const inProgressSeries = series.filter(series => {
+ return series.UserData && series.UserData.PlayedPercentage > 0;
+ });
+
+ LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`);
+ const sorted = sortInProgressSeries(inProgressSeries);
+
+ // Show first 10 series to verify sorting
+ LOG('First 10 sorted series:');
+ sorted.slice(0, 10).forEach((s, i) => {
+ LOG(`${i + 1}. ${s.Name} (${s.Status}) - Premiere: ${s.PremiereDate}, End: ${s.EndDate}`);
+ });
+ });
+ };
+
+ window.debugPagination = function() {
+ LOG('Testing pagination functionality');
+ LOG('Current cache state:', {
+ hasData: progressCache.data.length > 0,
+ totalSeries: progressCache.data.length,
+ totalPages: progressCache.totalPages,
+ pageSize: progressCache.pageSize,
+ cachedProgressCount: progressCache.data.length
+ });
+
+ if (progressCache.data.length > 0) {
+ LOG('Testing page retrieval:');
+ for (let page = 1; page <= Math.min(3, progressCache.totalPages); page++) {
+ const { pageData } = progressPagination.getCachedPage(page);
+ LOG(`Page ${page}: ${pageData.length} progress items`);
+ }
+ } else {
+ LOG('No cached data available. Run fetchAllProgressData first.');
+ }
+ };
+
+ window.debugCache = function() {
+ LOG('Cache management functions:');
+ LOG('clearProgressCache() - Clear all cached data');
+ LOG('isCacheValid() - Check if cache is still valid');
+ LOG('Current cache valid:', isCacheValid());
+ };
+
+ window.debugPageFetch = async function(page = 1) {
+ LOG(`Testing page ${page} fetch`);
+ const progress = await fetchProgressForPage(page);
+ LOG(`Page ${page} progress data:`, progress);
+ return progress;
+ };
+
+ window.debugSeriesCache = function() {
+ LOG('Series progress cache state:');
+ LOG('Cached series count:', progressCache.data.length);
+ LOG('Cached series IDs:', progressCache.data.map(item => item.series.Id));
+
+ // Show first few cached series
+ const cachedSeries = progressCache.data;
+ if (cachedSeries.length > 0) {
+ LOG('Sample cached series:');
+ cachedSeries.slice(0, 3).forEach(progress => {
+ LOG(`- ${progress.series.Name}: ${progress.watchedCount}/${progress.totalEpisodes} (${progress.percentage}%)`);
+ });
+ }
+ };
+
+ window.debugPreloading = function() {
+ LOG('Testing preloading functionality');
+ LOG('Current cache state:', {
+ hasSeriesData: progressCache.data.length > 0,
+ totalSeries: progressCache.data.length,
+ totalPages: progressCache.totalPages,
+ cachedProgressCount: progressCache.data.length
+ });
+
+ // Test preloading
+ preloadProgressData();
+ };
+
+ window.debugHash = function() {
+ LOG('Current hash:', window.location.hash);
+ LOG('Parsed params:', getUrlParams());
+ LOG('Test updating hash to progress page 2...');
+ updateUrlParams('progress', 2);
+ };
+
+ window.debugAllProgressData = async function() {
+ LOG('Testing new all progress data functionality');
+ const allData = await fetchAllProgressData();
+ LOG('All progress data loaded:', allData.length, 'items');
+ LOG('Cache state:', {
+ allDataLoaded: progressCache.allDataLoaded,
+ totalItems: progressCache.data.length,
+ totalPages: progressCache.totalPages,
+ pageSize: progressCache.pageSize
+ });
+
+ // Show first few items with their last watched dates
+ if (allData.length > 0) {
+ LOG('First 5 items (sorted by last watched):');
+ allData.slice(0, 5).forEach((progress, i) => {
+ const lastWatched = progress.lastWatchedEpisode && progress.lastWatchedEpisode.UserData && progress.lastWatchedEpisode.UserData.LastPlayedDate
+ ? new Date(progress.lastWatchedEpisode.UserData.LastPlayedDate).toLocaleString()
+ : 'Never';
+ LOG(`${i + 1}. ${progress.series.Name} - Last watched: ${lastWatched}`);
+ });
+ }
+
+ return allData;
+ };
+
+ window.debugNewPagination = function(page = 1) {
+ LOG(`Testing new pagination for page ${page}`);
+ const { pageData, totalPages, currentPage } = progressPagination.getCachedPage(page);
+ LOG(`Page ${currentPage}: ${pageData.length} items, ${totalPages} total pages`);
+ return { pageData, totalPages, currentPage };
+ };
+
+ window.debugStatistics = function() {
+ LOG('Testing statistics functionality');
+ if (progressCache.allDataLoaded && progressCache.data.length > 0) {
+ const stats = calculateProgressStatistics(progressCache.data);
+ LOG('Calculated statistics:', stats);
+ updateProgressStatistics(progressCache.data);
+ LOG('Statistics updated in UI');
+ return stats;
+ } else {
+ LOG('No progress data loaded yet. Run fetchAllProgressData() first.');
+ return null;
+ }
+ };
+
+ window.debugMovieHistory = async function() {
+ LOG('Testing movie history functionality');
+ const movies = await fetchWatchedMovies();
+ LOG('Found watched movies:', movies.length);
+ if (movies.length > 0) {
+ LOG('Sample movies:');
+ movies.slice(0, 3).forEach((movie, i) => {
+ LOG(`${i + 1}. ${movie.Name} (${movie.ProductionYear || 'Unknown year'})`);
+ });
+ }
+ return movies;
+ };
+
+ window.debugRenderMovieHistory = function() {
+ LOG('Testing movie history rendering');
+ renderHistoryContent();
+ };
+
+ window.debugMoviePagination = function(page = 1) {
+ LOG(`Testing movie pagination for page ${page}`);
+ const { pageData, totalPages, currentPage } = moviePagination.getCachedPage(page);
+ LOG(`Page ${currentPage}: ${pageData.length} movies, ${totalPages} total pages`);
+ return { pageData, totalPages, currentPage };
+ };
+
+ window.debugMovieCache = function() {
+ LOG('Movie cache state:', {
+ hasData: movieCache.data.length > 0,
+ totalMovies: movieCache.data.length,
+ totalPages: movieCache.totalPages,
+ pageSize: movieCache.pageSize,
+ allDataLoaded: movieCache.allDataLoaded
+ });
+
+ if (movieCache.data.length > 0) {
+ LOG('Sample movies:');
+ movieCache.data.slice(0, 3).forEach((movie, i) => {
+ const watchedDate = movie.UserData && movie.UserData.LastPlayedDate
+ ? new Date(movie.UserData.LastPlayedDate).toLocaleString()
+ : 'Unknown';
+ LOG(`${i + 1}. ${movie.Name} - Watched: ${watchedDate}`);
+ });
+ }
+ };
+
+ // Expose functions to global scope for onclick handlers
+ window.toggleMovieFavorite = toggleMovieFavorite;
+ window.playMovie = playMovie;
+
+ LOG('Debug functions available: window.debugWatchlistButtons(), window.debugWatchlistRendering(), window.debugWatchlistOptimization(), window.debugDetailPageWatchlist(), window.debugProgressTab(), window.debugFetchProgress(), window.debugFetchProgressWithMissing(), window.debugSorting(), window.debugPagination(), window.debugCache(), window.debugPageFetch(), window.debugSeriesCache(), window.debugPreloading(), window.debugHash(), window.debugAllProgressData(), window.debugNewPagination(), window.debugStatistics(), window.debugMovieHistory(), window.debugRenderMovieHistory(), window.debugMoviePagination(), window.debugMovieCache()');
+})();