mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
757 lines
36 KiB
JavaScript
757 lines
36 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';
|
|
|
|
// Main card builder object
|
|
const cardBuilder = {
|
|
/**
|
|
* Main entry point function to build a Jellyfin card
|
|
* @param {Object} item - The Jellyfin item object
|
|
* @returns {HTMLElement} - The constructed card element
|
|
*/
|
|
buildCard: function(item) {
|
|
return createJellyfinCardElement(item);
|
|
},
|
|
|
|
/**
|
|
* 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 {number} sectionIndex - Index for debugging
|
|
* @returns {HTMLElement} - The constructed scrollable container
|
|
*/
|
|
renderCards: function(items, title, viewMoreUrl = null, sectionIndex = 0) {
|
|
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
|
|
},
|
|
|
|
/**
|
|
* 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 {number} sectionIndex - Index for debugging
|
|
* @returns {Promise<HTMLElement>} - The constructed scrollable container
|
|
*/
|
|
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, sectionIndex = 0) {
|
|
const LOG = (...args) => console.log('[CardBuilder]', ...args);
|
|
|
|
if (!itemIds || itemIds.length === 0) {
|
|
LOG(`No item IDs provided for section: ${title}`);
|
|
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
|
|
}
|
|
|
|
try {
|
|
// Fetch all items at once
|
|
const items = await Promise.all(
|
|
itemIds.map(id => ApiClient.getItem(ApiClient.getCurrentUserId(), id))
|
|
);
|
|
|
|
LOG(`Fetched ${items.length} items for section: ${title}`);
|
|
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
|
|
} catch (error) {
|
|
console.error('[CardBuilder] Error fetching items:', error);
|
|
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates a Jellyfin card element from an item
|
|
* @param {Object} item - The Jellyfin item object
|
|
* @returns {HTMLElement} - The constructed card element
|
|
*/
|
|
function createJellyfinCardElement(item) {
|
|
const serverId = ApiClient.serverId();
|
|
const serverAddress = ApiClient.serverAddress();
|
|
|
|
// Determine card type based on item type
|
|
let cardClass, padderClass, imageParams;
|
|
if (item.Type === 'Episode' || item.Type === 'TvChannel') {
|
|
cardClass = 'overflowBackdropCard';
|
|
padderClass = 'cardPadder-overflowBackdrop';
|
|
imageParams = 'fillHeight=267&fillWidth=474';
|
|
} else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) {
|
|
cardClass = 'overflowSquareCard';
|
|
padderClass = 'cardPadder-overflowSquare';
|
|
imageParams = 'fillHeight=297&fillWidth=297';
|
|
} else {
|
|
// Default poster style for Movies, Series, etc.
|
|
cardClass = 'overflowPortraitCard';
|
|
padderClass = 'cardPadder-overflowPortrait';
|
|
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' : '');
|
|
|
|
// 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 = `#/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');
|
|
|
|
// Set background image or icon
|
|
if (item.ImageTags?.Primary) {
|
|
let imageUrl;
|
|
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}`;
|
|
}
|
|
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
|
} else if (item.ImageTags?.Thumb) {
|
|
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
|
|
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
|
} else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) {
|
|
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
|
|
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
|
} else if ((item.Type === 'Episode') && item.ParentThumbImageTag) {
|
|
const imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
|
|
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
|
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
|
|
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
|
|
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
|
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
|
|
const imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
|
|
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
|
} 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);
|
|
}
|
|
|
|
// 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 = `#/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);
|
|
|
|
// 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 = `#/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 = `#/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 = `#/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.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 = `#/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);
|
|
} else {
|
|
const secondaryText = document.createElement('div');
|
|
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
|
|
const yearBdi = document.createElement('bdi');
|
|
yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
|
|
secondaryText.appendChild(yearBdi);
|
|
cardBox.appendChild(secondaryText);
|
|
}
|
|
|
|
card.appendChild(cardBox);
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* 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 {number} sectionIndex - Index for debugging
|
|
* @returns {HTMLElement} - The constructed scrollable container
|
|
*/
|
|
function createScrollableContainer(items, title, viewMoreUrl = null, sectionIndex = 0) {
|
|
const LOG = (...args) => console.log('[CardBuilder]', ...args);
|
|
|
|
// Create the main vertical section container
|
|
const verticalSection = document.createElement('div');
|
|
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
|
|
|
|
// Create section title
|
|
const sectionTitle = document.createElement('h2');
|
|
sectionTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right';
|
|
|
|
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('span');
|
|
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);
|
|
sectionTitle.appendChild(titleLink);
|
|
} else {
|
|
// Regular non-clickable title
|
|
sectionTitle.textContent = title;
|
|
}
|
|
|
|
// Create "Show All" button (will be added after items are created)
|
|
const showAllButton = document.createElement('button');
|
|
showAllButton.type = 'button';
|
|
showAllButton.className = 'show-all-button';
|
|
showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer;';
|
|
showAllButton.textContent = 'Show All';
|
|
showAllButton.title = 'Show all items in a grid layout';
|
|
|
|
// Create scroll buttons container
|
|
const scrollButtons = document.createElement('div');
|
|
//scrollButtons.setAttribute('is', 'emby-scrollbuttons');
|
|
scrollButtons.className = 'emby-scrollbuttons custom-scrollbuttons padded-right';
|
|
|
|
// Previous button
|
|
const prevButton = document.createElement('button');
|
|
prevButton.type = 'button';
|
|
prevButton.setAttribute('is', 'paper-icon-button-light');
|
|
prevButton.setAttribute('data-ripple', 'false');
|
|
prevButton.setAttribute('data-direction', 'left');
|
|
prevButton.title = 'Previous';
|
|
prevButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
|
|
prevButton.disabled = true;
|
|
|
|
const prevIcon = document.createElement('span');
|
|
prevIcon.className = 'material-icons chevron_left';
|
|
prevIcon.setAttribute('aria-hidden', 'true');
|
|
prevButton.appendChild(prevIcon);
|
|
|
|
// Next button
|
|
const nextButton = document.createElement('button');
|
|
nextButton.type = 'button';
|
|
nextButton.setAttribute('is', 'paper-icon-button-light');
|
|
nextButton.setAttribute('data-ripple', 'false');
|
|
nextButton.setAttribute('data-direction', 'right');
|
|
nextButton.title = 'Next';
|
|
nextButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
|
|
|
|
const nextIcon = document.createElement('span');
|
|
nextIcon.className = 'material-icons chevron_right';
|
|
nextIcon.setAttribute('aria-hidden', 'true');
|
|
nextButton.appendChild(nextIcon);
|
|
|
|
scrollButtons.appendChild(prevButton);
|
|
scrollButtons.appendChild(nextButton);
|
|
|
|
// 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');
|
|
scroller.style.overflow = 'hidden';
|
|
|
|
// Create items container
|
|
const itemsContainer = document.createElement('div');
|
|
itemsContainer.setAttribute('is', 'emby-itemscontainer');
|
|
itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
|
|
itemsContainer.style.whiteSpace = 'nowrap';
|
|
itemsContainer.style.willChange = 'transform';
|
|
itemsContainer.style.transition = 'transform 270ms ease-out';
|
|
itemsContainer.style.transform = 'translateX(0px)';
|
|
|
|
// Add items to container
|
|
items.forEach((item, index) => {
|
|
const card = createJellyfinCardElement(item);
|
|
card.setAttribute('data-index', index);
|
|
itemsContainer.appendChild(card);
|
|
});
|
|
|
|
scroller.appendChild(itemsContainer);
|
|
|
|
// Add "Show All" button to title if there are more than 20 items
|
|
if (items.length > 20) {
|
|
sectionTitle.appendChild(showAllButton);
|
|
}
|
|
|
|
// Toggle between scroll and grid view
|
|
let isShowingAll = false;
|
|
const originalItemsContainerStyle = itemsContainer.style.cssText;
|
|
|
|
showAllButton.addEventListener('click', () => {
|
|
if (isShowingAll) {
|
|
// Switch back to scroll view
|
|
itemsContainer.style.cssText = originalItemsContainerStyle;
|
|
scrollButtons.style.display = '';
|
|
showAllButton.textContent = 'Show All';
|
|
showAllButton.title = 'Show all items in a grid layout';
|
|
isShowingAll = false;
|
|
} else {
|
|
// Switch to grid view
|
|
itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;';
|
|
scrollButtons.style.display = 'none';
|
|
showAllButton.textContent = 'Show Less';
|
|
showAllButton.title = 'Show items in scrollable layout';
|
|
isShowingAll = true;
|
|
}
|
|
});
|
|
|
|
// Scroll functionality - calculate dynamically when needed
|
|
let scrollPosition = 0;
|
|
|
|
// Calculate scroll values dynamically based on current DOM state
|
|
const calculateScrollValues = () => {
|
|
const firstCard = itemsContainer.querySelector('.card');
|
|
if (!firstCard) {
|
|
LOG(`[SCROLL ERROR] No cards found in scroller ${sectionIndex}`);
|
|
return { cardWidth: 212, visibleCards: 1, scrollStep: 1, maxScroll: 0 };
|
|
}
|
|
|
|
const cardRect = firstCard.getBoundingClientRect();
|
|
const computedStyle = window.getComputedStyle(firstCard);
|
|
const marginRight = parseFloat(computedStyle.marginRight) || 0;
|
|
const cardWidth = cardRect.width + marginRight; // Card width + gap
|
|
|
|
const visibleCards = Math.floor(scroller.offsetWidth / cardWidth);
|
|
const scrollStep = Math.max(1, visibleCards); // Scroll by the number of fully visible cards
|
|
const maxScroll = Math.max(0, items.length - visibleCards);
|
|
|
|
LOG(`[SCROLL CALC] Scroller ${sectionIndex}: containerWidth=${scroller.offsetWidth}, cardWidth=${cardWidth}, visibleCards=${visibleCards}, scrollStep=${scrollStep}, totalItems=${items.length}, maxScroll=${maxScroll}`);
|
|
|
|
return { cardWidth, visibleCards, scrollStep, maxScroll };
|
|
};
|
|
|
|
const updateScrollButtons = () => {
|
|
const { maxScroll } = calculateScrollValues();
|
|
prevButton.disabled = scrollPosition <= 0;
|
|
nextButton.disabled = scrollPosition >= maxScroll;
|
|
};
|
|
|
|
const scrollTo = (position, smooth = true) => {
|
|
const { cardWidth, maxScroll } = calculateScrollValues();
|
|
|
|
scrollPosition = Math.max(0, Math.min(position, maxScroll));
|
|
|
|
if (smooth) {
|
|
itemsContainer.style.transition = 'transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
|
} else {
|
|
itemsContainer.style.transition = 'none';
|
|
}
|
|
|
|
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
|
|
updateScrollButtons();
|
|
|
|
if (smooth) {
|
|
setTimeout(() => {
|
|
itemsContainer.style.transition = 'transform 270ms ease-out';
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
// Check if buttons already have event listeners (from Jellyfin's default handlers)
|
|
const hasExistingListeners = () => {
|
|
// Check if the buttons have any event listeners attached
|
|
// We'll look for the presence of Jellyfin's default scroll behavior
|
|
const existingButtons = document.querySelectorAll('.emby-scrollbuttons-button.paper-icon-button-light');
|
|
return existingButtons.length > 0 && existingButtons[0].onclick !== null;
|
|
};
|
|
|
|
// Native horizontal scrolling with momentum and snap
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
let touchCurrentX = 0;
|
|
let isDragging = false;
|
|
let startScrollPosition = 0;
|
|
let currentScrollOffset = 0;
|
|
let lastTouchTime = 0;
|
|
let velocity = 0;
|
|
let lastTouchX = 0;
|
|
let animationFrame = null;
|
|
let momentumAnimation = null;
|
|
|
|
const handleTouchStart = (e) => {
|
|
if (e.touches.length !== 1) return;
|
|
|
|
// Cancel any ongoing momentum animation
|
|
if (momentumAnimation) {
|
|
cancelAnimationFrame(momentumAnimation);
|
|
momentumAnimation = null;
|
|
}
|
|
|
|
touchStartX = e.touches[0].clientX;
|
|
touchStartY = e.touches[0].clientY;
|
|
lastTouchX = touchStartX;
|
|
lastTouchTime = Date.now();
|
|
isDragging = true;
|
|
startScrollPosition = scrollPosition;
|
|
currentScrollOffset = 0;
|
|
velocity = 0;
|
|
|
|
// Disable smooth transitions during drag
|
|
itemsContainer.style.transition = 'none';
|
|
|
|
// Don't prevent default here - let touch move determine if we should handle it
|
|
};
|
|
|
|
const handleTouchMove = (e) => {
|
|
if (!isDragging || e.touches.length !== 1) return;
|
|
|
|
touchCurrentX = e.touches[0].clientX;
|
|
const deltaX = touchStartX - touchCurrentX;
|
|
const deltaY = touchStartY - e.touches[0].clientY;
|
|
|
|
// Only process horizontal swipes
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
|
|
e.preventDefault(); // Prevent default scrolling behavior
|
|
|
|
// Calculate velocity for momentum
|
|
const currentTime = Date.now();
|
|
const timeDelta = currentTime - lastTouchTime;
|
|
if (timeDelta > 0) {
|
|
velocity = (lastTouchX - touchCurrentX) / timeDelta;
|
|
}
|
|
lastTouchX = touchCurrentX;
|
|
lastTouchTime = currentTime;
|
|
|
|
// Update scroll position in real-time
|
|
const { cardWidth } = calculateScrollValues();
|
|
currentScrollOffset = deltaX / cardWidth;
|
|
const newPosition = startScrollPosition + currentScrollOffset;
|
|
|
|
// Apply the transform immediately
|
|
itemsContainer.style.transform = `translateX(-${newPosition * cardWidth}px)`;
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = (e) => {
|
|
if (!isDragging) return;
|
|
|
|
isDragging = false;
|
|
|
|
// Calculate final position with momentum
|
|
const { cardWidth, maxScroll } = calculateScrollValues();
|
|
let finalPosition = startScrollPosition + currentScrollOffset;
|
|
|
|
// Apply momentum if velocity is significant
|
|
if (Math.abs(velocity) > 0.1) {
|
|
const momentumDistance = velocity * 200; // Adjust multiplier for momentum strength
|
|
finalPosition += momentumDistance / cardWidth;
|
|
}
|
|
|
|
// Clamp to bounds
|
|
finalPosition = Math.max(0, Math.min(finalPosition, maxScroll));
|
|
|
|
// Snap to nearest card
|
|
const snapPosition = Math.round(finalPosition);
|
|
finalPosition = Math.max(0, Math.min(snapPosition, maxScroll));
|
|
|
|
// Update scroll position and animate to final position
|
|
scrollPosition = finalPosition;
|
|
|
|
// Re-enable smooth transitions
|
|
itemsContainer.style.transition = 'transform 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
|
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
|
|
|
|
// Update button states
|
|
updateScrollButtons();
|
|
|
|
LOG(`[TOUCH END] Scroller ${sectionIndex}: velocity=${velocity.toFixed(3)}, finalPosition=${finalPosition}, snapPosition=${snapPosition}`);
|
|
|
|
// Reset velocity
|
|
velocity = 0;
|
|
currentScrollOffset = 0;
|
|
};
|
|
|
|
// Add touch event listeners to the scroller
|
|
scroller.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
scroller.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
scroller.addEventListener('touchend', handleTouchEnd, { passive: false });
|
|
scroller.addEventListener('touchcancel', handleTouchEnd, { passive: false });
|
|
|
|
// Only attach our event listeners if there are no existing ones
|
|
if (true) {
|
|
prevButton.addEventListener('click', () => {
|
|
const { scrollStep, maxScroll } = calculateScrollValues();
|
|
const newPosition = Math.max(0, scrollPosition - scrollStep);
|
|
LOG(`[PREV CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
|
|
scrollTo(newPosition, true);
|
|
});
|
|
|
|
nextButton.addEventListener('click', () => {
|
|
const { scrollStep, maxScroll } = calculateScrollValues();
|
|
const newPosition = Math.min(maxScroll, scrollPosition + scrollStep);
|
|
LOG(`[NEXT CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
|
|
scrollTo(newPosition, true);
|
|
});
|
|
}
|
|
|
|
// Initial state
|
|
updateScrollButtons();
|
|
|
|
// Assemble the section
|
|
verticalSection.appendChild(sectionTitle);
|
|
verticalSection.appendChild(scrollButtons);
|
|
verticalSection.appendChild(scroller);
|
|
|
|
return verticalSection;
|
|
}
|
|
|
|
// Add CSS to hide emby-scrollbuttons that aren't custom-scrollbuttons and improve touch experience
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
/* Native horizontal scrolling experience - ONLY for our custom scrollers */
|
|
|
|
|
|
/* Ensure smooth scrolling on mobile */
|
|
@media (max-width: 768px) {
|
|
.emby-scroller.custom-scroller {
|
|
scroll-behavior: smooth !important;
|
|
}
|
|
.custom-scroller-container .emby-scrollbuttons {
|
|
display: none;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Expose the cardBuilder to the global window object
|
|
window.cardBuilder = cardBuilder;
|
|
|
|
console.log('[CardBuilder] Module loaded and available at window.cardBuilder');
|
|
})();
|