mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
3295 lines
154 KiB
JavaScript
3295 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 postProcessItemsByQuery(sectionConfig, queryResults) {
|
|
let processed = [];
|
|
for (let i = 0; i < queryResults.length; i++) {
|
|
const queryConfig = sectionConfig.queries[i];
|
|
const queryResult = queryResults[i];
|
|
const items = queryResult?.Items || queryResult || [];
|
|
processed.push(...postProcessItems(queryConfig, items));
|
|
}
|
|
|
|
if (sectionConfig.id === 'continueWatchingAndNextUp') {
|
|
// Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them
|
|
const movies = processed.filter(item => item.Type === 'Movie');
|
|
const episodes = processed.filter(item => item.Type !== 'Movie');
|
|
processed = mergeNextUpAndContinueWatching(episodes, movies);
|
|
}
|
|
|
|
return processed;
|
|
}
|
|
|
|
function postProcessItems(sectionConfig, itemsData) {
|
|
let processed = itemsData?.Items || itemsData || [];
|
|
|
|
// Check if the IsUnplayed filter is set on any of the queries
|
|
const isUnplayedFilter = sectionConfig.queries?.some(query => query.queryOptions?.IsUnplayed === true || query.queryOptions?.Filters?.includes('IsUnplayed'));
|
|
if (isUnplayedFilter) {
|
|
processed = processed.filter(item => item.UserData?.Played === false);
|
|
}
|
|
|
|
// Check if the IsPlayed filter is set on any of the queries
|
|
const isPlayedFilter = sectionConfig.queries?.some(query => query.queryOptions?.IsPlayed === true || query.queryOptions?.Filters?.includes('IsPlayed'));
|
|
if (isPlayedFilter) {
|
|
processed = processed.filter(item => item.UserData?.Played === true);
|
|
}
|
|
|
|
// Check if the IsResumable filter is set on any of the queries
|
|
const isResumableFilter = sectionConfig.queries?.some(query => query.queryOptions?.IsResumable === true || query.queryOptions?.Filters?.includes('IsResumable'));
|
|
if (isResumableFilter) {
|
|
processed = processed.filter(item => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage !== 100 && item.UserData?.PlayedPercentage > 0);
|
|
}
|
|
|
|
// Flatten Series Episodes
|
|
if (sectionConfig.flattenSeries === true) {
|
|
processed = flattenSeriesEpisodes(processed);
|
|
}
|
|
|
|
/* if (sectionConfig.id === 'popularTVNetworks') {
|
|
// Sort items randomly
|
|
processed = shuffle(processed);
|
|
} */
|
|
|
|
if (sectionConfig.id === 'continueWatchingAndNextUp') {
|
|
// Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them
|
|
const movies = processed.filter(item => item.Type === 'Movie');
|
|
const episodes = processed.filter(item => item.Type !== 'Movie');
|
|
processed = mergeNextUpAndContinueWatching(episodes, movies);
|
|
}
|
|
|
|
if (sectionConfig.id === 'upcoming') {
|
|
// Remove items which aired before today
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
processed = processed.filter(item => {
|
|
if (!item.PremiereDate || item.LocationType !== 'Virtual')
|
|
return false;
|
|
const premiereDate = new Date(item.PremiereDate);
|
|
premiereDate.setHours(0, 0, 0, 0);
|
|
return premiereDate >= today;
|
|
});
|
|
}
|
|
|
|
// Add Custom Secondary Text for Recently Released Movies
|
|
if (sectionConfig.id === 'recentlyReleased.movies' || sectionConfig.id === 'upcoming' || sectionConfig.id === 'recentlyReleased.episodes') {
|
|
processed.forEach(item => {
|
|
if (item.PremiereDate) {
|
|
item.CustomFooterText = formatAirDate(item.PremiereDate);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sort items by UserData.PlayedPercentage if PlayedPercentage is set as the SortBy
|
|
if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'PlayedPercentage') {
|
|
processed = processed.sort((a, b) => {
|
|
const playedPercentageA = a.UserData?.PlayedPercentage || 0;
|
|
const playedPercentageB = b.UserData?.PlayedPercentage || 0;
|
|
return playedPercentageB - playedPercentageA;
|
|
});
|
|
|
|
// Remove any items which have no UserData.PlayedPercentage
|
|
processed = processed.filter(item => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0);
|
|
}
|
|
|
|
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,
|
|
postProcessItemsByQuery: postProcessItemsByQuery,
|
|
/**
|
|
>>>>>>> Stashed changes
|
|
* 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: rgba(255,255,255,0.12); animation: skeleton-pulse 1.5s ease-in-out infinite;';
|
|
|
|
cardScalable.appendChild(cardPadder);
|
|
cardBox.appendChild(cardScalable);
|
|
card.appendChild(cardBox);
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* Creates a skeleton spotlight section for progressive enhancement
|
|
* @param {string} title - Title for the spotlight section
|
|
* @param {Object} options - Options for the spotlight carousel
|
|
* @returns {HTMLElement} - Skeleton spotlight container
|
|
*/
|
|
function createSkeletonSpotlightSection(title, options = {}) {
|
|
const { viewMoreUrl = null } = options;
|
|
|
|
// Create main container (same structure as real spotlight)
|
|
const container = document.createElement('div');
|
|
container.className = 'spotlight-section padded-left';
|
|
|
|
// Create banner container
|
|
const bannerContainer = document.createElement('div');
|
|
bannerContainer.className = 'spotlight-banner-container';
|
|
|
|
// Add section title
|
|
if (title) {
|
|
let sectionTitleEl;
|
|
|
|
if (viewMoreUrl) {
|
|
const titleLink = document.createElement('a');
|
|
titleLink.className = 'spotlight-section-title spotlight-title-link';
|
|
titleLink.textContent = title;
|
|
titleLink.title = 'See All';
|
|
titleLink.style.textDecoration = 'none';
|
|
|
|
if (typeof viewMoreUrl === 'function') {
|
|
titleLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
viewMoreUrl();
|
|
});
|
|
} else {
|
|
titleLink.href = viewMoreUrl;
|
|
titleLink.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
}
|
|
|
|
sectionTitleEl = titleLink;
|
|
} else {
|
|
sectionTitleEl = document.createElement('div');
|
|
sectionTitleEl.className = 'spotlight-section-title';
|
|
sectionTitleEl.textContent = title;
|
|
}
|
|
|
|
bannerContainer.appendChild(sectionTitleEl);
|
|
}
|
|
|
|
// Create skeleton item (single placeholder)
|
|
const itemsContainer = document.createElement('div');
|
|
itemsContainer.className = 'spotlight-items-container';
|
|
|
|
const skeletonItem = document.createElement('div');
|
|
skeletonItem.className = 'spotlight-item skeleton-spotlight-item';
|
|
skeletonItem.setAttribute('data-index', '0');
|
|
skeletonItem.style.cssText = 'background: rgba(255,255,255,0.12); animation: skeleton-pulse 1.5s ease-in-out infinite;';
|
|
|
|
itemsContainer.appendChild(skeletonItem);
|
|
bannerContainer.appendChild(itemsContainer);
|
|
container.appendChild(bannerContainer);
|
|
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Creates a scrollable container with skeleton cards for progressive enhancement
|
|
* @param {string} title - Title for the scrollable container
|
|
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable
|
|
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square'
|
|
* @param {boolean} overflowCard - Use overflow card classes
|
|
* @returns {HTMLElement} - Container with skeleton cards
|
|
*/
|
|
function createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl = null, cardFormat = null, overflowCard = false) {
|
|
// Create the main vertical section container (same structure as createScrollableContainer)
|
|
const verticalSection = document.createElement('div');
|
|
verticalSection.className = 'verticalSection custom-scroller-container';
|
|
|
|
// Persist the card format if provided (ensures consistency for random/updates)
|
|
if (cardFormat) {
|
|
verticalSection.setAttribute('data-card-format', cardFormat);
|
|
}
|
|
|
|
// Create section title
|
|
const sectionTitleContainer = document.createElement('div');
|
|
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
|
|
|
|
if (viewMoreUrl) {
|
|
const titleLink = document.createElement('a');
|
|
titleLink.className = 'sectionTitle-link sectionTitleTextButton';
|
|
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
|
|
|
|
if (typeof viewMoreUrl === 'function') {
|
|
titleLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
viewMoreUrl();
|
|
});
|
|
} else {
|
|
titleLink.href = viewMoreUrl;
|
|
}
|
|
|
|
const titleText = document.createElement('h2');
|
|
titleText.className = 'sectionTitle sectionTitle-cards';
|
|
titleText.textContent = title;
|
|
|
|
const chevronIcon = document.createElement('span');
|
|
chevronIcon.className = 'material-icons chevron_right';
|
|
chevronIcon.setAttribute('aria-hidden', 'true');
|
|
|
|
titleLink.appendChild(titleText);
|
|
titleLink.appendChild(chevronIcon);
|
|
sectionTitleContainer.appendChild(titleLink);
|
|
} else {
|
|
const titleText = document.createElement('h2');
|
|
titleText.className = 'sectionTitle sectionTitle-cards';
|
|
titleText.textContent = title;
|
|
sectionTitleContainer.appendChild(titleText);
|
|
}
|
|
|
|
// Create scroller container
|
|
const scroller = document.createElement('div');
|
|
scroller.setAttribute('data-horizontal', 'true');
|
|
scroller.setAttribute('data-centerfocus', 'card');
|
|
scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller';
|
|
scroller.setAttribute('data-scroll-mode-x', 'custom');
|
|
scroller.style.scrollSnapType = 'none';
|
|
scroller.style.touchAction = 'auto';
|
|
scroller.style.overscrollBehaviorX = 'contain';
|
|
scroller.style.overscrollBehaviorY = 'auto';
|
|
scroller.style.webkitOverflowScrolling = 'touch';
|
|
|
|
// Create items container
|
|
const itemsContainer = document.createElement('div');
|
|
itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
|
|
itemsContainer.style.whiteSpace = 'nowrap';
|
|
|
|
// Add skeleton cards (enough to fill viewport width)
|
|
const skeletonCount = (cardFormat === 'backdrop' || cardFormat === 'thumb') ? 8 : 10;
|
|
for (let i = 0; i < skeletonCount; i++) {
|
|
const skeletonCard = createSkeletonCard(cardFormat, overflowCard);
|
|
skeletonCard.setAttribute('data-index', i);
|
|
itemsContainer.appendChild(skeletonCard);
|
|
}
|
|
|
|
scroller.appendChild(itemsContainer);
|
|
verticalSection.appendChild(sectionTitleContainer);
|
|
verticalSection.appendChild(scroller);
|
|
|
|
return verticalSection;
|
|
}
|
|
|
|
|
|
/**
|
|
* Measures the width of an element
|
|
* @param {HTMLElement} el - Element to measure
|
|
* @returns {number} - Width of the element
|
|
*/
|
|
function measure(el) {
|
|
if (!el) {
|
|
return 0;
|
|
}
|
|
|
|
|
|
let measureRoot = document.querySelector('#measure-root');
|
|
if (!measureRoot) {
|
|
measureRoot = document.createElement('div');
|
|
measureRoot.id = 'measure-root';
|
|
measureRoot.style.cssText = 'position: absolute; visibility: hidden; contain: layout style paint; white-space: nowrap; pointer-events: none;';
|
|
document.body.appendChild(measureRoot);
|
|
}
|
|
|
|
const clonedEl = el.cloneNode(true);
|
|
measureRoot.appendChild(clonedEl);
|
|
const width = clonedEl.getBoundingClientRect().width;
|
|
measureRoot.removeChild(clonedEl);
|
|
return width;
|
|
}
|
|
|
|
/**
|
|
* Checks the state of a promise
|
|
* @param {Promise} p - Promise to check
|
|
* @returns {string} - State of the promise: 'pending', 'fulfilled', or 'rejected'
|
|
*/
|
|
function promiseState(p) {
|
|
const t = {};
|
|
return Promise.race([p, t])
|
|
.then(v => (v === t)? "pending" : "fulfilled", () => "rejected");
|
|
}
|
|
|
|
// Add skeleton loading animation CSS if not already present
|
|
if (!document.getElementById('skeleton-animation-style')) {
|
|
const style = document.createElement('style');
|
|
style.id = 'skeleton-animation-style';
|
|
style.textContent = `
|
|
@keyframes skeleton-pulse {
|
|
0%, 100% { opacity: 0.5; }
|
|
50% { opacity: 1; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// Smart Lazy Image Loading with Global Observers
|
|
let lazyImageObserver = null;
|
|
let lazyMutationObserver = null;
|
|
|
|
/**
|
|
* 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');
|
|
})();
|