From 84c2261829ec1fc05d2f512ecce13021da63d004 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:28:16 -0600 Subject: [PATCH] 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) --- src/lib/components/QueuePanel.svelte | 1024 +++++++++++++++++--------- src/lib/i18n/locales/en.json | 8 +- src/lib/i18n/locales/es.json | 8 +- src/routes/+page.svelte | 83 ++- static/add-to-list.svg | 7 + static/trash-list.svg | 4 + 6 files changed, 785 insertions(+), 349 deletions(-) create mode 100644 static/add-to-list.svg create mode 100644 static/trash-list.svg diff --git a/src/lib/components/QueuePanel.svelte b/src/lib/components/QueuePanel.svelte index 0097277ec..74b36fb80 100644 --- a/src/lib/components/QueuePanel.svelte +++ b/src/lib/components/QueuePanel.svelte @@ -1,6 +1,7 @@ {#if isOpen} - + -
- +
+
-

{$t('player.queue')}

- +
+ + | + +
+
+ {#if activeTab === 'queue'} + + {/if} + +
- -
{ - // Stop wheel events from propagating to main content - e.stopPropagation(); - }} - > - - {#if currentTrack} -
-
{$t('player.nowPlaying')}
-
- {currentTrack.title} -
-
{currentTrack.title}
-
{currentTrack.artist}
+ +
+ {#if activeTab === 'queue'} + + {#if currentTrack} +
+ +
+
+ {currentTrack.title} + {#if isPlaying} +
+
+
+
+
+
+ {/if} +
+
+
{currentTrack.title}
+
{currentTrack.artist}
+
+
-
- {/if} + {/if} - - {#if upcomingTracks.length > 0} -
- - - {#if searchQuery} - + + {#if upcomingTracks.length > 0} +
+ +
+ {#each filteredTracks as track, index} + {@const originalIndex = upcomingTracks.findIndex(t => t.id === track.id)} + {@const isUnavailable = track.available === false} +
handleTrackClick(track)} + ondragstart={(e) => handleDragStart(e, originalIndex)} + ondragover={(e) => handleDragOver(e, originalIndex)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, originalIndex)} + ondragend={handleDragEnd} + role="button" + tabindex={isUnavailable ? -1 : 0} + > + {originalIndex + 1} +
+
{track.title}
+
{track.artist}
+
+ {track.duration} +
+ {/each} + {#if hasMoreTracks} + + {/if} + {#if searchQuery && filteredTracks.length === 0} +
{$t('player.noTracksMatch', { values: { query: searchQuery } })}
+ {/if} +
+
+ {:else if !currentTrack} +
+
🎵
+
{$t('player.queueEmpty')}
+
{$t('player.queueEmptyDescription')}
+
+ {/if} + + {:else} + +
+ + {#if historyTracks.length > 0} +
+ {#each historyTracks.slice(0, DISPLAY_LIMIT) as track} +
handleHistoryTrackClick(track)} + onmouseenter={() => hoveredHistoryTrack = track.id} + onmouseleave={() => hoveredHistoryTrack = null} + role="button" + tabindex="0" + > + {track.title} +
+
{track.title}
+
{track.artist}
+
+ {track.duration} + {#if hoveredHistoryTrack === track.id} + + {/if} +
+ {/each} +
+ {:else} +
+
{$t('player.noHistoryYet')}
+
{/if}
{/if} - - - {#if upcomingTracks.length > 0} -
-
- {$t('player.upNext')} ({filteredTracks.length}{searchQuery ? ` / ${upcomingTracks.length}` : ''}) -
-
- {#each filteredTracks as track, index} - {@const originalIndex = upcomingTracks.findIndex(t => t.id === track.id)} - {@const isUnavailable = track.available === false} -
(hoveredTrack = track.id)} - onmouseleave={() => (hoveredTrack = null)} - onclick={(e) => handleTrackClick(e, track)} - ondragstart={(e) => handleDragStart(e, originalIndex)} - ondragover={(e) => handleDragOver(e, originalIndex)} - ondragleave={handleDragLeave} - ondrop={(e) => handleDrop(e, originalIndex)} - ondragend={handleDragEnd} - role="button" - tabindex={isUnavailable ? -1 : 0} - title={isUnavailable ? $t('offline.trackNotAvailable') : undefined} - onkeydown={(e) => e.key === 'Enter' && !isUnavailable && onPlayTrack?.(track.id)} - > - - {#if isUnavailable} -
- -
- {:else} -
- -
- {/if} - - -
- {#if hoveredTrack === track.id && !isUnavailable} - - {:else} - {originalIndex + 1} - {/if} -
- - -
-
{track.title}
-
{track.artist}
-
- - -
{track.duration}
-
- {/each} - {#if searchQuery && filteredTracks.length === 0} -
{$t('player.noTracksMatch', { values: { query: searchQuery } })}
- {/if} -
-
- {/if} - - - {#if upcomingTracks.length === 0 && !currentTrack} -
-
🎵
-
{$t('player.queueEmpty')}
-
{$t('player.queueEmptyDescription')}
-
- {/if}
- - {#if upcomingTracks.length > 0 || currentTrack} + + {#if activeTab === 'queue' && (upcomingTracks.length > 0 || currentTrack)} {/if}
@@ -260,34 +411,29 @@ z-index: 40; } - :global(.queue-panel-container) { + .queue-panel { position: fixed; - top: 32px; /* Below TitleBar */ + top: 32px; right: 0; - bottom: 104px; /* Above NowPlayingBar */ + bottom: 104px; width: 340px; z-index: 50; display: flex; flex-direction: column; - animation: slideInRight 200ms ease-out; + animation: slideIn 200ms ease-out; background-color: var(--bg-secondary); border-left: 1px solid var(--bg-tertiary); box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3); } - /* Remove old glass panel override */ - - @keyframes slideInRight { - from { - transform: translateX(100%); - } - to { - transform: translateX(0); - } + @keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } } + /* Header */ .header { - padding: 16px; + padding: 12px 16px; border-bottom: 1px solid var(--bg-tertiary); display: flex; align-items: center; @@ -295,12 +441,65 @@ flex-shrink: 0; } - .header h2 { - font-size: 16px; + .tabs { + display: flex; + align-items: center; + gap: 8px; + } + + .tab { + background: none; + border: none; + font-size: 14px; font-weight: 600; + color: var(--text-muted); + cursor: pointer; + padding: 0; + transition: color 150ms ease; + } + + .tab:hover { + color: var(--text-secondary); + } + + .tab.active { color: var(--text-primary); } + .tab-separator { + color: var(--text-disabled); + font-size: 14px; + } + + .header-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .header-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 6px; + transition: all 150ms ease; + } + + .header-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + + .header-btn.active { + color: var(--accent-primary); + } + .close-btn { width: 28px; height: 28px; @@ -318,62 +517,16 @@ color: var(--text-primary); } - .search-container { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - padding: 8px 10px; - background-color: var(--bg-tertiary); - border-radius: 6px; - flex-shrink: 0; - flex-grow: 0; - } - - .search-container :global(.search-icon) { - color: var(--text-muted); - flex-shrink: 0; - } - - .search-input { - flex: 1; - background: none; - border: none; - color: var(--text-primary); - font-size: 12px; - outline: none; - } - - .search-input::placeholder { - color: var(--text-muted); - } - - .search-clear { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 2px; - display: flex; - align-items: center; - justify-content: center; - } - - .search-clear:hover { - color: var(--text-primary); - } - + /* Content */ .content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px 16px; - padding-right: 12px; /* Less padding on right for scrollbar */ min-height: 0; overscroll-behavior: contain; } - /* Thin fancy scrollbar */ .content::-webkit-scrollbar { width: 4px; } @@ -391,50 +544,181 @@ background: rgba(255, 255, 255, 0.25); } - .content::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.4); - } - + /* Sections */ .section { margin-bottom: 16px; } - .now-playing-section { - flex-shrink: 0; - } - - .next-up-section { - display: flex; - flex-direction: column; - min-height: 0; - } - - .section-header { + .section-label { font-size: 11px; - text-transform: uppercase; - color: #666666; font-weight: 600; + color: var(--text-muted); letter-spacing: 0.05em; margin-bottom: 10px; - flex-shrink: 0; } + /* Now Playing Card */ .now-playing-card { - background-color: var(--bg-tertiary); - border-radius: 6px; + background: var(--bg-tertiary); + border-radius: 8px; padding: 10px; display: flex; align-items: center; - gap: 10px; + gap: 12px; } - .now-playing-card img { + .np-artwork { + position: relative; width: 48px; height: 48px; + flex-shrink: 0; + } + + .np-artwork img { + width: 100%; + height: 100%; border-radius: 4px; object-fit: cover; } + .spectrum-bars { + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: flex-end; + gap: 2px; + height: 16px; + } + + .spectrum-bars .bar { + width: 3px; + background-color: #4285F4; + border-radius: 1px; + animation: spectrum 1s ease-in-out infinite; + } + + .spectrum-bars .bar:nth-child(1) { + height: 8px; + animation-delay: 0s; + } + + .spectrum-bars .bar:nth-child(2) { + height: 12px; + animation-delay: 0.15s; + } + + .spectrum-bars .bar:nth-child(3) { + height: 6px; + animation-delay: 0.3s; + } + + .spectrum-bars .bar:nth-child(4) { + height: 10px; + animation-delay: 0.45s; + } + + @keyframes spectrum { + 0%, 100% { + transform: scaleY(0.5); + opacity: 0.7; + } + 50% { + transform: scaleY(1); + opacity: 1; + } + } + + .np-info { + flex: 1; + min-width: 0; + } + + .np-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .np-artist { + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .np-favorite { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 6px; + transition: all 150ms ease; + flex-shrink: 0; + } + + .np-favorite:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + + .np-favorite.active { + color: #ef4444; + } + + /* Queue Tracks */ + .tracks-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + .queue-track { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 6px; + border-radius: 6px; + cursor: pointer; + transition: background-color 150ms ease; + } + + .queue-track:hover { + background: var(--bg-tertiary); + } + + .queue-track.dragging { + opacity: 0.5; + } + + .queue-track.drag-over { + border-top: 2px solid var(--accent-primary); + margin-top: -2px; + } + + .queue-track.unavailable { + opacity: 0.4; + cursor: not-allowed; + } + + .track-number { + width: 24px; + font-size: 13px; + color: var(--text-muted); + text-align: center; + flex-shrink: 0; + } + .track-info { flex: 1; min-width: 0; @@ -457,95 +741,106 @@ white-space: nowrap; } - .tracks { + .track-duration { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + flex-shrink: 0; + } + + .load-more { + padding: 12px; + text-align: center; + background: none; + border: none; + color: var(--accent-primary); + font-size: 12px; + cursor: pointer; + width: 100%; + border-radius: 6px; + transition: background 150ms ease; + } + + .load-more:hover { + background: var(--bg-tertiary); + } + + /* History */ + .history-list { display: flex; flex-direction: column; gap: 2px; } - .queue-track { - height: 40px; + .history-track { display: flex; align-items: center; - gap: 8px; - padding: 0 6px; + gap: 10px; + padding: 6px; border-radius: 6px; cursor: pointer; transition: background-color 150ms ease; + position: relative; } - .queue-track.hovered { - background-color: var(--bg-tertiary); + .history-track:hover { + background: var(--bg-tertiary); } - .queue-track.dragging { - opacity: 0.5; - background-color: var(--bg-tertiary); + .history-artwork { + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; } - .queue-track.drag-over { - border-top: 2px solid var(--accent-primary); - margin-top: -2px; - } - - .queue-track[draggable="true"] { - user-select: none; - -webkit-user-select: none; - } - - .drag-handle { - color: #666666; - opacity: 0; - transition: opacity 150ms ease; - cursor: grab; - } - - .drag-handle:active { - cursor: grabbing; - } - - .drag-handle.visible { - opacity: 1; - } - - /* Unavailable track styles (offline mode) */ - .queue-track.unavailable { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; - } - - .queue-track.unavailable .track-info, - .queue-track.unavailable .track-number, - .queue-track.unavailable .track-duration { - filter: grayscale(100%); - } - - .unavailable-indicator { - color: var(--text-muted); - display: flex; - align-items: center; - justify-content: center; - width: 14px; - } - - .track-number { + .track-menu { + position: absolute; + right: 6px; width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; + background: var(--bg-secondary); + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; } - .track-number span { + .track-menu:hover { + color: var(--text-primary); + } + + /* Empty State */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 0; + text-align: center; + } + + .emoji { + font-size: 32px; + margin-bottom: 12px; + } + + .empty-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; + } + + .empty-text { font-size: 13px; - color: #666666; - } - - .track-duration { - font-size: 12px; - color: #666666; - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; + color: var(--text-muted); + max-width: 200px; } .no-results { @@ -555,33 +850,7 @@ font-size: 12px; } - .empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 64px 0; - text-align: center; - } - - .emoji { - font-size: 24px; - margin-bottom: 16px; - } - - .empty-title { - font-size: 16px; - font-weight: 500; - color: var(--text-primary); - margin-bottom: 8px; - } - - .empty-text { - font-size: 14px; - color: var(--text-muted); - max-width: 240px; - } - + /* Footer */ .footer { padding: 12px 16px; border-top: 1px solid var(--bg-tertiary); @@ -591,32 +860,97 @@ flex-shrink: 0; } - .clear-btn { - font-size: 12px; - color: var(--text-muted); + .footer-left, + .footer-right { + display: flex; + align-items: center; + gap: 8px; + } + + .footer-icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; background: none; border: none; + color: var(--text-muted); cursor: pointer; - transition: color 150ms ease; + border-radius: 6px; + transition: all 150ms ease; } - .clear-btn:hover { + .footer-icon-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + + .footer-icon { + width: 18px; + height: 18px; + filter: brightness(0) saturate(100%) invert(50%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%); + transition: filter 150ms ease; + } + + .footer-icon-btn:hover .footer-icon { + filter: brightness(0) saturate(100%) invert(100%); + } + + /* Search Bar */ + .search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-tertiary); + border-radius: 6px; + width: 180px; + animation: expandSearch 150ms ease-out; + } + + @keyframes expandSearch { + from { + width: 32px; + opacity: 0; + } + to { + width: 180px; + opacity: 1; + } + } + + .search-bar :global(svg) { + color: var(--text-muted); + flex-shrink: 0; + } + + .search-input { + flex: 1; + background: none; + border: none; + color: var(--text-primary); + font-size: 12px; + outline: none; + min-width: 0; + } + + .search-input::placeholder { + color: var(--text-muted); + } + + .search-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + justify-content: center; + } + + .search-close:hover { color: var(--text-primary); } - - .save-btn { - padding: 6px 14px; - border-radius: 6px; - background-color: var(--accent-primary); - color: white; - font-size: 12px; - font-weight: 500; - border: none; - cursor: pointer; - transition: background-color 150ms ease; - } - - .save-btn:hover { - background-color: var(--accent-hover); - } diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 8e857ac6c..7efcf45d1 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -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", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index d0730ae10..dd2028fcb 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -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", diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 47a6f3e6a..05928c17a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -331,6 +331,8 @@ let repeatMode = $state('off'); let queue = $state([]); let queueTotalTracks = $state(0); + let historyTracks = $state([]); + let infinitePlayEnabled = $state(false); // Toast State (from store subscription) let toast = $state(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); @@ -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} /> diff --git a/static/add-to-list.svg b/static/add-to-list.svg new file mode 100644 index 000000000..a8a5fcf5d --- /dev/null +++ b/static/add-to-list.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/trash-list.svg b/static/trash-list.svg new file mode 100644 index 000000000..2c599711a --- /dev/null +++ b/static/trash-list.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file