Files
KefinTweaks/scripts/cardBuilder.js
T

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