mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(queue): add Timeline display mode (#1004)
* feat(queue): add Timeline display mode A third queue display mode alongside Queue and Playlist. Timeline shows the full queue anchored on the current track — played history above (dimmed), upcoming below — with 'History' and 'Up next' dividers and the current row auto-centered. It already follows shuffle order since the queue is stored in play order. The header mode button now cycles queue → timeline → playlist; Settings gains a third option. * i18n(queue): Timeline mode strings (9 locales) * docs(changelog): note Timeline queue mode (#1004)
This commit is contained in:
committed by
GitHub
parent
41157ccaca
commit
1c23305887
@@ -156,16 +156,16 @@ describe('QueuePanel — display mode', () => {
|
||||
expect(container.textContent).toContain('No upcoming tracks');
|
||||
});
|
||||
|
||||
it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => {
|
||||
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
// 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").
|
||||
// collapse chevron is the second). The toggle's label names its target;
|
||||
// from the default 'queue' that is the next mode in the cycle, "Timeline".
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Playlist');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
|
||||
toggle!.click();
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('playlist');
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
|
||||
import { ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
@@ -81,6 +81,16 @@ export function QueueHeader({
|
||||
|
||||
const isEta = durationMode === 'eta';
|
||||
|
||||
// Cycle the panel through the three render modes; icon + title show the active
|
||||
// one, tooltip names the one a click switches to.
|
||||
const DISPLAY_MODE_CYCLE: QueueDisplayMode[] = ['queue', 'timeline', 'playlist'];
|
||||
const nextDisplayMode =
|
||||
DISPLAY_MODE_CYCLE[(DISPLAY_MODE_CYCLE.indexOf(queueDisplayMode) + 1) % DISPLAY_MODE_CYCLE.length];
|
||||
const displayModeLabel = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? t('queue.modePlaylist') : m === 'timeline' ? t('queue.modeTimeline') : t('queue.title');
|
||||
const displayModeIcon = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? <ListMusic size={15} /> : m === 'timeline' ? <AlignCenterVertical size={15} /> : <ListOrdered size={15} />;
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
@@ -91,17 +101,17 @@ export function QueueHeader({
|
||||
<button
|
||||
type="button"
|
||||
className="queue-action-btn"
|
||||
onClick={() => setQueueDisplayMode(queueDisplayMode === 'playlist' ? 'queue' : 'playlist')}
|
||||
data-tooltip={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
|
||||
aria-label={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
|
||||
onClick={() => setQueueDisplayMode(nextDisplayMode)}
|
||||
data-tooltip={displayModeLabel(nextDisplayMode)}
|
||||
aria-label={displayModeLabel(nextDisplayMode)}
|
||||
style={{ width: 24, height: 24, alignSelf: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{queueDisplayMode === 'playlist' ? <ListMusic size={15} /> : <ListOrdered size={15} />}
|
||||
{displayModeIcon(queueDisplayMode)}
|
||||
</button>
|
||||
{/* Title doubles as the mode indicator so the panel reads "Playlist"
|
||||
in playlist mode rather than the misleading "Queue". */}
|
||||
{/* Title doubles as the mode indicator so the panel names the active
|
||||
mode rather than always reading "Queue". */}
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>
|
||||
{queueDisplayMode === 'playlist' ? t('queue.modePlaylist') : t('queue.title')}
|
||||
{displayModeLabel(queueDisplayMode)}
|
||||
</h2>
|
||||
{queue.length > 0 && (
|
||||
<span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
|
||||
|
||||
@@ -108,6 +108,16 @@ export function QueueList({
|
||||
return pinToTop(0, displayBaseIndex);
|
||||
}
|
||||
|
||||
if (queueDisplayMode === 'timeline') {
|
||||
// Anchor the current track in the middle — history above, up-next below.
|
||||
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
||||
const id = requestAnimationFrame(() => {
|
||||
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
// Playlist: lazy. Let the highlight wander while the now-playing row stays
|
||||
// visible; only re-pin it to the top once it has scrolled out of view.
|
||||
const viewport = queueListRef.current;
|
||||
@@ -148,6 +158,8 @@ export function QueueList({
|
||||
const base = queue[idx];
|
||||
const track = resolveQueueTrack(base);
|
||||
const isPlaying = absIdx === queueIndex;
|
||||
const isTimeline = queueDisplayMode === 'timeline';
|
||||
const isPast = isTimeline && absIdx < queueIndex;
|
||||
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
@@ -169,6 +181,16 @@ export function QueueList({
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{isTimeline && idx === 0 && queueIndex > 0 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.history')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isTimeline && absIdx === queueIndex + 1 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.upNext')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isFirstRadioAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
||||
@@ -213,7 +235,7 @@ export function QueueList({
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={dragStyle}
|
||||
style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
|
||||
@@ -84,8 +84,8 @@ export function PersonalisationTab() {
|
||||
icon={<ListOrdered size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Two mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the other off; the active one cannot be
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
@@ -116,6 +116,21 @@ export function PersonalisationTab() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeTimelineSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modeTimeline')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user