Jellyfin Watchlist functionality

This commit is contained in:
HighImKevin
2025-09-09 00:23:54 -04:00
commit 55b4d4b00e
2 changed files with 1083 additions and 0 deletions
+308
View File
@@ -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');
})();
+775
View File
@@ -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 += '<div class="card overflowPortraitCard card-hoverable card-withuserdata discover-card" data-index="' + index + '">';
html += ' <div class="cardBox cardBox-bottompadded">';
html += ' <div class="cardScalable discoverCard-' + item.SourceType + '">';
html += ' <div class="cardPadder cardPadder-overflowPortrait lazy-hidden-children"></div>';
html += ' <canvas aria-hidden="true" width="20" height="20" class="blurhash-canvas lazy-hidden"></canvas>';
html += ' <a href="' + itemUrl + '" class="cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast" aria-label="" style="background-image: url(' + imgUrl + ');"></a>';
html += ' <div class="cardOverlayContainer itemAction" data-action="link">';
html += ' <div class="cardOverlayButton-br flex">';
html += ' <button is="discover-requestbutton" type="button" data-action="none" class="discover-requestbutton cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button" data-id="' + item.id + '" data-media-type="' + item.SourceType + '">';
html += ' <span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover add" aria-hidden="true"></span>';
html += ' </button>';
html += ' </div>';
html += ' </div>';
html += ' </div>';
html += ' <div class="cardText cardTextCentered cardText-first">';
html += ' <bdi>';
html += ' <a target="_blank" href="' + item.id + '/' + item.SourceType + '/' + item.id + '" class="itemAction textActionButton" title="' + item.Name + '" data-action="link">' + item.Name + '</a>';
html += ' </bdi>';
html += ' </div>';
html += ' <div class="cardText cardTextCentered cardText-secondary">';
html += ' <bdi>';
var date = new Date(item.PremiereDate);
html += ' <a target="_blank" href="' + item.id + '/' + item.SourceType + '/' + item.id + '" class="itemAction textActionButton" title="' + date.getFullYear() + '" data-action="link">' + date.getFullYear() + '</a>';
html += ' </bdi>';
html += ' </div>';
html += ' </div>';
html += '</div>';
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 += "</div>";
hasOpenRow = false;
itemsInRow = 0;
}
if (hasOpenSection) {
html += "</div>";
hasOpenSection = false;
}
// Start new section (always horizontal)
html += "<div class=\"horizontalSection\">";
html += `<${sectionTitleTagName} class="sectionTitle">${newIndexValue}</${sectionTitleTagName}>`;
currentIndexValue = newIndexValue;
hasOpenSection = true;
}
// Handle row creation for multi-row layouts
if (options.rows && itemsInRow === 0) {
html += "<div class=\"cardColumn\">";
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 += "</div>";
hasOpenRow = false;
itemsInRow = 0;
}
}
// Close any remaining open elements
if (hasOpenRow) html += "</div>";
if (hasOpenSection) html += "</div>";
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 `<a href="${itemUrl}" class="card-link" style="text-decoration: none; color: inherit;">${cardContent}</a>`;
}
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 = `
<div style="margin-bottom: 20px;">
<span class="material-icons" style="font-size: 48px; color: #666;">bookmark_border</span>
</div>
<h3 style="color: #fff; margin-bottom: 16px; font-size: 20px;">Your Watchlist is Empty</h3>
<p style="margin: 0; max-width: 400px; margin-left: auto; margin-right: auto;">
Press the <span class="material-icons" style="font-size: 18px; vertical-align: middle; color: #00a4dc;">bookmark_border</span>
button to add an item to your Watchlist
</p>
`;
// 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()');
})();