// Jellyfin Card Builder // This module provides a main entry point function to build Jellyfin cards // Usage: window.cardBuilder.buildCard(jellyfinItem) (function() { 'use strict'; const state = { useEpisodeImages: false, }; /** * Helper function to shuffle array (Fisher-Yates) */ function shuffle(array) { const arr = [...array]; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** * Sorts items based on sort order and direction * @param {Array} items - Array of Jellyfin items * @param {string} sortOrder - Sort order: Random, ReleaseDate, CriticRating, CommunityRating, SortTitle, DateAdded * @param {string} sortOrderDirection - Direction: Ascending or Descending * @returns {Array} - Sorted array */ function sortItems(items, sortOrder = 'Random', sortOrderDirection = 'Ascending') { if (!items || items.length === 0) return items; const ascending = sortOrderDirection === 'Ascending'; const sorted = [...items]; switch(sortOrder) { case 'Random': return shuffle(sorted); case 'ReleaseDate': return sorted.sort((a, b) => { const dateA = new Date(a.PremiereDate || a.ProductionYear || 0); const dateB = new Date(b.PremiereDate || b.ProductionYear || 0); return ascending ? dateA - dateB : dateB - dateA; }); case 'CriticRating': return sorted.sort((a, b) => { const ratingA = a.CriticRating || 0; const ratingB = b.CriticRating || 0; return ascending ? ratingA - ratingB : ratingB - ratingA; }); case 'CommunityRating': return sorted.sort((a, b) => { const ratingA = a.CommunityRating || 0; const ratingB = b.CommunityRating || 0; return ascending ? ratingA - ratingB : ratingB - ratingA; }); case 'SortTitle': return sorted.sort((a, b) => { const titleA = (a.SortTitle || a.Name || '').toLowerCase(); const titleB = (b.SortTitle || b.Name || '').toLowerCase(); return ascending ? titleA.localeCompare(titleB) : titleB.localeCompare(titleA); }); case 'DateAdded': return sorted.sort((a, b) => { const dateA = new Date(a.DateCreated || 0); const dateB = new Date(b.DateCreated || 0); return ascending ? dateA - dateB : dateB - dateA; }); default: return sorted; } } /** * Picks a random card format for a section * @returns {string} - Random card format: 'portrait', 'thumb' */ function getRandomCardFormat() { const formats = ['portrait', 'thumb']; return formats[Math.floor(Math.random() * formats.length)]; } function flattenSeriesEpisodes(items) { if (!items || items.length === 0) return items; // Keep only the first item in the array for any given series const seriesMap = new Map(); items.forEach(item => { if (item.Type !== 'Episode') { return; } if (seriesMap.has(item.SeriesId)) { return; } seriesMap.set(item.SeriesId, item); }); return Array.from(seriesMap.values()); } function formatAirDate(premiereDate) { if (!premiereDate) return ''; // Ignore timezone offset const date = new Date(premiereDate.replace('Z', '')); // Use format: Tues Jan 4 const options = { weekday: 'short', month: 'short', day: 'numeric' }; return date.toLocaleDateString('en-US', options); } function deduplicateItems(items) { const deduplicated = []; const ids = new Set(); for (const item of items) { if (!item.ProviderIds) { deduplicated.push(item); continue; } if (item.ProviderIds.Imdb && ids.has(item.ProviderIds.Imdb)) continue; if (item.ProviderIds.Tmdb && ids.has(item.ProviderIds.Tmdb)) continue; if (item.ProviderIds.Tvdb && ids.has(item.ProviderIds.Tvdb)) continue; deduplicated.push(item); ids.add(item.ProviderIds.Imdb); ids.add(item.ProviderIds.Tmdb); ids.add(item.ProviderIds.Tvdb); } return deduplicated; } /** * Merges NextUp and ContinueWatching items chronologically by LastPlayedDate * Preserves NextUp's internal order and assigns LastPlayedDate to items without it * @param {Array} nextUpItems - Array of Episode items from NextUp endpoint (already ordered) * @param {Array} continueWatchingItems - Array of Movie items from Continue Watching endpoint * @returns {Array} - Merged array sorted by LastPlayedDate (descending) */ function mergeNextUpAndContinueWatching(nextUpItems, continueWatchingItems) { if (!nextUpItems || nextUpItems.length === 0) { return continueWatchingItems || []; } if (!continueWatchingItems || continueWatchingItems.length === 0) { return nextUpItems || []; } // 1. Assign LastPlayedDate to NextUp items without it // Items without LastPlayedDate inherit the date from the next item that has one const processedNextUp = [...nextUpItems]; for (let i = processedNextUp.length - 1; i >= 0; i--) { const item = processedNextUp[i]; if (!item.UserData || !item.UserData.LastPlayedDate) { // Look ahead to find next item with LastPlayedDate for (let j = i + 1; j < processedNextUp.length; j++) { if (processedNextUp[j].UserData && processedNextUp[j].UserData.LastPlayedDate) { // Assign the date from the next item if (!item.UserData) item.UserData = {}; item.UserData.LastPlayedDate = processedNextUp[j].UserData.LastPlayedDate; break; } } // If no date found ahead, assign null (will be placed at end) if (!item.UserData || !item.UserData.LastPlayedDate) { if (!item.UserData) item.UserData = {}; item.UserData.LastPlayedDate = null; } } } // 2. Sort ContinueWatching items by LastPlayedDate (descending) const sortedContinueWatching = [...continueWatchingItems, ...processedNextUp].sort((a, b) => { const dateA = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0; const dateB = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0; return dateB - dateA; // Descending }); return sortedContinueWatching; } function postProcessItems(sectionConfig, itemsData) { let processed = itemsData?.Items || itemsData || []; // Check if the IsUnplayed filter is set on any of the queries const isUnplayedFilter = sectionConfig.queries?.some(query => query.queryOptions?.IsUnplayed === true || query.queryOptions?.Filters?.includes('IsUnplayed')); if (isUnplayedFilter) { processed = processed.filter(item => item.UserData?.Played === false); } // Check if the IsPlayed filter is set on any of the queries const isPlayedFilter = sectionConfig.queries?.some(query => query.queryOptions?.IsPlayed === true || query.queryOptions?.Filters?.includes('IsPlayed')); if (isPlayedFilter) { processed = processed.filter(item => item.UserData?.Played === true); } // Check if the IsResumable filter is set on any of the queries const isResumableFilter = sectionConfig.queries?.some(query => query.queryOptions?.IsResumable === true || query.queryOptions?.Filters?.includes('IsResumable')); if (isResumableFilter) { processed = processed.filter(item => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage !== 100 && item.UserData?.PlayedPercentage > 0); } // Flatten Series Episodes if (sectionConfig.flattenSeries === true) { processed = flattenSeriesEpisodes(processed); } /* if (sectionConfig.id === 'popularTVNetworks') { // Sort items randomly processed = shuffle(processed); } */ if (sectionConfig.id === 'continueWatchingAndNextUp') { // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them const movies = processed.filter(item => item.Type === 'Movie'); const episodes = processed.filter(item => item.Type !== 'Movie'); processed = mergeNextUpAndContinueWatching(episodes, movies); } if (sectionConfig.id === 'upcoming') { // Remove items which aired before today const today = new Date(); today.setHours(0, 0, 0, 0); processed = processed.filter(item => { if (!item.PremiereDate || item.LocationType !== 'Virtual') return false; const premiereDate = new Date(item.PremiereDate); premiereDate.setHours(0, 0, 0, 0); return premiereDate >= today; }); } // Add Custom Secondary Text for Recently Released Movies if (sectionConfig.id === 'recentlyReleased.movies' || sectionConfig.id === 'upcoming' || sectionConfig.id === 'recentlyReleased.episodes') { processed.forEach(item => { if (item.PremiereDate) { item.CustomFooterText = formatAirDate(item.PremiereDate); } }); } // Sort items by UserData.PlayedPercentage if PlayedPercentage is set as the SortBy if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'PlayedPercentage') { processed = processed.sort((a, b) => { const playedPercentageA = a.UserData?.PlayedPercentage || 0; const playedPercentageB = b.UserData?.PlayedPercentage || 0; return playedPercentageB - playedPercentageA; }); // Remove any items which have no UserData.PlayedPercentage processed = processed.filter(item => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0); } const limit = sectionConfig.itemLimit || sectionConfig.queries?.[0]?.queryOptions?.Limit || 0; // Apply local limits for non-API sources if (limit > 0) { processed = processed.slice(0, limit); } // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids processed = deduplicateItems(processed); return processed; } /** * Refresh section queries by re-executing them without cache * @param {Object} sectionConfig - Section configuration with queries array * @returns {Promise} - Processed items array */ async function refreshSectionQueries(sectionConfig) { if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) { console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`); return []; } const userId = ApiClient.getCurrentUserId(); const serverUrl = ApiClient.serverAddress(); const ApiHelper = window.apiHelper; const results = []; // Process each query in the queries array for (const query of sectionConfig.queries) { let queryResult; if (query.dataSource) { // Handle cache-based data sources queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}, false); } else { // Build and execute query const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl); if (typeof queryUrl === 'string') { // Standard query - use useCache: false to bypass cache // When useCache is false, getQuery returns a Promise directly const data = await ApiHelper.getQuery(queryUrl, { useCache: false }); // Wrap in expected format for consistency queryResult = { data: data, dataPromise: Promise.resolve(data), isStalePromise: Promise.resolve(false) }; } else { console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`); continue; } } results.push(queryResult); } // If multiple queries, merge results using section-level sortBy/sortOrder let finalResult; if (results.length > 1) { finalResult = ApiHelper.mergeMultiQueryResults(results, sectionConfig); // Extract items from merged result const items = finalResult.result?.data?.Items || finalResult.result?.data || []; return items; } else if (results.length === 1) { // Single query result const data = results[0].data || results[0]; const items = data?.Items || data || []; return postProcessItems(sectionConfig, items); } return []; } /** * Show checkmark icon temporarily, then revert to refresh icon * @param {HTMLElement} button - The refresh button element */ async function showRefreshComplete(button) { // Wait a brief moment for the spinning animation to complete smoothly // This ensures the transition from spinning to checkmark looks natural await new Promise(resolve => requestAnimationFrame(resolve)); // Fade out current icon /* button.classList.add('icon-fade-out'); // Wait for fade out to complete await new Promise(resolve => setTimeout(resolve, 200));*/ // Change to checkmark icon button.classList.remove('refresh', 'icon-fade-out'); button.classList.add('check_circle', 'icon-fade-in'); await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => setTimeout(resolve, 0)); // Wait for fade in to complete await new Promise(resolve => setTimeout(resolve, 200)); button.classList.remove('icon-fade-in'); // After 1.5 seconds, fade out checkmark and fade in refresh icon setTimeout(async () => { // Fade out checkmark button.classList.add('icon-fade-out'); await new Promise(resolve => setTimeout(resolve, 200)); // Change to refresh icon button.classList.remove('check_circle', 'icon-fade-out'); button.classList.add('refresh', 'icon-fade-in'); // Wait for fade in to complete await new Promise(resolve => setTimeout(resolve, 200)); button.classList.remove('icon-fade-in'); }, 1500); } /** * Create refresh button for progressive sections * @param {Object} sectionConfig - Section configuration * @param {HTMLElement} sectionElement - The section element to refresh * @returns {HTMLElement} - The refresh button element */ function createRefreshButton(sectionConfig, sectionElement) { const button = document.createElement('button'); button.type = 'button'; button.className = 'section-refresh-button material-icons refresh'; button.title = 'Refresh Section'; button.setAttribute('aria-label', 'Refresh Section'); // Click handler button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); // Set refreshing state sectionElement.dataset.refreshing = 'true'; try { // Refresh the queries const freshItems = await refreshSectionQueries(sectionConfig); if (freshItems.length === 0) { // If no items, remove section sectionElement.remove(); return; } // Get card format from current section let finalCardFormat = sectionConfig.cardFormat; const currentCardFormat = sectionElement.getAttribute('data-card-format'); if (currentCardFormat) { finalCardFormat = currentCardFormat; } // Create new content let content = null; if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { content = createSpotlightSection(freshItems, sectionConfig.name, { viewMoreUrl: sectionConfig.viewMoreUrl, ...sectionConfig.spotlightConfig }); } else { content = createScrollableContainer(freshItems, sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.overflowCard, finalCardFormat, sectionConfig.forcedImageType); } // Preserve all styles, classes, and attributes content.style.cssText = sectionElement.style.cssText; content.className = sectionElement.className; Array.from(sectionElement.attributes).forEach(attr => { content.setAttribute(attr.name, attr.value); }); // Preserve data-expanded state from old itemsContainer to new one const oldItemsContainer = sectionElement.querySelector('.itemsContainer'); const newItemsContainer = content.querySelector('.itemsContainer'); if (oldItemsContainer && newItemsContainer) { const isExpanded = oldItemsContainer.getAttribute('data-expanded') === 'true'; if (isExpanded) { newItemsContainer.setAttribute('data-expanded', 'true'); // Also update the show-all-button text if it exists const showAllButton = content.querySelector('.show-all-button'); if (showAllButton) { showAllButton.textContent = 'Collapse'; showAllButton.title = 'Show items in scrollable layout'; } // Hide scroll buttons if they exist const scrollButtons = content.querySelector('.emby-scrollbuttons'); if (scrollButtons) { scrollButtons.style.display = 'none'; } } } // Replace the section element sectionElement.replaceWith(content); // Add refresh button to new content const sectionTitleContainer = content.querySelector('.sectionTitleContainer'); let newButton = null; if (sectionTitleContainer) { newButton = createRefreshButton(sectionConfig, content); sectionTitleContainer.appendChild(newButton); } // Set refreshing state to false content.dataset.refreshing = 'false'; // Show checkmark on the new button if (newButton) { showRefreshComplete(newButton); } } catch (error) { console.error('[KefinTweaks CardBuilder] Error refreshing section:', error); sectionElement.dataset.refreshing = 'false'; } }); return button; } // Main card builder object const cardBuilder = { /** * Post-processes items for a section * @param {Object} sectionConfig - Section configuration * @param {Array} itemsData - Array of Jellyfin items * @returns {Array} - Post-processed array of Jellyfin items */ postProcessItems: postProcessItems, /** * Main entry point function to build a Jellyfin card * @param {Object} item - The Jellyfin item object * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square' * @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes) * @returns {HTMLElement} - The constructed card element */ buildCard: function(item, overflowCard = false, cardFormat = null, customFooterText = null) { return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText); }, /** * Renders a scrollable container with cards * @param {Array} items - Array of Jellyfin item objects * @param {string} title - Title for the scrollable container * @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random' * @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded' * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {HTMLElement} - The constructed scrollable container */ renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending', forcedImageType = null) { // Handle Random card format - pick one format for entire section let finalCardFormat = cardFormat; if (cardFormat === 'random' || cardFormat === 'Random') { finalCardFormat = getRandomCardFormat(); } // Sort items if sortOrder is provided let sortedItems = items; if (sortOrder && sortOrder !== 'Random') { sortedItems = sortItems(items, sortOrder, sortOrderDirection); } else if (sortOrder === 'Random') { sortedItems = shuffle([...items]); } const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat, forcedImageType); return container; }, /** * Renders a scrollable container with cards from item IDs * @param {Array} itemIds - Array of Jellyfin item IDs * @param {string} title - Title for the scrollable container * @param {string} viewMoreUrl - Optional URL to make title clickable * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random' * @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded' * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {Promise} - The constructed scrollable container */ /** * Renders cards progressively with skeleton loading state * @param {Promise} itemsPromise - Promise that resolves to array of Jellyfin items * @param {string} title - Title for the scrollable container * @param {string|Function} viewMoreUrl - Optional URL to make title clickable * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random' * @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded' * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {HTMLElement} - The constructed scrollable container (initially with skeletons) */ renderProgressivelyEnhancedCards: async function(itemsPromise, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending', forcedImageType = null, minimumItems = -1) { // Handle Random card format - Must resolve ONCE for both skeleton and real cards let resolvedFormat = cardFormat; if (cardFormat === 'random' || cardFormat === 'Random') { resolvedFormat = getRandomCardFormat(); } // Check if promise is already fulfilled if (await promiseState(itemsPromise) === 'fulfilled') { // Resolve the promise and get the items const items = await itemsPromise; // Replace the skeleton with the real cards const container = createScrollableContainer(items, title, viewMoreUrl, overflowCard, resolvedFormat, forcedImageType); return container; } // Create skeleton container immediately using the resolved format const skeletonContainer = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard); const randomId = Math.random().toString(36).substring(2, 15); skeletonContainer.setAttribute('data-skeleton-id', randomId); // When promise resolves, replace skeletons with real cards itemsPromise.then(async items => { // Remove the skeleton container if it exists let skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); // Let's try to find the skeleton 20 times every 100ms for (let i = 0; i < 20; i++) { skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); if (skeletonContaineElement) { break; } await new Promise(resolve => setTimeout(resolve, 150)); } if (!items || items.length === 0) { // Remove container if no items skeletonContaineElement.remove(); return; } if (items.length < minimumItems) { // Remove container if not enough items skeletonContaineElement.remove(); return; } // Sort items if needed let sortedItems = items; if (sortOrder && sortOrder !== 'Random') { sortedItems = sortItems(items, sortOrder, sortOrderDirection); } else if (sortOrder === 'Random') { sortedItems = shuffle([...items]); } // Generate real cards container (off-DOM) using the SAME resolved format const realContainer = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat, forcedImageType); // Copy critical attributes to preserve layout/order if (skeletonContaineElement.style.order) { realContainer.style.order = skeletonContainer.style.order; } if (skeletonContaineElement.hasAttribute('data-section-id')) { realContainer.setAttribute('data-section-id', skeletonContainer.getAttribute('data-section-id')); } // Replace content of skeleton container with real content // This preserves the container element itself and its layout properties (like order) skeletonContaineElement.replaceWith(realContainer); }).catch(error => { console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error); // Optionally remove skeleton on error or show error state if (skeletonContaineElement) { skeletonContaineElement.remove(); } }); return skeletonContainer; }, /** * Renders a progressive section (Cards or Spotlight) * @param {HTMLElement} container - The parent container to append the section to * @param {Array|Promise} itemsOrPromises - Data array or Promise resolving to items * @param {string} title - Section title * @param {Object} options - Configuration options */ renderProgressiveSections: async function(container, sections, options = {}) { const { revealSectionsSequentially = false } = options; const renderProgressiveSectionsStartTime = performance.now(); // Get the HTML for all of the progressive sections and append it to the container at once let sectionElements = []; const fragment = document.createDocumentFragment(); for (const sectionPromise of sections) { const sectionPromiseStartTime = performance.now(); const section = await sectionPromise; const sectionPromiseEndTime = performance.now(); const sectionPromiseDuration = sectionPromiseEndTime - sectionPromiseStartTime; console.log(`Section promise initialization time: ${sectionPromiseDuration.toFixed(2)}ms`); const sectionStartTime = performance.now(); const sectionConfig = section.config; // Skip if the section is not enabled if (!sectionConfig.enabled) { console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} is not enabled, skipping...`); continue; } // Handle Random card format - pick one format for entire section let finalCardFormat = sectionConfig.cardFormat; if (finalCardFormat === 'random' || finalCardFormat === 'Random') { finalCardFormat = getRandomCardFormat(); } let dataItems = section.result?.data?.Items ?? section.result?.data; // If the data is already available, we should render the section instead of the skeleton if (dataItems && dataItems.length > 0) { let content = null; if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { content = createSpotlightSection(dataItems, sectionConfig.name, { viewMoreUrl: sectionConfig.viewMoreUrl, progressiveEnhancement: true, ...sectionConfig.spotlightConfig }); } else { content = createScrollableContainer(dataItems, sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.overflowCard, finalCardFormat, sectionConfig.forcedImageType); } content.setAttribute('data-section-id', sectionConfig.id); content.style.order = sectionConfig.order; // Add refresh button to rendered section const sectionTitleContainer = content.querySelector('.sectionTitleContainer'); if (sectionTitleContainer) { const refreshButton = createRefreshButton(sectionConfig, content); sectionTitleContainer.appendChild(refreshButton); } sectionElements.push(content); //container.appendChild(content); //content.style.contentVisibility = 'hidden'; if (revealSectionsSequentially) { content.classList.add('cardbuilder-section-reveal'); content.style.display = 'none'; } fragment.appendChild(content); const sectionEndTime = performance.now(); const sectionDuration = sectionEndTime - sectionStartTime; console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); continue; } // Otherwise, we need to create a skeleton section let sectionElement = null; if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { sectionElement = createSkeletonSpotlightSection(sectionConfig.name, { viewMoreUrl: sectionConfig.viewMoreUrl }); } else { sectionElement = createProgressivelyEnhancedScrollableContainer(sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.cardFormat, sectionConfig.overflowCard); } sectionElement.setAttribute('data-section-id', sectionConfig.id); sectionElement.style.order = sectionConfig.order; sectionElements.push(sectionElement); //container.appendChild(sectionElement); // set section element content-visibility to hidden //sectionElement.style.contentVisibility = 'hidden'; if (revealSectionsSequentially) { sectionElement.classList.add('cardbuilder-section-reveal'); sectionElement.style.display = 'none'; } fragment.appendChild(sectionElement); const sectionEndTime = performance.now(); const sectionDuration = sectionEndTime - sectionStartTime; console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); } container.appendChild(fragment); /* for (const sectionElement of sectionElements) { sectionElement.style.contentVisibility = 'hidden'; container.appendChild(sectionElement); await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => setTimeout(resolve, 0)); } */ /* for (const sectionElement of sectionElements) { container.appendChild(sectionElement); } */ // Append all of the section elements to the container at once //container.append(...sectionElements); // Wait 5 seconds //await new Promise(resolve => setTimeout(resolve, 500)); // Set up section reveal animations if enabled if (revealSectionsSequentially) { initializeSectionRevealObserver(); sectionElements.forEach(section => { section.style.display = ''; observeSectionForReveal(section); }); } for (let index = 0; index < sectionElements.length; index++) { const sectionStartTime = performance.now(); const sectionElement = sectionElements[index]; const section = await sections[index]; section.result?.isStalePromise?.then(async isStale => { if (isStale) { // Add refreshing state to the section element sectionElement.dataset.refreshing = 'true'; return; } }); section.result?.dataPromise?.then(async result => { // Remove refreshing state from the section element sectionElement.dataset.refreshing = 'false'; const sectionConfig = section.config; let items = result?.Items ?? result ?? []; // Check if the data is stale if it was previously available first const dataItems = section.result?.data?.Items ?? section.result?.data ?? []; let itemsMatch = true; // Check if the items in the section are the same as the items in the data const itemsInSection = sectionElement.querySelectorAll('.itemsContainer .card[data-id]'); // Check that each item appears in order in the section by going through the itemsInSection and making sure the itemsInData index matches for (let i = 0; i < itemsInSection.length; i++) { const itemInSection = itemsInSection[i]; const item = items[i]; if (item && itemInSection && itemInSection.getAttribute('data-id') !== item.Id) { itemsMatch = false; break; } } if (items.length !== dataItems.length) { itemsMatch = false; } for (const item of items) { const dataItem = dataItems.find(dataItem => dataItem.Id === item.Id); if (!dataItem) { itemsMatch = false; break; } if (dataItem.UserData) { for (const key in dataItem.UserData) { if (dataItem.UserData[key] !== item.UserData[key]) { itemsMatch = false; break; } } } } const shouldRefresh = !itemsMatch; // If there are no items, remove any existing section element if (items.length === 0) { sectionElement.remove(); return; } if (!shouldRefresh) { return; } let content = null; let finalCardFormat = sectionConfig.cardFormat; // Get card format from skeleton container const skeletonCardFormat = sectionElement.getAttribute('data-card-format'); if (skeletonCardFormat) { finalCardFormat = skeletonCardFormat; } //items = postProcessItems(sectionConfig, items); if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { content = createSpotlightSection(items, sectionConfig.name, { viewMoreUrl: sectionConfig.viewMoreUrl, ...sectionConfig.spotlightConfig }); } else { content = createScrollableContainer(items, sectionConfig.name, sectionConfig.viewMoreUrl, sectionConfig.overflowCard, finalCardFormat, sectionConfig.forcedImageType); } //content.setAttribute('data-section-id', sectionConfig.id); //content.style.order = sectionConfig.order; //content.setAttribute('data-card-format', finalCardFormat); //content.style.contentVisibility = sectionElement.style.contentVisibility ?? 'hidden'; // Ensure all styles and classes and attributes are retained in the new content content.style.cssText = sectionElement.style.cssText; content.className = sectionElement.className; Array.from(sectionElement.attributes).forEach(attr => { content.setAttribute(attr.name, attr.value); }); // Preserve data-expanded state from old itemsContainer to new one const oldItemsContainer = sectionElement.querySelector('.itemsContainer'); const newItemsContainer = content.querySelector('.itemsContainer'); if (oldItemsContainer && newItemsContainer) { const isExpanded = oldItemsContainer.getAttribute('data-expanded') === 'true'; if (isExpanded) { newItemsContainer.setAttribute('data-expanded', 'true'); // Also update the show-all-button text if it exists const showAllButton = content.querySelector('.show-all-button'); if (showAllButton) { showAllButton.textContent = 'Collapse'; showAllButton.title = 'Show items in scrollable layout'; } // Hide scroll buttons if they exist const scrollButtons = content.querySelector('.emby-scrollbuttons'); if (scrollButtons) { scrollButtons.style.display = 'none'; } } } // Add refresh button to new content const sectionTitleContainer = content.querySelector('.sectionTitleContainer'); if (sectionTitleContainer) { const refreshButton = createRefreshButton(sectionConfig, content); sectionTitleContainer.appendChild(refreshButton); } sectionElement.replaceWith(content); // If revealSectionsSequentially is enabled and section hasn't been revealed yet, re-observe it if (revealSectionsSequentially && content.classList.contains('cardbuilder-section-reveal') && !content.classList.contains('in-viewport')) { observeSectionForReveal(content); } }); const sectionEndTime = performance.now(); const sectionDuration = sectionEndTime - sectionStartTime; console.log(`Section ${index} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); } const renderProgressiveSectionsEndTime = performance.now(); const renderProgressiveSectionsDuration = renderProgressiveSectionsEndTime - renderProgressiveSectionsStartTime; console.log(`Render progressive sections initialization time: ${renderProgressiveSectionsDuration.toFixed(2)}ms`); }, /** * Unified function to render progressive sections (Cards or Spotlight) * @param {HTMLElement} container - The parent container to append the section to * @param {Array|Promise} itemsOrPromise - Data array or Promise resolving to items * @param {string} title - Section title * @param {Object} options - Configuration options */ renderProgressiveCards: function(container, itemsOrPromise, title, options = {}) { const { spotlight = false, viewMoreUrl = null, cardFormat = null, overflowCard = false, sortOrder = null, sortOrderDirection = 'Ascending', forcedImageType = null, sectionId = null, order = null, expectedItemType = null, minimumItems = 1 } = options; // Handle Random card format once let resolvedFormat = cardFormat; if (cardFormat === 'random' || cardFormat === 'Random') { resolvedFormat = getRandomCardFormat(); } // Function to setup attributes const setupElement = (el) => { if (sectionId) el.setAttribute('data-section-id', sectionId); if (order !== null) el.style.order = order; // Stamp resolved format for consistency on updates if (resolvedFormat && !spotlight) el.setAttribute('data-card-format', resolvedFormat); }; // Helper to render final content const renderContent = (items) => { if (!items || items.length < minimumItems) return null; // Sort items let sortedItems = items; if (sortOrder && sortOrder !== 'Random') { sortedItems = sortItems(items, sortOrder, sortOrderDirection); } else if (sortOrder === 'Random') { sortedItems = shuffle([...items]); } if (spotlight) { return createSpotlightSection(sortedItems, title, { viewMoreUrl }); } else { return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat, forcedImageType); } }; // Handle Promise if (itemsOrPromise && typeof itemsOrPromise.then === 'function') { // Render Skeleton let skeleton; if (spotlight) { skeleton = createSkeletonSpotlightSection(title, { viewMoreUrl }); } else { skeleton = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard); } setupElement(skeleton); container.appendChild(skeleton); // Handle Resolution itemsOrPromise.then(items => { const content = renderContent(items); if (content) { setupElement(content); // Copy of any existing data attributes to the new content Array.from(skeleton.attributes).forEach(attr => { content.setAttribute(attr.name, attr.value); }); skeleton.replaceWith(content); // Update skeleton inner HTML with the content //skeleton.innerHTML = content.innerHTML; } else { skeleton.remove(); } }).catch(err => { console.error('[CardBuilder] Error loading items:', err); skeleton.remove(); }); return skeleton; } // Handle Direct Array else { const items = Array.isArray(itemsOrPromise) ? itemsOrPromise : []; const content = renderContent(items); if (content) { setupElement(content); container.appendChild(content); return content; } } }, renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') { const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args); if (!itemIds || itemIds.length === 0) { LOG(`No item IDs provided for section: ${title}`); return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat); } try { // Fetch all items at once const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), { Ids: itemIds.join(','), Recursive: false }); const items = response.Items; LOG(`Fetched ${items.length} items for section: ${title}`); // Handle Random card format - pick one format for entire section let finalCardFormat = cardFormat; if (cardFormat === 'random' || cardFormat === 'Random') { finalCardFormat = getRandomCardFormat(); } // Sort items if sortOrder is provided let sortedItems = items; if (sortOrder && sortOrder !== 'Random') { sortedItems = sortItems(items, sortOrder, sortOrderDirection); } else if (sortOrder === 'Random') { sortedItems = shuffle([...items]); } const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); return container; } catch (error) { console.error('[KefinTweaks CardBuilder] Error fetching items:', error); return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat); } }, /** * Renders a spotlight section (Netflix-style slim banner carousel) * @param {Array|Promise} items - Array of Jellyfin item objects or Promise resolving to array * @param {string} title - Title for the spotlight section * @param {Object} options - Options for the spotlight carousel * @param {boolean} options.progressiveEnhancement - If true and items is a Promise, render skeleton first * @returns {HTMLElement} - The constructed spotlight container */ renderSpotlightSection: function(items, title, options = {}) { const { progressiveEnhancement = false } = options; // If progressive enhancement is enabled and items is a Promise if (progressiveEnhancement && items && typeof items.then === 'function') { // Create skeleton spotlight section immediately const skeletonSpotlightContainer = createSkeletonSpotlightSection(title, options); // Add identifying data-id const randomId = Math.random().toString(36).substring(2, 15); skeletonSpotlightContainer.setAttribute('data-skeleton-id', randomId); // When promise resolves, replace skeleton with real spotlight items.then(async items => { // Remove the skeleton container if it exists let skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); // Let's try to find the skeleton 20 times every 100ms for (let i = 0; i < 20; i++) { skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); if (skeletonContainerElement) { break; } await new Promise(resolve => setTimeout(resolve, 150)); } // Generate real spotlight section const realContainer = createSpotlightSection(items, title, options); // Copy critical attributes to preserve layout/order if (skeletonContainerElement.style.order) { realContainer.style.order = skeletonContainer.style.order; } if (skeletonContainerElement.hasAttribute('data-section-id')) { realContainer.setAttribute('data-section-id', skeletonContainerElement.getAttribute('data-section-id')); } // Replace skeleton container with real container skeletonContainerElement.replaceWith(realContainer); }).catch(error => { console.error('[KefinTweaks CardBuilder] Error loading items for spotlight progressive enhancement:', error); // Optionally remove skeleton on error or show error state skeletonSpotlightContainer.remove(); }); return skeletonSpotlightContainer; } // Normal rendering (items is already an array) const container = createSpotlightSection(items, title, options); return container; }, /** * Sorts items based on sort order and direction (exposed for use in homeScreen) * @param {Array} items - Array of Jellyfin items * @param {string} sortOrder - Sort order * @param {string} sortOrderDirection - Direction * @returns {Array} - Sorted array */ sortItems: function(items, sortOrder, sortOrderDirection) { return sortItems(items, sortOrder, sortOrderDirection); }, }; /** * Creates a Jellyfin card element from an item * @param {Object} item - The Jellyfin item object * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square' * @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes) * @param {string} forcedImageType - Force specific image type (e.g., 'Primary') * @returns {HTMLElement} - The constructed card element */ function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null, forcedImageType = null) { const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); // Determine card type based on cardFormat override or item type let cardClass, padderClass, imageParams; cardFormat = cardFormat?.toLowerCase() || null; forcedImageType = forcedImageType?.toLowerCase() || null; // Use custom footer text from item property if not explicitly provided if (!customFooterText && item.CustomFooterText) { customFooterText = item.CustomFooterText; } if (cardFormat) { // Use specified cardFormat if (cardFormat === 'backdrop' || cardFormat === 'thumb' || forcedImageType) { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; imageParams = 'fillHeight=267&fillWidth=474'; } else if (cardFormat === 'square') { cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; padderClass = 'cardPadder-square'; imageParams = 'fillHeight=297&fillWidth=297'; } else { // portrait (default) cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; padderClass = 'cardPadder-portrait'; imageParams = 'fillHeight=446&fillWidth=297'; } } else { // Use item type to determine card type if (item.Type === 'Episode' || item.Type === 'TvChannel') { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; imageParams = 'fillHeight=267&fillWidth=474'; } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) { cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; padderClass = 'cardPadder-square'; imageParams = 'fillHeight=297&fillWidth=297'; } else { // Default poster style for Movies, Series, etc. cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; padderClass = 'cardPadder-portrait'; 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' : ''); if (item.UserData?.PlaybackPositionTicks && item.UserData?.PlaybackPositionTicks > 0) { card.setAttribute('data-positionticks', item.UserData?.PlaybackPositionTicks); } // 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 = `${ApiClient._serverAddress}/web/#/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'); // Force specific image if card format is specified /* if (forcedImageType === 'Primary' && item.ImageTags?.Primary) { const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; } else */ let imageUrl = ''; if (cardFormat === 'backdrop') { if (item.BackdropImageTags[0]) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; } else if (item.ImageTags?.Thumb) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; } else { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; } } else if (cardFormat === 'square') { if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') { imageUrl = item.BackdropImageTags && item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : ''; } else if (item.ImageTags?.Primary) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; } else if (item.SeriesPrimaryImageTag) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; } else if (item.BackdropImageTags && item.BackdropImageTags[0]) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ImageTags?.Thumb) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; } } else if (cardFormat === 'portrait' || cardFormat === 'poster') { if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) { imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; } else { imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; } } else if (cardFormat === 'thumb') { if (forcedImageType && state.useEpisodeImages && item.Type === 'Episode') { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; } else if (item.ImageTags?.Thumb) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; } else if (item.ParentThumbImageTag) { imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; } else if (item.SeriesPrimaryImageTag) { imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; } else if (item.ImageTags?.Primary) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; } } else if (item.ImageTags?.Primary) { 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}`; } } else if (item.ImageTags?.Thumb) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`; } else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; } else if ((item.Type === 'Episode') && item.ParentThumbImageTag) { imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; } else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; } 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); } if (imageUrl) { // Use data-src for lazy loading instead of immediate backgroundImage cardImageContainer.setAttribute('data-src', imageUrl); // Add lazy class for styling/selection cardImageContainer.classList.add('lazy'); } //countIndicator indicator const cardIndicators = document.createElement('div'); cardIndicators.className = 'cardIndicators'; if (item.UserData?.Played) { const playedIndicator = document.createElement('div'); playedIndicator.className = 'playedIndicator indicator'; const playedIndicatorIcon = document.createElement('span'); playedIndicatorIcon.className = 'material-icons indicatorIcon check'; playedIndicatorIcon.setAttribute('aria-hidden', 'true'); playedIndicator.appendChild(playedIndicatorIcon); cardIndicators.appendChild(playedIndicator); } if (item.UserData?.UnplayedItemCount && item.UserData?.UnplayedItemCount > 0) { const unplayedIndicator = document.createElement('div'); unplayedIndicator.className = 'countIndicator indicator'; unplayedIndicator.textContent = item.UserData?.UnplayedItemCount; cardIndicators.appendChild(unplayedIndicator); } if (cardIndicators.childElementCount > 0) { cardImageContainer.appendChild(cardIndicators); } if (item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0) { const innerCardFooter = document.createElement('div'); innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear'; cardBox.appendChild(innerCardFooter); const itemProgressBar = document.createElement('div'); itemProgressBar.className = 'itemProgressBar'; const itemProgressBarForeground = document.createElement('div'); itemProgressBarForeground.className = 'itemProgressBarForeground'; itemProgressBarForeground.style.width = `${item.UserData?.PlayedPercentage}%`; itemProgressBar.appendChild(itemProgressBarForeground); innerCardFooter.appendChild(itemProgressBar); cardImageContainer.appendChild(innerCardFooter); } // 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 = `${ApiClient._serverAddress}/web/#/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'; // 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'; 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); const moreButton = document.createElement('button'); moreButton.setAttribute('is', 'paper-icon-button-light'); moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light'; moreButton.setAttribute('data-action', 'menu'); moreButton.title = 'More'; const moreIcon = document.createElement('span'); moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert'; moreIcon.setAttribute('aria-hidden', 'true'); moreButton.appendChild(moreIcon); buttonContainer.appendChild(moreButton); // Assemble overlay cardOverlayContainer.appendChild(overlayLink); cardOverlayContainer.appendChild(playButton); 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 = `${ApiClient._serverAddress}/web/#/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 = `${ApiClient._serverAddress}/web/#/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 = `${ApiClient._serverAddress}/web/#/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.CustomFooterText || 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'); const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name; episodeLink.href = `${ApiClient._serverAddress}/web/#/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 = episodeName; episodeLink.textContent = episodeName; const episodeBdi = document.createElement('bdi'); episodeBdi.appendChild(episodeLink); secondaryText.appendChild(episodeBdi); cardBox.appendChild(secondaryText); // Add custom footer text if provided (e.g., air date) if (customFooterText) { const footerText = document.createElement('div'); footerText.className = 'cardText cardTextCentered cardText-secondary'; const footerBdi = document.createElement('bdi'); footerBdi.textContent = customFooterText; footerText.appendChild(footerBdi); cardBox.appendChild(footerText); } } else { const secondaryText = document.createElement('div'); secondaryText.className = 'cardText cardTextCentered cardText-secondary'; const yearBdi = document.createElement('bdi'); yearBdi.textContent = item.CustomFooterText || item.ProductionYear || item.PremiereDate?.substring(0, 4) || ''; secondaryText.appendChild(yearBdi); cardBox.appendChild(secondaryText); } card.appendChild(cardBox); return card; } /** * Renders a spotlight section (Netflix-style slim banner carousel) * @param {Array} items - Array of Jellyfin item objects * @param {string} title - Title for the spotlight section * @param {Object} options - Options for the spotlight carousel * @param {boolean} options.autoPlay - Auto-cycle through items (default: true) * @param {number} options.interval - Auto-play interval in ms (default: 5000) * @param {boolean} options.showDots - Show dot indicators (default: true) * @param {boolean} options.showNavButtons - Show prev/next buttons (default: true) * @returns {HTMLElement} - The constructed spotlight container */ function createSpotlightSection(items, title, options = {}) { if (!items || items.length === 0) { return document.createElement('div'); } const { autoPlay = true, interval = 5000, showDots = true, showNavButtons = true, showClearArt = false, viewMoreUrl = null } = options; const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); let currentIndex = 0; let autoPlayTimer = null; let isPaused = false; // Create main container const container = document.createElement('div'); container.className = 'spotlight-section padded-left'; // Create banner container const bannerContainer = document.createElement('div'); bannerContainer.className = 'spotlight-banner-container'; // Add section title in top left corner (absolute positioning, aligned with controls) if (title) { let sectionTitleEl; if (viewMoreUrl) { // Create clickable title const titleLink = document.createElement('a'); titleLink.className = 'spotlight-section-title spotlight-title-link'; titleLink.textContent = title; titleLink.title = 'See All'; titleLink.style.textDecoration = 'none'; const cssStyle = document.createElement('style'); cssStyle.textContent = ` .spotlight-title-link:hover { text-decoration: underline !important; } `; titleLink.appendChild(cssStyle); // Handle both URL and function if (typeof viewMoreUrl === 'function') { titleLink.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); viewMoreUrl(); }); } else { titleLink.href = viewMoreUrl; titleLink.addEventListener('click', (e) => { e.stopPropagation(); }); } sectionTitleEl = titleLink; } else { // Regular non-clickable title sectionTitleEl = document.createElement('div'); sectionTitleEl.className = 'spotlight-section-title'; sectionTitleEl.textContent = title; } bannerContainer.appendChild(sectionTitleEl); } // Create items container (for fade transitions) const itemsContainer = document.createElement('div'); itemsContainer.className = 'spotlight-items-container'; // Create items items.forEach((item, index) => { // Get item type early for use throughout const itemType = item.Type || 'Movie'; const itemDiv = document.createElement('div'); itemDiv.className = 'spotlight-item'; itemDiv.setAttribute('data-id', item.Id); itemDiv.setAttribute('data-index', index); itemDiv.setAttribute('data-item-type', itemType); if (index === 0) { itemDiv.style.opacity = '1'; } else { itemDiv.style.opacity = '0'; } let imageUrl = ''; if (item.Type === 'Episode' && item.ParentBackdropImageTags?.[0]) { // Series backdrop image imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ParentBackdropImageTags[0]}`; } else if (item.BackdropImageTags?.[0]) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ImageTags?.Primary) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ImageTags.Primary}`; } if (imageUrl) { itemDiv.style.backgroundImage = `url("${imageUrl}")`; } // Create left overlay (40-50% width, dark semi-transparent) // Add top padding to prevent content from overlapping with section title const overlay = document.createElement('div'); overlay.className = 'spotlight-overlay' + (title ? ' has-title' : ''); // Item title or logo let titleEl = null; const hasLogo = item.ImageTags?.Logo; if (hasLogo) { // Use logo image instead of text title const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=200&quality=96&tag=${item.ImageTags.Logo}`; titleEl = document.createElement('div'); titleEl.className = 'spotlight-item-logo'; titleEl.style.backgroundImage = `url("${logoUrl}")`; titleEl.alt = item.Name || 'Unknown'; } else { // Use text title as fallback titleEl = document.createElement('h3'); titleEl.className = 'spotlight-item-title'; titleEl.textContent = item.Name || 'Unknown'; } // Combined: Year + Time + Ends At + Genres (all on one line) const metadataRow = document.createElement('div'); metadataRow.className = 'spotlight-metadata-row'; // Year const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null); if (year) { const yearEl = document.createElement('span'); yearEl.textContent = year; metadataRow.appendChild(yearEl); } // Runtime if (item.RunTimeTicks) { const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60); const hours = Math.floor(runtimeMinutes / 60); const minutes = runtimeMinutes % 60; let runtimeText = ''; if (hours > 0) { runtimeText = `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim(); } else { runtimeText = `${minutes}m`; } const runtimeEl = document.createElement('span'); runtimeEl.textContent = runtimeText; metadataRow.appendChild(runtimeEl); // End time (when it would end if started now) const now = new Date(); // RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds const runtimeMs = item.RunTimeTicks / 10000; const endTime = new Date(now.getTime() + runtimeMs); const endTimeEl = document.createElement('span'); endTimeEl.className = 'spotlight-end-time'; endTimeEl.textContent = `Ends at ${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; metadataRow.appendChild(endTimeEl); } // Genres (on same line) - make clickable if IDs are available if (item.Genres && item.Genres.length > 0) { const genresContainer = document.createElement('span'); if (item.GenreItems && item.GenreItems.length > 0) { item.GenreItems.forEach((genreItem, index) => { const genreName = genreItem.Name; const genreId = genreItem.Id; if (genreName) { const genreLink = document.createElement('a'); genreLink.className = 'spotlight-genre-link'; genreLink.href = `${serverAddress}/web/#/list.html?genreId=${genreId}&serverId=${serverId}`; genreLink.textContent = genreName; genreLink.addEventListener('click', (e) => { e.stopPropagation(); }); if (index > 0) { const comma = document.createElement('span'); comma.textContent = ', '; genresContainer.appendChild(comma); } genresContainer.appendChild(genreLink); } }); } if (genresContainer.children.length > 0) { metadataRow.appendChild(genresContainer); } } // Helper function to create truncated list that expands on ellipsis hover // items can be array of strings (names) or array of objects with Name and Id function createTruncatedList(label, items, maxItems = 3, isPeople = false) { const container = document.createElement('div'); container.className = 'spotlight-truncated-list'; if (!items || items.length === 0) { return container; } const displayContainer = document.createElement('span'); const labelSpan = document.createElement('span'); labelSpan.textContent = `${label} `; displayContainer.appendChild(labelSpan); // Extract names and IDs from items (handle both strings and objects) const itemsData = items.map(item => { if (typeof item === 'string') { return { name: item, id: null }; } else if (item && typeof item === 'object') { return { name: item.Name || item.name || null, id: item.Id || item.id || null }; } return container; }).filter(item => item && item.name); if (itemsData.length === 0) return null; const hasMore = itemsData.length > maxItems; // Store references to hidden elements for easy show/hide const hiddenElements = []; // Create spans for all names (but initially hide the ones beyond maxItems) itemsData.forEach((itemData, index) => { let nameElement; // Create link if it's a person with an ID, otherwise just text if (isPeople && itemData.id) { nameElement = document.createElement('a'); nameElement.className = 'spotlight-person-link'; nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`; nameElement.textContent = itemData.name; nameElement.addEventListener('click', (e) => { e.stopPropagation(); }); } else { nameElement = document.createElement('span'); nameElement.textContent = itemData.name; } nameElement.setAttribute('data-name-index', index); // Hide names beyond maxItems initially if (index >= maxItems) { nameElement.style.display = 'none'; hiddenElements.push(nameElement); } displayContainer.appendChild(nameElement); // Add comma separator (except for last item) if (index < itemsData.length - 1) { const comma = document.createElement('span'); comma.textContent = ', '; comma.setAttribute('data-comma-after', index); // Hide comma if it's after the last visible item if (index >= maxItems - 1) { comma.style.cssText = 'display: none;'; hiddenElements.push(comma); } displayContainer.appendChild(comma); } }); // Add ellipsis as separate hoverable element if there are more items if (hasMore) { const ellipsisElement = document.createElement('span'); ellipsisElement.className = 'spotlight-ellipsis'; ellipsisElement.textContent = '...'; displayContainer.appendChild(ellipsisElement); let isExpanded = false; // On ellipsis hover, reveal all hidden names and hide ellipsis ellipsisElement.addEventListener('mouseenter', () => { if (!isExpanded) { // Show all hidden elements hiddenElements.forEach(el => { el.style.display = ''; }); ellipsisElement.style.display = 'none'; isExpanded = true; } }); // Keep expanded state when mouse moves within the container // Only collapse when mouse leaves the entire container container.addEventListener('mouseleave', () => { if (isExpanded) { // Hide names beyond maxItems again hiddenElements.forEach(el => { el.style.display = 'none'; }); ellipsisElement.style.display = ''; isExpanded = false; } }); } container.appendChild(displayContainer); return container; } // For Series: show seasons/episodes. For Movies: show director/writer let directorContainer = null; let writerContainer = null; let combinedContainer = null; let seriesInfoContainer = null; // Create series info container seriesInfoContainer = document.createElement('div'); seriesInfoContainer.className = 'spotlight-truncated-list'; if (itemType === 'Series') { // For Series, show seasons and episodes count const seasonsCount = item.ChildCount || 0; // Initial text with seasons count const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`; seriesInfoContainer.textContent = seasonsText; // Fetch episode count asynchronously and update (async () => { try { const episodesCount = item.RecursiveItemCount || 0; // Update the series info container with episode count if (seriesInfoContainer) { const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; seriesInfoContainer.textContent = `${seasonsText} - ${episodesText}`; } } catch (err) { console.error('Failed to fetch episode count:', err); // Keep just the seasons text if episode fetch fails } })(); } else if (itemType === 'Movie') { // For Movies, show director/writer (existing logic) if (item.People && Array.isArray(item.People)) { const directors = item.People.filter(p => p.Type === 'Director'); const writers = item.People.filter(p => p.Type === 'Writer'); // Extract names for comparison const directorNames = directors.map(p => p.Name); const writerNames = writers.map(p => p.Name); // Check if directors and writers are identical (same length and same names) const areIdentical = directorNames.length === writerNames.length && directorNames.length > 0 && directorNames.every((name, index) => name === writerNames[index]); if (areIdentical) { // Combine into single "Directed and Written by" row (with person objects for links) combinedContainer = createTruncatedList('Directed and Written by', directors, 3, true); // Add empty writer container to maintain spacing writerContainer = document.createElement('div'); writerContainer.className = 'spotlight-empty-writer-container'; } else { // Show separately (with person objects for links) directorContainer = createTruncatedList('Directed by', directors, 3, true); writerContainer = createTruncatedList('Written by', writers, 3, true); } } } else if (itemType === 'Season') { // For Seasons, show episodes count const episodesCount = item.RecursiveItemCount || 0; const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; seriesInfoContainer.textContent = episodesText; } else if (itemType === 'Episode') { // For Episodes, show episode number const seasonNumber = item.ParentIndexNumber || 0; const seasonText = `Season ${seasonNumber}`; const episodeNumber = item.IndexNumber || 0; const episodeText = `Episode ${episodeNumber}`; seriesInfoContainer.textContent = `${seasonText} - ${episodeText}`; } // Taglines for Movies, Overview for Series let taglineEl = null; taglineEl = document.createElement('p'); taglineEl.className = 'spotlight-tagline'; if (itemType === 'Series') { // For Series, show Overview instead of Tagline if (item.Overview) { taglineEl.textContent = item.Overview; } } else { // For Movies, show Tagline (if available) if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) { taglineEl.textContent = item.Taglines[0]; // Show first tagline } else if (item.Overview) { taglineEl.textContent = item.Overview; } } // Buttons container const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'spotlight-buttons-container'; // Play button (material icon span button) const playButton = document.createElement('button'); playButton.className = 'spotlight-play-button emby-button'; const playIcon = document.createElement('span'); playIcon.className = 'material-icons'; playIcon.textContent = 'play_arrow'; playButton.appendChild(playIcon); playButton.addEventListener('click', async (e) => { e.stopPropagation(); try { // TODO - this needs to be updated to use the data-action resume to play the item natively, if possible if (window.apiHelper && window.apiHelper.playItem) { await window.apiHelper.playItem(item.Id); } else { // Fallback to ApiClient.play if apiHelper is not available if (window.ApiClient && typeof window.ApiClient.play === 'function') { window.ApiClient.play({ ids: [item.Id] }); } else { console.error('[KefinTweaks CardBuilder] No play method available'); } } } catch (error) { console.error('[KefinTweaks CardBuilder] Error playing item:', error); } }); // Watchlist button (material icon span button with bookmark icon) const watchlistButton = document.createElement('button'); watchlistButton.className = 'spotlight-watchlist-button emby-button'; // Check watchlist status - try to get from cache or API let isInWatchlist = false; const sectionName = itemType === 'Movie' ? 'movies' : itemType === 'Series' ? 'series' : itemType === 'Season' ? 'seasons' : itemType === 'Episode' ? 'episodes' : null; // Check watchlist cache if available if (sectionName && typeof window.watchlistCache !== 'undefined' && window.watchlistCache[sectionName]?.data) { isInWatchlist = window.watchlistCache[sectionName].data.some(watchlistItem => watchlistItem.Id === item.Id); } const watchlistIcon = document.createElement('span'); watchlistIcon.className = 'material-icons'; watchlistIcon.textContent = 'bookmark'; watchlistButton.appendChild(watchlistIcon); if (isInWatchlist) { watchlistButton.classList.add('watchlisted'); } watchlistButton.addEventListener('click', async (e) => { e.stopPropagation(); const userId = ApiClient.getCurrentUserId(); const newStatus = !isInWatchlist; try { // Update watchlist status using proper API await ApiClient.updateUserItemRating(userId, item.Id, newStatus ? 'true' : 'false'); // Update local state isInWatchlist = newStatus; // Update button class if (isInWatchlist) { watchlistButton.classList.add('watchlisted'); } else { watchlistButton.classList.remove('watchlisted'); } // Update watchlist cache if available if (sectionName && typeof window.updateWatchlistCacheOnToggle === 'function') { await window.updateWatchlistCacheOnToggle(item.Id, itemType, isInWatchlist); } } catch (err) { console.error('Failed to update watchlist status:', err); } }); // Info button (shows overview on hover) const infoButton = document.createElement('button'); infoButton.className = 'spotlight-info-button emby-button'; const infoIcon = document.createElement('span'); infoIcon.className = 'material-icons'; infoIcon.textContent = 'info'; infoButton.appendChild(infoIcon); // Overview tooltip (hidden by default, shown on hover) if (item.Overview) { const overviewTooltip = document.createElement('div'); overviewTooltip.className = 'spotlight-overview-tooltip'; overviewTooltip.textContent = item.Overview; infoButton.appendChild(overviewTooltip); } buttonsContainer.appendChild(playButton); buttonsContainer.appendChild(watchlistButton); buttonsContainer.appendChild(infoButton); // Rating with star icon const rating = item.CommunityRating || item.CriticRating; if (rating && typeof rating === 'number') { const ratingContainer = document.createElement('div'); ratingContainer.className = 'spotlight-rating-container'; const starIcon = document.createElement('span'); starIcon.className = 'material-icons spotlight-rating-star'; starIcon.textContent = 'star'; const ratingValue = document.createElement('span'); ratingValue.className = 'spotlight-rating-value'; if (rating && typeof rating === 'number') { ratingValue.textContent = rating.toFixed(1); } else { ratingValue.textContent = 'N/A'; ratingValue.classList.add('na'); } ratingContainer.appendChild(starIcon); ratingContainer.appendChild(ratingValue); buttonsContainer.appendChild(ratingContainer); } // Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons overlay.appendChild(titleEl); const metadataContainer = document.createElement('div'); metadataContainer.className = 'metadata-container'; if (metadataRow.children.length > 0) { metadataContainer.appendChild(metadataRow); } // For Series, show seasons/episodes. For Movies, show director/writer if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') { if (seriesInfoContainer) { metadataContainer.appendChild(seriesInfoContainer); } } else { let emptyDirectorContainer = document.createElement('div'); emptyDirectorContainer.className = 'spotlight-empty-director-container'; let emptyWriterContainer = document.createElement('div'); emptyWriterContainer.className = 'spotlight-empty-writer-container'; if (combinedContainer) { metadataContainer.appendChild(combinedContainer); metadataContainer.appendChild(emptyWriterContainer); } else { if (directorContainer) { metadataContainer.appendChild(directorContainer); } if (writerContainer) { metadataContainer.appendChild(writerContainer); if (!directorContainer) { metadataContainer.appendChild(emptyDirectorContainer); } } else { if (!directorContainer) { metadataContainer.appendChild(emptyDirectorContainer); } metadataContainer.appendChild(emptyWriterContainer); } } } if (metadataContainer.children.length > 0) { overlay.appendChild(metadataContainer); } if (taglineEl) { overlay.appendChild(taglineEl); } overlay.appendChild(buttonsContainer); itemDiv.appendChild(overlay); // ClearArt image (bottom right corner, if enabled and available) if (showClearArt && item.ImageTags?.Art) { const clearArtUrl = `${serverAddress}/Items/${item.Id}/Images/Art?fillHeight=300&quality=96&tag=${item.ImageTags.Art}`; const clearArtEl = document.createElement('img'); clearArtEl.className = 'spotlight-clearart'; clearArtEl.src = clearArtUrl; clearArtEl.alt = item.Name || 'Unknown'; itemDiv.appendChild(clearArtEl); } // Click to navigate (but not on buttons) infoButton.addEventListener('click', (e) => { window.location.href = `${serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; }); itemsContainer.appendChild(itemDiv); }); bannerContainer.appendChild(itemsContainer); // Navigation buttons (top right) if (showNavButtons && items.length > 1) { const navContainer = document.createElement('div'); navContainer.className = 'spotlight-nav-container'; const prevButton = document.createElement('button'); prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button'; const prevIcon = document.createElement('span'); prevIcon.className = 'material-icons'; prevIcon.textContent = 'chevron_left'; prevButton.appendChild(prevIcon); prevButton.addEventListener('click', (e) => { e.stopPropagation(); // Reset timer when manually navigating goToItem((currentIndex - 1 + items.length) % items.length, true); }); const nextButton = document.createElement('button'); nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button'; const nextIcon = document.createElement('span'); nextIcon.className = 'material-icons'; nextIcon.textContent = 'chevron_right'; nextButton.appendChild(nextIcon); nextButton.addEventListener('click', (e) => { e.stopPropagation(); // Reset timer when manually navigating goToItem((currentIndex + 1) % items.length, true); }); navContainer.appendChild(prevButton); navContainer.appendChild(nextButton); bannerContainer.appendChild(navContainer); } // Pause button (bottom right) if (autoPlay && items.length > 1) { const navContainer = bannerContainer.querySelector('.spotlight-nav-container'); const pauseButton = document.createElement('button'); pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; const pauseIcon = document.createElement('span'); pauseIcon.className = 'material-icons'; pauseIcon.textContent = 'pause'; pauseButton.appendChild(pauseIcon); pauseButton.addEventListener('click', (e) => { e.stopPropagation(); isPaused = !isPaused; if (isPaused) { clearInterval(autoPlayTimer); pauseIcon.textContent = 'play_arrow'; } else { startAutoPlay(); pauseIcon.textContent = 'pause'; } }); navContainer.insertBefore(pauseButton, navContainer.firstChild); } // Dot indicators if (showDots && items.length > 1) { const dotsContainer = document.createElement('div'); dotsContainer.className = 'spotlight-dots'; items.forEach((_, index) => { const dot = document.createElement('button'); dot.className = 'spotlight-dot' + (index === 0 ? ' active' : ''); dot.setAttribute('data-index', index); dot.addEventListener('click', (e) => { e.stopPropagation(); // Reset timer when manually navigating goToItem(index, true); }); dotsContainer.appendChild(dot); }); bannerContainer.appendChild(dotsContainer); } // Go to item function (fade transition) function goToItem(index, resetTimer = true) { if (index === currentIndex) return; const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`); const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`); if (currentItem && nextItem) { currentItem.style.opacity = '0'; nextItem.style.opacity = '1'; } currentIndex = index; // Update dots if (showDots) { const dots = bannerContainer.querySelectorAll('.spotlight-dot'); dots.forEach((dot, i) => { if (i === index) { dot.classList.add('active'); } else { dot.classList.remove('active'); } }); } // Reset auto-play timer when manually navigating (always reset delay) if (resetTimer && autoPlay && !isPaused) { clearInterval(autoPlayTimer); autoPlayTimer = null; startAutoPlay(); } } // Auto-play function function startAutoPlay() { // Always clear any existing timer first if (autoPlayTimer) { clearInterval(autoPlayTimer); autoPlayTimer = null; } if (!autoPlay || items.length <= 1 || isPaused) return; autoPlayTimer = setInterval(() => { // Don't reset timer when auto-advancing (to maintain interval) goToItem((currentIndex + 1) % items.length, false); }, interval); } // Pause on hover - prevent auto-cycling when hovering let isHovering = false; bannerContainer.addEventListener('mouseenter', () => { isHovering = true; if (autoPlayTimer) { clearInterval(autoPlayTimer); autoPlayTimer = null; } }); bannerContainer.addEventListener('mouseleave', () => { isHovering = false; // Only restart if auto-play is enabled, not paused, and not hovering if (autoPlay && !isPaused && !isHovering) { startAutoPlay(); } }); // Start auto-play if (autoPlay) { startAutoPlay(); } // Touch swipe handling let touchStartX = 0; let touchStartY = 0; bannerContainer.addEventListener('touchstart', (e) => { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }, { passive: true }); bannerContainer.addEventListener('touchmove', (e) => { if (!e.touches[0]) return; const touchX = e.touches[0].clientX; const touchY = e.touches[0].clientY; const deltaX = touchX - touchStartX; const deltaY = touchY - touchStartY; // If horizontal movement is dominant, prevent vertical scrolling and propagation if (Math.abs(deltaX) > Math.abs(deltaY)) { if (e.cancelable) { e.preventDefault(); e.stopPropagation(); } } }, { passive: false }); bannerContainer.addEventListener('touchend', (e) => { if (!e.changedTouches[0]) return; const touchEndX = e.changedTouches[0].clientX; const touchEndY = e.changedTouches[0].clientY; const deltaX = touchEndX - touchStartX; const deltaY = touchEndY - touchStartY; // Threshold for swipe (50px) if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) { // Stop propagation to prevent parent handlers (like tab switching) e.stopPropagation(); if (deltaX > 0) { // Swipe right (previous) goToItem((currentIndex - 1 + items.length) % items.length, true); } else { // Swipe left (next) goToItem((currentIndex + 1) % items.length, true); } } }, { passive: true }); container.appendChild(bannerContainer); return container; } /** * Creates a scrollable container with horizontal scrolling functionality * @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 {boolean} overflowCard - Use overflow card classes instead of normal card classes * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {HTMLElement} - The constructed scrollable container */ function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, forcedImageType = null) { // Create the main vertical section container const verticalSection = document.createElement('div'); verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; // Persist the card format if provided (ensures consistency for random/updates) if (cardFormat) { verticalSection.setAttribute('data-card-format', cardFormat); } // Create section title const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; if (viewMoreUrl) { // Create clickable title with chevron icon const titleLink = document.createElement('a'); 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('h2'); titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; const chevronIcon = document.createElement('span'); chevronIcon.className = 'material-icons chevron_right'; chevronIcon.setAttribute('aria-hidden', 'true'); titleLink.appendChild(titleText); titleLink.appendChild(chevronIcon); sectionTitleContainer.appendChild(titleLink); } else { // Regular non-clickable title const titleText = document.createElement('h2'); titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; sectionTitleContainer.appendChild(titleText); } // Create "Show All" button (always created, shown/hidden based on data-expandable attribute) const showAllButton = document.createElement('button'); showAllButton.type = 'button'; showAllButton.className = 'show-all-button'; showAllButton.textContent = 'Expand'; showAllButton.title = 'Show all items'; sectionTitleContainer.appendChild(showAllButton); // 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 custom-scroller'; scroller.setAttribute('data-scroll-mode-x', 'custom'); // Enable mobile swipe scrolling scroller.style.scrollSnapType = 'none'; // Allow both axes so vertical page scroll isn't blocked when gesture starts over the scroller scroller.style.touchAction = 'auto'; // Keep horizontal scroll self-contained but allow vertical to bubble to page scroller.style.overscrollBehaviorX = 'contain'; scroller.style.overscrollBehaviorY = 'auto'; // iOS inertia scrolling scroller.style.webkitOverflowScrolling = 'touch'; // Create items container const itemsContainer = document.createElement('div'); itemsContainer.setAttribute('is', 'emby-itemscontainer'); itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX'; itemsContainer.style.whiteSpace = 'nowrap'; // If the items contain more episodes than non-episodes, and the card format is Poster, set the forced image type to Primary if (items.filter(item => item.Type === 'Episode').length > items.filter(item => item.Type !== 'Episode').length && cardFormat === 'Poster') { forcedImageType = 'Primary'; } // Add items to container items.forEach((item, index) => { const card = createJellyfinCardElement(item, overflowCard, cardFormat, null, forcedImageType); card.setAttribute('data-index', index); itemsContainer.appendChild(card); }); scroller.appendChild(itemsContainer); // Create the left/right scroll buttons for the scroller /* const scrollButtons = document.createElement('div'); //scrollButtons.setAttribute('is', 'emby-scrollbuttons'); scrollButtons.className = 'emby-scrollbuttons padded-right'; const leftButton = document.createElement('button'); leftButton.type = 'button'; //leftButton.setAttribute('is', 'paper-icon-button-light'); leftButton.setAttribute('data-ripple', 'false'); leftButton.setAttribute('data-direction', 'left'); leftButton.setAttribute('title', 'Previous'); leftButton.className = 'emby-scrollbuttons-button paper-icon-button-light'; leftButton.disabled = true; const leftSpan = document.createElement('span'); leftSpan.className = 'material-icons chevron_left'; leftSpan.setAttribute('aria-hidden', 'true'); leftButton.appendChild(leftSpan); leftButton.addEventListener('click', () => { // Scroll backward by viewport width const scrollAmount = scroller.clientWidth; // 80% of visible width scroller.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); }); scrollButtons.appendChild(leftButton); const rightButton = document.createElement('button'); rightButton.type = 'button'; //rightButton.setAttribute('is', 'paper-icon-button-light'); rightButton.setAttribute('data-ripple', 'false'); rightButton.setAttribute('data-direction', 'right'); rightButton.setAttribute('title', 'Next'); rightButton.className = 'emby-scrollbuttons-button paper-icon-button-light'; const rightSpan = document.createElement('span'); rightSpan.className = 'material-icons chevron_right'; rightSpan.setAttribute('aria-hidden', 'true'); rightButton.appendChild(rightSpan); rightButton.addEventListener('click', () => { // Scroll forward by width of the scroller parent const paddingLeft = parseFloat(window.getComputedStyle(scroller).paddingLeft) || 0; const paddingRight = parseFloat(window.getComputedStyle(scroller).paddingRight) || 0; const scrollAmount = scroller.clientWidth - paddingLeft - paddingRight; scroller.scrollBy({ left: scrollAmount, behavior: 'smooth' }); }); scrollButtons.appendChild(rightButton); // Update button states based on scroll position function updateScrollButtons() { const scrollLeft = scroller.scrollLeft; const maxScroll = scroller.scrollWidth - scroller.clientWidth; // Disable left button at start leftButton.disabled = scrollLeft <= 1; // 1px threshold for rounding // Disable right button at end rightButton.disabled = scrollLeft >= maxScroll - 1; } // Listen for scroll events to update button states scroller.addEventListener('scroll', updateScrollButtons, { passive: true }); // Update on resize (for responsive layouts) const resizeObserver = new ResizeObserver(() => updateScrollButtons()); resizeObserver.observe(scroller); // Initial button state on creation updateScrollButtons(); // Touch and drag support let dragState = { isDragging: false, startX: 0, startY: 0, startScrollLeft: 0, hasMoved: false, lastX: 0, lastTime: 0, velocity: 0, momentumId: null }; function onDragStart(e) { // Only handle left mouse button for drag (button 0) // Allow middle click (button 1) and right click (button 2) to work normally if (e.type === 'mousedown' && e.button !== 0) { return; } // Get initial touch/mouse position const point = e.touches ? e.touches[0] : e; // Cancel any existing momentum animation if (dragState.momentumId !== null) { cancelAnimationFrame(dragState.momentumId); dragState.momentumId = null; } dragState.isDragging = true; dragState.startX = point.clientX; dragState.startY = point.clientY; dragState.startScrollLeft = scroller.scrollLeft; dragState.hasMoved = false; dragState.lastX = point.clientX; dragState.lastTime = Date.now(); dragState.velocity = 0; // Add move and end listeners if (e.touches) { document.addEventListener('touchmove', onDragMove, { passive: false }); document.addEventListener('touchend', onDragEnd, { passive: true }); } else { document.addEventListener('mousemove', onDragMove); document.addEventListener('mouseup', onDragEnd); e.preventDefault(); // Prevent text selection on mouse drag } } function onDragMove(e) { if (!dragState.isDragging) return; const point = e.touches ? e.touches[0] : e; const deltaX = dragState.startX - point.clientX; const deltaY = dragState.startY - point.clientY; // Directional check: require horizontal movement to be dominant // Only trigger horizontal scroll if: // 1. Moved more than 10px horizontally (increased threshold) // 2. Horizontal movement is greater than vertical movement (directional check) if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { dragState.hasMoved = true; scroller.scrollLeft = dragState.startScrollLeft + deltaX; // Calculate velocity for momentum scrolling const currentTime = Date.now(); const timeDelta = currentTime - dragState.lastTime; if (timeDelta > 0) { const positionDelta = point.clientX - dragState.lastX; dragState.velocity = positionDelta / timeDelta; // pixels per millisecond } dragState.lastX = point.clientX; dragState.lastTime = currentTime; // Prevent default to stop page scrolling on touch if (e.touches) { e.preventDefault(); } } } function onDragEnd(e) { if (!dragState.isDragging) return; // Clean up listeners if (e.type === 'touchend') { document.removeEventListener('touchmove', onDragMove); document.removeEventListener('touchend', onDragEnd); } else { document.removeEventListener('mousemove', onDragMove); document.removeEventListener('mouseup', onDragEnd); } // If user dragged, prevent click events from firing if (dragState.hasMoved) { // Temporarily disable clicks on the scroller scroller.style.pointerEvents = 'none'; requestAnimationFrame(() => { scroller.style.pointerEvents = ''; }); } dragState.isDragging = false; // Apply momentum scrolling if there's sufficient velocity const friction = 0.93; // Higher = slides longer const velocityThreshold = 0.1; // pixels per millisecond if (Math.abs(dragState.velocity) > velocityThreshold) { const step = () => { if (Math.abs(dragState.velocity) > velocityThreshold) { const maxScroll = scroller.scrollWidth - scroller.clientWidth; const currentScroll = scroller.scrollLeft; const newScroll = currentScroll - dragState.velocity * 16; // 16ms approx for 60fps // Apply scroll with boundary checking if (newScroll <= 0) { scroller.scrollLeft = 0; dragState.velocity = 0; } else if (newScroll >= maxScroll) { scroller.scrollLeft = maxScroll; dragState.velocity = 0; } else { scroller.scrollLeft = newScroll; dragState.velocity *= friction; // Apply deceleration dragState.momentumId = requestAnimationFrame(step); } } else { dragState.velocity = 0; dragState.momentumId = null; } }; dragState.momentumId = requestAnimationFrame(step); } else { dragState.velocity = 0; dragState.momentumId = null; } } // Attach drag start listeners scroller.addEventListener('touchstart', onDragStart, { passive: true }); scroller.addEventListener('mousedown', onDragStart); */ // Overflow detection will be handled by ResizeObserver below // Toggle between scroll and grid view showAllButton.addEventListener('click', () => { const performanceTimerStart = performance.now(); // Find the scroll buttons for this container const scrollerContainer = showAllButton.closest('.emby-scroller-container'); const performanceTimerEnd = performance.now(); const performanceTime = performanceTimerEnd - performanceTimerStart; console.log(`[KefinTweaks CardBuilder] Time to find scroller container: ${performanceTime}ms`); const performanceTimerStart2 = performance.now(); const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null; const performanceTimerEnd2 = performance.now(); const performanceTime2 = performanceTimerEnd2 - performanceTimerStart2; console.log(`[KefinTweaks CardBuilder] Time to find scroll buttons: ${performanceTime2}ms`); const performanceTimerStart3 = performance.now(); const isExpanded = itemsContainer.getAttribute('data-expanded') === 'true'; if (isExpanded) { // Switch back to scroll view itemsContainer.removeAttribute('data-expanded'); if (scrollButtons) scrollButtons.style.display = ''; showAllButton.textContent = 'Expand'; showAllButton.title = 'Show all items in a grid layout'; } else { // Switch to grid view itemsContainer.setAttribute('data-expanded', 'true'); if (scrollButtons) scrollButtons.style.display = 'none'; showAllButton.textContent = 'Collapse'; showAllButton.title = 'Show items in scrollable layout'; } const performanceTimerEnd3 = performance.now(); const performanceTime3 = performanceTimerEnd3 - performanceTimerStart3; console.log(`[KefinTweaks CardBuilder] Time to switch view: ${performanceTime3}ms`); }); // Check if items container overflows the scroller width // Only sets data-expandable if it doesn't already exist (one-time check) function checkOverflow(forceUpdate = false) { // Only update if data-expandable doesn't exist, or if forceUpdate is true (window resize) if (!forceUpdate && verticalSection.hasAttribute('data-expandable')) { return; // Already set, don't update } const hasOverflow = itemsContainer.scrollWidth > scroller.clientWidth; verticalSection.setAttribute('data-expandable', hasOverflow ? 'true' : 'false'); } // Track window size to detect actual window resize events let lastWindowWidth = window.innerWidth; let lastWindowHeight = window.innerHeight; // Window resize handler - only update data-expandable on actual window resize let windowResizeTimer = null; function handleWindowResize() { const currentWidth = window.innerWidth; const currentHeight = window.innerHeight; // Only update if window size actually changed if (currentWidth !== lastWindowWidth || currentHeight !== lastWindowHeight) { lastWindowWidth = currentWidth; lastWindowHeight = currentHeight; // Debounce window resize checks clearTimeout(windowResizeTimer); windowResizeTimer = setTimeout(() => { checkOverflow(true); // Force update on window resize }, 100); // 100ms debounce } } // Listen for window resize events window.addEventListener('resize', handleWindowResize); // Initial overflow check after cards are rendered // Use requestAnimationFrame to ensure layout is complete, then a small delay for images requestAnimationFrame(() => { setTimeout(() => { checkOverflow(false); // Initial check - only set if doesn't exist }, 200); // Allow time for images to load and affect card dimensions }); // Assemble the section verticalSection.appendChild(sectionTitleContainer); //verticalSection.appendChild(scrollButtons); verticalSection.appendChild(scroller); return verticalSection; } /** * Creates a skeleton card element for loading states * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square' * @param {boolean} overflowCard - Use overflow card classes * @returns {HTMLElement} - Skeleton card element */ function createSkeletonCard(cardFormat = null, overflowCard = false) { const card = document.createElement('div'); // Determine card classes based on format let cardClass, padderClass; cardFormat = cardFormat?.toLowerCase() || 'portrait'; if (cardFormat === 'backdrop' || cardFormat === 'thumb') { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; } else if (cardFormat === 'square') { cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; padderClass = 'cardPadder-square'; } else { // portrait (default) cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; padderClass = 'cardPadder-portrait'; } card.className = `card ${cardClass} skeleton-card`; card.setAttribute('data-skeleton', 'true'); const cardBox = document.createElement('div'); cardBox.className = 'cardBox cardBox-bottompadded'; const cardScalable = document.createElement('div'); cardScalable.className = 'cardScalable'; const cardPadder = document.createElement('div'); cardPadder.className = `cardPadder ${padderClass} skeleton-padder`; cardPadder.style.cssText = 'background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.1) 75%); background-size: 200% 100%; animation: skeleton-loading 1.5s ease-in-out infinite;'; cardScalable.appendChild(cardPadder); cardBox.appendChild(cardScalable); card.appendChild(cardBox); return card; } /** * Creates a skeleton spotlight section for progressive enhancement * @param {string} title - Title for the spotlight section * @param {Object} options - Options for the spotlight carousel * @returns {HTMLElement} - Skeleton spotlight container */ function createSkeletonSpotlightSection(title, options = {}) { const { viewMoreUrl = null } = options; // Create main container (same structure as real spotlight) const container = document.createElement('div'); container.className = 'spotlight-section padded-left'; // Create banner container const bannerContainer = document.createElement('div'); bannerContainer.className = 'spotlight-banner-container'; // Add section title if (title) { let sectionTitleEl; if (viewMoreUrl) { const titleLink = document.createElement('a'); titleLink.className = 'spotlight-section-title spotlight-title-link'; titleLink.textContent = title; titleLink.title = 'See All'; titleLink.style.textDecoration = 'none'; if (typeof viewMoreUrl === 'function') { titleLink.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); viewMoreUrl(); }); } else { titleLink.href = viewMoreUrl; titleLink.addEventListener('click', (e) => { e.stopPropagation(); }); } sectionTitleEl = titleLink; } else { sectionTitleEl = document.createElement('div'); sectionTitleEl.className = 'spotlight-section-title'; sectionTitleEl.textContent = title; } bannerContainer.appendChild(sectionTitleEl); } // Create skeleton item (single placeholder) const itemsContainer = document.createElement('div'); itemsContainer.className = 'spotlight-items-container'; const skeletonItem = document.createElement('div'); skeletonItem.className = 'spotlight-item skeleton-spotlight-item'; skeletonItem.setAttribute('data-index', '0'); skeletonItem.style.opacity = '1'; skeletonItem.style.cssText += 'background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.1) 75%); background-size: 200% 100%; animation: skeleton-loading 1.5s ease-in-out infinite;'; itemsContainer.appendChild(skeletonItem); bannerContainer.appendChild(itemsContainer); container.appendChild(bannerContainer); return container; } /** * Creates a scrollable container with skeleton cards for progressive enhancement * @param {string} title - Title for the scrollable container * @param {string|Function} viewMoreUrl - Optional URL to make title clickable * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square' * @param {boolean} overflowCard - Use overflow card classes * @returns {HTMLElement} - Container with skeleton cards */ function createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl = null, cardFormat = null, overflowCard = false) { // Create the main vertical section container (same structure as createScrollableContainer) const verticalSection = document.createElement('div'); verticalSection.className = 'verticalSection custom-scroller-container'; // Persist the card format if provided (ensures consistency for random/updates) if (cardFormat) { verticalSection.setAttribute('data-card-format', cardFormat); } // Create section title const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; if (viewMoreUrl) { const titleLink = document.createElement('a'); titleLink.className = 'sectionTitle-link sectionTitleTextButton'; titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; if (typeof viewMoreUrl === 'function') { titleLink.addEventListener('click', (e) => { e.preventDefault(); viewMoreUrl(); }); } else { titleLink.href = viewMoreUrl; } const titleText = document.createElement('h2'); titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; const chevronIcon = document.createElement('span'); chevronIcon.className = 'material-icons chevron_right'; chevronIcon.setAttribute('aria-hidden', 'true'); titleLink.appendChild(titleText); titleLink.appendChild(chevronIcon); sectionTitleContainer.appendChild(titleLink); } else { const titleText = document.createElement('h2'); titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; sectionTitleContainer.appendChild(titleText); } // Create scroller container const scroller = document.createElement('div'); scroller.setAttribute('data-horizontal', 'true'); scroller.setAttribute('data-centerfocus', 'card'); scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller'; scroller.setAttribute('data-scroll-mode-x', 'custom'); scroller.style.scrollSnapType = 'none'; scroller.style.touchAction = 'auto'; scroller.style.overscrollBehaviorX = 'contain'; scroller.style.overscrollBehaviorY = 'auto'; scroller.style.webkitOverflowScrolling = 'touch'; // Create items container const itemsContainer = document.createElement('div'); itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX'; itemsContainer.style.whiteSpace = 'nowrap'; // Add skeleton cards (enough to fill viewport width) const skeletonCount = (cardFormat === 'backdrop' || cardFormat === 'thumb') ? 8 : 10; for (let i = 0; i < skeletonCount; i++) { const skeletonCard = createSkeletonCard(cardFormat, overflowCard); skeletonCard.setAttribute('data-index', i); itemsContainer.appendChild(skeletonCard); } scroller.appendChild(itemsContainer); verticalSection.appendChild(sectionTitleContainer); verticalSection.appendChild(scroller); return verticalSection; } /** * Measures the width of an element * @param {HTMLElement} el - Element to measure * @returns {number} - Width of the element */ function measure(el) { if (!el) { return 0; } let measureRoot = document.querySelector('#measure-root'); if (!measureRoot) { measureRoot = document.createElement('div'); measureRoot.id = 'measure-root'; measureRoot.style.cssText = 'position: absolute; visibility: hidden; contain: layout style paint; white-space: nowrap; pointer-events: none;'; document.body.appendChild(measureRoot); } const clonedEl = el.cloneNode(true); measureRoot.appendChild(clonedEl); const width = clonedEl.getBoundingClientRect().width; measureRoot.removeChild(clonedEl); return width; } /** * Checks the state of a promise * @param {Promise} p - Promise to check * @returns {string} - State of the promise: 'pending', 'fulfilled', or 'rejected' */ function promiseState(p) { const t = {}; return Promise.race([p, t]) .then(v => (v === t)? "pending" : "fulfilled", () => "rejected"); } // Add skeleton loading animation CSS if not already present if (!document.getElementById('skeleton-animation-style')) { const style = document.createElement('style'); style.id = 'skeleton-animation-style'; style.textContent = ` @keyframes skeleton-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } `; document.head.appendChild(style); } // Smart Lazy Image Loading with Global Observers let lazyImageObserver = null; let lazyMutationObserver = null; /** * Initialize the global IntersectionObserver for lazy loading images * Watches when elements enter the viewport and loads their images */ function initLazyImageObserver() { if (lazyImageObserver) return; // Already initialized const observerOptions = { threshold: 0.1, // Trigger when 10% of element is visible rootMargin: '100px' // Start loading 100px before element enters viewport }; lazyImageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const cardImageContainer = entry.target; const imageUrl = cardImageContainer.getAttribute('data-src'); if (imageUrl) { // Load the image cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; // Remove lazy class and add loaded class cardImageContainer.classList.remove('lazy'); cardImageContainer.classList.add('lazy-loaded'); // Remove data-src to prevent re-loading cardImageContainer.removeAttribute('data-src'); // Unobserve this element lazyImageObserver.unobserve(cardImageContainer); } } }); }, observerOptions); } /** * Initialize the global MutationObserver to detect new elements * Automatically adds new cardImageContainer elements with data-src to the IntersectionObserver */ function initLazyMutationObserver() { if (lazyMutationObserver) return; // Already initialized if (!lazyImageObserver) { initLazyImageObserver(); } lazyMutationObserver = new MutationObserver((mutations) => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { // Check if the added node itself is a cardImageContainer with data-src if (node.nodeType === 1 && // Element node node.classList && node.classList.contains('cardImageContainer') && node.hasAttribute('data-src')) { lazyImageObserver.observe(node); } // Check for cardImageContainer descendants if (node.nodeType === 1 && node.querySelectorAll) { const lazyImages = node.querySelectorAll('.cardImageContainer[data-src]'); lazyImages.forEach(img => { lazyImageObserver.observe(img); }); } }); }); }); } /** * Initialize the smart lazy loading system * Scans existing elements and starts both observers */ function initSmartLazyLoading() { if (typeof IntersectionObserver === 'undefined' || typeof MutationObserver === 'undefined') { return; // Browser doesn't support observers } // Initialize observers initLazyImageObserver(); initLazyMutationObserver(); // Scan existing elements with data-src const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]'); existingLazyImages.forEach(img => { lazyImageObserver.observe(img); }); // Start watching for new elements if (document.body) { lazyMutationObserver.observe(document.body, { childList: true, subtree: true }); } else { // If body isn't ready, wait for DOMContentLoaded document.addEventListener('DOMContentLoaded', () => { if (lazyMutationObserver && document.body) { lazyMutationObserver.observe(document.body, { childList: true, subtree: true }); } }); } } // Initialize smart lazy loading on module load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initSmartLazyLoading); } else { initSmartLazyLoading(); } // Progressive Section Reveal with IntersectionObserver let sectionRevealObserver = null; /** * Initialize the global IntersectionObserver for section reveal animations * Watches when sections enter the viewport and triggers fade-in animations */ function initializeSectionRevealObserver() { if (sectionRevealObserver) return; // Already initialized if (typeof IntersectionObserver === 'undefined') { return; // Browser doesn't support IntersectionObserver } const observerOptions = { threshold: 0.1, // Trigger when 10% of section is visible rootMargin: '0px 0px -50px 0px' // Trigger slightly before fully in view }; sectionRevealObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { // Add in-viewport class to trigger animation entry.target.classList.add('in-viewport'); // Unobserve after animation is triggered (one-time animation) sectionRevealObserver.unobserve(entry.target); } }); }, observerOptions); } /** * Observe a section element for viewport entry to trigger reveal animation * @param {HTMLElement} sectionElement - The section element to observe */ function observeSectionForReveal(sectionElement) { if (!sectionElement) return; // Initialize observer if not already done if (!sectionRevealObserver) { initializeSectionRevealObserver(); } // Only observe if observer was successfully created if (sectionRevealObserver) { sectionRevealObserver.observe(sectionElement); } } function initialize() { state.useEpisodeImages = window.userHelper.useEpisodeImages(); } initialize(); // Expose the cardBuilder to the global window object window.cardBuilder = cardBuilder; console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder'); })();