commit 55b4d4b00eb96d789a3d3608e715a8cbae0965ee Author: HighImKevin Date: Tue Sep 9 00:23:54 2025 -0400 Jellyfin Watchlist functionality diff --git a/cardBuilder.js b/cardBuilder.js new file mode 100644 index 0000000..3e2dd96 --- /dev/null +++ b/cardBuilder.js @@ -0,0 +1,308 @@ +// Jellyfin Card Builder +// This module provides a main entry point function to build Jellyfin cards +// Usage: window.cardBuilder.buildCard(jellyfinItem) + +(function() { + 'use strict'; + + // Main card builder object + const cardBuilder = { + /** + * Main entry point function to build a Jellyfin card + * @param {Object} item - The Jellyfin item object + * @returns {HTMLElement} - The constructed card element + */ + buildCard: function(item) { + return createJellyfinCardElement(item); + } + }; + + /** + * Creates a Jellyfin card element from an item + * @param {Object} item - The Jellyfin item object + * @returns {HTMLElement} - The constructed card element + */ + function createJellyfinCardElement(item) { + const serverId = ApiClient.serverId(); + const serverAddress = ApiClient.serverAddress(); + + // Determine card type based on item type + let cardClass, padderClass, imageParams; + if (item.Type === 'Episode' || item.Type === 'TvChannel') { + cardClass = 'overflowBackdropCard'; + padderClass = 'cardPadder-overflowBackdrop'; + imageParams = 'fillHeight=267&fillWidth=474'; + } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) { + cardClass = 'overflowSquareCard'; + padderClass = 'cardPadder-overflowSquare'; + imageParams = 'fillHeight=297&fillWidth=297'; + } else { + // Default poster style for Movies, Series, etc. + cardClass = 'overflowPortraitCard'; + padderClass = 'cardPadder-overflowPortrait'; + imageParams = 'fillHeight=446&fillWidth=297'; + } + + // Create the main card container + const card = document.createElement('div'); + card.className = `card ${cardClass} card-hoverable card-withuserdata`; + card.setAttribute('data-index', '0'); + card.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' ? 'true' : 'false'); + card.setAttribute('data-serverid', serverId); + card.setAttribute('data-id', item.Id); + card.setAttribute('data-type', item.Type); + card.setAttribute('data-mediatype', item.MediaType || 'Video'); + card.setAttribute('data-prefix', item.Name?.startsWith('The ') ? 'THE' : ''); + + // Card box container + const cardBox = document.createElement('div'); + cardBox.className = 'cardBox cardBox-bottompadded'; + + // Card scalable container + const cardScalable = document.createElement('div'); + cardScalable.className = 'cardScalable'; + + // Card padder with icon + const cardPadder = document.createElement('div'); + cardPadder.className = `cardPadder ${padderClass} lazy-hidden-children`; + + const cardIcon = document.createElement('span'); + cardIcon.className = 'cardImageIcon material-icons'; + cardIcon.setAttribute('aria-hidden', 'true'); + + // Set icon based on item type + if (item.Type === 'Movie') { + cardIcon.textContent = 'movie'; + } else if (item.Type === 'Series') { + cardIcon.textContent = 'tv'; + } else if (item.Type === 'Episode') { + cardIcon.textContent = 'play_circle'; + } else if (item.Type === 'MusicAlbum') { + cardIcon.textContent = 'album'; + } else if (item.Type === 'Audio') { + cardIcon.textContent = 'music_note'; + } else if (item.Type === 'Artist') { + cardIcon.textContent = 'person'; + } else { + cardIcon.textContent = 'folder'; + } + + cardPadder.appendChild(cardIcon); + + // Blurhash canvas (placeholder) + const blurhashCanvas = document.createElement('canvas'); + blurhashCanvas.setAttribute('aria-hidden', 'true'); + blurhashCanvas.width = 20; + blurhashCanvas.height = 20; + blurhashCanvas.className = 'blurhash-canvas lazy-hidden'; + + // Card image container + const cardImageContainer = document.createElement('a'); + cardImageContainer.href = `#/details?id=${item.Id}&serverId=${serverId}`; + cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast'; + cardImageContainer.setAttribute('data-action', 'link'); + cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown'); + + // Set background image or icon + if (item.ImageTags?.Primary) { + let imageUrl; + if (item.IsJellyseerr) { + // Jellyseerr items use external image URLs + imageUrl = item.ImageTags.Primary.startsWith('http') + ? item.ImageTags.Primary + : `https://image.tmdb.org/t/p/w500${item.ImageTags.Primary}`; + } else { + // Regular Jellyfin items + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; + } + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } else { + // No image - add icon as inner element + const iconSpan = document.createElement('span'); + iconSpan.className = 'cardImageIcon material-icons'; + iconSpan.setAttribute('aria-hidden', 'true'); + + // Set icon based on item type + if (item.Type === 'Movie') { + iconSpan.textContent = 'movie'; + } else if (item.Type === 'Series') { + iconSpan.textContent = 'tv'; + } else if (item.Type === 'Episode') { + iconSpan.textContent = 'tv'; + } else if (item.Type === 'Person') { + iconSpan.textContent = 'person'; + } else if (item.Type === 'MusicAlbum') { + iconSpan.textContent = 'album'; + } else if (item.Type === 'Audio') { + iconSpan.textContent = 'music_note'; + } else if (item.Type === 'Artist' || item.Type === 'MusicArtist') { + iconSpan.textContent = 'person'; + } else { + iconSpan.textContent = 'folder'; + } + + cardImageContainer.appendChild(iconSpan); + } + + // Card overlay container + const cardOverlayContainer = document.createElement('div'); + cardOverlayContainer.className = 'cardOverlayContainer itemAction'; + cardOverlayContainer.setAttribute('data-action', 'link'); + + // Overlay link + const overlayLink = document.createElement('a'); + overlayLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + overlayLink.className = 'cardImageContainer'; + + // Play button + const playButton = document.createElement('button'); + playButton.setAttribute('is', 'paper-icon-button-light'); + playButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary'; + playButton.setAttribute('data-action', 'resume'); + + const playIcon = document.createElement('span'); + playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow'; + playIcon.setAttribute('aria-hidden', 'true'); + playButton.appendChild(playIcon); + + // Button container for additional overlay buttons (watchlist, etc.) + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'cardOverlayButton-br flex'; + + // Add play button to button container + buttonContainer.appendChild(playButton); + + // Assemble overlay + cardOverlayContainer.appendChild(overlayLink); + cardOverlayContainer.appendChild(buttonContainer); + + // Card text container - different structure for episodes + const cardTextContainer = document.createElement('div'); + cardTextContainer.className = 'cardText cardTextCentered cardText-first'; + + if (item.Type === 'Episode') { + // Episodes: Series name as primary, episode title as secondary + const seriesLink = document.createElement('a'); + seriesLink.href = `#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`; + seriesLink.className = 'itemAction textActionButton'; + seriesLink.setAttribute('data-id', item.SeriesId || item.Id); + seriesLink.setAttribute('data-serverid', serverId); + seriesLink.setAttribute('data-type', 'Series'); + seriesLink.setAttribute('data-mediatype', 'undefined'); + seriesLink.setAttribute('data-channelid', 'undefined'); + seriesLink.setAttribute('data-isfolder', 'true'); + seriesLink.setAttribute('data-action', 'link'); + seriesLink.title = item.SeriesName || 'Unknown Series'; + seriesLink.textContent = item.SeriesName || 'Unknown Series'; + + const seriesBdi = document.createElement('bdi'); + seriesBdi.appendChild(seriesLink); + cardTextContainer.appendChild(seriesBdi); + + // Episode title as secondary + const secondaryText = document.createElement('div'); + secondaryText.className = 'cardText cardTextCentered cardText-secondary'; + const episodeLink = document.createElement('a'); + episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + episodeLink.className = 'itemAction textActionButton'; + episodeLink.setAttribute('data-id', item.Id); + episodeLink.setAttribute('data-serverid', serverId); + episodeLink.setAttribute('data-type', 'Episode'); + episodeLink.setAttribute('data-mediatype', 'undefined'); + 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'; + + const episodeBdi = document.createElement('bdi'); + episodeBdi.appendChild(episodeLink); + secondaryText.appendChild(episodeBdi); + } else { + // Default: Item name as primary, year as secondary + const titleLink = document.createElement('a'); + titleLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + titleLink.className = 'itemAction textActionButton'; + titleLink.setAttribute('data-id', item.Id); + titleLink.setAttribute('data-serverid', serverId); + titleLink.setAttribute('data-type', item.Type); + titleLink.setAttribute('data-mediatype', 'undefined'); + titleLink.setAttribute('data-channelid', 'undefined'); + titleLink.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' || item.Type === 'MusicArtist' ? 'true' : 'false'); + titleLink.setAttribute('data-action', 'link'); + titleLink.title = item.Name || 'Unknown'; + titleLink.textContent = item.Name || 'Unknown'; + + const titleBdi = document.createElement('bdi'); + titleBdi.appendChild(titleLink); + cardTextContainer.appendChild(titleBdi); + + // Secondary text (year) + const secondaryText = document.createElement('div'); + secondaryText.className = 'cardText cardTextCentered cardText-secondary'; + const yearBdi = document.createElement('bdi'); + yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || ''; + secondaryText.appendChild(yearBdi); + } + + // Assemble card + cardScalable.appendChild(cardPadder); + cardScalable.appendChild(blurhashCanvas); + cardScalable.appendChild(cardImageContainer); + cardScalable.appendChild(cardOverlayContainer); + + cardBox.appendChild(cardScalable); + cardBox.appendChild(cardTextContainer); + + // Add secondary text after primary text + if (item.Type === 'Episode') { + const secondaryText = document.createElement('div'); + secondaryText.className = 'cardText cardTextCentered cardText-secondary'; + const episodeLink = document.createElement('a'); + episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + episodeLink.className = 'itemAction textActionButton'; + episodeLink.setAttribute('data-id', item.Id); + episodeLink.setAttribute('data-serverid', serverId); + episodeLink.setAttribute('data-type', 'Episode'); + episodeLink.setAttribute('data-mediatype', 'undefined'); + 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'; + + const episodeBdi = document.createElement('bdi'); + episodeBdi.appendChild(episodeLink); + secondaryText.appendChild(episodeBdi); + cardBox.appendChild(secondaryText); + } else { + const secondaryText = document.createElement('div'); + secondaryText.className = 'cardText cardTextCentered cardText-secondary'; + const yearBdi = document.createElement('bdi'); + yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || ''; + secondaryText.appendChild(yearBdi); + cardBox.appendChild(secondaryText); + } + + card.appendChild(cardBox); + + // Add click handler + card.addEventListener('click', (e) => { + if (!e.target.closest('button')) { + console.log('[CardBuilder] item click', { id: item.Id, name: item.Name }); + try { + Dashboard.navigate(`#!/details?id=${item.Id}&serverId=${serverId}`); + } catch(e) { + console.error('[CardBuilder]', e); + } + } + }); + + return card; + } + + // Expose the cardBuilder to the global window object + window.cardBuilder = cardBuilder; + + console.log('[CardBuilder] Module loaded and available at window.cardBuilder'); +})(); diff --git a/watchlist.js b/watchlist.js new file mode 100644 index 0000000..911d6c2 --- /dev/null +++ b/watchlist.js @@ -0,0 +1,775 @@ +// Jellyfin Watchlist Enhancement Script +// Adds watchlist functionality throughout Jellyfin interface +// Requires: cardBuilder.js module to be loaded before this script + +(function() { + 'use strict'; + + // Common logging function + const LOG = (...args) => console.log('[Watchlist]', ...args); + const WARN = (...args) => console.warn('[Watchlist]', ...args); + const ERR = (...args) => console.error('[Watchlist]', ...args); + + LOG('Script loaded - JS Injector mode'); + +/************ Helpers ************/ + +function formatPremiereDate(dateStr) { + if (!dateStr) return ""; + return new Date(dateStr).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); +} + +function setCardData(items, options) { + // Stub function for compatibility +} + +function buildCard(index, item, apiClient, options) { + // Check if cardBuilder is available + if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) { + // Use cardBuilder to create a proper Jellyfin card + const cardElement = window.cardBuilder.buildCard(item); + cardElement.setAttribute('data-index', index); + cardElement.classList.add('discover-card'); + + // Convert DOM element to HTML string + return cardElement.outerHTML; + } else { + // Fallback to simple card if cardBuilder is not available + WARN("cardBuilder not available, using fallback card creation"); + return buildFallbackCard(index, item, apiClient, options); + } +} + +function buildFallbackCard(index, item, apiClient, options) { + var serverUrl = apiClient.serverAddress(); + var imgUrl = serverUrl + "/Items/" + item.Id + "/Images/Primary?maxWidth=200&tag=" + item.PrimaryImageTag; + var itemUrl = getItemDetailUrl(item); + + var html = ''; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ' '; + html += ' '; + html += '
'; + html += '
'; + html += ' '; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ' '; + html += ' ' + item.Name + ''; + html += ' '; + html += '
'; + html += '
'; + html += ' '; + + var date = new Date(item.PremiereDate); + + html += ' ' + date.getFullYear() + ''; + html += ' '; + html += '
'; + html += '
'; + html += '
'; + + return html; +} + +async function toggleFavorite(itemId, btn) { + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + const isFav = btn.textContent === "♡"; + try { + await fetch(`${serverUrl}/Users/${userId}/FavoriteItems/${itemId}?IsFavorite=${isFav}`, { + method: "POST", + headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } + }); + btn.textContent = isFav ? "♥" : "♡"; + } catch (err) { + ERR("Favorite toggle failed", err); + } +} + +function buildCardsHtmlInternal(items, options) { + setCardData(items, options); + let html = ""; + let itemsInRow = 0; + let currentIndexValue; + let hasOpenRow = false; + let hasOpenSection = false; + const sectionTitleTagName = options.sectionTitleTagName || "div"; + const apiClient = window.ApiClient; + let lastServerId; + + for (const [i, item] of items.entries()) { + const serverId = item.ServerId || options.serverId; + if (serverId !== lastServerId) lastServerId = serverId; + + let newIndexValue = ""; + if (options.indexBy === "PremiereDate") { + newIndexValue = formatPremiereDate(item.PremiereDate); + } else if (options.indexBy === "ProductionYear") { + newIndexValue = item.ProductionYear; + } else if (options.indexBy === "CommunityRating") { + const rounded = item.CommunityRating % 1 >= 0.5 ? 0.5 : 0; + newIndexValue = item.CommunityRating ? (Math.floor(item.CommunityRating) + rounded) + "+" : ""; + } + + // Handle section changes + if (newIndexValue !== currentIndexValue) { + + // Close previous row and section if open + if (hasOpenRow) { + html += ""; + hasOpenRow = false; + itemsInRow = 0; + } + if (hasOpenSection) { + html += ""; + hasOpenSection = false; + } + + // Start new section (always horizontal) + html += "
"; + html += `<${sectionTitleTagName} class="sectionTitle">${newIndexValue}`; + + currentIndexValue = newIndexValue; + hasOpenSection = true; + } + + // Handle row creation for multi-row layouts + if (options.rows && itemsInRow === 0) { + html += "
"; + hasOpenRow = true; + } + + // Build the card with direct linking + html += buildCard(i, item, apiClient, options); + itemsInRow++; + + // Close row if we've reached the row limit + if (options.rows && itemsInRow >= options.rows) { + html += "
"; + hasOpenRow = false; + itemsInRow = 0; + } + } + + // Close any remaining open elements + if (hasOpenRow) html += "
"; + if (hasOpenSection) html += ""; + + return html; +} + +function buildCardWithLink(index, item, apiClient, options) { + // Generate the URL for the item detail page + const itemUrl = getItemDetailUrl(item); + + // Build the original card content + const cardContent = buildCard(index, item, apiClient, options); + + // Wrap the card in a clickable link + return `${cardContent}`; +} + +function getItemDetailUrl(item) { + // Construct the URL to the item's detail page + // Adjust this based on your Jellyfin setup and routing + const baseUrl = window.location.origin; + const serverId = item.ServerId; + + // Standard Jellyfin item detail URL format + return `${baseUrl}/web/#/details?id=${item.Id}&serverId=${serverId}`; +} + +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 []; + } +} + +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 create proper Jellyfin section structure + container.style.display = ''; + const sectionHtml = createWatchlistSection(type, items); + container.innerHTML = sectionHtml; + return { type, itemCount: items.length }; +} + +function createWatchlistSection(type, items) { + // This function assumes items array is not empty (checked at higher level) + + // Create the main vertical section container + const verticalSection = document.createElement('div'); + verticalSection.className = 'verticalSection emby-scroller-container'; + + // Create section title + const sectionTitle = document.createElement('h2'); + sectionTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right'; + sectionTitle.textContent = getTypeDisplayName(type); + + // Create scroll buttons container + const scrollButtons = document.createElement('div'); + scrollButtons.setAttribute('is', 'emby-scrollbuttons'); + scrollButtons.className = 'emby-scrollbuttons padded-right'; + + // Previous button + const prevButton = document.createElement('button'); + prevButton.type = 'button'; + prevButton.setAttribute('is', 'paper-icon-button-light'); + prevButton.setAttribute('data-ripple', 'false'); + prevButton.setAttribute('data-direction', 'left'); + prevButton.title = 'Previous'; + prevButton.className = 'emby-scrollbuttons-button paper-icon-button-light'; + prevButton.disabled = true; + + const prevIcon = document.createElement('span'); + prevIcon.className = 'material-icons chevron_left'; + prevIcon.setAttribute('aria-hidden', 'true'); + prevButton.appendChild(prevIcon); + + // Next button + const nextButton = document.createElement('button'); + nextButton.type = 'button'; + nextButton.setAttribute('is', 'paper-icon-button-light'); + nextButton.setAttribute('data-ripple', 'false'); + nextButton.setAttribute('data-direction', 'right'); + nextButton.title = 'Next'; + nextButton.className = 'emby-scrollbuttons-button paper-icon-button-light'; + + const nextIcon = document.createElement('span'); + nextIcon.className = 'material-icons chevron_right'; + nextIcon.setAttribute('aria-hidden', 'true'); + nextButton.appendChild(nextIcon); + + scrollButtons.appendChild(prevButton); + scrollButtons.appendChild(nextButton); + + // Create scroller container + const scroller = document.createElement('div'); + scroller.setAttribute('is', 'emby-scroller'); + scroller.setAttribute('data-horizontal', 'true'); + scroller.setAttribute('data-centerfocus', 'card'); + scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller'; + scroller.setAttribute('data-scroll-mode-x', 'custom'); + scroller.style.overflow = 'hidden'; + + // Create items container + const itemsContainer = document.createElement('div'); + itemsContainer.setAttribute('is', 'emby-itemscontainer'); + itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX'; + itemsContainer.style.whiteSpace = 'nowrap'; + itemsContainer.style.willChange = 'transform'; + itemsContainer.style.transition = 'transform 270ms ease-out'; + itemsContainer.style.transform = 'translateX(0px)'; + + // Add items to container using cardBuilder + items.forEach((item, index) => { + if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) { + const card = window.cardBuilder.buildCard(item); + card.setAttribute('data-index', index); + card.classList.add('discover-card'); + itemsContainer.appendChild(card); + } else { + // Fallback to HTML string + const cardHtml = buildFallbackCard(index, item, window.ApiClient, {}); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = cardHtml; + itemsContainer.appendChild(tempDiv.firstElementChild); + } + }); + + scroller.appendChild(itemsContainer); + + // Add scroll functionality + let scrollPosition = 0; + const cardWidth = 212; // 200px card + 12px gap + const visibleCards = Math.floor(scroller.offsetWidth / cardWidth); + const scrollStep = Math.max(1, Math.floor(visibleCards * 0.9)); + const maxScroll = Math.max(0, items.length - visibleCards); + + const updateScrollButtons = () => { + prevButton.disabled = scrollPosition <= 0; + nextButton.disabled = scrollPosition >= maxScroll; + }; + + const scrollTo = (position, smooth = true) => { + scrollPosition = Math.max(0, Math.min(position, maxScroll)); + + if (smooth) { + itemsContainer.style.transition = 'transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'; + } else { + itemsContainer.style.transition = 'none'; + } + + itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`; + updateScrollButtons(); + + if (smooth) { + setTimeout(() => { + itemsContainer.style.transition = 'transform 270ms ease-out'; + }, 500); + } + }; + + prevButton.addEventListener('click', () => { + const newPosition = Math.max(0, scrollPosition - scrollStep); + scrollTo(newPosition, true); + }); + + nextButton.addEventListener('click', () => { + const newPosition = Math.min(maxScroll, scrollPosition + scrollStep); + scrollTo(newPosition, true); + }); + + // Initial state + updateScrollButtons(); + + // Assemble the section + verticalSection.appendChild(sectionTitle); + verticalSection.appendChild(scrollButtons); + verticalSection.appendChild(scroller); + + return verticalSection.outerHTML; +} + +function getTypeDisplayName(itemType) { + const typeMap = { + 'Movie': 'Movies', + 'Series': 'TV Shows', + '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' + }; + return typeMap[itemType] || itemType; +} + +/************ Watchlist Section Observer ************/ + +// Function to render watchlist content when .sections.watchlist is found +async function renderWatchlistContent() { + try { + const results = await Promise.all([ + renderCards(".sections.watchlist > .watchlist-movies", "Movie"), + renderCards(".sections.watchlist > .watchlist-series", "Series"), + renderCards(".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); + } +} + +// Function to check if watchlist section exists and render content +function checkAndRenderWatchlist() { + const watchlistSection = document.querySelector('.sections.watchlist'); + if (watchlistSection && !watchlistSection.dataset.watchlistRendered) { + watchlistSection.dataset.watchlistRendered = 'true'; + renderWatchlistContent(); + } +} + +// 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 + }); +} + +// Initialize the watchlist section observer +setupWatchlistSectionObserver(); + +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); + } +} + +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; + } + + // 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; + } + + // 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'; + watchlistIcon.textContent = 'bookmark_border'; + watchlistIcon.setAttribute('aria-hidden', 'true'); + + watchlistButton.appendChild(watchlistIcon); + + // Add click event listener + watchlistButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Toggle watchlist status + const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false'; + ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating); + watchlistButton.dataset.active = newRating; + + // Update icon and title based on state + const isActive = watchlistButton.dataset.active === 'true'; + watchlistIcon.textContent = isActive ? 'bookmark' : 'bookmark_border'; + watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist'; + }); + + // 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'; + watchlistIcon.textContent = 'bookmark'; + 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'; + + // 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"; + 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")) { + + if (watchlistIcon && watchlistIcon.classList.contains("hide")) { + watchlistIcon.classList.remove('hide'); + observer.disconnect(); + } + } + }); + }); + + 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; + } + + addDetailPageWatchlistButton(); + }); + + // 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() { + LOG('Manual debug trigger called'); + LOG('Current overlay containers:', document.querySelectorAll('.cardOverlayContainer').length); + LOG('Current watchlist buttons:', document.querySelectorAll('.watchlist-button').length); + processExistingOverlayContainers(); + }; + + window.debugWatchlistRendering = function() { + LOG('Manual watchlist rendering trigger called'); + checkAndRenderWatchlist(); + }; + + window.debugDetailPageWatchlist = function() { + LOG('Manual detail page watchlist trigger called'); + addDetailPageWatchlistButton(); + }; + + LOG('Debug functions available: window.debugWatchlistButtons(), window.debugWatchlistRendering(), window.debugDetailPageWatchlist()'); +})(); \ No newline at end of file