Improve Playlist UX for resuming playback and fix issue with long URI

This commit is contained in:
HighImKevin
2025-11-26 02:45:19 -05:00
parent b125d9fc50
commit 4ed3e227e0
2 changed files with 231 additions and 50 deletions
+87 -2
View File
@@ -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<string>} 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');
}
};
+144 -48
View File
@@ -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>} Array of child items
* @returns {Promise<Array>} 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<string>>} 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);