// Jellyfin Playlist Script // Modifies playlist view page behavior to navigate to item details instead of playing // Adds play button to playlist items // Adds sorting functionality to playlist pages // Requires: utils.js, cardBuilder.js, modal.js modules to be loaded before this script (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks Playlist]', ...args); const WARN = (...args) => console.warn('[KefinTweaks Playlist]', ...args); const ERR = (...args) => console.error('[KefinTweaks Playlist]', ...args); LOG('Initializing...'); // ========== SORTING FUNCTIONALITY ========== // localStorage key prefix const STORAGE_KEY_PREFIX_SORT = 'kefinTweaks_playlistSort_'; const STORAGE_KEY_PREFIX_DIRECTION = 'kefinTweaks_playlistSortDirection_'; // Sort options const SORT_OPTIONS = { 'default': { label: 'Default', field: 'Default', defaultDirection: 'asc' }, 'sortTitle': { label: 'Sort Title', field: 'SortName', defaultDirection: 'asc' }, 'releaseDate': { label: 'Release Date', field: 'PremiereDate', defaultDirection: 'asc' }, 'dateAdded': { label: 'Date Added', field: 'DateCreated', defaultDirection: 'desc' }, 'communityRating': { label: 'Community Rating', field: 'CommunityRating', defaultDirection: 'desc' }, 'criticRating': { label: 'Critic Rating', field: 'CriticRating', defaultDirection: 'desc' } }; // Store playlist data per page const playlistData = new Map(); // Single MutationObserver for the current playlist (only one can exist at a time) let playlistObserver = null; let ignoreNextMutation = false; /** * Get current sort preference from localStorage * @param {string} playlistId - Playlist item ID * @returns {string} Sort key */ function getCurrentSort(playlistId) { const storageKey = STORAGE_KEY_PREFIX_SORT + playlistId; const savedSort = localStorage.getItem(storageKey); if (savedSort && SORT_OPTIONS[savedSort]) { return savedSort; } return 'default'; // Default sort } /** * Get current sort direction from localStorage * @param {string} playlistId - Playlist item ID * @param {string} sortKey - Current sort key * @returns {string} 'asc' or 'desc' */ function getCurrentSortDirection(playlistId, sortKey) { const storageKey = STORAGE_KEY_PREFIX_DIRECTION + playlistId; const savedDirection = localStorage.getItem(storageKey); if (savedDirection === 'asc' || savedDirection === 'desc') { return savedDirection; } // Default to the sort option's default direction return SORT_OPTIONS[sortKey].defaultDirection; } /** * Save sort preference to localStorage * @param {string} playlistId - Playlist item ID * @param {string} sortKey - Sort key * @param {string} direction - 'asc' or 'desc' */ function saveSortPreference(playlistId, sortKey, direction) { const sortStorageKey = STORAGE_KEY_PREFIX_SORT + playlistId; const directionStorageKey = STORAGE_KEY_PREFIX_DIRECTION + playlistId; localStorage.setItem(sortStorageKey, sortKey); localStorage.setItem(directionStorageKey, direction); } /** * Fetch playlist children items with server-side sorting * @param {string} playlistId - Playlist item ID * @returns {Promise} Array of child items (already sorted by server) */ async function fetchPlaylistChildren(playlistId) { try { const userId = ApiClient.getCurrentUserId(); const response = await ApiClient.getItems(userId, { ParentId: playlistId, Fields: 'PrimaryImageAspectRatio,DateCreated,CommunityRating,CriticRating,SortName,PremiereDate,UserData' }); return response.Items || []; } catch (error) { ERR('Error fetching playlist children:', error); return []; } } /** * Sort items array based on current sort criteria * @param {Array} items - Array of item objects * @param {string} sortKey - Sort key * @param {string} direction - 'asc' or 'desc' * @returns {Array} Sorted array */ function sortItems(items, sortKey, direction) { const sortOption = SORT_OPTIONS[sortKey]; if (!sortOption) { WARN('Invalid sort key:', sortKey); return items; } if (sortKey === 'default') { return items; } const field = sortOption.field; const sorted = [...items].sort((a, b) => { let valueA = a[field]; let valueB = b[field]; // Handle null/undefined values if (valueA == null && valueB == null) return 0; if (valueA == null) return 1; if (valueB == null) return -1; // Handle dates if (field === 'PremiereDate' || field === 'DateCreated') { valueA = valueA ? new Date(valueA).getTime() : 0; valueB = valueB ? new Date(valueB).getTime() : 0; } // Handle numbers (ratings) if (field === 'CommunityRating' || field === 'CriticRating') { valueA = valueA || 0; valueB = valueB || 0; } // Handle strings (sort title) if (typeof valueA === 'string' && typeof valueB === 'string') { valueA = valueA.toLowerCase(); valueB = valueB.toLowerCase(); } // Compare values if (valueA < valueB) return direction === 'asc' ? -1 : 1; if (valueA > valueB) return direction === 'asc' ? 1 : -1; return 0; }); return sorted; } /** * Reorder DOM list items to match sorted items order * @param {HTMLElement} itemsContainer - Container with .listItem elements * @param {Array} sortedItems - Sorted array of items */ function reorderListItems(itemsContainer, sortedItems) { if (!itemsContainer) { WARN('Items container not found'); return; } const listItems = Array.from(itemsContainer.querySelectorAll('.listItem[data-playlistitemid]')); if (listItems.length === 0) { WARN('No list items found in container'); return; } // Create a map of item ID to list item element const itemMap = new Map(); listItems.forEach(listItem => { const itemId = listItem.getAttribute('data-playlistitemid'); if (itemId) { itemMap.set(itemId, listItem); } }); // Check if reordering is needed by comparing current order with desired order const currentOrder = listItems.map(item => item.getAttribute('data-playlistitemid')); const desiredOrder = sortedItems.map(item => item.Id); // If order matches, no need to reorder if (currentOrder.length === desiredOrder.length && currentOrder.every((id, index) => id === desiredOrder[index])) { LOG('List items already in correct order'); return; } // Reorder list items in-place without removing/re-adding them all let lastInserted = null; sortedItems.forEach((item) => { const listItem = itemMap.get(item.Id); if (listItem) { // Only move if not already in correct position if (lastInserted === null) { // Should be first child if (itemsContainer.firstChild !== listItem) { itemsContainer.insertBefore(listItem, itemsContainer.firstChild); } } else { // Should come right after lastInserted if (lastInserted.nextSibling !== listItem) { itemsContainer.insertBefore(listItem, lastInserted.nextSibling); } } lastInserted = listItem; } }); } /** * Sort playlist items and update DOM * @param {string} playlistId - Playlist item ID */ async function sortPlaylistItems(playlistId) { const sortedItems = await getSortedPlaylistItems(playlistId); // Reorder DOM ignoreNextMutation = true; reorderListItems(getPlaylistItemsContainer(), sortedItems); // Update play button text and "Play from beginning" button visibility in case first item changed const activePage = document.querySelector('.libraryPage:not(.hide)'); if (activePage) { const mainDetailButtons = activePage.querySelector('.mainDetailButtons'); if (mainDetailButtons) { const playButton = mainDetailButtons.querySelector('.btnPlay') || mainDetailButtons.querySelector('button[data-kt-playlist-sorting-overridden="true"]'); if (playButton) { updatePlayButtonText(playButton, playlistId, sortedItems); } // Update "Play from beginning" button visibility addResumeButton(mainDetailButtons, playlistId, sortedItems); } } } /** * Show sort selection modal * @param {string} playlistId - Playlist item ID */ function showSortModal(playlistId) { const currentSort = getCurrentSort(playlistId); const currentDirection = getCurrentSortDirection(playlistId, currentSort); // Build modal content matching collections sort modal structure const sortOptionsContent = `

