Files
KefinTweaks/scripts/cardBuilder.js
T

3980 lines
193 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
// Global toggle for progressive section fade-in.
// Developers can set this to false to disable the behavior.
const FADE_IN_SECTIONS = true;
// Sliding window: render current slide + 4 prev + 4 next (9 total). As user navigates, remove/add slides.
const SPOTLIGHT_WINDOW_PREV = 4;
const SPOTLIGHT_WINDOW_NEXT = 4;
function getSpotlightWindowIndices(currentIndex, totalLength) {
if (totalLength <= 9) {
return Array.from({ length: totalLength }, (_, i) => i);
}
const indices = new Set();
for (let i = -SPOTLIGHT_WINDOW_PREV; i <= SPOTLIGHT_WINDOW_NEXT; i++) {
const idx = (currentIndex + i + totalLength) % totalLength;
indices.add(idx);
}
return Array.from(indices);
}
/**
* 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 postProcessItemsByQuery(sectionConfig, queryResults) {
let processed = [];
for (let i = 0; i < queryResults.length; i++) {
const queryConfig = sectionConfig.queries[i];
const queryResult = queryResults[i];
const items = queryResult?.Items || queryResult || [];
processed.push(...postProcessItems(queryConfig, items));
}
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);
}
return processed;
}
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);
}
// Check for SortBy ChildCount and use ChildCount to sort the items
// ChildCount = ChildCount + SeriesCount + EpisodeCount + AlbumCount + ArtistCount + ProgramCount + MovieCount + SongCount
if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'ChildCount') {
processed = processed.sort((a, b) => {
const childCountA = (a.ChildCount || 0) + (a.SeriesCount || 0) + (a.EpisodeCount || 0) + (a.AlbumCount || 0) + (a.ArtistCount || 0) + (a.ProgramCount || 0) + (a.MovieCount || 0) + (a.SongCount || 0);
const childCountB = (b.ChildCount || 0) + (b.SeriesCount || 0) + (b.EpisodeCount || 0) + (b.AlbumCount || 0) + (b.ArtistCount || 0) + (b.ProgramCount || 0) + (b.MovieCount || 0) + (b.SongCount || 0);
if (sectionConfig.queries?.[0]?.queryOptions?.SortOrder === 'Descending') {
return childCountB - childCountA;
}
return childCountA - childCountB;
});
}
if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'Random') {
processed = shuffle(processed);
}
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<Array>} - 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, sectionConfig.renderMode === 'Spotlight');
if (typeof queryUrl === 'string') {
// Standard query - use useCache: false to bypass cache
// When useCache is false, getQuery returns a Promise<data> 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);
//checkSectionOverflow(content, true);
setTimeout(() => checkSectionOverflow(content, true), 150);
// 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,
postProcessItemsByQuery: postProcessItemsByQuery,
/**
* 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<HTMLElement>} - The constructed scrollable container
*/
/**
* Renders cards progressively with skeleton loading state
* @param {Promise<Array>} 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);
checkSectionOverflow(realContainer, true);
//setTimeout(() => checkSectionOverflow(realContainer, true), 150);
}).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, waitForContainerClass = null } = 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;
content.dataset.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;
//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';
}
sectionElements.push(sectionElement);
fragment.appendChild(sectionElement);
const sectionEndTime = performance.now();
const sectionDuration = sectionEndTime - sectionStartTime;
console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
}
// Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds
// Append the container to the page for now invisibly so that Jellyfin handles enhancing it with the typical Scroller functionality
// Finally when the container has the class specified in waitForContainerClass, we can move the pre-rendered sections into the container
if (waitForContainerClass && !container.classList.contains(waitForContainerClass)) {
// Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds
console.log(`Waiting for container to have class ${waitForContainerClass}...`);
const maxAttempts = 100;
let attempts = 0;
while (!container.classList.contains(waitForContainerClass) && attempts < maxAttempts) {
attempts++;
await new Promise(resolve => setTimeout(resolve, 100));
if (container.classList.contains(waitForContainerClass)) {
break;
}
}
if (!container.classList.contains(waitForContainerClass)) {
console.log(`Container did not have class ${waitForContainerClass} after ${maxAttempts} attempts, continuing...`);
}
}
// Batch append sections to avoid Jellyfin emby-scroller processing
// all at once (causes render lag when many sections are added)
/* const BATCH_SIZE = 4;
const sectionNodes = Array.from(fragment.childNodes);
for (let i = 0; i < sectionNodes.length; i += BATCH_SIZE) {
const batch = sectionNodes.slice(i, i + BATCH_SIZE);
batch.forEach(node => container.appendChild(node));
if (i + BATCH_SIZE < sectionNodes.length) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
} */
for (const sectionElement of sectionElements) {
container.appendChild(sectionElement);
await new Promise(resolve => requestAnimationFrame(resolve));
await new Promise(resolve => setTimeout(resolve, 0));
}
/* 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);
checkSectionOverflow(content, true);
//setTimeout(() => checkSectionOverflow(content, true), 150);
// 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);
/* checkSectionOverflow(content, true);
setTimeout(() => checkSectionOverflow(content, true), 150); */
} 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<Array>} 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);
},
};
/**
* Blurhash base83 character set (must match official blurhash package: includes comma and period)
* https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts
*/
const BLURHASH_B83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
function blurhashDecode83(str, from, length) {
if (from + length > str.length) return NaN;
let value = 0;
for (let i = 0; i < length; i++) {
const idx = BLURHASH_B83.indexOf(str[from + i]);
if (idx < 0) return NaN;
value = value * 83 + idx;
}
return value;
}
function blurhashSignPow(val, exp) {
const sign = val < 0 ? -1 : 1;
return sign * Math.pow(Math.abs(val), exp);
}
function blurhashLinearToSrgb(value) {
const v = Math.max(0, Math.min(1, value));
return v <= 0.0031308 ? Math.trunc(v * 12.92 * 255 + 0.5) : Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
}
function blurhashSrgbToLinear(value) {
const v = value / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
/**
* Decode a blurhash string to RGBA pixel data (Uint8ClampedArray).
* Matches the official blurhash package (same as Jellyfin's worker uses).
* Layout: 1 char size, 1 char quantised max, 4 chars DC (24-bit), then 2 chars per AC (base-19 packed).
*/
function blurhashDecode(blurhash, width, height) {
if (!blurhash || blurhash.length < 6) return null;
try {
const sizeVal = blurhashDecode83(blurhash, 0, 1);
if (isNaN(sizeVal)) return null;
const numX = (sizeVal % 9) + 1;
const numY = Math.floor(sizeVal / 9) + 1;
const expectedLen = 4 + 2 * numX * numY;
if (blurhash.length !== expectedLen) return null;
const quantisedMax = blurhashDecode83(blurhash, 1, 1);
if (isNaN(quantisedMax)) return null;
const maxVal = (quantisedMax + 1) / 166;
const components = [];
for (let i = 0; i < numX * numY; i++) {
if (i === 0) {
const dc = blurhashDecode83(blurhash, 2, 4);
if (isNaN(dc)) return null;
const intR = dc >> 16;
const intG = (dc >> 8) & 255;
const intB = dc & 255;
components.push([
blurhashSrgbToLinear(intR),
blurhashSrgbToLinear(intG),
blurhashSrgbToLinear(intB)
]);
} else {
const acVal = blurhashDecode83(blurhash, 4 + i * 2, 2);
if (isNaN(acVal)) return null;
const quantR = Math.floor(acVal / (19 * 19));
const quantG = Math.floor(acVal / 19) % 19;
const quantB = acVal % 19;
components.push([
blurhashSignPow((quantR - 9) / 9, 2) * maxVal,
blurhashSignPow((quantG - 9) / 9, 2) * maxVal,
blurhashSignPow((quantB - 9) / 9, 2) * maxVal
]);
}
}
const pixels = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0;
for (let j = 0; j < numY; j++) {
const basisY = Math.cos((Math.PI * y * j) / height);
for (let i = 0; i < numX; i++) {
const basis = Math.cos((Math.PI * x * i) / width) * basisY;
const color = components[i + j * numX];
r += color[0] * basis;
g += color[1] * basis;
b += color[2] * basis;
}
}
const off = (y * width + x) * 4;
pixels[off] = blurhashLinearToSrgb(r);
pixels[off + 1] = blurhashLinearToSrgb(g);
pixels[off + 2] = blurhashLinearToSrgb(b);
pixels[off + 3] = 255;
}
}
return pixels;
} catch (e) {
return null;
}
}
/**
* Draw decoded blurhash into an existing canvas (e.g. 20x20).
* @param {HTMLCanvasElement} canvas - Canvas to draw into
* @param {string} blurhashString - Blurhash string
* @returns {boolean} - True if drawn successfully
*/
function drawBlurhashToCanvas(canvas, blurhashString) {
if (!canvas || !blurhashString) return false;
const w = canvas.width || 20;
const h = canvas.height || 20;
const pixels = blurhashDecode(blurhashString, w, h);
if (!pixels) return false;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
const imageData = ctx.createImageData(w, h);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
return true;
}
/**
* Get blurhash string for the card image when the image is from the current item.
* Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes).
* @param {Object} item - Jellyfin item
* @param {string|null} cardFormat - 'backdrop'|'square'|'portrait'|'poster'|'thumb'|null
* @param {string|null} forcedImageType - e.g. 'Primary'
* @returns {string|undefined} - Blurhash string or undefined
*/
function getBlurhashForCard(item, cardFormat, forcedImageType) {
const hashes = item.ImageBlurHashes;
if (!hashes || typeof hashes !== 'object') return undefined;
cardFormat = cardFormat?.toLowerCase() || null;
forcedImageType = forcedImageType?.toLowerCase() || null;
if (cardFormat === 'backdrop') {
if (item.BackdropImageTags && item.BackdropImageTags[0])
return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (cardFormat === 'square') {
if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
if (item.BackdropImageTags && item.BackdropImageTags[0])
return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.BackdropImageTags && item.BackdropImageTags[0])
return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
return undefined;
}
if (cardFormat === 'portrait' || cardFormat === 'poster') {
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
return undefined;
}
if (cardFormat === 'thumb') {
if (forcedImageType && state.useEpisodeImages && item.Type === 'Episode' && item.ImageTags?.Primary)
return hashes.Primary?.[item.ImageTags.Primary];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (item.IsJellyseerr) return undefined;
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.BackdropImageTags && item.BackdropImageTags.length > 0)
return hashes.Backdrop?.[item.BackdropImageTags[0]];
return undefined;
}
/**
* 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) – will be filled and shown when we have a blurhash
const blurhashCanvas = document.createElement('canvas');
blurhashCanvas.setAttribute('aria-hidden', 'true');
blurhashCanvas.width = 20;
blurhashCanvas.height = 20;
// lazy-hidden only when we have no blurhash (grey card until image loads)
blurhashCanvas.className = 'blurhash-canvas';
// 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 if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
} else if (item.PrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${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);
}
const blurhashStr = getBlurhashForCard(item, cardFormat, forcedImageType);
if (!blurhashStr) {
blurhashCanvas.classList.add('lazy-hidden');
} else {
// Draw blurhash immediately so placeholder is visible ASAP (canvas is behind cardImageContainer)
if (!drawBlurhashToCanvas(blurhashCanvas, blurhashStr)) {
blurhashCanvas.classList.add('lazy-hidden');
} else {
// Hide the image container so the blurhash canvas shows until the real image loads
cardImageContainer.classList.add('lazy-hidden');
}
}
if (imageUrl) {
// Use data-src for lazy loading instead of immediate backgroundImage
cardImageContainer.setAttribute('data-src', imageUrl);
if (blurhashStr) cardImageContainer.setAttribute('data-blurhash', blurhashStr);
// 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 (blurhash already drawn above when blurhashStr was set)
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: 10000)
* @param {boolean} options.showSlideState - Show slide state container (dots or numeric) (default: true)
* @param {boolean} options.showDots - When showSlideState is true: true = dots (max 5), false = numeric "N / X" (default: true)
* @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
* @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen)
* @returns {HTMLElement} - The constructed spotlight container
*/
function createSpotlightSection(items, title, options = {}) {
if (!items || items.length === 0) {
return document.createElement('div');
}
const {
autoPlay = true,
interval = 10000,
showSlideState = true,
showDots = true,
showNavButtons = true,
showClearArt = false,
panAnimation = true,
fullScreen = false,
spotlightSize,
dualBackdrops = true,
viewMoreUrl = null,
pauseOnHover = !fullScreen // Default: don't pause on hover in fullscreen (continue cycling)
} = options;
const layout = fullScreen ? 'Borderless' : 'Border';
const size = spotlightSize ?? (fullScreen ? 'full' : 'normal');
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
let currentIndex = 0;
let autoPlayTimer = null;
let isPaused = false;
let isVisible = true; // updated by Intersection Observer; start true so initial startAutoPlay runs
let currentSlideAnimationComplete = 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 = 'emby-tab-button emby-tab-button-active emby-button-foreground';
titleLink.textContent = title;
titleLink.title = 'See All';
titleLink.style.textDecoration = 'none';
// 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 = 'emby-tab-button emby-tab-button-active emby-button-foreground';
sectionTitleEl.textContent = title;
}
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link'} headerTabs sectionTabs`;
sectionTitleContainer.appendChild(sectionTitleEl);
bannerContainer.appendChild(sectionTitleContainer);
}
// Create items container (for fade transitions)
const itemsContainer = document.createElement('div');
itemsContainer.className = 'spotlight-items-container';
const isMobileLayout = document.documentElement.classList.contains('mobile-layout');
// Sliding window: render current + 4 prev + 4 next (9 slides). Update DOM as user navigates.
let currentWindowIndices = getSpotlightWindowIndices(0, items.length);
function createAndAppendSlide(item, index, eagerLoadImages) {
const itemType = item.Type || 'Movie';
const itemDiv = document.createElement('div');
itemDiv.className = 'spotlight-item';
itemDiv.setAttribute('data-id', item.Id);
itemDiv.setAttribute('data-index', String(index));
itemDiv.setAttribute('data-item-type', itemType);
itemDiv.style.opacity = eagerLoadImages ? '1' : '0';
let imageUrl = '';
// Build list of available backdrop sources for this item
let backdropSources = [];
if (item.Type === 'Episode' && Array.isArray(item.ParentBackdropImageTags) && item.ParentBackdropImageTags.length) {
backdropSources = item.ParentBackdropImageTags.map(tag => ({
itemId: item.ParentBackdropItemId,
tag
}));
} else if (Array.isArray(item.BackdropImageTags) && item.BackdropImageTags.length) {
backdropSources = item.BackdropImageTags.map(tag => ({
itemId: item.Id,
tag
}));
}
// Set backdrop width based on client width either 1920 or 1280, 640 is the minimum
const clientWidth = window.innerWidth;
let backdropWidth = 1920;
if (clientWidth < 1280) {
backdropWidth = 1280;
} else if (clientWidth < 640) {
backdropWidth = 640;
}
// On non-mobile layouts, if we have more than one backdrop, render two side-by-side
if (!isMobileLayout && backdropSources.length > 1 && dualBackdrops) {
const firstBackdropIndex = Math.floor(Math.random() * backdropSources.length);
let secondBackdropIndex = Math.floor(Math.random() * backdropSources.length);
if (secondBackdropIndex === firstBackdropIndex) {
// Get another random backdrop index that is not the same as the first one
secondBackdropIndex = (secondBackdropIndex + 1) % backdropSources.length;
}
// Pick two distinct random backdrops
const first = backdropSources[firstBackdropIndex];
const second = backdropSources[secondBackdropIndex];
const firstUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop/${firstBackdropIndex}?fillWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
const secondUrl = `${serverAddress}/Items/${second.itemId}/Images/Backdrop/${secondBackdropIndex}?fillWidth=${backdropWidth}&quality=96&tag=${second.tag}`;
const dualBackgroundContainer = document.createElement('div');
dualBackgroundContainer.className = 'spotlight-background-dual';
const leftLayer = document.createElement('div');
leftLayer.className = 'spotlight-background-layer spotlight-background-layer-left';
const leftImg = document.createElement('img');
leftImg.setAttribute('data-src', firstUrl);
leftImg.alt = '';
leftImg.draggable = false;
if (eagerLoadImages) {
leftImg.src = firstUrl;
leftImg.loading = 'eager';
}
leftLayer.appendChild(leftImg);
const rightLayer = document.createElement('div');
rightLayer.className = 'spotlight-background-layer spotlight-background-layer-right';
const rightImg = document.createElement('img');
rightImg.setAttribute('data-src', secondUrl);
rightImg.alt = '';
rightImg.draggable = false;
if (eagerLoadImages) {
rightImg.src = secondUrl;
rightImg.loading = 'eager';
}
rightLayer.appendChild(rightImg);
dualBackgroundContainer.appendChild(leftLayer);
dualBackgroundContainer.appendChild(rightLayer);
itemDiv.appendChild(dualBackgroundContainer);
} else {
// Fallback: single backdrop or primary image, including mobile layouts
if (backdropSources.length) {
const first = backdropSources[0];
imageUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop?fillWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillWidth=${backdropWidth}&quality=96&tag=${item.ImageTags.Primary}`;
}
if (imageUrl) {
// Use img in a layer (like dual) so pan animation can be applied
const singleBackdropContainer = document.createElement('div');
singleBackdropContainer.className = 'spotlight-background-single';
const singleImg = document.createElement('img');
singleImg.setAttribute('data-src', imageUrl);
singleImg.alt = '';
singleImg.draggable = false;
if (eagerLoadImages) {
singleImg.src = imageUrl;
singleImg.loading = 'eager';
}
singleBackdropContainer.appendChild(singleImg);
itemDiv.appendChild(singleBackdropContainer);
}
}
// 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 logoHeight = size === 'full' ? '300' : (size === 'large' ? '250' : '200');
const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=${logoHeight}&quality=96&tag=${item.ImageTags.Logo}`;
titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-logo';
titleEl.setAttribute('data-background-url', logoUrl);
if (eagerLoadImages) 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';
}
// Helper: wrap content in native Jellyfin itemDetailsGroup > detailsGroupItem structure
// icon: optional Material Icons name (e.g. 'schedule') - same pattern as genres/director/writer
function createMetadataItem(content, tag = 'span', extraClasses = '', icon = null) {
const group = document.createElement('div');
group.className = 'headerTabs sectionTabs';
const item = document.createElement(tag);
item.className = 'emby-tab-button' + (extraClasses ? ' ' + extraClasses : '');
if (icon) item.setAttribute('data-icon', icon);
if (typeof content === 'string') {
item.textContent = content;
} else {
item.appendChild(content);
}
group.appendChild(item);
return group;
}
// 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) {
metadataRow.appendChild(createMetadataItem(String(year)));
}
// Runtime and estimated end time (Movies only - not for Series/Season/Episode)
const isSeriesType = itemType === 'Series' || itemType === 'Season' || itemType === 'Episode';
if (!isSeriesType && 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`;
}
metadataRow.appendChild(createMetadataItem(runtimeText));
// End time (when it would end if started now)
const now = new Date();
const runtimeMs = item.RunTimeTicks / 10000;
const endTime = new Date(now.getTime() + runtimeMs);
const endTimeItem = createMetadataItem(endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), 'span', 'spotlight-end-time', 'schedule');
metadataRow.appendChild(endTimeItem);
}
// Genres - merged into single container, max 3 visible, tooltip shows full list on hover
if (item.Genres && item.Genres.length > 0 && item.GenreItems && item.GenreItems.length > 0) {
const genreContainer = createTruncatedList('', item.GenreItems, 3, false, 'theater_comedy', 'genre');
if (genreContainer) {
metadataRow.appendChild(genreContainer);
}
}
// Helper function to create truncated list with tooltip for overflow items
// items can be array of strings (names) or array of objects with Name and Id
// icon: optional Material Icons name for full-screen display (e.g. 'movie', 'edit')
// linkType: 'person' | 'genre' | 'studio' | null - when set, create links
function createTruncatedList(label, items, maxItems = 3, isPeople = false, icon = null, linkType = null) {
const container = document.createElement('div');
container.className = 'spotlight-truncated-list headerTabs sectionTabs';
container.setAttribute('data-label', label);
if (icon) container.setAttribute('data-icon', icon);
if (!items || items.length === 0) {
return container;
}
const displayContainer = document.createElement('span');
displayContainer.className = 'emby-tab-button';
if (icon) displayContainer.setAttribute('data-icon', icon);
if (label) {
const labelSpan = document.createElement('span');
labelSpan.className = 'spotlight-truncated-list-label';
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;
// Resolve link type: explicit linkType, or fall back to isPeople for person links
const usePersonLinks = linkType === 'person' || (linkType !== 'genre' && linkType !== 'studio' && isPeople);
const useGenreLinks = linkType === 'genre';
const useStudioLinks = linkType === 'studio';
// Create spans for visible names only (no layout-shifting expand)
itemsData.forEach((itemData, index) => {
if (index >= maxItems) return;
let nameElement;
if (usePersonLinks && 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 if (useGenreLinks && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-genre-link';
nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
nameElement.addEventListener('click', (e) => e.stopPropagation());
} else if (useStudioLinks && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-studio-link';
nameElement.href = `${serverAddress}/web/#/list.html?studioId=${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);
displayContainer.appendChild(nameElement);
// Bullet separator (except for last visible item - use actual count when fewer than maxItems)
const lastVisibleIndex = Math.min(maxItems, itemsData.length) - 1;
if (index < lastVisibleIndex) {
const separator = document.createElement('span');
separator.className = 'spotlight-list-separator';
separator.textContent = '•';
displayContainer.appendChild(separator);
}
});
// Tooltip when more items exist (full list on hover)
if (hasMore) {
// Tooltip: full list as vertical stack with clickable links (no layout shift)
const tooltip = document.createElement('div');
tooltip.className = 'spotlight-truncated-list-tooltip';
if (label) {
const labelSpan = document.createElement('span');
labelSpan.className = 'spotlight-truncated-list-tooltip-label';
labelSpan.textContent = label + ' ';
tooltip.appendChild(labelSpan);
}
const listWrapper = document.createElement('div');
listWrapper.className = 'spotlight-truncated-list-tooltip-items';
itemsData.forEach((itemData) => {
const itemRow = document.createElement('div');
itemRow.className = 'spotlight-truncated-list-tooltip-item';
let linkEl;
if (usePersonLinks && itemData.id) {
linkEl = document.createElement('a');
linkEl.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
linkEl.className = 'spotlight-person-link';
linkEl.textContent = itemData.name;
linkEl.addEventListener('click', (e) => e.stopPropagation());
} else if (useGenreLinks && itemData.id) {
linkEl = document.createElement('a');
linkEl.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
linkEl.className = 'spotlight-genre-link';
linkEl.textContent = itemData.name;
linkEl.addEventListener('click', (e) => e.stopPropagation());
} else if (useStudioLinks && itemData.id) {
linkEl = document.createElement('a');
linkEl.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`;
linkEl.className = 'spotlight-studio-link';
linkEl.textContent = itemData.name;
linkEl.addEventListener('click', (e) => e.stopPropagation());
} else {
linkEl = document.createElement('span');
linkEl.textContent = itemData.name;
}
itemRow.appendChild(linkEl);
listWrapper.appendChild(itemRow);
});
tooltip.appendChild(listWrapper);
container.appendChild(tooltip);
container.style.position = 'relative';
const TOOLTIP_DELAY_MS = 600;
const HIDE_DELAY_MS = 150;
let tooltipTimer = null;
let hideTimer = null;
const showTooltip = () => {
tooltip.classList.add('visible');
tooltipTimer = null;
};
const hideTooltip = () => {
tooltip.classList.remove('visible');
if (tooltipTimer) {
clearTimeout(tooltipTimer);
tooltipTimer = null;
}
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
const scheduleHide = () => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(hideTooltip, HIDE_DELAY_MS);
};
container.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
tooltipTimer = setTimeout(showTooltip, TOOLTIP_DELAY_MS);
});
container.addEventListener('mouseleave', () => {
scheduleHide();
});
tooltip.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
tooltip.addEventListener('mouseleave', () => {
hideTooltip();
});
}
container.appendChild(displayContainer);
return container;
}
// Studios - show 1 visible, rest in tooltip with links (like directors/writers)
const studiosData = Array.isArray(item.Studios) && item.Studios.length > 0
? item.Studios
: (typeof item.Studio === 'string' && item.Studio)
? item.Studio.split('|').map(s => ({ Name: s.trim(), Id: null })).filter(x => x.Name)
: [];
const studioContainer = studiosData.length > 0
? createTruncatedList('Produced by', studiosData, 1, false, 'apartment', 'studio')
: null;
// For Series: show seasons/episodes. For Movies: show director/writer
let directorContainer = null;
let writerContainer = null;
let combinedContainer = null;
// Create series info container (seasons/episodes count)
const seriesInfoContainer = document.createElement('div');
seriesInfoContainer.className = 'spotlight-metadata-row spotlight-series-info';
if (itemType === 'Series') {
// Studio first (before seasons/episodes)
if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Series, show seasons and episodes count
const seasonsCount = item.ChildCount || 0;
if (seasonsCount > 1) {
const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`;
seriesInfoContainer.appendChild(createMetadataItem(seasonsText));
}
const episodesCount = item.RecursiveItemCount || 0;
if (episodesCount > 0) {
const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
seriesInfoContainer.appendChild(createMetadataItem(episodesText));
}
} 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, 1, true, 'movie');
// 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)
if (directors.length > 0) {
directorContainer = createTruncatedList('Directed by', directors, 1, true, 'movie');
}
if (writers.length > 0) {
writerContainer = createTruncatedList('Written by', writers, 1, true, 'edit');
}
}
}
} else if (itemType === 'Season') {
// Studio first (before episodes)
if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Seasons, show episodes count
const episodesCount = item.RecursiveItemCount || 0;
const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
seriesInfoContainer.appendChild(createMetadataItem(episodesText));
} else if (itemType === 'Episode') {
// Studio first (before season/episode)
if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Episodes, show episode number
const seasonNumber = item.ParentIndexNumber || 0;
const seasonText = `Season ${seasonNumber}`;
const episodeNumber = item.IndexNumber || 0;
const episodeText = `Episode ${episodeNumber}`;
seriesInfoContainer.appendChild(createMetadataItem(`${seasonText} - ${episodeText}`));
}
// Tagline (tagline only) and Overview (separate element) - show/hide via CSS based on spotlight type
const taglineEl = document.createElement('p');
taglineEl.className = 'emby-tab-button';
const tagline = (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0)
? item.Taglines[0] : '';
if (tagline) taglineEl.textContent = tagline;
const overviewEl = document.createElement('p');
overviewEl.className = 'spotlight-overview';
if (item.Overview) overviewEl.textContent = item.Overview;
// Buttons container
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons';
// Play button (material icon span button)
const playButton = document.createElement('button');
playButton.className = 'emby-button submit-button raised button-flat btnPlay detailButton';
playButton.title = 'Play';
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 = 'emby-button button-flat';
// 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';
watchlistIcon.title = isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist';
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 = 'emby-button button-flat';
const infoIcon = document.createElement('span');
infoIcon.className = 'material-icons';
infoIcon.textContent = 'info';
infoIcon.title = 'Go To Item';
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 = 'starRatingContainer mediaInfoItem spotlight-rating-container';
const starIcon = document.createElement('span');
starIcon.className = 'material-icons starIcon star spotlight-rating-star';
if (rating && typeof rating === 'number') {
ratingContainer.innerText = rating.toFixed(1);
ratingContainer.prepend(starIcon);
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) {
const creditsRow = document.createElement('div');
creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (studioContainer) creditsRow.appendChild(studioContainer);
creditsRow.appendChild(combinedContainer);
metadataContainer.appendChild(creditsRow);
} else if (directorContainer || writerContainer) {
const creditsRow = document.createElement('div');
creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (studioContainer) creditsRow.appendChild(studioContainer);
if (directorContainer) creditsRow.appendChild(directorContainer);
if (writerContainer) creditsRow.appendChild(writerContainer);
metadataContainer.appendChild(creditsRow);
} else if (studioContainer) {
const studioRow = document.createElement('div');
studioRow.className = 'spotlight-metadata-row spotlight-credits-row';
studioRow.appendChild(studioContainer);
metadataContainer.appendChild(studioRow);
}
}
// Fixed 4-section grid: Logo, Metadata, Overview, Buttons (always render all to prevent layout shift)
const logoSection = document.createElement('div');
logoSection.className = 'spotlight-logo-section';
logoSection.appendChild(titleEl);
const metadataSection = document.createElement('div');
metadataSection.className = 'spotlight-metadata-section';
metadataSection.appendChild(metadataContainer);
const overviewSection = document.createElement('div');
overviewSection.className = 'spotlight-overview-section';
if (overviewEl.textContent) overviewSection.appendChild(overviewEl);
const buttonsSection = document.createElement('div');
buttonsSection.className = 'spotlight-buttons-section';
buttonsSection.appendChild(buttonsContainer);
overlay.appendChild(logoSection);
overlay.appendChild(metadataSection);
overlay.appendChild(overviewSection);
overlay.appendChild(buttonsSection);
itemDiv.appendChild(overlay);
if (taglineEl.textContent) {
const taglineContainer = document.createElement('div');
taglineContainer.className = 'spotlight-tagline headerTabs sectionTabs';
taglineContainer.appendChild(taglineEl);
itemDiv.appendChild(taglineContainer);
}
// 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.setAttribute('data-src', clearArtUrl);
if (eagerLoadImages) 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);
return itemDiv;
}
// Initial render: create slides for window around index 0
currentWindowIndices.forEach((idx) => {
createAndAppendSlide(items[idx], idx, idx === 0);
});
bannerContainer.appendChild(itemsContainer);
// Apply initial pan animation to first slide (helper also sets up completion tracking)
if (panAnimation && items.length > 0) {
const firstItem = bannerContainer.querySelector('.spotlight-item[data-index="0"]');
if (firstItem) applyPanAnimationToSlide(firstItem);
}
// Navigation buttons (top right)
if (showNavButtons && items.length > 1) {
const navButtonsContainer = document.createElement('div');
navButtonsContainer.className = 'spotlight-nav-buttons-container emby-tab-button emby-tab-button-active';
const prevButton = document.createElement('button');
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
const prevIcon = document.createElement('span');
prevIcon.className = 'material-icons emby-button-foreground';
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 emby-button-foreground';
nextIcon.textContent = 'chevron_right';
nextButton.appendChild(nextIcon);
nextButton.addEventListener('click', (e) => {
e.stopPropagation();
// Reset timer when manually navigating
goToItem((currentIndex + 1) % items.length, true);
});
navButtonsContainer.appendChild(prevButton);
navButtonsContainer.appendChild(nextButton);
const navContainer = document.createElement('div');
navContainer.className = 'spotlight-nav-container headerTabs sectionTabs';
navContainer.appendChild(navButtonsContainer);
bannerContainer.appendChild(navContainer);
}
// Pause button (bottom right)
if (autoPlay && items.length > 1) {
const navButtonsContainer = bannerContainer.querySelector('.spotlight-nav-container .spotlight-nav-buttons-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 emby-button-foreground';
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';
}
});
navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild);
}
// Slide state container (dots or numeric "N / X")
const MAX_DOTS = 5;
if (showSlideState && items.length > 1) {
const dotsContainer = document.createElement('div');
dotsContainer.className = 'spotlight-dots' + (showDots ? '' : ' spotlight-dots-numeric');
dotsContainer.setAttribute('data-index', '1'); /* 1-based for "N / X" display */
dotsContainer.setAttribute('data-total-items', String(items.length));
if (showDots) {
const numDots = Math.min(items.length, MAX_DOTS);
for (let i = 0; i < numDots; i++) {
const dot = document.createElement('button');
dot.className = 'spotlight-dot' + (i === 0 ? ' active' : '');
dot.setAttribute('data-dot-index', i);
dot.addEventListener('click', (e) => {
e.stopPropagation();
const windowStart = Math.floor(currentIndex / numDots) * numDots;
const targetIndex = Math.min(windowStart + i, items.length - 1);
goToItem(targetIndex, true);
});
dotsContainer.appendChild(dot);
}
}
bannerContainer.appendChild(dotsContainer);
}
// Load images for a slide (and optionally preload next). Only sets src/backgroundImage from data-* so browser loads on demand.
function ensureSlideImagesLoaded(slideIndex) {
if (slideIndex < 0 || slideIndex >= items.length) return;
const itemEl = bannerContainer.querySelector(`.spotlight-item[data-index="${slideIndex}"]`);
if (!itemEl) return;
itemEl.querySelectorAll('img[data-src]').forEach(img => {
const url = img.getAttribute('data-src');
if (url && !img.src) img.src = url;
});
const bgUrl = itemEl.getAttribute('data-background-url');
if (bgUrl && !itemEl.style.backgroundImage) itemEl.style.backgroundImage = `url("${bgUrl}")`;
const logoEl = itemEl.querySelector('.spotlight-item-logo[data-background-url]');
if (logoEl) {
const logoUrl = logoEl.getAttribute('data-background-url');
if (logoUrl && !logoEl.style.backgroundImage) logoEl.style.backgroundImage = `url("${logoUrl}")`;
}
}
// Apply pan animation to a slide and track when it completes (for re-trigger on viewport re-entry)
function applyPanAnimationToSlide(slideElement) {
if (!slideElement || !panAnimation) return;
const imgs = slideElement.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img');
if (!imgs.length) return;
currentSlideAnimationComplete = false;
let finishedCount = 0;
const checkAllDone = () => {
finishedCount++;
if (finishedCount >= imgs.length) currentSlideAnimationComplete = true;
};
imgs.forEach(img => {
img.classList.remove('animate');
void img.offsetWidth; // force reflow to restart animation
img.classList.add('animate');
const handler = (e) => {
if (['kenBurnsZoomOut', 'kenBurnsZoomIn', 'kenBurnsZoomOutFullscreen', 'kenBurnsZoomInFullscreen'].includes(e.animationName)) {
img.removeEventListener('animationend', handler);
checkAllDone();
}
};
img.addEventListener('animationend', handler);
});
}
// Go to item function (fade transition)
function goToItem(index, resetTimer = true) {
if (index === currentIndex) return;
const newWindowIndices = getSpotlightWindowIndices(index, items.length);
const indexToRemove = currentWindowIndices.find(i => !newWindowIndices.includes(i));
const indexToAdd = newWindowIndices.find(i => !currentWindowIndices.includes(i));
if (indexToRemove !== undefined) {
const slideToRemove = bannerContainer.querySelector(`.spotlight-item[data-index="${indexToRemove}"]`);
if (slideToRemove) slideToRemove.remove();
}
if (indexToAdd !== undefined) {
const newSlide = createAndAppendSlide(items[indexToAdd], indexToAdd, false);
newSlide.style.opacity = '0';
}
currentWindowIndices = newWindowIndices;
ensureSlideImagesLoaded(index);
ensureSlideImagesLoaded((index + 1) % items.length);
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';
applyPanAnimationToSlide(nextItem);
}
currentIndex = index;
// Update slide state (dots or data attrs for numeric)
if (showSlideState) {
const dotsContainer = bannerContainer.querySelector('.spotlight-dots');
if (dotsContainer) {
dotsContainer.setAttribute('data-index', String(index + 1)); // 1-based for display
}
if (showDots) {
const dots = bannerContainer.querySelectorAll('.spotlight-dot');
const numDots = dots.length;
const activeDotIndex = numDots > 0 ? (index % numDots) : 0;
dots.forEach((dot, i) => {
if (i === activeDotIndex) {
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 (only runs when section is visible in viewport)
function startAutoPlay() {
// Always clear any existing timer first
if (autoPlayTimer) {
clearInterval(autoPlayTimer);
autoPlayTimer = null;
}
if (!autoPlay || items.length <= 1 || isPaused || !isVisible) return;
if (pauseOnHover && isHovering) 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 (skipped when pauseOnHover is false, e.g. fullscreen)
let isHovering = false;
bannerContainer.addEventListener('mouseenter', () => {
isHovering = true;
if (pauseOnHover && autoPlayTimer) {
clearInterval(autoPlayTimer);
autoPlayTimer = null;
}
});
bannerContainer.addEventListener('mouseleave', () => {
isHovering = false;
// Only restart if auto-play is enabled, not paused, not hovering, and section is visible
if (autoPlay && !isPaused && !isHovering && isVisible) {
startAutoPlay();
}
});
// Only cycle when spotlight is in view: stop when off-screen to avoid loading images unnecessarily
const visibilityObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) return;
const wasVisible = isVisible;
isVisible = entry.isIntersecting;
if (!isVisible) {
if (autoPlayTimer) {
clearInterval(autoPlayTimer);
autoPlayTimer = null;
}
} else {
// Re-entering viewport: advance slide (don't restart animation)
if (!wasVisible && autoPlay && !isPaused && !(pauseOnHover && isHovering) && items.length > 1) {
const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
if (panAnimation && currentItem) {
if (currentSlideAnimationComplete) {
// Animation done - cycle to next card immediately
goToItem((currentIndex + 1) % items.length, true);
} else {
// Animation still playing - schedule cycle for when it finishes + interval
const imgs = currentItem.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img');
if (imgs.length > 0) {
let finishedCount = 0;
const checkAllDone = () => {
finishedCount++;
if (finishedCount >= imgs.length) {
currentSlideAnimationComplete = true;
setTimeout(() => {
if (autoPlay && !isPaused && isVisible && items.length > 1) {
goToItem((currentIndex + 1) % items.length, true);
}
}, interval);
}
};
imgs.forEach(img => {
const handler = (e) => {
if (['kenBurnsZoomOut', 'kenBurnsZoomIn', 'kenBurnsZoomOutFullscreen', 'kenBurnsZoomInFullscreen'].includes(e.animationName)) {
img.removeEventListener('animationend', handler);
checkAllDone();
}
};
img.addEventListener('animationend', handler);
});
} else {
startAutoPlay();
}
}
} else {
startAutoPlay();
}
} else if (autoPlay && !isPaused && !(pauseOnHover && isHovering)) {
startAutoPlay();
}
}
},
{ root: null, rootMargin: '0px', threshold: 0 }
);
visibilityObserver.observe(container);
// Start auto-play (will no-op if container not yet in DOM or not visible once observer runs)
if (autoPlay) {
startAutoPlay();
}
// Preload next slide soon after mount so transition has no pop
if (items.length > 1) {
requestAnimationFrame(() => ensureSlideImagesLoaded(1));
}
// 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);
container.dataset.layout = layout;
container.dataset.size = size;
return container;
}
/**
* Check if a section's items overflow the scroller and set data-expandable for show-all button visibility.
* Can be called for any .emby-scroller-container that contains .itemsContainer (e.g. when section is detected in DOM).
* @param {HTMLElement} verticalSection - Section element (emby-scroller-container)
* @param {boolean} forceUpdate - If true, update even when data-expandable is already set (e.g. on resize)
*/
function checkSectionOverflow(verticalSection, forceUpdate = false) {
if (!verticalSection || verticalSection.nodeType !== 1) return;
const itemsContainer = verticalSection.querySelector('.itemsContainer');
const scroller = verticalSection.querySelector('.emby-scroller');
if (!itemsContainer || !scroller) return;
if (!forceUpdate && verticalSection.hasAttribute('data-expandable')) return;
const hasOverflow = itemsContainer.scrollWidth > scroller.clientWidth;
verticalSection.setAttribute('data-expandable', hasOverflow ? 'true' : 'false');
}
/**
* 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 button-flat button-flat-mini sectionTitleTextButton emby-button';
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`);
});
// 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(() => {
checkSectionOverflow(verticalSection, true); // Force update on window resize
}, 100); // 100ms debounce
}
}
// Listen for window resize events
window.addEventListener('resize', handleWindowResize);
// Initial overflow check is done when section is detected in the DOM by overflowMutationObserver
// 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: rgba(255,255,255,0.12); animation: skeleton-pulse 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.cssText = 'background: rgba(255,255,255,0.12); animation: skeleton-pulse 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-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
`;
document.head.appendChild(style);
}
// Smart Lazy Image Loading with Global Observers
let lazyImageObserver = null;
let lazyMutationObserver = null;
// Overflow check: run checkSectionOverflow when .emby-scroller-container with .itemsContainer appears in DOM
let overflowMutationObserver = 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: '800px' // Start loading well before element enters viewport to avoid grey flash
};
lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const cardImageContainer = entry.target;
const imageUrl = cardImageContainer.getAttribute('data-src');
if (!imageUrl) return;
if (cardImageContainer.hasAttribute('data-loading')) return;
cardImageContainer.setAttribute('data-loading', 'true');
const img = new Image();
img.onload = () => {
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
cardImageContainer.classList.remove('lazy');
cardImageContainer.classList.add('lazy-loaded');
cardImageContainer.removeAttribute('data-src');
cardImageContainer.removeAttribute('data-loading');
cardImageContainer.classList.remove('lazy-hidden');
const canvas = cardImageContainer.previousElementSibling;
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
canvas.classList.add('lazy-hidden');
}
lazyImageObserver.unobserve(cardImageContainer);
};
img.onerror = () => {
cardImageContainer.removeAttribute('data-loading');
cardImageContainer.removeAttribute('data-src');
cardImageContainer.classList.remove('lazy');
cardImageContainer.classList.add('lazy-loaded');
cardImageContainer.classList.remove('lazy-hidden');
const canvas = cardImageContainer.previousElementSibling;
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
canvas.classList.add('lazy-hidden');
}
lazyImageObserver.unobserve(cardImageContainer);
};
img.src = imageUrl;
});
}, 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);
});
}
});
});
});
}
/**
* Collect section elements (.emby-scroller-container containing .itemsContainer) from an added node
* @param {Node} node - Added node (element or fragment)
* @returns {HTMLElement[]} - Deduplicated section elements to run overflow check on
*/
function getScrollerSectionsForOverflowCheck(node) {
if (!node || node.nodeType !== 1 || !node.querySelectorAll) return [];
const sections = [];
const selfMatch = node.classList && node.classList.contains('emby-scroller-container') && node.querySelector('.itemsContainer');
if (selfMatch) sections.push(node);
const inner = node.querySelectorAll('.emby-scroller-container');
inner.forEach(el => {
if (el !== node && el.querySelector('.itemsContainer')) sections.push(el);
});
return sections;
}
/**
* Initialize the MutationObserver that runs checkSectionOverflow when sections appear in the DOM
*/
function initOverflowMutationObserver() {
if (overflowMutationObserver) return;
overflowMutationObserver = new MutationObserver((mutations) => {
const sectionsToCheck = new Set();
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
getScrollerSectionsForOverflowCheck(node).forEach(section => sectionsToCheck.add(section));
});
});
sectionsToCheck.forEach(section => {
requestAnimationFrame(() => {
checkSectionOverflow(section, false);
// Re-check after a short delay so images/layout can affect dimensions
setTimeout(() => checkSectionOverflow(section, true), 150);
});
});
});
}
/**
* 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();
initOverflowMutationObserver();
// Scan existing elements with data-src
const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]');
existingLazyImages.forEach(img => {
lazyImageObserver.observe(img);
});
// Run overflow check on any sections already in the DOM
document.querySelectorAll('.emby-scroller-container').forEach(section => {
if (section.querySelector('.itemsContainer')) {
requestAnimationFrame(() => checkSectionOverflow(section, false));
}
});
// Start watching for new elements
if (document.body) {
lazyMutationObserver.observe(document.body, {
childList: true,
subtree: true
});
overflowMutationObserver.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
});
}
if (overflowMutationObserver && document.body) {
overflowMutationObserver.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() {
if (!state.useEpisodeImages) {
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');
})();