From 4ed3e227e05385f5847a0eacc9cdba8beea7c9bc Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Wed, 26 Nov 2025 02:45:19 -0500 Subject: [PATCH] Improve Playlist UX for resuming playback and fix issue with long URI --- scripts/apiHelper.js | 89 +++++++++++++++++++- scripts/playlist.js | 192 ++++++++++++++++++++++++++++++++----------- 2 files changed, 231 insertions(+), 50 deletions(-) diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 282c00d..c08ff5e 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -59,6 +59,8 @@ throw new Error('No active session found for device'); } + + const MAX_PLAY_NOW_IDS = 200; /** * Sends a PlayNow command directly to the active session @@ -71,10 +73,12 @@ const token = ApiClient.accessToken(); const serverUrl = ApiClient.serverAddress(); + + const limitedIds = ids.slice(0, MAX_PLAY_NOW_IDS); const params = new URLSearchParams({ playCommand: options.playCommand || 'PlayNow', - itemIds: ids.join(',') + itemIds: limitedIds.join(',') }); if (options.startPositionTicks !== undefined) { @@ -98,7 +102,7 @@ const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { method: 'POST', headers: { - 'X-Emby-Token': token + 'X-Emby-Token': token, } }); @@ -106,6 +110,35 @@ throw new Error(`PlayNow request failed with HTTP ${response.status}: ${response.statusText}`); } } + + async function sendPlayLast(ids) { + const session = await getActiveSession(); + const sessionId = session.Id; + + const token = ApiClient.accessToken(); + const serverUrl = ApiClient.serverAddress(); + + const params = new URLSearchParams({ + playCommand: 'PlayLast', + itemIds: ids.join(',') + }); + + LOG('Sending PlayLast command:', { + sessionId, + params: params.toString() + }); + + const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { + method: 'POST', + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`PlayLast request failed with HTTP ${response.status}: ${response.statusText}`); + } + } /** * Query cache for API responses @@ -411,6 +444,58 @@ // Last resort: Navigate to item details page const itemUrl = `/web/#/details?id=${ids[0]}&serverId=${ApiClient.serverId()}`; window.location.href = `${ApiClient.serverAddress()}${itemUrl}`; + }, + + /** + * Updates the NowPlayingQueue for the active session + * @param {Array} itemIds - Array of item IDs to set as the queue + * @returns {Promise} - Promise that resolves when queue is updated + */ + updateQueue: async function(itemIds) { + if (!Array.isArray(itemIds) || itemIds.length === 0) { + throw new Error('Item IDs array is required and cannot be empty'); + } + + ensureApiClient(); + + const session = await getActiveSession(); + const sessionId = session?.Id; + + if (!sessionId) { + throw new Error('No active session found'); + } + + const token = ApiClient.accessToken(); + const serverUrl = ApiClient.serverAddress(); + + // Build NowPlayingQueue array - each item needs at least an Id + const nowPlayingQueue = itemIds.map(id => ({ + Id: id + })); + + const payload = { + NowPlayingQueue: nowPlayingQueue + }; + + LOG('Updating playback queue:', { + itemCount: itemIds.length + }); + + const response = await fetch(`${serverUrl}/Sessions/Playing`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `MediaBrowser Token="${token}"` + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`Queue update failed with HTTP ${response.status}: ${errorText}`); + } + + LOG('Queue updated successfully'); } }; diff --git a/scripts/playlist.js b/scripts/playlist.js index bf88722..c208df6 100644 --- a/scripts/playlist.js +++ b/scripts/playlist.js @@ -79,16 +79,30 @@ } /** - * Fetch playlist children items + * Fetch playlist children items with server-side sorting * @param {string} playlistId - Playlist item ID - * @returns {Promise} Array of child items + * @returns {Promise} Array of child items (already sorted by server) */ async function fetchPlaylistChildren(playlistId) { try { const userId = ApiClient.getCurrentUserId(); + + // Get current sort preferences + const sortKey = getCurrentSort(playlistId); + const direction = getCurrentSortDirection(playlistId, sortKey); + + // Map sort key to Jellyfin API SortBy parameter + const sortOption = SORT_OPTIONS[sortKey]; + const sortBy = sortOption ? sortOption.field : 'SortName'; + + // Map direction to Jellyfin API SortOrder parameter + const sortOrder = direction === 'asc' ? 'Ascending' : 'Descending'; + const response = await ApiClient.getItems(userId, { ParentId: playlistId, - Fields: 'PrimaryImageAspectRatio,DateCreated,CommunityRating,CriticRating,SortName,PremiereDate,UserData' + Fields: 'PrimaryImageAspectRatio,DateCreated,CommunityRating,CriticRating,SortName,PremiereDate,UserData', + SortBy: sortBy, + SortOrder: sortOrder }); return response.Items || []; } catch (error) { @@ -231,6 +245,21 @@ // Reorder DOM reorderListItems(data.itemsContainer, 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); + } + // Update "Play from beginning" button visibility + addResumeButton(mainDetailButtons, playlistId); + } + } } /** @@ -347,6 +376,7 @@ /** * 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 @@ -356,7 +386,7 @@ const data = playlistData.get(playlistId); let items = data?.items; - // If no cached data, fetch it + // If no cached data, fetch it (already sorted by server) if (!items || items.length === 0) { items = await fetchPlaylistChildren(playlistId); if (items.length === 0) { @@ -365,19 +395,14 @@ } } - // Get current sort preferences - const sortKey = getCurrentSort(playlistId); - const direction = getCurrentSortDirection(playlistId, sortKey); - - // Sort items - let sortedItems = 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 < sortedItems.length; i++) { - const isPlayed = sortedItems[i].UserData?.Played === true; + for (let i = 0; i < items.length; i++) { + const isPlayed = items[i].UserData?.Played === true; if (!isPlayed) { firstUnplayedIndex = i; break; @@ -386,8 +411,8 @@ // If we found an unplayed item, return from that index onwards (includes all remaining items) if (firstUnplayedIndex >= 0) { - sortedItems = sortedItems.slice(firstUnplayedIndex); - LOG(`Resume mode: Starting from item ${firstUnplayedIndex + 1} (${sortedItems.length} items remaining)`); + 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'); @@ -395,11 +420,54 @@ } } - // Limit to 300 items max - sortedItems = sortedItems.slice(0, 300); - // Extract IDs - return sortedItems.map(item => item.Id); + return items.map(item => item.Id); + } + + /** + * 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) { + try { + // Check if we have cached data + const data = playlistData.get(playlistId); + let items = data?.items; + + // If no cached data, fetch it (already sorted by server) + if (!items || items.length === 0) { + items = await fetchPlaylistChildren(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); + } } /** @@ -458,39 +526,35 @@ // Replace the original button with the cloned one (this removes all event listeners) playButton.parentNode.replaceChild(clonedButton, playButton); - // Now add our custom click handler + // 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(); // Prevent any other handlers try { - // Get sorted item IDs - const sortedIds = await getSortedPlaylistItemIds(playlistId); + // Get sorted item IDs with resume mode (skip played items from start) + const sortedIds = await getSortedPlaylistItemIds(playlistId, true); if (sortedIds.length === 0) { - WARN('No items to play for playlist:', playlistId); + WARN('No items to resume for playlist:', playlistId); return; } - LOG(`Playing ${sortedIds.length} sorted items from playlist:`, playlistId); + LOG(`Resuming ${sortedIds.length} sorted items from playlist:`, playlistId); // Use apiHelper if available, otherwise fallback to ApiClient if (window.apiHelper && window.apiHelper.playItem) { await window.apiHelper.playItem(sortedIds); - } else if (window.ApiClient && typeof window.ApiClient.play === 'function') { - await window.ApiClient.play({ ids: sortedIds }); - } else if (window.PlaybackManager && window.PlaybackManager.play) { - await window.PlaybackManager.play({ - ids: sortedIds, - serverId: window.ApiClient.serverId() - }); } else { ERR('No play method available'); } } catch (error) { - ERR('Error playing sorted playlist:', error); + ERR('Error resuming sorted playlist:', error); } }; @@ -555,13 +619,45 @@ } /** - * Create and add resume button next to play 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 */ - function addResumeButton(mainDetailButtons, playlistId) { - // Check if button already exists - if (mainDetailButtons.querySelector('.kt-playlist-resume-btn')) { + async function addResumeButton(mainDetailButtons, playlistId) { + // 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 { + const data = playlistData.get(playlistId); + let items = data?.items; + + // If no cached data, fetch it (already sorted by server) + if (!items || items.length === 0) { + items = await fetchPlaylistChildren(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; } @@ -582,45 +678,45 @@ } if (!playButton) { - WARN('Play button not found, cannot add resume button'); + WARN('Play button not found, cannot add play from beginning button'); return; } - // Create resume button using emby-playstatebutton format + // 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 = 'Resume Playlist'; + button.title = 'Play from Beginning'; // Create detailButton-content wrapper const contentWrapper = document.createElement('div'); contentWrapper.className = 'detailButton-content'; - // Create icon + // Create icon - using refresh/replay icon for "play from beginning" const icon = document.createElement('span'); - icon.className = 'material-icons detailButton-icon play_circle'; + icon.className = 'material-icons detailButton-icon replay'; icon.setAttribute('aria-hidden', 'true'); contentWrapper.appendChild(icon); button.appendChild(contentWrapper); - // Add click handler + // Add click handler - play all items from beginning (no resume filtering) button.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); try { - // Get sorted item IDs with resume mode - const sortedIds = await getSortedPlaylistItemIds(playlistId, true); + // Get sorted item IDs without resume mode (play all items from beginning) + const sortedIds = await getSortedPlaylistItemIds(playlistId, false); if (sortedIds.length === 0) { - WARN('No items to resume for playlist:', playlistId); + WARN('No items to play for playlist:', playlistId); return; } - LOG(`Resuming ${sortedIds.length} items from playlist:`, playlistId); + LOG(`Playing from beginning ${sortedIds.length} items from playlist:`, playlistId); // Use apiHelper if available, otherwise fallback to ApiClient if (window.apiHelper && window.apiHelper.playItem) { @@ -636,7 +732,7 @@ ERR('No play method available'); } } catch (error) { - ERR('Error resuming sorted playlist:', error); + ERR('Error playing from beginning:', error); } }; @@ -694,7 +790,7 @@ addSortButton(mainDetailButtons, playlistId); } // Re-add resume button in case it was recreated - addResumeButton(mainDetailButtons, playlistId); + await addResumeButton(mainDetailButtons, playlistId); // Re-override play button in case it was recreated overridePlayButton(mainDetailButtons, playlistId); } @@ -844,7 +940,7 @@ } // Add resume button (only if it doesn't exist) - addResumeButton(detailButtons, playlistId); + await addResumeButton(detailButtons, playlistId); // Override play button to use sorted items overridePlayButton(detailButtons, playlistId);