Sort By

${Object.entries(SORT_OPTIONS).map(([key, option]) => { const checked = key === currentSort ? 'checked=""' : ''; return ` `; }).join('')}

Sort Order

`; // Create modal using the generic modal system const modal = window.ModalSystem.create({ id: 'kt-playlist-sort-modal', content: sortOptionsContent, onOpen: (modalInstance) => { // Set current selection const currentRadio = modalInstance.dialog.querySelector(`input[value="${currentSort}"]`); const currentDirectionRadio = modalInstance.dialog.querySelector(`input[value="${currentDirection}"]`); if (currentRadio) { currentRadio.checked = true; } if (currentDirectionRadio) { currentDirectionRadio.checked = true; } // Add event listeners for radio button changes const sortOptions = modalInstance.dialog.querySelectorAll('input[name="sortOption"]'); const sortDirections = modalInstance.dialog.querySelectorAll('input[name="sortDirection"]'); // Apply changes immediately when radio buttons change const applyChanges = () => { const selectedOption = modalInstance.dialog.querySelector('input[name="sortOption"]:checked'); const selectedDirection = modalInstance.dialog.querySelector('input[name="sortDirection"]:checked'); if (selectedOption) { const direction = selectedDirection ? selectedDirection.value : getCurrentSortDirection(playlistId, selectedOption.value); saveSortPreference(playlistId, selectedOption.value, direction); sortPlaylistItems(playlistId); } }; sortOptions.forEach(option => { option.addEventListener('change', applyChanges); }); sortDirections.forEach(direction => { direction.addEventListener('change', applyChanges); }); } }); } /** * Get sorted playlist item IDs for playback * Items are already sorted by the server via fetchPlaylistChildren * @param {string} playlistId - Playlist item ID * @param {boolean} resume - If true, filter out played items from the start * @returns {Promise>} Array of sorted item IDs */ async function getSortedPlaylistItems(playlistId, resume = false) { let items = await fetchPlaylistChildren(playlistId); if (items.length === 0) { WARN('No items found for playlist:', playlistId); return []; } const sortKey = getCurrentSort(playlistId); const direction = getCurrentSortDirection(playlistId, sortKey); // Add data-sort-key and data-sort-direction attributes to the playlist items container const itemsContainer = getPlaylistItemsContainer(); if (itemsContainer) { itemsContainer.setAttribute('data-sort-key', sortKey); itemsContainer.setAttribute('data-sort-direction', direction); } items = sortItems(items, sortKey, direction); // Items are already sorted by the server, no need to sort client-side // If resume mode, filter out played items from the start if (resume) { // Find the first unplayed item let firstUnplayedIndex = -1; for (let i = 0; i < items.length; i++) { const isPlayed = items[i].UserData?.Played === true; if (!isPlayed) { firstUnplayedIndex = i; break; } } // If we found an unplayed item, return from that index onwards (includes all remaining items) if (firstUnplayedIndex >= 0) { items = items.slice(firstUnplayedIndex); LOG(`Resume mode: Starting from item ${firstUnplayedIndex + 1} (${items.length} items remaining)`); } else { // All items are played, return empty array LOG('Resume mode: All items are already played'); return []; } } // Extract IDs return items; } /** * Update play button text to "Resume" if first item is played * @param {HTMLElement} playButton - The play button element * @param {string} playlistId - Playlist item ID */ async function updatePlayButtonText(playButton, playlistId, sortedPlaylistItems) { try { let items = sortedPlaylistItems; if (!items) { items = await getSortedPlaylistItems(playlistId); } if (items && items.length > 0) { const firstItem = items[0]; const isFirstItemPlayed = firstItem.UserData?.Played === true; if (isFirstItemPlayed) { // Update button title/text to "Resume" playButton.title = 'Resume'; // Also update any text content if present const textSpan = playButton.querySelector('.detailButton-text, .buttonText'); if (textSpan) { textSpan.textContent = 'Resume'; } LOG('First item is played, updated Play button to Resume'); } else { // Ensure it says "Play" playButton.title = playButton.title || 'Play'; const textSpan = playButton.querySelector('.detailButton-text, .buttonText'); if (textSpan) { textSpan.textContent = 'Play'; } } } } catch (error) { WARN('Error updating play button text:', error); } } /** * Play playlist items in sort order, optionally resuming from first unplayed. * @param {string} playlistId * @param {{ resume?: boolean }} [options] */ async function playPlaylistItems(playlistId, options = {}) { const resume = options.resume === true; const sortedItems = await getSortedPlaylistItems(playlistId, resume); const sortedIds = sortedItems.map(item => item.Id); if (sortedIds.length === 0) { WARN(resume ? 'No items to resume for playlist:' : 'No items to play for playlist:', playlistId); return; } LOG( resume ? `Resuming ${sortedIds.length} sorted items from playlist:` : `Playing ${sortedIds.length} items from playlist:`, playlistId ); const playOptions = {}; if (resume && sortedItems[0].UserData?.PlaybackPositionTicks > 0) { playOptions.startPositionTicks = sortedItems[0].UserData.PlaybackPositionTicks; } if (window.apiHelper && window.apiHelper.playItem) { await window.apiHelper.playItem(sortedIds, playOptions); } else if (window.ApiClient && typeof window.ApiClient.play === 'function') { await window.ApiClient.play({ ids: sortedIds, ...playOptions }); } else { ERR('No play method available'); } } /** * Override play button to use sorted playlist items * @param {HTMLElement} mainDetailButtons - Main detail buttons container * @param {string} playlistId - Playlist item ID */ function overridePlayButton(mainDetailButtons, playlistId) { // Find the play button - try multiple selectors let playButton = mainDetailButtons.querySelector('.btnPlay') || mainDetailButtons.querySelector('button[data-action="play"]'); // If not found, search for button with play icon if (!playButton) { const buttons = mainDetailButtons.querySelectorAll('button'); playButton = Array.from(buttons).find(btn => { const icon = btn.querySelector('.material-icons'); if (icon) { const iconText = icon.textContent.trim(); return iconText === 'play_arrow' || iconText === 'play'; } return false; }); } if (!playButton) { WARN('Play button not found in mainDetailButtons'); return; } // Check if already overridden if (playButton.dataset.ktPlaylistSortingOverridden === 'true') { return; } LOG('Overriding play button for playlist:', playlistId); // Forcefully clear all existing event listeners by cloning the node // This removes all event listeners attached via addEventListener const clonedButton = playButton.cloneNode(true); // Preserve important attributes and classes clonedButton.className = playButton.className; clonedButton.id = playButton.id; clonedButton.title = playButton.title; clonedButton.setAttribute('is', playButton.getAttribute('is') || ''); clonedButton.type = playButton.type || 'button'; // Remove data-action and other attributes that might trigger default handlers clonedButton.removeAttribute('data-action'); clonedButton.removeAttribute('onclick'); // Clear any onclick property clonedButton.onclick = null; // Replace the original button with the cloned one (this removes all event listeners) playButton.parentNode.replaceChild(clonedButton, playButton); // Check if first item is played and update button text accordingly updatePlayButtonText(clonedButton, playlistId); // Now add our custom click handler - Play button now defaults to Resume behavior clonedButton.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); try { await playPlaylistItems(playlistId, { resume: true }); } catch (error) { ERR('Error resuming sorted playlist:', error); } }; // Mark as overridden clonedButton.dataset.ktPlaylistSortingOverridden = 'true'; } /** * Create and add sort button to mainDetailButtons container (after Favorite button) * @param {HTMLElement} mainDetailButtons - Main detail buttons container * @param {string} playlistId - Playlist item ID */ function addSortButton(mainDetailButtons, playlistId) { // Check if button already exists if (mainDetailButtons.querySelector('.kt-playlist-sort-btn')) { return; } // Find Favorite button to insert after it const favoriteButton = mainDetailButtons.querySelector('button[data-isfavorite]') || mainDetailButtons.querySelector('button[is="emby-ratingbutton"]') || Array.from(mainDetailButtons.querySelectorAll('button')).find(btn => { const icon = btn.querySelector('.material-icons'); return icon && icon.textContent.trim() === 'favorite'; }); // Create button using emby-playstatebutton format const button = document.createElement('button'); button.setAttribute('is', 'emby-playstatebutton'); button.type = 'button'; button.className = 'button-flat btnPlaystate detailButton emby-button kt-playlist-sort-btn'; button.title = 'Sort Playlist'; // Create detailButton-content wrapper const contentWrapper = document.createElement('div'); contentWrapper.className = 'detailButton-content'; // Create icon const icon = document.createElement('span'); icon.className = 'material-icons detailButton-icon sort'; icon.setAttribute('aria-hidden', 'true'); contentWrapper.appendChild(icon); button.appendChild(contentWrapper); // Add click handler button.onclick = (e) => { e.preventDefault(); e.stopPropagation(); showSortModal(playlistId); }; // Insert after Favorite button, or append if not found if (favoriteButton && favoriteButton.nextSibling) { mainDetailButtons.insertBefore(button, favoriteButton.nextSibling); } else if (favoriteButton) { favoriteButton.insertAdjacentElement('afterend', button); } else { // Fallback: append to container mainDetailButtons.appendChild(button); } } /** * Create and add "Play from beginning" button next to play button * Only shows if the first item is already played * @param {HTMLElement} mainDetailButtons - Main detail buttons container * @param {string} playlistId - Playlist item ID */ async function addResumeButton(mainDetailButtons, playlistId, sortedPlaylistItems) { // Check if button already exists - if so, update its visibility const existingButton = mainDetailButtons.querySelector('.kt-playlist-resume-btn'); // Check if first item is played let isFirstItemPlayed = false; try { let items = sortedPlaylistItems; if (!items) { items = await getSortedPlaylistItems(playlistId); } if (items && items.length > 0) { const firstItem = items[0]; isFirstItemPlayed = firstItem.UserData?.Played === true; } } catch (error) { WARN('Error checking first item played status:', error); } // If first item is not played, hide or remove the button if (!isFirstItemPlayed) { if (existingButton) { existingButton.style.display = 'none'; } return; } // If button exists and first item is played, show it if (existingButton) { existingButton.style.display = ''; return; } // Find the play button to insert after it let playButton = mainDetailButtons.querySelector('.btnPlay') || mainDetailButtons.querySelector('button[data-action="play"]'); if (!playButton) { const buttons = mainDetailButtons.querySelectorAll('button'); playButton = Array.from(buttons).find(btn => { const icon = btn.querySelector('.material-icons'); if (icon) { const iconText = icon.textContent.trim(); return iconText === 'play_arrow' || iconText === 'play'; } return false; }); } if (!playButton) { WARN('Play button not found, cannot add play from beginning button'); return; } // Create "Play from beginning" button using emby-playstatebutton format const button = document.createElement('button'); button.setAttribute('is', 'emby-playstatebutton'); button.type = 'button'; button.className = 'button-flat btnPlaystate detailButton emby-button kt-playlist-resume-btn'; button.title = 'Play from Beginning'; // Create detailButton-content wrapper const contentWrapper = document.createElement('div'); contentWrapper.className = 'detailButton-content'; // Create icon - using refresh/replay icon for "play from beginning" const icon = document.createElement('span'); icon.className = 'material-icons detailButton-icon replay'; icon.setAttribute('aria-hidden', 'true'); contentWrapper.appendChild(icon); button.appendChild(contentWrapper); // Add click handler - play all items from beginning (no resume filtering) button.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); try { await playPlaylistItems(playlistId, { resume: false }); } catch (error) { ERR('Error playing from beginning:', error); } }; // Insert after play button if (playButton.nextSibling) { mainDetailButtons.insertBefore(button, playButton.nextSibling); } else { playButton.insertAdjacentElement('afterend', button); } } /** * Poll for an element to appear in the DOM * @param {Function} selectorFn - Function that returns the element or null * @param {number} maxWaitTime - Maximum time to wait in milliseconds (default: 5000) * @param {number} pollInterval - Interval between checks in milliseconds (default: 500) * @returns {Promise} The element if found, null otherwise */ async function pollForElement(selectorFn, maxWaitTime = 5000, pollInterval = 500) { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { const element = selectorFn(); if (element) { return element; } await new Promise(resolve => setTimeout(resolve, pollInterval)); } return null; } function getPlaylistItemsContainer() { const activePage = document.querySelector('.libraryPage:not(.hide)'); if (!activePage) { return; } let playlistItemsContainer = activePage.querySelector('.childrenItemsContainer') || activePage.querySelector('#listChildrenCollapsible .itemsContainer'); if (!playlistItemsContainer) { WARN('Playlist items container not found'); return; } return playlistItemsContainer; } /** * Re-apply sorting after container re-render * @param {string} playlistId - Playlist item ID * @param {HTMLElement} playlistItemsContainer - Playlist items container */ async function reapplyPlaylistSorting(playlistId, playlistItemsContainer) { // Find items container const itemsContainer = playlistItemsContainer.classList.contains('itemsContainer') ? playlistItemsContainer : playlistItemsContainer.querySelector('.childrenItemsContainer') || playlistItemsContainer.querySelector('#listChildrenCollapsible'); if (!itemsContainer) { WARN('Items container not found after re-render for playlist:', playlistId); return; } // Check if sort button exists in mainDetailButtons, if not add it const activePage = document.querySelector('.libraryPage:not(.hide)'); if (activePage) { const mainDetailButtons = activePage.querySelector('.mainDetailButtons'); if (mainDetailButtons) { const sortButton = mainDetailButtons.querySelector('.kt-playlist-sort-btn'); if (!sortButton) { LOG('Sort button missing after re-render, re-adding for playlist:', playlistId); addSortButton(mainDetailButtons, playlistId); } // Re-add resume button in case it was recreated await addResumeButton(mainDetailButtons, playlistId); // Re-override play button in case it was recreated overridePlayButton(mainDetailButtons, playlistId); } } sortPlaylistItems(playlistId); } /** * Setup MutationObserver for playlist items container * @param {string} playlistId - Playlist item ID * @param {HTMLElement} playlistItemsContainer - Playlist items container */ function setupPlaylistObserver(playlistId, playlistItemsContainer) { // Disconnect existing observer if any if (playlistObserver) { playlistObserver.disconnect(); playlistObserver = null; } // Debounce timer for re-apply let debounceTimer = null; playlistObserver = new MutationObserver((mutations) => { const sortKey = getCurrentSort(playlistId); let shouldReapply = false; mutations.forEach((mutation) => { if (mutation.type === 'childList') { // Check if nodes were added (indicating a re-render) if (mutation.addedNodes.length > 0) { // Check if any added node is a direct child or contains important elements mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // If listItem was added, we need to reapply if (node.classList?.contains('listItem')) { LOG('Playlist container re-rendered, re-applying sort for:', playlistId); node.setAttribute('data-action', 'link'); shouldReapply = true; } } }); } } }); if (sortKey !== 'default' && shouldReapply) { // Debounce to avoid multiple rapid re-applications clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { LOG('Playlist container re-rendered, re-applying sort for:', playlistId); reapplyPlaylistSorting(playlistId, playlistItemsContainer); }, 300); // 300ms debounce } }); // Observe the playlist items container for childList and data-action attribute changes playlistObserver.observe(playlistItemsContainer, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-action'] }); LOG('MutationObserver set up for playlist:', playlistId); } /** * Add playlist sorting functionality * @param {Object} item - Playlist item */ async function addPlaylistSorting(item) { if (!item || item.Type !== 'Playlist' || !item.Id) { return; } const playlistId = item.Id; const activePage = document.querySelector('.libraryPage:not(.hide)'); if (!activePage) { return; } // Find playlist items container let playlistItemsContainer = getPlaylistItemsContainer(); if (!playlistItemsContainer) { WARN('Playlist items container not found'); return; } // Check if already processed (but allow re-processing if button is missing) const mainDetailButtons = activePage.querySelector('.mainDetailButtons'); const sortButton = mainDetailButtons?.querySelector('.kt-playlist-sort-btn'); if (activePage.dataset.playlistSortingAdded === 'true' && sortButton) { return; } // Poll for mainDetailButtons (up to 5 seconds, every 100ms) const detailButtons = await pollForElement(() => { return activePage.querySelector('.mainDetailButtons'); }, 5000, 100); if (!detailButtons) { WARN('Main detail buttons not found after polling for 5 seconds'); return; } LOG('Adding playlist sorting for:', playlistId); // Fetch playlist children const children = await fetchPlaylistChildren(playlistId); if (children.length === 0) { WARN('No children found for playlist:', playlistId); return; } // Store playlist data playlistData.set(playlistId, { items: children, itemsContainer: playlistItemsContainer }); // Updated played item count const playedItemCount = children.filter(item => item.UserData?.Played === true).length; const itemMiscInfoPrimary = document.querySelector('.libraryPage:not(.hide) .itemMiscInfo-primary'); if (itemMiscInfoPrimary && playedItemCount > 0) { const isAudioPlaylist = children.every(item => item.MediaType === 'Audio'); const isBookPlaylist = children.every(item => item.MediaType === 'Book'); const progressText = isAudioPlaylist ? 'played' : isBookPlaylist ? 'read' : 'watched'; const incompleteText = isAudioPlaylist ? 'unplayed' : isBookPlaylist ? 'unread' : 'unwatched'; // Add CSS Var --played-item-count itemMiscInfoPrimary.dataset.playedItemCount = playedItemCount; itemMiscInfoPrimary.dataset.totalItemCount = children.length; const mediaInfoItem = document.createElement('div'); mediaInfoItem.className = 'mediaInfoItem'; mediaInfoItem.textContent = `${playedItemCount} ${progressText}`; itemMiscInfoPrimary.appendChild(mediaInfoItem); const mediaInfoItemIncomplete = document.createElement('div'); mediaInfoItemIncomplete.className = 'mediaInfoItem'; mediaInfoItemIncomplete.textContent = `${children.length - playedItemCount} ${incompleteText}`; itemMiscInfoPrimary.appendChild(mediaInfoItemIncomplete); } // Add sort button (only if it doesn't exist) if (!sortButton) { addSortButton(detailButtons, playlistId); } // Add resume button (only if it doesn't exist) await addResumeButton(detailButtons, playlistId); // Override play button to use sorted items overridePlayButton(detailButtons, playlistId); // Apply initial sort on page load sortPlaylistItems(playlistId); // Setup MutationObserver to handle re-renders setupPlaylistObserver(playlistId, playlistItemsContainer); // Mark as processed activePage.dataset.playlistSortingAdded = 'true'; } /** * Modifies playlist item click behavior to navigate to details page */ function modifyPlaylistItemClicks() { const libraryPage = document.querySelector('.libraryPage:not(.hide)'); if (!libraryPage) { WARN('Library page not found'); return; } let childrenItemsContainer = getPlaylistItemsContainer(); if (!childrenItemsContainer) { WARN('Children items container not found'); return; } const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); LOG(`Found ${playlistItems.length} playlist items`); playlistItems.forEach((item, index) => { const playlistItemId = item.getAttribute('data-playlistitemid'); if (!playlistItemId) { WARN(`Playlist item ${index} has no data-playlistitemid`); return; } item.setAttribute('data-action', 'link'); item.style.cursor = 'pointer'; }); } /** * Adds play button to each playlist item's listViewUserDataButtons container */ function addPlayButtons() { const libraryPage = document.querySelector('.libraryPage:not(.hide)'); if (!libraryPage) { WARN('Library page not found for play buttons'); return; } let childrenItemsContainer = getPlaylistItemsContainer(); if (!childrenItemsContainer) { WARN('Children items container not found for play buttons'); return; } const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); LOG(`Found ${playlistItems.length} playlist items to add play buttons to`); playlistItems.forEach((item, index) => { const playlistItemId = item.getAttribute('data-playlistitemid'); if (!playlistItemId) { WARN(`Playlist item ${index} has no data-playlistitemid`); return; } const buttonsContainer = item.querySelector('.listViewUserDataButtons'); if (!buttonsContainer) { WARN(`No listViewUserDataButtons container found for playlist item ${index}`); return; } // Check if play button already exists for this item const existingPlayButton = buttonsContainer.querySelector('.playlist-play-button'); if (existingPlayButton) { LOG(`Play button already exists for item ${index}`); return; } // Create play button following the same pattern as other buttons // Play button const playButton = document.createElement('button'); playButton.setAttribute('is', 'paper-icon-button-light'); playButton.className = 'itemAction paper-icon-button-light playBtn playlist-play-button'; // Don't use data-action="resume" - we'll handle it ourselves const playIcon = document.createElement('span'); playIcon.className = 'material-icons play_arrow'; playIcon.setAttribute('aria-hidden', 'true'); playButton.appendChild(playIcon); // Add custom click handler to queue next 200 items playButton.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); try { // Get the clicked item's data-id const clickedItemId = playlistItemId; // Get all playlist items in the container const allPlaylistItems = Array.from(childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]')); // Find the index of the clicked item const clickedIndex = allPlaylistItems.findIndex(listItem => { return listItem.getAttribute('data-playlistitemid') === clickedItemId; }); if (clickedIndex === -1) { WARN(`Could not find clicked item in playlist: ${clickedItemId}`); return; } // Get the next 200 items (including the clicked one) const itemsToQueue = allPlaylistItems.slice(clickedIndex, clickedIndex + 200); // Extract item IDs const itemIds = itemsToQueue.map(listItem => listItem.getAttribute('data-playlistitemid')).filter(id => id); if (itemIds.length === 0) { WARN('No item IDs found to queue'); return; } const options = {}; const positionTicks = item.getAttribute('data-positionticks'); if (positionTicks) { options.startPositionTicks = positionTicks; } LOG(`Queueing ${itemIds.length} items starting from index ${clickedIndex} (item: ${clickedItemId})`); // Use apiHelper if available, otherwise fallback to ApiClient if (window.apiHelper && window.apiHelper.playItem) { await window.apiHelper.playItem(itemIds, options); } else if (window.ApiClient && typeof window.ApiClient.play === 'function') { await window.ApiClient.play({ ids: itemIds }); } else { ERR('No play method available'); } } catch (error) { ERR('Error queueing playlist items:', error); } }); // Prepend the play button to the container buttonsContainer.insertBefore(playButton, buttonsContainer.firstChild); }); LOG(`Added play buttons to ${playlistItems.length} playlist items`); } /** * Main function to modify the playlist page * @param {string} itemId - The playlist item ID */ async function modifyPlaylistPage() { if (document.querySelector('.libraryPage:not(.hide)')?.dataset?.kefinPlaylist === 'true') { LOG('Playlist page already modified, skipping'); return; } // Poll for page elements to be ready (every 100ms for up to 10 seconds) const pollInterval = 100; const maxAttempts = 100; // 100ms * 100 = 10 seconds let attempts = 0; const pollForElements = () => { attempts++; const libraryPage = document.querySelector('.libraryPage:not(.hide)'); if (!libraryPage) { if (attempts < maxAttempts) { setTimeout(pollForElements, pollInterval); } else { WARN('Library page not found after 10 seconds'); } return; } let childrenItemsContainer = getPlaylistItemsContainer(); if (!childrenItemsContainer) { WARN('Children items container not found after 10 seconds'); return; } const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); if (playlistItems.length === 0) { if (attempts < maxAttempts) { setTimeout(pollForElements, pollInterval); } else { WARN('No playlist items found after 10 seconds'); } return; } if (libraryPage.dataset.kefinPlaylist === 'true') { LOG('Playlist page already modified, skipping'); return; } // Elements found, proceed with modifications libraryPage.dataset.kefinPlaylist = 'true'; modifyPlaylistItemClicks(); addPlayButtons(); LOG('Playlist page modifications complete'); }; pollForElements(); } function addPlaylistCSS() { // Check if the CSS is already added if (document.querySelector('style[data-kefin-playlist-css]')) { return; } const css = ` [data-kefin-playlist="true"]:has(#listChildrenCollapsible[data-sort-key]:not([data-sort-key="default"])) .listViewDragHandle, [data-kefin-playlist="true"]:has(.childrenItemsContainer[data-sort-key]:not([data-sort-key="default"])) .listViewDragHandle { display: none; } `; const style = document.createElement('style'); style.setAttribute('data-kefin-playlist-css', 'true'); style.textContent = css; document.head.appendChild(style); } /** * Override overlay Play on Playlist cards so play resumes mid-playlist. * @param {HTMLElement} overlayContainer */ function overridePlaylistCardPlayButton(overlayContainer) { if (!overlayContainer) return; const card = overlayContainer.closest('.card'); if (!card || card.getAttribute('data-type') !== 'Playlist') return; const playlistId = card.getAttribute('data-id'); if (!playlistId) { WARN('Playlist card missing data-id'); return; } let playButton = overlayContainer.querySelector('.cardOverlayFab-primary') || overlayContainer.querySelector('button[data-action="resume"]') || overlayContainer.querySelector('button[data-action="play"]'); if (!playButton) { const buttons = overlayContainer.querySelectorAll('button'); playButton = Array.from(buttons).find(btn => { const icon = btn.querySelector('.material-icons'); if (!icon) return false; const iconText = icon.textContent.trim(); return iconText === 'play_arrow' || iconText === 'play'; }); } if (!playButton) return; if (playButton.dataset.ktPlaylistCardOverridden === 'true') return; LOG('Overriding card overlay play for playlist:', playlistId); const clonedButton = playButton.cloneNode(true); clonedButton.className = playButton.className; clonedButton.id = playButton.id; clonedButton.title = 'Resume'; clonedButton.setAttribute('is', playButton.getAttribute('is') || ''); clonedButton.type = playButton.type || 'button'; clonedButton.removeAttribute('data-action'); clonedButton.removeAttribute('onclick'); clonedButton.onclick = null; playButton.parentNode.replaceChild(clonedButton, playButton); clonedButton.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); try { await playPlaylistItems(playlistId, { resume: true }); } catch (error) { ERR('Error resuming playlist from card:', error); } }; clonedButton.dataset.ktPlaylistCardOverridden = 'true'; } function processExistingPlaylistCardOverlays() { document.querySelectorAll('.cardOverlayContainer').forEach((overlayContainer) => { overridePlaylistCardPlayButton(overlayContainer); }); } function setupPlaylistCardPlayObserver() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type !== 'childList') return; mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.classList && node.classList.contains('cardOverlayContainer')) { overridePlaylistCardPlayButton(node); } const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer'); if (overlayContainers && overlayContainers.length > 0) { overlayContainers.forEach((overlayContainer) => { overridePlaylistCardPlayButton(overlayContainer); }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); processExistingPlaylistCardOverlays(); } /** * Initialize playlist hook using utils */ function initializePlaylistHook() { if (!window.KefinTweaksUtils) { WARN('KefinTweaksUtils not available, retrying in 1 second'); setTimeout(initializePlaylistHook, 1000); return; } addPlaylistCSS(); LOG('Registering playlist handler with KefinTweaksUtils'); setupPlaylistCardPlayObserver(); // Register handler for details pages window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => { // Await the item promise to get the actual item data const testItem = Emby.Page.promiseShow ? await Emby.Page.promiseShow() : null; const item = await itemPromise; if (item && item.Type === 'Playlist') { // Run both original functionality and sorting functionality modifyPlaylistPage(); await addPlaylistSorting(item); // Small delay to ensure details DOM is ready for sorting /* setTimeout(async () => { if (item && item.Id) { await addPlaylistSorting(item); } }, 100); return; */ } }, { pages: ['details'] }); LOG('Playlist hook initialized successfully'); } // Initialize the hook when the script loads initializePlaylistHook(); console.log('[KefinTweaks Playlist] Module loaded and ready'); })();