Merge pull request #157 from blacksigkill/feature/sorting-playlists

Allow sorting all playlists
This commit is contained in:
Eduard Prigoana 2026-02-06 14:10:10 +02:00 committed by GitHub
commit 36a9627ee7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 129 additions and 56 deletions

View file

@ -53,10 +53,12 @@
<div id="sort-menu" style="display: none"> <div id="sort-menu" style="display: none">
<ul> <ul>
<li data-sort="added-newest">Date Added (Newest)</li> <li data-sort="custom" class="requires-custom-order">Playlist Order</li>
<li data-sort="added-oldest">Date Added (Oldest)</li> <li data-sort="added-newest" class="requires-added-date">Date Added (Newest)</li>
<li data-sort="added-oldest" class="requires-added-date">Date Added (Oldest)</li>
<li data-sort="title">Title (A-Z)</li> <li data-sort="title">Title (A-Z)</li>
<li data-sort="artist">Artist (A-Z)</li> <li data-sort="artist">Artist (A-Z)</li>
<li data-sort="album">Album (A-Z)</li>
</ul> </ul>
</div> </div>

144
js/ui.js
View file

@ -43,6 +43,37 @@ import {
createTrackFromSong, createTrackFromSong,
} from './tracker.js'; } from './tracker.js';
function sortTracks(tracks, sortType) {
if (sortType === 'custom') return [...tracks];
const sorted = [...tracks];
switch (sortType) {
case 'added-newest':
return sorted.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
case 'added-oldest':
return sorted.sort((a, b) => (a.addedAt || 0) - (b.addedAt || 0));
case 'title':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'artist':
return sorted.sort((a, b) => {
const artistA = a.artist?.name || a.artists?.[0]?.name || '';
const artistB = b.artist?.name || b.artists?.[0]?.name || '';
return artistA.localeCompare(artistB);
});
case 'album':
return sorted.sort((a, b) => {
const albumA = a.album?.title || '';
const albumB = b.album?.title || '';
const albumCompare = albumA.localeCompare(albumB);
if (albumCompare !== 0) return albumCompare;
const trackNumA = a.trackNumber || a.position || 0;
const trackNumB = b.trackNumber || b.position || 0;
return trackNumA - trackNumB;
});
default:
return sorted;
}
}
export class UIRenderer { export class UIRenderer {
constructor(api, player) { constructor(api, player) {
this.api = api; this.api = api;
@ -2130,10 +2161,15 @@ export class UIRenderer {
descEl.textContent = playlistData.description || ''; descEl.textContent = playlistData.description || '';
const originalTracks = [...tracks]; const originalTracks = [...tracks];
let currentTracks = [...tracks]; // Default sort: first available option (Playlist Order if no addedAt, else Date Added Newest)
const hasAddedDate = tracks.some((t) => t.addedAt);
currentSort = hasAddedDate ? 'added-newest' : 'custom';
let currentTracks = sortTracks(originalTracks, currentSort);
const renderTracks = () => { const renderTracks = () => {
tracklistContainer.innerHTML = ` // Re-fetch container each time because enableTrackReordering clones it
const container = document.getElementById('playlist-detail-tracklist');
container.innerHTML = `
<div class="track-list-header"> <div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span> <span style="width: 40px; text-align: center;">#</span>
<span>Title</span> <span>Title</span>
@ -2141,11 +2177,11 @@ export class UIRenderer {
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span> <span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div> </div>
`; `;
this.renderListWithTracks(tracklistContainer, currentTracks, true, true); this.renderListWithTracks(container, currentTracks, true, true);
// Add remove buttons and enable reordering ONLY IF OWNED // Add remove buttons and enable reordering ONLY IF OWNED
if (ownedPlaylist) { if (ownedPlaylist) {
const trackItems = tracklistContainer.querySelectorAll('.track-item'); const trackItems = container.querySelectorAll('.track-item');
trackItems.forEach((item, index) => { trackItems.forEach((item, index) => {
const actionsDiv = item.querySelector('.track-item-actions'); const actionsDiv = item.querySelector('.track-item-actions');
const removeBtn = document.createElement('button'); const removeBtn = document.createElement('button');
@ -2160,35 +2196,19 @@ export class UIRenderer {
}); });
if (currentSort === 'custom') { if (currentSort === 'custom') {
tracklistContainer.classList.add('is-editable'); container.classList.add('is-editable');
this.enableTrackReordering(tracklistContainer, currentTracks, playlistId, syncManager); this.enableTrackReordering(container, currentTracks, playlistId, syncManager);
} else { } else {
tracklistContainer.classList.remove('is-editable'); container.classList.remove('is-editable');
} }
} else { } else {
tracklistContainer.classList.remove('is-editable'); container.classList.remove('is-editable');
} }
}; };
const applySort = (sortType) => { const applySort = (sortType) => {
currentSort = sortType; currentSort = sortType;
if (sortType === 'custom') { currentTracks = sortTracks(originalTracks, sortType);
currentTracks = [...originalTracks];
} else if (sortType === 'added-newest') {
currentTracks = [...originalTracks].sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
} else if (sortType === 'added-oldest') {
currentTracks = [...originalTracks].sort((a, b) => (a.addedAt || 0) - (b.addedAt || 0));
} else if (sortType === 'title') {
currentTracks = [...originalTracks].sort((a, b) =>
(a.title || '').localeCompare(b.title || '')
);
} else if (sortType === 'artist') {
currentTracks = [...originalTracks].sort((a, b) => {
const artistA = a.artist?.name || a.artists?.[0]?.name || '';
const artistB = b.artist?.name || b.artists?.[0]?.name || '';
return artistA.localeCompare(artistB);
});
}
renderTracks(); renderTracks();
}; };
@ -2205,13 +2225,14 @@ export class UIRenderer {
this.loadRecommendedSongsForPlaylist(tracks); this.loadRecommendedSongsForPlaylist(tracks);
} }
// Render Actions (Shuffle, Edit, Delete, Share, Sort) // Render Actions (Sort, Shuffle, Edit, Delete, Share)
this.updatePlaylistHeaderActions( this.updatePlaylistHeaderActions(
playlistData, playlistData,
!!ownedPlaylist, !!ownedPlaylist,
tracks, currentTracks,
false, false,
ownedPlaylist ? applySort : null applySort,
() => currentSort
); );
playBtn.onclick = () => { playBtn.onclick = () => {
@ -2276,6 +2297,11 @@ export class UIRenderer {
metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`; metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`;
descEl.textContent = playlist.description || ''; descEl.textContent = playlist.description || '';
const originalTracks = [...tracks];
let currentTracks = [...tracks];
let currentSort = 'custom';
const renderTracks = () => {
tracklistContainer.innerHTML = ` tracklistContainer.innerHTML = `
<div class="track-list-header"> <div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span> <span style="width: 40px; text-align: center;">#</span>
@ -2284,8 +2310,21 @@ export class UIRenderer {
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span> <span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div> </div>
`; `;
this.renderListWithTracks(tracklistContainer, currentTracks, true, true);
};
this.renderListWithTracks(tracklistContainer, tracks, true, true); const applySort = (sortType) => {
currentSort = sortType;
currentTracks = sortTracks(originalTracks, sortType);
renderTracks();
};
renderTracks();
playBtn.onclick = () => {
this.player.setQueue(currentTracks, 0);
this.player.playTrackFromQueue();
};
// Update header like button // Update header like button
const playlistLikeBtn = document.getElementById('like-playlist-btn'); const playlistLikeBtn = document.getElementById('like-playlist-btn');
@ -2308,8 +2347,8 @@ export class UIRenderer {
recommendedSection.style.display = 'none'; recommendedSection.style.display = 'none';
} }
// Render Actions (Shuffle + Share) // Render Actions (Shuffle + Sort + Share)
this.updatePlaylistHeaderActions(playlist, false, tracks, false); this.updatePlaylistHeaderActions(playlist, false, currentTracks, false, applySort, () => currentSort);
recentActivityManager.addPlaylist(playlist); recentActivityManager.addPlaylist(playlist);
document.title = playlist.title || 'Artist Mix'; document.title = playlist.title || 'Artist Mix';
@ -2817,7 +2856,7 @@ export class UIRenderer {
await renderTrackerTrackContent(trackId, container, this); await renderTrackerTrackContent(trackId, container, this);
} }
updatePlaylistHeaderActions(playlist, isOwned, tracks, showShare = false, onSort = null) { updatePlaylistHeaderActions(playlist, isOwned, tracks, showShare = false, onSort = null, getCurrentSort = null) {
const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions'); const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions');
// Cleanup existing dynamic buttons // Cleanup existing dynamic buttons
@ -2845,10 +2884,11 @@ export class UIRenderer {
this.player.setQueue(shuffledTracks, 0); this.player.setQueue(shuffledTracks, 0);
this.player.playTrackFromQueue(); this.player.playTrackFromQueue();
}; };
fragment.appendChild(shuffleBtn);
// Sort button (always available if onSort is provided)
let sortBtn = null;
if (onSort) { if (onSort) {
const sortBtn = document.createElement('button'); sortBtn = document.createElement('button');
sortBtn.id = 'sort-playlist-btn'; sortBtn.id = 'sort-playlist-btn';
sortBtn.className = 'btn-secondary'; sortBtn.className = 'btn-secondary';
sortBtn.innerHTML = sortBtn.innerHTML =
@ -2858,6 +2898,21 @@ export class UIRenderer {
e.stopPropagation(); e.stopPropagation();
const menu = document.getElementById('sort-menu'); const menu = document.getElementById('sort-menu');
// Show "Date Added" if tracks have addedAt, otherwise show "Playlist Order"
const hasAddedDate = tracks.some((t) => t.addedAt);
menu.querySelectorAll('.requires-added-date').forEach((opt) => {
opt.style.display = hasAddedDate ? '' : 'none';
});
menu.querySelectorAll('.requires-custom-order').forEach((opt) => {
opt.style.display = hasAddedDate ? 'none' : '';
});
// Highlight current sort option
const currentSortType = getCurrentSort ? getCurrentSort() : 'custom';
menu.querySelectorAll('li').forEach((opt) => {
opt.classList.toggle('sort-active', opt.dataset.sort === currentSortType);
});
const rect = sortBtn.getBoundingClientRect(); const rect = sortBtn.getBoundingClientRect();
menu.style.top = `${rect.bottom + 5}px`; menu.style.top = `${rect.bottom + 5}px`;
menu.style.left = `${rect.left}px`; menu.style.left = `${rect.left}px`;
@ -2880,7 +2935,6 @@ export class UIRenderer {
setTimeout(() => document.addEventListener('click', closeMenu), 0); setTimeout(() => document.addEventListener('click', closeMenu), 0);
}; };
fragment.appendChild(sortBtn);
} }
// Edit/Delete (Owned Only) // Edit/Delete (Owned Only)
@ -2915,8 +2969,10 @@ export class UIRenderer {
fragment.appendChild(shareBtn); fragment.appendChild(shareBtn);
} }
// Insert before Download button if possible, else append // Insert buttons in the correct order: Play, Shuffle, Download, Sort, Like, Edit/Delete/Share
const dlBtn = actionsDiv.querySelector('#download-playlist-btn'); const dlBtn = actionsDiv.querySelector('#download-playlist-btn');
const likeBtn = actionsDiv.querySelector('#like-playlist-btn');
if (dlBtn) { if (dlBtn) {
// We want Shuffle first, then Edit/Delete/Share. // We want Shuffle first, then Edit/Delete/Share.
// But Download is usually first or second. // But Download is usually first or second.
@ -2942,12 +2998,24 @@ export class UIRenderer {
// Let's stick to appending for now to minimize visual layout shifts from previous (where Edit/Delete were appended). // Let's stick to appending for now to minimize visual layout shifts from previous (where Edit/Delete were appended).
// Shuffle was inserted before Download. // Shuffle was inserted before Download.
actionsDiv.insertBefore(shuffleBtn, dlBtn); actionsDiv.insertBefore(shuffleBtn, dlBtn);
// Append the rest // Insert Sort after Download, before Like
if (sortBtn && likeBtn) {
actionsDiv.insertBefore(sortBtn, likeBtn);
} else if (sortBtn) {
actionsDiv.appendChild(sortBtn);
}
// Append Edit/Delete/Share buttons after Like
while (fragment.firstChild) { while (fragment.firstChild) {
actionsDiv.appendChild(fragment.firstChild); actionsDiv.appendChild(fragment.firstChild);
} }
} else { } else {
actionsDiv.appendChild(fragment); // If no Download button, just append everything
actionsDiv.appendChild(shuffleBtn);
if (sortBtn) actionsDiv.appendChild(sortBtn);
while (fragment.firstChild) {
actionsDiv.appendChild(fragment.firstChild);
}
} }
} }

View file

@ -2179,7 +2179,8 @@ input:checked + .slider::before {
pointer-events: none; pointer-events: none;
} }
#context-menu { #context-menu,
#sort-menu {
display: none; display: none;
position: fixed; position: fixed;
background-color: var(--card); background-color: var(--card);
@ -2189,15 +2190,19 @@ input:checked + .slider::before {
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
z-index: 3000; z-index: 3000;
min-width: 160px; min-width: 160px;
transform-origin: top left;
animation: scale-in var(--transition-fast) var(--ease-out-back);
} }
#context-menu ul { #context-menu ul,
#sort-menu ul {
list-style: none; list-style: none;
} }
#context-menu li, #context-menu li,
#sort-menu li { #sort-menu li {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin-right: 8px;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
transition: transition:
@ -2212,6 +2217,10 @@ input:checked + .slider::before {
align-items: center; align-items: center;
} }
#sort-menu li.sort-active {
font-weight: bold;
}
#context-menu li:hover, #context-menu li:hover,
#sort-menu li:hover { #sort-menu li:hover {
background-color: var(--secondary); background-color: var(--secondary);
@ -2221,12 +2230,6 @@ input:checked + .slider::before {
color: var(--foreground); color: var(--foreground);
} }
#context-menu,
#sort-menu {
transform-origin: top left;
animation: scale-in var(--transition-fast) var(--ease-out-back);
}
#queue-modal-overlay { #queue-modal-overlay {
display: none; display: none;
position: fixed; position: fixed;