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

1571
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'); const btn = e.target.closest('#play-album-btn');
if (btn.disabled) return; 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; if (!albumId) return;
try { try {
const { tracks } = await api.getAlbum(albumId); const { tracks } = await api.getAlbum(albumId);
if (tracks.length > 0) { if (tracks && tracks.length > 0) {
player.setQueue(tracks, 0); // Sort tracks by disc and track number for consistent playback
document.getElementById('shuffle-btn').classList.remove('active'); 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(); player.playTrackFromQueue();
} }
} catch (error) { } catch (error) {
console.error('Failed to play album:', 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')) { if (e.target.closest('#download-mix-btn')) {
const btn = e.target.closest('#download-mix-btn'); const btn = e.target.closest('#download-mix-btn');
if (btn.disabled) return; if (btn.disabled) return;
@ -1180,6 +1226,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const performSearch = debounce((query) => { const performSearch = debounce((query) => {
if (query) { if (query) {
ui.addToSearchHistory(query);
navigate(`/search/${encodeURIComponent(query)}`); navigate(`/search/${encodeURIComponent(query)}`);
} }
}, 300); }, 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) => { searchForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const query = searchInput.value.trim(); const query = searchInput.value.trim();
if (query) { if (query) {
ui.addToSearchHistory(query);
navigate(`/search/${encodeURIComponent(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'); shuffleBtn.classList.add('active');
} }
if (player.repeatMode !== REPEAT_MODE.OFF) { if (player.repeatMode && player.repeatMode !== REPEAT_MODE.OFF) {
repeatBtn.classList.add('active'); repeatBtn.classList.add('active');
if (player.repeatMode === REPEAT_MODE.ONE) { if (player.repeatMode === REPEAT_MODE.ONE) {
repeatBtn.classList.add('repeat-one'); repeatBtn.classList.add('repeat-one');
@ -808,8 +808,7 @@ export async function handleTrackAction(
return ` return `
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}"> <div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
<span>${p.name}</span> <span>${p.name}</span>
${ ${alreadyContains
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>` ? `<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>`
: '' : ''
} }

View file

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

View file

@ -466,7 +466,8 @@ export const trackListSettings = {
getMode() { getMode() {
try { 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); document.documentElement.setAttribute('data-track-actions-mode', mode);
return mode; return mode;
} catch { } catch {

View file

@ -2854,4 +2854,83 @@ export class UIRenderer {
artistEl.textContent = e.message || 'Track not found or unavailable'; 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 */ /* Ensure content sits on top of background */
.main-header, .main-header {
position: relative;
z-index: 100;
}
.page { .page {
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -450,7 +454,7 @@ kbd {
cursor: pointer; cursor: pointer;
} }
.search-bar svg { .search-bar svg.search-icon {
position: absolute; position: absolute;
left: 0.75rem; left: 0.75rem;
top: 50%; top: 50%;
@ -493,6 +497,7 @@ kbd {
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl);
gap: var(--spacing-md); gap: var(--spacing-md);
position: relative; position: relative;
z-index: 1000;
} }
.navigation-controls { .navigation-controls {
@ -573,6 +578,83 @@ body.has-page-background .track-item:hover {
background-color: var(--track-hover-bg, var(--secondary)); 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 { .page {
display: none; display: none;
} }
@ -637,6 +719,7 @@ body.has-page-background .track-item:hover {
} }
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
opacity: 1; opacity: 1;
@ -955,6 +1038,7 @@ body.has-page-background .track-item:hover {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
#home-recommended-songs, #home-recommended-songs,
#artist-detail-tracks, #artist-detail-tracks,
#playlist-detail-recommended { #playlist-detail-recommended {
@ -1080,7 +1164,8 @@ body.has-page-background .track-item:hover {
.track-item.dragging { .track-item.dragging {
opacity: 0.5; opacity: 0.5;
z-index: 1000; z-index: 2000;
pointer-events: auto;
} }
.track-number { .track-number {
@ -1553,11 +1638,11 @@ body.has-page-background .track-item:hover {
border-radius: 50%; border-radius: 50%;
} }
input:checked + .slider { input:checked+.slider {
background-color: var(--primary); background-color: var(--primary);
} }
input:checked + .slider::before { input:checked+.slider::before {
transform: translateX(16px); transform: translateX(16px);
background-color: var(--primary-foreground); background-color: var(--primary-foreground);
} }
@ -3362,8 +3447,8 @@ img[src=''] {
/* fuck this chud ass shit bro */ /* fuck this chud ass shit bro */
.csv-import-progress { .csv-import-progress {
position: fixed; position: fixed;
bottom: 20px; bottom: 120px;
right: 20px; right: 3%;
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@ -4049,7 +4134,7 @@ img[src=''] {
} }
.csv-import-progress { .csv-import-progress {
bottom: 10px; bottom: 145px;
right: 10px; right: 10px;
left: 10px; left: 10px;
max-width: none; max-width: none;