diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd9ec1c..c6be3973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Queue — choose between Queue and Playlist view + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#922](https://github.com/Psychotoxical/psysonic/pull/922)** + +* **Settings → Personalisation → Queue Display Mode:** *Queue* shows only upcoming tracks — the current one stays in the header and leaves the list once played; *Playlist* keeps the full queue with the current track highlighted at the top. A small icon in the queue header flips the mode, and the title follows it. +* New default is *Queue* — switch to *Playlist* in settings if you prefer the full list with the playing track shown in place. + + + ## Changed ### CI — hot-path coverage gates block merges diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx index 428e86a4..706aaa7f 100644 --- a/src/components/QueuePanel.test.tsx +++ b/src/components/QueuePanel.test.tsx @@ -74,6 +74,9 @@ describe('QueuePanel — render surface', () => { .mockImplementation(function (this: HTMLElement) { return this.classList.contains('queue-list') ? 600 : 52; }); + // These characterize the full-queue rendering (one row per track), which is + // playlist mode. The default mode is 'queue' (upcoming-only), so pin it. + useAuthStore.getState().setQueueDisplayMode('playlist'); }); afterEach(() => offsetSpy.mockRestore()); @@ -106,6 +109,66 @@ describe('QueuePanel — render surface', () => { }); }); +describe('QueuePanel — display mode', () => { + // Same virtualizer layout shim as the render-surface block: jsdom has no + // layout, so give the scroll viewport a height and rows a fixed height. + let offsetSpy: ReturnType; + beforeEach(() => { + offsetSpy = vi + .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.classList.contains('queue-list') ? 600 : 52; + }); + }); + afterEach(() => offsetSpy.mockRestore()); + + it('playlist mode: header reads "Playlist", full queue renders, no "Next Tracks" divider', () => { + const tracks = makeTracks(4); + useAuthStore.getState().setQueueDisplayMode('playlist'); + seedQueue(tracks, { index: 1, currentTrack: tracks[1] }); + const { container } = renderWithProviders(); + expect(container.querySelector('.queue-header h2')?.textContent).toBe('Playlist'); + const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx')); + expect(idxs).toEqual(['0', '1', '2', '3']); + expect(container.textContent).not.toContain('Next Tracks'); + }); + + it('queue mode: header reads "Queue", only upcoming rows render with absolute indices + titles', () => { + const tracks = makeTracks(5); + useAuthStore.getState().setQueueDisplayMode('queue'); + seedQueue(tracks, { index: 1, currentTrack: tracks[1] }); + const { container } = renderWithProviders(); + expect(container.querySelector('.queue-header h2')?.textContent).toBe('Queue'); + const rows = [...container.querySelectorAll('[data-queue-idx]')]; + // Played (0) + current (1) are gone; only 2,3,4 remain, with absolute idx. + expect(rows.map(r => r.getAttribute('data-queue-idx'))).toEqual(['2', '3', '4']); + // The first displayed row maps to the absolute track at index 2, not 0. + expect(rows[0]?.textContent).toContain(tracks[2].title); + expect(container.textContent).toContain('Next Tracks'); + }); + + it('queue mode with the current track last: shows the "no upcoming" empty state, no rows', () => { + const tracks = makeTracks(3); + useAuthStore.getState().setQueueDisplayMode('queue'); + seedQueue(tracks, { index: 2, currentTrack: tracks[2] }); + const { container } = renderWithProviders(); + expect(container.querySelectorAll('[data-queue-idx]').length).toBe(0); + expect(container.textContent).toContain('No upcoming tracks'); + }); + + it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => { + seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() }); + const { container } = renderWithProviders(); + // The mode toggle is the first .queue-action-btn in the header (the + // collapse chevron is the second). The default mode is 'queue', so the + // toggle's label names its target ("Playlist"). + const toggle = container.querySelector('.queue-header .queue-action-btn'); + expect(toggle?.getAttribute('aria-label')).toBe('Playlist'); + toggle!.click(); + expect(useAuthStore.getState().queueDisplayMode).toBe('playlist'); + }); +}); + describe('QueuePanel — toolbar', () => { it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => { const tracks = makeTracks(3); diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index ddc8e4ff..b794a62f 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,3 +1,4 @@ +import { Play } from 'lucide-react'; import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; import { songToTrack } from '../utils/playback/songToTrack'; import type { Track } from '../store/playerStoreTypes'; @@ -117,6 +118,8 @@ function QueuePanelHostOrSolo() { const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed); const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed); + const queueDisplayMode = useAuthStore(s => s.queueDisplayMode); + const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode); const toolbarButtons = useQueueToolbarStore(s => s.buttons); const durationMode = useAuthStore(s => s.queueDurationDisplayMode); const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode); @@ -210,6 +213,19 @@ function QueuePanelHostOrSolo() { else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); }; + // Queue mode shows upcoming tracks only — the current track lives in the + // header and drops out of the list once played. Playlist mode keeps the full + // timeline. `queueItems` stays the canonical list either way; the slice is a + // view. `displayBaseIndex` maps a displayed row back to its absolute queue + // index for every index-based handler (play / remove / reorder / drag). + const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0; + const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems; + // In queue mode the list can be empty while the queue still holds the + // now-playing (last) track — say "no upcoming" rather than "queue is empty". + const queueEmptyLabel = queueDisplayMode === 'queue' && queueItems.length > 0 + ? t('queue.noUpcoming') + : t('queue.emptyQueue'); + return (