Files
KefinTweaks/scripts/cardBuilder.js
T

3638 lines
173 KiB
JavaScript

// 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;
/**
* 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;
});
}
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) {
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);
},
};
/**
* Creates a Jellyfin card element from an item
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square'
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
* @param {string} forcedImageType - Force specific image type (e.g., 'Primary')
* @returns {HTMLElement} - The constructed card element
*/
function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null, forcedImageType = null) {
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
// Determine card type based on cardFormat override or item type
let cardClass, padderClass, imageParams;
cardFormat = cardFormat?.toLowerCase() || null;
forcedImageType = forcedImageType?.toLowerCase() || null;
// Use custom footer text from item property if not explicitly provided
if (!customFooterText && item.CustomFooterText) {
customFooterText = item.CustomFooterText;
}
if (cardFormat) {
// Use specified cardFormat
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || forcedImageType) {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (cardFormat === 'square') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// portrait (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
} else {
// Use item type to determine card type
if (item.Type === 'Episode' || item.Type === 'TvChannel') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// Default poster style for Movies, Series, etc.
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
}
// Create the main card container
const card = document.createElement('div');
card.className = `card ${cardClass} card-hoverable card-withuserdata`;
card.setAttribute('data-index', '0');
card.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' ? 'true' : 'false');
card.setAttribute('data-serverid', serverId);
card.setAttribute('data-id', item.Id);
card.setAttribute('data-type', item.Type);
card.setAttribute('data-mediatype', item.MediaType || 'Video');
card.setAttribute('data-prefix', item.Name?.startsWith('The ') ? 'THE' : '');
if (item.UserData?.PlaybackPositionTicks && item.UserData?.PlaybackPositionTicks > 0) {
card.setAttribute('data-positionticks', item.UserData?.PlaybackPositionTicks);
}
// Card box container
const cardBox = document.createElement('div');
cardBox.className = 'cardBox cardBox-bottompadded';
// Card scalable container
const cardScalable = document.createElement('div');
cardScalable.className = 'cardScalable';
// Card padder with icon
const cardPadder = document.createElement('div');
cardPadder.className = `cardPadder ${padderClass} lazy-hidden-children`;
const cardIcon = document.createElement('span');
cardIcon.className = 'cardImageIcon material-icons';
cardIcon.setAttribute('aria-hidden', 'true');
// Set icon based on item type
if (item.Type === 'Movie') {
cardIcon.textContent = 'movie';
} else if (item.Type === 'Series') {
cardIcon.textContent = 'tv';
} else if (item.Type === 'Episode') {
cardIcon.textContent = 'play_circle';
} else if (item.Type === 'MusicAlbum') {
cardIcon.textContent = 'album';
} else if (item.Type === 'Audio') {
cardIcon.textContent = 'music_note';
} else if (item.Type === 'Artist') {
cardIcon.textContent = 'person';
} else {
cardIcon.textContent = 'folder';
}
cardPadder.appendChild(cardIcon);
// Blurhash canvas (placeholder)
const blurhashCanvas = document.createElement('canvas');
blurhashCanvas.setAttribute('aria-hidden', 'true');
blurhashCanvas.width = 20;
blurhashCanvas.height = 20;
blurhashCanvas.className = 'blurhash-canvas lazy-hidden';
// Card image container
const cardImageContainer = document.createElement('a');
cardImageContainer.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast';
cardImageContainer.setAttribute('data-action', 'link');
cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown');
// Force specific image if card format is specified
/* if (forcedImageType === 'Primary' && item.ImageTags?.Primary) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else */
let imageUrl = '';
if (cardFormat === 'backdrop') {
if (item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
} else if (cardFormat === 'square') {
if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
imageUrl = item.BackdropImageTags && item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : '';
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
} else if (item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.BackdropImageTags && item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
}
} else if (cardFormat === 'portrait' || cardFormat === 'poster') {
if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else 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);
}
if (imageUrl) {
// Use data-src for lazy loading instead of immediate backgroundImage
cardImageContainer.setAttribute('data-src', imageUrl);
// Add lazy class for styling/selection
cardImageContainer.classList.add('lazy');
}
//countIndicator indicator
const cardIndicators = document.createElement('div');
cardIndicators.className = 'cardIndicators';
if (item.UserData?.Played) {
const playedIndicator = document.createElement('div');
playedIndicator.className = 'playedIndicator indicator';
const playedIndicatorIcon = document.createElement('span');
playedIndicatorIcon.className = 'material-icons indicatorIcon check';
playedIndicatorIcon.setAttribute('aria-hidden', 'true');
playedIndicator.appendChild(playedIndicatorIcon);
cardIndicators.appendChild(playedIndicator);
}
if (item.UserData?.UnplayedItemCount && item.UserData?.UnplayedItemCount > 0) {
const unplayedIndicator = document.createElement('div');
unplayedIndicator.className = 'countIndicator indicator';
unplayedIndicator.textContent = item.UserData?.UnplayedItemCount;
cardIndicators.appendChild(unplayedIndicator);
}
if (cardIndicators.childElementCount > 0) {
cardImageContainer.appendChild(cardIndicators);
}
if (item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0) {
const innerCardFooter = document.createElement('div');
innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
cardBox.appendChild(innerCardFooter);
const itemProgressBar = document.createElement('div');
itemProgressBar.className = 'itemProgressBar';
const itemProgressBarForeground = document.createElement('div');
itemProgressBarForeground.className = 'itemProgressBarForeground';
itemProgressBarForeground.style.width = `${item.UserData?.PlayedPercentage}%`;
itemProgressBar.appendChild(itemProgressBarForeground);
innerCardFooter.appendChild(itemProgressBar);
cardImageContainer.appendChild(innerCardFooter);
}
// Card overlay container
const cardOverlayContainer = document.createElement('div');
cardOverlayContainer.className = 'cardOverlayContainer itemAction';
cardOverlayContainer.setAttribute('data-action', 'link');
// Overlay link
const overlayLink = document.createElement('a');
overlayLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
overlayLink.className = 'cardImageContainer';
// Play button
const playButton = document.createElement('button');
playButton.setAttribute('is', 'paper-icon-button-light');
playButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary';
playButton.setAttribute('data-action', 'resume');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow';
playIcon.setAttribute('aria-hidden', 'true');
playButton.appendChild(playIcon);
// Button container for additional overlay buttons (watchlist, etc.)
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
// Watched button
const watchedButton = document.createElement('button');
watchedButton.setAttribute('is', 'emby-playstatebutton');
watchedButton.type = 'button';
watchedButton.setAttribute('data-action', 'none');
watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
watchedButton.setAttribute('data-id', item.Id);
watchedButton.setAttribute('data-serverid', serverId);
watchedButton.setAttribute('data-itemtype', item.Type);
watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
watchedButton.title = 'Mark played';
const watchedIcon = document.createElement('span');
watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
watchedIcon.setAttribute('aria-hidden', 'true');
watchedButton.appendChild(watchedIcon);
buttonContainer.appendChild(watchedButton);
// Favorite button
const favoriteButton = document.createElement('button');
favoriteButton.setAttribute('is', 'emby-ratingbutton');
favoriteButton.type = 'button';
favoriteButton.setAttribute('data-action', 'none');
favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
favoriteButton.setAttribute('data-id', item.Id);
favoriteButton.setAttribute('data-serverid', serverId);
favoriteButton.setAttribute('data-itemtype', item.Type);
favoriteButton.setAttribute('data-likes', '');
favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
favoriteButton.title = 'Add to favorites';
const favoriteIcon = document.createElement('span');
favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
favoriteIcon.setAttribute('aria-hidden', 'true');
favoriteButton.appendChild(favoriteIcon);
buttonContainer.appendChild(favoriteButton);
const moreButton = document.createElement('button');
moreButton.setAttribute('is', 'paper-icon-button-light');
moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
moreButton.setAttribute('data-action', 'menu');
moreButton.title = 'More';
const moreIcon = document.createElement('span');
moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert';
moreIcon.setAttribute('aria-hidden', 'true');
moreButton.appendChild(moreIcon);
buttonContainer.appendChild(moreButton);
// Assemble overlay
cardOverlayContainer.appendChild(overlayLink);
cardOverlayContainer.appendChild(playButton);
cardOverlayContainer.appendChild(buttonContainer);
// Card text container - different structure for episodes
const cardTextContainer = document.createElement('div');
cardTextContainer.className = 'cardText cardTextCentered cardText-first';
if (item.Type === 'Episode') {
// Episodes: Series name as primary, episode title as secondary
const seriesLink = document.createElement('a');
seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`;
seriesLink.className = 'itemAction textActionButton';
seriesLink.setAttribute('data-id', item.SeriesId || item.Id);
seriesLink.setAttribute('data-serverid', serverId);
seriesLink.setAttribute('data-type', 'Series');
seriesLink.setAttribute('data-mediatype', 'undefined');
seriesLink.setAttribute('data-channelid', 'undefined');
seriesLink.setAttribute('data-isfolder', 'true');
seriesLink.setAttribute('data-action', 'link');
seriesLink.title = item.SeriesName || 'Unknown Series';
seriesLink.textContent = item.SeriesName || 'Unknown Series';
const seriesBdi = document.createElement('bdi');
seriesBdi.appendChild(seriesLink);
cardTextContainer.appendChild(seriesBdi);
// Episode title as secondary
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
episodeLink.setAttribute('data-serverid', serverId);
episodeLink.setAttribute('data-type', 'Episode');
episodeLink.setAttribute('data-mediatype', 'undefined');
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
episodeLink.title = item.Name || 'Unknown Episode';
episodeLink.textContent = item.Name || 'Unknown Episode';
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
secondaryText.appendChild(episodeBdi);
} else {
// Default: Item name as primary, year as secondary
const titleLink = document.createElement('a');
titleLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
titleLink.className = 'itemAction textActionButton';
titleLink.setAttribute('data-id', item.Id);
titleLink.setAttribute('data-serverid', serverId);
titleLink.setAttribute('data-type', item.Type);
titleLink.setAttribute('data-mediatype', 'undefined');
titleLink.setAttribute('data-channelid', 'undefined');
titleLink.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' || item.Type === 'MusicArtist' ? 'true' : 'false');
titleLink.setAttribute('data-action', 'link');
titleLink.title = item.Name || 'Unknown';
titleLink.textContent = item.Name || 'Unknown';
const titleBdi = document.createElement('bdi');
titleBdi.appendChild(titleLink);
cardTextContainer.appendChild(titleBdi);
// Secondary text (year)
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const yearBdi = document.createElement('bdi');
yearBdi.textContent = item.CustomFooterText || item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
secondaryText.appendChild(yearBdi);
}
// Assemble card
cardScalable.appendChild(cardPadder);
cardScalable.appendChild(blurhashCanvas);
cardScalable.appendChild(cardImageContainer);
cardScalable.appendChild(cardOverlayContainer);
cardBox.appendChild(cardScalable);
cardBox.appendChild(cardTextContainer);
// Add secondary text after primary text
if (item.Type === 'Episode') {
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
episodeLink.setAttribute('data-serverid', serverId);
episodeLink.setAttribute('data-type', 'Episode');
episodeLink.setAttribute('data-mediatype', 'undefined');
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
episodeLink.title = episodeName;
episodeLink.textContent = episodeName;
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
secondaryText.appendChild(episodeBdi);
cardBox.appendChild(secondaryText);
// Add custom footer text if provided (e.g., air date)
if (customFooterText) {
const footerText = document.createElement('div');
footerText.className = 'cardText cardTextCentered cardText-secondary';
const footerBdi = document.createElement('bdi');
footerBdi.textContent = customFooterText;
footerText.appendChild(footerBdi);
cardBox.appendChild(footerText);
}
} else {
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const yearBdi = document.createElement('bdi');
yearBdi.textContent = item.CustomFooterText || item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
secondaryText.appendChild(yearBdi);
cardBox.appendChild(secondaryText);
}
card.appendChild(cardBox);
return card;
}
/**
* Renders a spotlight section (Netflix-style slim banner carousel)
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel
* @param {boolean} options.autoPlay - Auto-cycle through items (default: true)
* @param {number} options.interval - Auto-play interval in ms (default: 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,
dualBackdrops = true,
viewMoreUrl = null,
pauseOnHover = !fullScreen // Default: don't pause on hover in fullscreen (continue cycling)
} = options;
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 = 'spotlight-section-title spotlight-title-link';
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 = 'spotlight-section-title';
sectionTitleEl.textContent = title;
}
bannerContainer.appendChild(sectionTitleEl);
}
// Create items container (for fade transitions)
const itemsContainer = document.createElement('div');
itemsContainer.className = 'spotlight-items-container';
const isMobileLayout = document.documentElement.classList.contains('mobile-layout');
// Create items
items.forEach((item, index) => {
// Get item type early for use throughout
const itemType = item.Type || 'Movie';
const itemDiv = document.createElement('div');
itemDiv.className = 'spotlight-item';
itemDiv.setAttribute('data-id', item.Id);
itemDiv.setAttribute('data-index', index);
itemDiv.setAttribute('data-item-type', itemType);
if (index === 0) {
itemDiv.style.opacity = '1';
} else {
itemDiv.style.opacity = '0';
}
let imageUrl = '';
// 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 (index === 0) {
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 (index === 0) {
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 (index === 0) {
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 logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=${fullScreen ? '300' : '200'}&quality=96&tag=${item.ImageTags.Logo}`;
titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-logo';
titleEl.setAttribute('data-background-url', logoUrl);
if (index === 0) titleEl.style.backgroundImage = `url("${logoUrl}")`;
titleEl.alt = item.Name || 'Unknown';
} else {
// Use text title as fallback
titleEl = document.createElement('h3');
titleEl.className = 'spotlight-item-title';
titleEl.textContent = item.Name || 'Unknown';
}
// Combined: Year + Time + Ends At + Genres (all on one line)
const metadataRow = document.createElement('div');
metadataRow.className = 'spotlight-metadata-row';
// Year
const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null);
if (year) {
const yearEl = document.createElement('span');
yearEl.textContent = year;
metadataRow.appendChild(yearEl);
}
// Runtime
if (item.RunTimeTicks) {
const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60);
const hours = Math.floor(runtimeMinutes / 60);
const minutes = runtimeMinutes % 60;
let runtimeText = '';
if (hours > 0) {
runtimeText = `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim();
} else {
runtimeText = `${minutes}m`;
}
const runtimeEl = document.createElement('span');
runtimeEl.textContent = runtimeText;
metadataRow.appendChild(runtimeEl);
// End time (when it would end if started now)
const now = new Date();
// RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds
const runtimeMs = item.RunTimeTicks / 10000;
const endTime = new Date(now.getTime() + runtimeMs);
const endTimeEl = document.createElement('span');
endTimeEl.className = 'spotlight-end-time';
endTimeEl.textContent = `${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
metadataRow.appendChild(endTimeEl);
}
// Genres (on same line) - make clickable if IDs are available
if (item.Genres && item.Genres.length > 0) {
const genreFragment = document.createDocumentFragment();
if (item.GenreItems && item.GenreItems.length > 0) {
item.GenreItems.forEach((genreItem, index) => {
const genreName = genreItem.Name;
const genreId = genreItem.Id;
if (genreName) {
const genreLink = document.createElement('a');
genreLink.className = 'spotlight-genre-link';
genreLink.href = `${serverAddress}/web/#/list.html?genreId=${genreId}&serverId=${serverId}`;
genreLink.textContent = genreName;
genreLink.addEventListener('click', (e) => {
e.stopPropagation();
});
genreFragment.appendChild(genreLink);
}
});
}
metadataRow.appendChild(genreFragment);
}
// Helper function to create truncated list that expands on ellipsis hover
// items can be array of strings (names) or array of objects with Name and Id
function createTruncatedList(label, items, maxItems = 3, isPeople = false) {
const container = document.createElement('div');
container.className = 'spotlight-truncated-list';
if (!items || items.length === 0) {
return container;
}
const displayContainer = document.createElement('span');
const labelSpan = document.createElement('span');
labelSpan.textContent = `${label} `;
displayContainer.appendChild(labelSpan);
// Extract names and IDs from items (handle both strings and objects)
const itemsData = items.map(item => {
if (typeof item === 'string') {
return { name: item, id: null };
} else if (item && typeof item === 'object') {
return {
name: item.Name || item.name || null,
id: item.Id || item.id || null
};
}
return container;
}).filter(item => item && item.name);
if (itemsData.length === 0) return null;
const hasMore = itemsData.length > maxItems;
// Store references to hidden elements for easy show/hide
const hiddenElements = [];
// Create spans for all names (but initially hide the ones beyond maxItems)
itemsData.forEach((itemData, index) => {
let nameElement;
// Create link if it's a person with an ID, otherwise just text
if (isPeople && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-person-link';
nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
nameElement.addEventListener('click', (e) => {
e.stopPropagation();
});
} else {
nameElement = document.createElement('span');
nameElement.textContent = itemData.name;
}
nameElement.setAttribute('data-name-index', index);
// Hide names beyond maxItems initially
if (index >= maxItems) {
nameElement.style.display = 'none';
hiddenElements.push(nameElement);
}
displayContainer.appendChild(nameElement);
// Add comma separator (except for last item)
if (index < itemsData.length - 1) {
const comma = document.createElement('span');
comma.textContent = ', ';
comma.setAttribute('data-comma-after', index);
// Hide comma if it's after the last visible item
if (index >= maxItems - 1) {
comma.style.cssText = 'display: none;';
hiddenElements.push(comma);
}
displayContainer.appendChild(comma);
}
});
// Add ellipsis as separate hoverable element if there are more items
if (hasMore) {
const ellipsisElement = document.createElement('span');
ellipsisElement.className = 'spotlight-ellipsis';
ellipsisElement.textContent = '...';
displayContainer.appendChild(ellipsisElement);
let isExpanded = false;
// On ellipsis hover, reveal all hidden names and hide ellipsis
ellipsisElement.addEventListener('mouseenter', () => {
if (!isExpanded) {
// Show all hidden elements
hiddenElements.forEach(el => {
el.style.display = '';
});
ellipsisElement.style.display = 'none';
isExpanded = true;
}
});
// Keep expanded state when mouse moves within the container
// Only collapse when mouse leaves the entire container
container.addEventListener('mouseleave', () => {
if (isExpanded) {
// Hide names beyond maxItems again
hiddenElements.forEach(el => {
el.style.display = 'none';
});
ellipsisElement.style.display = '';
isExpanded = false;
}
});
}
container.appendChild(displayContainer);
return container;
}
// For Series: show seasons/episodes. For Movies: show director/writer
let directorContainer = null;
let writerContainer = null;
let combinedContainer = null;
let seriesInfoContainer = null;
// Create series info container
seriesInfoContainer = document.createElement('div');
seriesInfoContainer.className = 'spotlight-truncated-list';
if (itemType === 'Series') {
// For Series, show seasons and episodes count
const seasonsCount = item.ChildCount || 0;
// Initial text with seasons count
const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`;
seriesInfoContainer.textContent = seasonsText;
// Fetch episode count asynchronously and update
(async () => {
try {
const episodesCount = item.RecursiveItemCount || 0;
// Update the series info container with episode count
if (seriesInfoContainer) {
const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
seriesInfoContainer.textContent = `${seasonsText} - ${episodesText}`;
}
} catch (err) {
console.error('Failed to fetch episode count:', err);
// Keep just the seasons text if episode fetch fails
}
})();
} else if (itemType === 'Movie') {
// For Movies, show director/writer (existing logic)
if (item.People && Array.isArray(item.People)) {
const directors = item.People.filter(p => p.Type === 'Director');
const writers = item.People.filter(p => p.Type === 'Writer');
// Extract names for comparison
const directorNames = directors.map(p => p.Name);
const writerNames = writers.map(p => p.Name);
// Check if directors and writers are identical (same length and same names)
const areIdentical = directorNames.length === writerNames.length &&
directorNames.length > 0 &&
directorNames.every((name, index) => name === writerNames[index]);
if (areIdentical) {
// Combine into single "Directed and Written by" row (with person objects for links)
combinedContainer = createTruncatedList('Directed and Written by', directors, 3, true);
// Add empty writer container to maintain spacing
writerContainer = document.createElement('div');
writerContainer.className = 'spotlight-empty-writer-container';
} else {
// Show separately (with person objects for links)
directorContainer = createTruncatedList('Directed by', directors, 3, true);
writerContainer = createTruncatedList('Written by', writers, 3, true);
}
}
} else if (itemType === 'Season') {
// For Seasons, show episodes count
const episodesCount = item.RecursiveItemCount || 0;
const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
seriesInfoContainer.textContent = episodesText;
} else if (itemType === 'Episode') {
// For Episodes, show episode number
const seasonNumber = item.ParentIndexNumber || 0;
const seasonText = `Season ${seasonNumber}`;
const episodeNumber = item.IndexNumber || 0;
const episodeText = `Episode ${episodeNumber}`;
seriesInfoContainer.textContent = `${seasonText} - ${episodeText}`;
}
// Taglines for Movies, Overview for Series
let taglineEl = null;
taglineEl = document.createElement('p');
taglineEl.className = 'spotlight-tagline';
if (itemType === 'Series') {
// For Series, show Overview instead of Tagline
if (item.Overview) {
taglineEl.textContent = item.Overview;
}
} else {
// For Movies, show Tagline (if available)
if (item.Overview && fullScreen) {
taglineEl.textContent = item.Overview;
} else {
if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) {
taglineEl.textContent = item.Taglines[0]; // Show first tagline
} else if (item.Overview) {
taglineEl.textContent = item.Overview;
}
}
}
// Buttons container
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'spotlight-buttons-container';
// Play button (material icon span button)
const playButton = document.createElement('button');
playButton.className = 'spotlight-play-button emby-button';
const playIcon = document.createElement('span');
playIcon.className = 'material-icons';
playIcon.textContent = 'play_arrow';
playButton.appendChild(playIcon);
playButton.addEventListener('click', async (e) => {
e.stopPropagation();
try {
// TODO - this needs to be updated to use the data-action resume to play the item natively, if possible
if (window.apiHelper && window.apiHelper.playItem) {
await window.apiHelper.playItem(item.Id);
} else {
// Fallback to ApiClient.play if apiHelper is not available
if (window.ApiClient && typeof window.ApiClient.play === 'function') {
window.ApiClient.play({ ids: [item.Id] });
} else {
console.error('[KefinTweaks CardBuilder] No play method available');
}
}
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error playing item:', error);
}
});
// Watchlist button (material icon span button with bookmark icon)
const watchlistButton = document.createElement('button');
watchlistButton.className = 'spotlight-watchlist-button emby-button';
// Check watchlist status - try to get from cache or API
let isInWatchlist = false;
const sectionName = itemType === 'Movie' ? 'movies' :
itemType === 'Series' ? 'series' :
itemType === 'Season' ? 'seasons' :
itemType === 'Episode' ? 'episodes' : null;
// Check watchlist cache if available
if (sectionName && typeof window.watchlistCache !== 'undefined' && window.watchlistCache[sectionName]?.data) {
isInWatchlist = window.watchlistCache[sectionName].data.some(watchlistItem => watchlistItem.Id === item.Id);
}
const watchlistIcon = document.createElement('span');
watchlistIcon.className = 'material-icons';
watchlistIcon.textContent = 'bookmark';
watchlistButton.appendChild(watchlistIcon);
if (isInWatchlist) {
watchlistButton.classList.add('watchlisted');
}
watchlistButton.addEventListener('click', async (e) => {
e.stopPropagation();
const userId = ApiClient.getCurrentUserId();
const newStatus = !isInWatchlist;
try {
// Update watchlist status using proper API
await ApiClient.updateUserItemRating(userId, item.Id, newStatus ? 'true' : 'false');
// Update local state
isInWatchlist = newStatus;
// Update button class
if (isInWatchlist) {
watchlistButton.classList.add('watchlisted');
} else {
watchlistButton.classList.remove('watchlisted');
}
// Update watchlist cache if available
if (sectionName && typeof window.updateWatchlistCacheOnToggle === 'function') {
await window.updateWatchlistCacheOnToggle(item.Id, itemType, isInWatchlist);
}
} catch (err) {
console.error('Failed to update watchlist status:', err);
}
});
// Info button (shows overview on hover)
const infoButton = document.createElement('button');
infoButton.className = 'spotlight-info-button emby-button';
const infoIcon = document.createElement('span');
infoIcon.className = 'material-icons';
infoIcon.textContent = 'info';
infoButton.appendChild(infoIcon);
// Overview tooltip (hidden by default, shown on hover)
if (item.Overview) {
const overviewTooltip = document.createElement('div');
overviewTooltip.className = 'spotlight-overview-tooltip';
overviewTooltip.textContent = item.Overview;
infoButton.appendChild(overviewTooltip);
}
buttonsContainer.appendChild(playButton);
buttonsContainer.appendChild(watchlistButton);
buttonsContainer.appendChild(infoButton);
// Rating with star icon
const rating = item.CommunityRating || item.CriticRating;
if (rating && typeof rating === 'number') {
const ratingContainer = document.createElement('div');
ratingContainer.className = 'spotlight-rating-container';
const starIcon = document.createElement('span');
starIcon.className = 'material-icons spotlight-rating-star';
starIcon.textContent = 'star';
const ratingValue = document.createElement('span');
ratingValue.className = 'spotlight-rating-value';
if (rating && typeof rating === 'number') {
ratingValue.textContent = rating.toFixed(1);
} else {
ratingValue.textContent = 'N/A';
ratingValue.classList.add('na');
}
ratingContainer.appendChild(starIcon);
ratingContainer.appendChild(ratingValue);
buttonsContainer.appendChild(ratingContainer);
}
// Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons
overlay.appendChild(titleEl);
const metadataContainer = document.createElement('div');
metadataContainer.className = 'metadata-container';
if (metadataRow.children.length > 0) {
metadataContainer.appendChild(metadataRow);
}
// For Series, show seasons/episodes. For Movies, show director/writer
if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') {
if (seriesInfoContainer) {
metadataContainer.appendChild(seriesInfoContainer);
}
} else {
let emptyDirectorContainer = document.createElement('div');
emptyDirectorContainer.className = 'spotlight-empty-director-container';
let emptyWriterContainer = document.createElement('div');
emptyWriterContainer.className = 'spotlight-empty-writer-container';
if (combinedContainer) {
metadataContainer.appendChild(combinedContainer);
metadataContainer.appendChild(emptyWriterContainer);
} else {
if (directorContainer) {
metadataContainer.appendChild(directorContainer);
}
if (writerContainer) {
metadataContainer.appendChild(writerContainer);
if (!directorContainer) {
metadataContainer.appendChild(emptyDirectorContainer);
}
} else {
if (!directorContainer) {
metadataContainer.appendChild(emptyDirectorContainer);
}
metadataContainer.appendChild(emptyWriterContainer);
}
}
}
if (metadataContainer.children.length > 0) {
overlay.appendChild(metadataContainer);
}
if (taglineEl) {
overlay.appendChild(taglineEl);
}
overlay.appendChild(buttonsContainer);
itemDiv.appendChild(overlay);
// ClearArt image (bottom right corner, if enabled and available)
if (showClearArt && item.ImageTags?.Art) {
const clearArtUrl = `${serverAddress}/Items/${item.Id}/Images/Art?fillHeight=300&quality=96&tag=${item.ImageTags.Art}`;
const clearArtEl = document.createElement('img');
clearArtEl.className = 'spotlight-clearart';
clearArtEl.setAttribute('data-src', clearArtUrl);
if (index === 0) clearArtEl.src = clearArtUrl;
clearArtEl.alt = item.Name || 'Unknown';
itemDiv.appendChild(clearArtEl);
}
// Click to navigate (but not on buttons)
infoButton.addEventListener('click', (e) => {
window.location.href = `${serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
});
itemsContainer.appendChild(itemDiv);
});
bannerContainer.appendChild(itemsContainer);
// 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 navContainer = document.createElement('div');
navContainer.className = 'spotlight-nav-container';
const prevButton = document.createElement('button');
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
const prevIcon = document.createElement('span');
prevIcon.className = 'material-icons';
prevIcon.textContent = 'chevron_left';
prevButton.appendChild(prevIcon);
prevButton.addEventListener('click', (e) => {
e.stopPropagation();
// Reset timer when manually navigating
goToItem((currentIndex - 1 + items.length) % items.length, true);
});
const nextButton = document.createElement('button');
nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button';
const nextIcon = document.createElement('span');
nextIcon.className = 'material-icons';
nextIcon.textContent = 'chevron_right';
nextButton.appendChild(nextIcon);
nextButton.addEventListener('click', (e) => {
e.stopPropagation();
// Reset timer when manually navigating
goToItem((currentIndex + 1) % items.length, true);
});
navContainer.appendChild(prevButton);
navContainer.appendChild(nextButton);
bannerContainer.appendChild(navContainer);
}
// Pause button (bottom right)
if (autoPlay && items.length > 1) {
const navContainer = bannerContainer.querySelector('.spotlight-nav-container');
const pauseButton = document.createElement('button');
pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button';
const pauseIcon = document.createElement('span');
pauseIcon.className = 'material-icons';
pauseIcon.textContent = 'pause';
pauseButton.appendChild(pauseIcon);
pauseButton.addEventListener('click', (e) => {
e.stopPropagation();
isPaused = !isPaused;
if (isPaused) {
clearInterval(autoPlayTimer);
pauseIcon.textContent = 'play_arrow';
} else {
startAutoPlay();
pauseIcon.textContent = 'pause';
}
});
navContainer.insertBefore(pauseButton, navContainer.firstChild);
}
// 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;
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);
if (fullScreen) {
container.dataset.fullScreen = 'true';
}
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: '100px' // Start loading 100px before element enters viewport
};
lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const cardImageContainer = entry.target;
const imageUrl = cardImageContainer.getAttribute('data-src');
if (imageUrl) {
// Load the image
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
// Remove lazy class and add loaded class
cardImageContainer.classList.remove('lazy');
cardImageContainer.classList.add('lazy-loaded');
// Remove data-src to prevent re-loading
cardImageContainer.removeAttribute('data-src');
// Unobserve this element
lazyImageObserver.unobserve(cardImageContainer);
}
}
});
}, observerOptions);
}
/**
* Initialize the global MutationObserver to detect new elements
* Automatically adds new cardImageContainer elements with data-src to the IntersectionObserver
*/
function initLazyMutationObserver() {
if (lazyMutationObserver) return; // Already initialized
if (!lazyImageObserver) {
initLazyImageObserver();
}
lazyMutationObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
// Check if the added node itself is a cardImageContainer with data-src
if (node.nodeType === 1 && // Element node
node.classList &&
node.classList.contains('cardImageContainer') &&
node.hasAttribute('data-src')) {
lazyImageObserver.observe(node);
}
// Check for cardImageContainer descendants
if (node.nodeType === 1 && node.querySelectorAll) {
const lazyImages = node.querySelectorAll('.cardImageContainer[data-src]');
lazyImages.forEach(img => {
lazyImageObserver.observe(img);
});
}
});
});
});
}
/**
* 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');
})();