feat(queue): unified persistent queue list with playing indicator
Show all queue entries (played and upcoming) in one list instead of splitting into a "Now playing" card + "Next up" tail, so previously played tracks don't disappear and reappear when navigating back/forward. The current track is outlined and shows a reusable "hopping bars" PlayingIndicator (modern-sk style equalizer animation) for future reuse across track lists.
This commit is contained in:
@@ -2,6 +2,7 @@ import { Slider, Badge } from '@olly/modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { ArtTile } from '../common/ArtTile';
|
||||
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import {
|
||||
goToIndex,
|
||||
@@ -18,21 +19,10 @@ export function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||
|
||||
const nowEntry =
|
||||
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
|
||||
const now = useResolvedQueueEntry(nowEntry);
|
||||
const nowArtUrl = now
|
||||
? (getCoverUrl(now.albumArtUrl) ??
|
||||
(token && now.hasCover
|
||||
? getTrackCoverUrl(now.trackId, token, true)
|
||||
: undefined))
|
||||
: undefined;
|
||||
const upNext = queue.entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ index }) => index > queue.currentIndex);
|
||||
const hasEntries = queue.entries.length > 0;
|
||||
const isRadio = queue.source === 'radio';
|
||||
const sourceLabel = queue.sourceName ?? queue.source;
|
||||
|
||||
@@ -76,35 +66,8 @@ export function QueuePanel() {
|
||||
</div>
|
||||
|
||||
<div className="qd-scroll">
|
||||
{now ? (
|
||||
{hasEntries ? (
|
||||
<>
|
||||
<span
|
||||
className="msk-label"
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('queue.nowPlaying')}
|
||||
</span>
|
||||
<div className="qd-now">
|
||||
<ArtTile
|
||||
seed={now.albumTitle}
|
||||
size={44}
|
||||
label={now.albumTitle}
|
||||
src={nowArtUrl}
|
||||
/>
|
||||
<div className="qt">
|
||||
<div className="t">{now.title}</div>
|
||||
<div className="r">{now.artistName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(openTrackInfo(now.trackId))}
|
||||
title={t('trackInfo.open')}
|
||||
>
|
||||
<Icon name="info" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isRadio && (
|
||||
<div className="qd-radio">
|
||||
<div className="row">
|
||||
@@ -142,18 +105,16 @@ export function QueuePanel() {
|
||||
>
|
||||
{t('queue.nextUp')}
|
||||
</span>
|
||||
{upNext.length === 0 ? (
|
||||
<div className="qd-empty">{t('queue.nothingNext')}</div>
|
||||
) : (
|
||||
upNext.map(({ entry, index }) => (
|
||||
<QueueRow
|
||||
key={`${entry.trackId}-${index}`}
|
||||
entry={entry}
|
||||
onPlay={() => dispatch(goToIndex(index))}
|
||||
onRemove={() => dispatch(removeFromQueue(index))}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{queue.entries.map((entry, index) => (
|
||||
<QueueRow
|
||||
key={`${entry.trackId}-${index}`}
|
||||
entry={entry}
|
||||
isCurrent={index === queue.currentIndex}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={() => dispatch(goToIndex(index))}
|
||||
onRemove={() => dispatch(removeFromQueue(index))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isRadio && (
|
||||
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
||||
@@ -168,18 +129,24 @@ export function QueuePanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/** An "up next" row, resolving its display fields against the live Track cache
|
||||
* (same read-through as the now-playing entry) so enrichment updates show. */
|
||||
/** A queue row, resolving its display fields against the live Track cache so
|
||||
* enrichment updates show. The currently-playing entry is outlined and shows
|
||||
* a playing-bars indicator in place of the drag grip. */
|
||||
function QueueRow({
|
||||
entry,
|
||||
isCurrent,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: QueueEntry;
|
||||
isCurrent: boolean;
|
||||
isPlaying: boolean;
|
||||
onPlay: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
const resolved = useResolvedQueueEntry(entry);
|
||||
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
||||
@@ -191,18 +158,32 @@ function QueueRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="qrow"
|
||||
className={`qrow${isCurrent ? ' current' : ''}`}
|
||||
onDoubleClick={onPlay}
|
||||
title={t('queue.doubleClickPlay')}
|
||||
>
|
||||
<span className="grip">
|
||||
<Icon name="dots-six-vertical" />
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<PlayingIndicator animate={isPlaying} />
|
||||
) : (
|
||||
<span className="grip">
|
||||
<Icon name="dots-six-vertical" />
|
||||
</span>
|
||||
)}
|
||||
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
||||
<div className="qt">
|
||||
<div className="t">{resolved?.title ?? entry.title}</div>
|
||||
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(openTrackInfo(entry.trackId))}
|
||||
title={t('trackInfo.open')}
|
||||
>
|
||||
<Icon name="info" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
|
||||
Reference in New Issue
Block a user