feat(ui): add search history and shuffle buttons

- Implement persistent search history with 'Clear All' functionality
- Add shuffle buttons for album and artist headers with robust logic
- Fix repeat mode incorrectly defaulting to ON on startup
- Adjust CSV progress bar position to avoid control overlap
- Remove obsolete 'Inline Buttons' track action setting
- Fix search history dropdown stacking and icon selector specificity
This commit is contained in:
Julien Maille 2026-01-26 21:31:41 +01:00
parent 601262b70f
commit c34d3a7db6
7 changed files with 2182 additions and 2730 deletions

4617
index.html

File diff suppressed because one or more lines are too long

View file

@ -464,21 +464,67 @@ document.addEventListener('DOMContentLoaded', async () => {
const btn = e.target.closest('#play-album-btn');
if (btn.disabled) return;
const albumId = window.location.pathname.split('/')[2];
const pathParts = window.location.pathname.split('/');
const albumIndex = pathParts.indexOf('album');
const albumId = albumIndex !== -1 ? pathParts[albumIndex + 1] : null;
if (!albumId) return;
try {
const { tracks } = await api.getAlbum(albumId);
if (tracks.length > 0) {
player.setQueue(tracks, 0);
document.getElementById('shuffle-btn').classList.remove('active');
if (tracks && tracks.length > 0) {
// Sort tracks by disc and track number for consistent playback
const sortedTracks = [...tracks].sort((a, b) => {
const discA = a.volumeNumber ?? a.discNumber ?? 1;
const discB = b.volumeNumber ?? b.discNumber ?? 1;
if (discA !== discB) return discA - discB;
return a.trackNumber - b.trackNumber;
});
player.setQueue(sortedTracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
player.shuffleActive = false;
player.playTrackFromQueue();
}
} catch (error) {
console.error('Failed to play album:', error);
alert('Failed to play album: ' + error.message);
showNotification('Failed to play album');
}
}
if (e.target.closest('#shuffle-album-btn')) {
const btn = e.target.closest('#shuffle-album-btn');
if (btn.disabled) return;
const pathParts = window.location.pathname.split('/');
const albumIndex = pathParts.indexOf('album');
const albumId = albumIndex !== -1 ? pathParts[albumIndex + 1] : null;
if (!albumId) return;
try {
const { tracks } = await api.getAlbum(albumId);
if (tracks && tracks.length > 0) {
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
player.setQueue(shuffledTracks, 0);
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
player.shuffleActive = false;
player.playTrackFromQueue();
showNotification('Shuffling album');
}
} catch (error) {
console.error('Failed to shuffle album:', error);
showNotification('Failed to shuffle album');
}
}
if (e.target.closest('#shuffle-artist-btn')) {
const btn = e.target.closest('#shuffle-artist-btn');
if (btn.disabled) return;
document.getElementById('play-artist-radio-btn')?.click();
}
if (e.target.closest('#download-mix-btn')) {
const btn = e.target.closest('#download-mix-btn');
if (btn.disabled) return;
@ -1180,6 +1226,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const performSearch = debounce((query) => {
if (query) {
ui.addToSearchHistory(query);
navigate(`/search/${encodeURIComponent(query)}`);
}
}, 300);
@ -1191,11 +1238,25 @@ document.addEventListener('DOMContentLoaded', async () => {
}
});
searchInput.addEventListener('focus', () => {
ui.renderSearchHistory();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-bar')) {
const historyEl = document.getElementById('search-history');
if (historyEl) historyEl.style.display = 'none';
}
});
searchForm.addEventListener('submit', (e) => {
e.preventDefault();
const query = searchInput.value.trim();
if (query) {
ui.addToSearchHistory(query);
navigate(`/search/${encodeURIComponent(query)}`);
const historyEl = document.getElementById('search-history');
if (historyEl) historyEl.style.display = 'none';
}
});

View file

@ -40,7 +40,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
shuffleBtn.classList.add('active');
}
if (player.repeatMode !== REPEAT_MODE.OFF) {
if (player.repeatMode && player.repeatMode !== REPEAT_MODE.OFF) {
repeatBtn.classList.add('active');
if (player.repeatMode === REPEAT_MODE.ONE) {
repeatBtn.classList.add('repeat-one');
@ -808,10 +808,9 @@ export async function handleTrackAction(
return `
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
<span>${p.name}</span>
${
alreadyContains
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
: ''
${alreadyContains
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
: ''
}
</div>
`;
@ -1109,12 +1108,12 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const type = card.dataset.albumId
? 'album'
: card.dataset.playlistId
? 'playlist'
: card.dataset.mixId
? 'mix'
: card.dataset.href
? card.dataset.href.split('/')[1]
: 'item';
? 'playlist'
: card.dataset.mixId
? 'mix'
: card.dataset.href
? card.dataset.href.split('/')[1]
: 'item';
const id = card.dataset.albumId || card.dataset.playlistId || card.dataset.mixId;
const item = trackDataStore.get(card) || {

View file

@ -100,7 +100,7 @@ export class Player {
this.originalQueueBeforeShuffle = savedState.originalQueueBeforeShuffle || [];
this.currentQueueIndex = savedState.currentQueueIndex ?? -1;
this.shuffleActive = savedState.shuffleActive || false;
this.repeatMode = savedState.repeatMode || REPEAT_MODE.OFF;
this.repeatMode = savedState.repeatMode !== undefined ? savedState.repeatMode : REPEAT_MODE.OFF;
// Restore current track if queue exists and index is valid
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
@ -244,7 +244,7 @@ export class Player {
// Warm connection/cache
// For Blob URLs (DASH), this head request is not needed and can cause errors.
if (!streamUrl.startsWith('blob:')) {
fetch(streamUrl, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch(() => {});
fetch(streamUrl, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch(() => { });
}
} catch (error) {
if (error.name !== 'AbortError') {

View file

@ -466,7 +466,8 @@ export const trackListSettings = {
getMode() {
try {
const mode = localStorage.getItem(this.STORAGE_KEY) || 'dropdown';
let mode = localStorage.getItem(this.STORAGE_KEY) || 'dropdown';
if (mode === 'inline') mode = 'dropdown';
document.documentElement.setAttribute('data-track-actions-mode', mode);
return mode;
} catch {

View file

@ -1533,10 +1533,10 @@ export class UIRenderer {
dateDisplay =
window.innerWidth > 768
? releaseDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
year: 'numeric',
month: 'long',
day: 'numeric',
})
: year;
}
}
@ -2268,9 +2268,9 @@ export class UIRenderer {
<span>${artist.popularity}% popularity</span>
<div class="artist-tags">
${(artist.artistRoles || [])
.filter((role) => role.category)
.map((role) => `<span class="artist-tag">${role.category}</span>`)
.join('')}
.filter((role) => role.category)
.map((role) => `<span class="artist-tag">${role.category}</span>`)
.join('')}
</div>
`;
@ -2854,4 +2854,83 @@ export class UIRenderer {
artistEl.textContent = e.message || 'Track not found or unavailable';
}
}
renderSearchHistory() {
const historyEl = document.getElementById('search-history');
if (!historyEl) return;
const history = JSON.parse(localStorage.getItem('search-history') || '[]');
if (history.length === 0) {
historyEl.style.display = 'none';
return;
}
historyEl.innerHTML = history
.map(
(query) => `
<div class="search-history-item" data-query="${escapeHtml(query)}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="history-icon">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span class="query-text">${escapeHtml(query)}</span>
<span class="delete-history-btn" title="Remove from history">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</span>
</div>
`
)
.join('') +
`
<div class="search-history-clear-all" id="clear-search-history">
Clear all history
</div>
`;
historyEl.style.display = 'block';
// Add event listeners
historyEl.querySelectorAll('.search-history-item').forEach((item) => {
item.addEventListener('click', (e) => {
if (e.target.closest('.delete-history-btn')) {
e.stopPropagation();
this.removeFromSearchHistory(item.dataset.query);
return;
}
const query = item.dataset.query;
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.value = query;
searchInput.dispatchEvent(new Event('input'));
historyEl.style.display = 'none';
}
});
});
const clearBtn = document.getElementById('clear-search-history');
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
localStorage.removeItem('search-history');
this.renderSearchHistory();
});
}
}
removeFromSearchHistory(query) {
let history = JSON.parse(localStorage.getItem('search-history') || '[]');
history = history.filter((q) => q !== query);
localStorage.setItem('search-history', JSON.stringify(history));
this.renderSearchHistory();
}
addToSearchHistory(query) {
if (!query || query.trim().length === 0) return;
let history = JSON.parse(localStorage.getItem('search-history') || '[]');
history = history.filter((q) => q !== query);
history.unshift(query);
history = history.slice(0, 10);
localStorage.setItem('search-history', JSON.stringify(history));
}
}

View file

@ -343,7 +343,11 @@ kbd {
}
/* Ensure content sits on top of background */
.main-header,
.main-header {
position: relative;
z-index: 100;
}
.page {
position: relative;
z-index: 1;
@ -450,7 +454,7 @@ kbd {
cursor: pointer;
}
.search-bar svg {
.search-bar svg.search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
@ -493,6 +497,7 @@ kbd {
margin-bottom: var(--spacing-xl);
gap: var(--spacing-md);
position: relative;
z-index: 1000;
}
.navigation-controls {
@ -573,6 +578,83 @@ body.has-page-background .track-item:hover {
background-color: var(--track-hover-bg, var(--secondary));
}
.search-history {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--card);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
max-height: 300px;
overflow-y: auto;
z-index: 9999;
box-shadow: var(--shadow-xl);
margin-top: -1px;
}
.search-history-item {
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: background var(--transition);
}
.search-history-item:hover {
background: var(--secondary);
}
.search-history-item .history-icon {
color: var(--muted-foreground);
opacity: 0.5;
flex-shrink: 0;
}
.search-history-item .query-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-history-item .delete-history-btn {
padding: 4px;
color: var(--muted-foreground);
opacity: 0.5;
transition: opacity var(--transition);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.search-history-item .delete-history-btn:hover {
opacity: 1;
color: #ef4444;
}
.search-history-clear-all {
padding: 0.75rem 1rem;
text-align: center;
font-size: 0.85rem;
color: var(--primary);
cursor: pointer;
border-top: 1px solid var(--border);
transition: background var(--transition);
}
.search-history-clear-all:hover {
background: var(--secondary);
}
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
display: none;
}
.page {
display: none;
}
@ -637,6 +719,7 @@ body.has-page-background .track-item:hover {
}
@keyframes pulse {
0%,
100% {
opacity: 1;
@ -955,6 +1038,7 @@ body.has-page-background .track-item:hover {
}
@media (max-width: 1100px) {
#home-recommended-songs,
#artist-detail-tracks,
#playlist-detail-recommended {
@ -1080,7 +1164,8 @@ body.has-page-background .track-item:hover {
.track-item.dragging {
opacity: 0.5;
z-index: 1000;
z-index: 2000;
pointer-events: auto;
}
.track-number {
@ -1553,11 +1638,11 @@ body.has-page-background .track-item:hover {
border-radius: 50%;
}
input:checked + .slider {
input:checked+.slider {
background-color: var(--primary);
}
input:checked + .slider::before {
input:checked+.slider::before {
transform: translateX(16px);
background-color: var(--primary-foreground);
}
@ -3362,8 +3447,8 @@ img[src=''] {
/* fuck this chud ass shit bro */
.csv-import-progress {
position: fixed;
bottom: 20px;
right: 20px;
bottom: 120px;
right: 3%;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
@ -4049,7 +4134,7 @@ img[src=''] {
}
.csv-import-progress {
bottom: 10px;
bottom: 145px;
right: 10px;
left: 10px;
max-width: none;
@ -4708,4 +4793,4 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
width: 25px;
height: 25px;
}
}
}