feat(queue): redesign QueuePanel with tabs, history, and infinite play

- Add Queue/History tabs with new layout
- Implement Now Playing card with spectrum animation
- Add History tab showing recently played tracks from backend
- Add Infinite Play toggle (persisted to localStorage)
- Relocate search to footer (expands left)
- Add Up Next counter (X/Y format)
- Implement 20-track pagination with load more
- Add proper i18n keys for new features
- Add new SVG icons (add-to-list, trash-list)
This commit is contained in:
vicrodh committed 2026-01-23 10:28:16 -06:00
1 parent e624d770b9
commit 84c2261829
6 files changed
+785 -349

No files matched your search

File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -221,6 +221,11 @@
"queue": "Queue",
"upNext": "Up Next",
"history": "History",
"recentlyPlayed": "Recently Played",
"noHistoryYet": "No tracks played yet",
"infinitePlay": "Infinite Play",
"infinitePlayEnabled": "Infinite play enabled",
"infinitePlayDisabled": "Infinite play disabled",
"lyrics": "Lyrics",
"noLyrics": "No lyrics available",
"fetchingLyrics": "Fetching lyrics...",
@@ -299,7 +304,8 @@
"yes": "Yes",
"no": "No",
"ok": "OK",
"loading": "Loading..."
"loading": "Loading...",
"loadMore": "Load More"
},
"settings": {
"title": "Settings",
+7 -1
View File
@@ -221,6 +221,11 @@
"queue": "Cola",
"upNext": "Siguiente",
"history": "Historial",
"recentlyPlayed": "Reproducido Recientemente",
"noHistoryYet": "Sin pistas reproducidas aún",
"infinitePlay": "Reproducción Infinita",
"infinitePlayEnabled": "Reproducción infinita activada",
"infinitePlayDisabled": "Reproducción infinita desactivada",
"lyrics": "Letras",
"noLyrics": "No hay letras disponibles",
"fetchingLyrics": "Buscando letras...",
@@ -299,7 +304,8 @@
"yes": "Sí",
"no": "No",
"ok": "OK",
"loading": "Cargando..."
"loading": "Cargando...",
"loadMore": "Cargar Más"
},
"settings": {
"title": "Ajustes",
+81 -2
View File
@@ -331,6 +331,8 @@
let repeatMode = $state<RepeatMode>('off');
let queue = $state<QueueTrack[]>([]);
let queueTotalTracks = $state(0);
let historyTracks = $state<QueueTrack[]>([]);
let infinitePlayEnabled = $state(false);
// Toast State (from store subscription)
let toast = $state<ToastData | null>(null);
@@ -1040,6 +1042,54 @@
closeQueue();
}
// Toggle infinite play mode (auto-refill queue with similar tracks)
function handleToggleInfinitePlay() {
infinitePlayEnabled = !infinitePlayEnabled;
// Persist to localStorage
try {
localStorage.setItem('qbz-infinite-play', JSON.stringify(infinitePlayEnabled));
} catch {
// Ignore storage errors
}
showToast(infinitePlayEnabled ? 'Infinite play enabled' : 'Infinite play disabled', 'info');
}
// Play a track from history
async function handlePlayHistoryTrack(trackId: string) {
try {
// Get the full queue state to find the track in history
const queueState = await getBackendQueueState();
if (!queueState) {
showToast('Failed to play track', 'error');
return;
}
// Find the track in history
const numericId = parseInt(trackId, 10);
const historyTrack = queueState.history.find(t => t.id === numericId);
if (!historyTrack) {
showToast('Track not found in history', 'error');
return;
}
// Play the track directly
await handleTrackPlay({
id: historyTrack.id,
title: historyTrack.title,
artist: historyTrack.artist,
album: historyTrack.album,
duration: historyTrack.duration_secs,
artwork: historyTrack.artwork_url || '',
quality: historyTrack.hires ? 'Hi-Res' : 'CD',
bitDepth: historyTrack.bit_depth || 16,
samplingRate: historyTrack.sample_rate || 44100
});
} catch (err) {
console.error('Failed to play history track:', err);
showToast('Failed to play track', 'error');
}
}
// Play all tracks from album (starting from first track)
async function handlePlayAllAlbum() {
if (!selectedAlbum?.tracks?.length) return;
@@ -1827,6 +1877,16 @@
initPlaybackContextStore();
initPlaybackPreferences();
// Load infinite play preference
try {
const stored = localStorage.getItem('qbz-infinite-play');
if (stored !== null) {
infinitePlayEnabled = JSON.parse(stored);
}
} catch {
// Ignore storage errors
}
// Note: loadFavorites() is called in handleLoginSuccess after login is confirmed
// This prevents API calls before authentication is complete
@@ -2066,10 +2126,23 @@
};
});
// Sync queue state when opening queue panel
// Sync queue state when opening queue panel (including history)
$effect(() => {
if (isQueueOpen) {
syncQueueState();
// Also fetch history from backend
getBackendQueueState().then((state) => {
if (state?.history) {
historyTracks = state.history.map(t => ({
id: String(t.id),
artwork: t.artwork_url || '',
title: t.title,
artist: t.artist,
duration: formatDuration(t.duration_secs),
trackId: t.id
}));
}
});
}
});
@@ -2093,7 +2166,8 @@
artwork: currentTrack.artwork,
title: currentTrack.title,
artist: currentTrack.artist,
duration: formatDuration(currentTrack.duration)
duration: formatDuration(currentTrack.duration),
trackId: currentTrack.id // For favorite checking in QueuePanel
} : null);
</script>
@@ -2448,10 +2522,15 @@
onClose={closeQueue}
currentTrack={currentQueueTrack ?? undefined}
upcomingTracks={queue}
{historyTracks}
onPlayTrack={handleQueueTrackPlay}
onPlayHistoryTrack={handlePlayHistoryTrack}
onClearQueue={handleClearQueue}
onSaveAsPlaylist={handleSaveQueueAsPlaylist}
onReorderTrack={handleQueueReorder}
onToggleInfinitePlay={handleToggleInfinitePlay}
{infinitePlayEnabled}
{isPlaying}
/>
<!-- Expanded Player -->
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#000000" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 902 B