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:
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* "Hopping bars" equalizer indicator (YTM-style) shown next to the currently
|
||||||
|
* playing track. `animate` controls whether the bars bounce (playback active)
|
||||||
|
* or sit frozen at full height (paused). Reusable across track lists.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayingIndicator({ animate = true, className }: Props) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`playing-bars${animate ? '' : ' paused'}${className ? ` ${className}` : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Slider, Badge } from '@olly/modern-sk';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Icon } from '../common/Icon';
|
import { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
goToIndex,
|
goToIndex,
|
||||||
@@ -18,21 +19,10 @@ export function QueuePanel() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const queue = useAppSelector((s) => s.queue);
|
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 isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||||
|
|
||||||
const nowEntry =
|
const hasEntries = queue.entries.length > 0;
|
||||||
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 isRadio = queue.source === 'radio';
|
const isRadio = queue.source === 'radio';
|
||||||
const sourceLabel = queue.sourceName ?? queue.source;
|
const sourceLabel = queue.sourceName ?? queue.source;
|
||||||
|
|
||||||
@@ -76,35 +66,8 @@ export function QueuePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="qd-scroll">
|
<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 && (
|
{isRadio && (
|
||||||
<div className="qd-radio">
|
<div className="qd-radio">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -142,18 +105,16 @@ export function QueuePanel() {
|
|||||||
>
|
>
|
||||||
{t('queue.nextUp')}
|
{t('queue.nextUp')}
|
||||||
</span>
|
</span>
|
||||||
{upNext.length === 0 ? (
|
{queue.entries.map((entry, index) => (
|
||||||
<div className="qd-empty">{t('queue.nothingNext')}</div>
|
<QueueRow
|
||||||
) : (
|
key={`${entry.trackId}-${index}`}
|
||||||
upNext.map(({ entry, index }) => (
|
entry={entry}
|
||||||
<QueueRow
|
isCurrent={index === queue.currentIndex}
|
||||||
key={`${entry.trackId}-${index}`}
|
isPlaying={isPlaying}
|
||||||
entry={entry}
|
onPlay={() => dispatch(goToIndex(index))}
|
||||||
onPlay={() => dispatch(goToIndex(index))}
|
onRemove={() => dispatch(removeFromQueue(index))}
|
||||||
onRemove={() => dispatch(removeFromQueue(index))}
|
/>
|
||||||
/>
|
))}
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
<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
|
/** A queue row, resolving its display fields against the live Track cache so
|
||||||
* (same read-through as the now-playing entry) so enrichment updates show. */
|
* enrichment updates show. The currently-playing entry is outlined and shows
|
||||||
|
* a playing-bars indicator in place of the drag grip. */
|
||||||
function QueueRow({
|
function QueueRow({
|
||||||
entry,
|
entry,
|
||||||
|
isCurrent,
|
||||||
|
isPlaying,
|
||||||
onPlay,
|
onPlay,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
entry: QueueEntry;
|
entry: QueueEntry;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const token = useAppSelector((s) => s.auth.accessToken);
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const resolved = useResolvedQueueEntry(entry);
|
const resolved = useResolvedQueueEntry(entry);
|
||||||
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
||||||
@@ -191,18 +158,32 @@ function QueueRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="qrow"
|
className={`qrow${isCurrent ? ' current' : ''}`}
|
||||||
onDoubleClick={onPlay}
|
onDoubleClick={onPlay}
|
||||||
title={t('queue.doubleClickPlay')}
|
title={t('queue.doubleClickPlay')}
|
||||||
>
|
>
|
||||||
<span className="grip">
|
{isCurrent ? (
|
||||||
<Icon name="dots-six-vertical" />
|
<PlayingIndicator animate={isPlaying} />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="grip">
|
||||||
|
<Icon name="dots-six-vertical" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
||||||
<div className="qt">
|
<div className="qt">
|
||||||
<div className="t">{resolved?.title ?? entry.title}</div>
|
<div className="t">{resolved?.title ?? entry.title}</div>
|
||||||
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn sm"
|
||||||
|
onClick={() => dispatch(openTrackInfo(entry.trackId))}
|
||||||
|
title={t('trackInfo.open')}
|
||||||
|
>
|
||||||
|
<Icon name="info" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="iconbtn sm"
|
className="iconbtn sm"
|
||||||
|
|||||||
@@ -138,9 +138,7 @@ const en = {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
from: 'From {{source}}',
|
from: 'From {{source}}',
|
||||||
radio: 'Radio · {{source}}',
|
radio: 'Radio · {{source}}',
|
||||||
nowPlaying: 'Now playing',
|
|
||||||
nextUp: 'Next up',
|
nextUp: 'Next up',
|
||||||
nothingNext: 'Nothing queued next',
|
|
||||||
empty: 'Queue is empty',
|
empty: 'Queue is empty',
|
||||||
radioActive: 'Radio active',
|
radioActive: 'Radio active',
|
||||||
mixing: '∞ mixing',
|
mixing: '∞ mixing',
|
||||||
|
|||||||
@@ -140,9 +140,7 @@ const ru: Translations = {
|
|||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
from: 'Из: {{source}}',
|
from: 'Из: {{source}}',
|
||||||
radio: 'Радио · {{source}}',
|
radio: 'Радио · {{source}}',
|
||||||
nowPlaying: 'Сейчас играет',
|
|
||||||
nextUp: 'Далее',
|
nextUp: 'Далее',
|
||||||
nothingNext: 'Очередь пуста',
|
|
||||||
empty: 'Очередь пуста',
|
empty: 'Очередь пуста',
|
||||||
radioActive: 'Радио активно',
|
radioActive: 'Радио активно',
|
||||||
mixing: '∞ микс',
|
mixing: '∞ микс',
|
||||||
|
|||||||
+53
-30
@@ -404,6 +404,47 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- playing indicator ("hopping bars" equalizer, YTM-style) ---- */
|
||||||
|
.playing-bars {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.playing-bars span {
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--lime);
|
||||||
|
border-radius: 1px;
|
||||||
|
height: 30%;
|
||||||
|
animation: playing-bar-bounce 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.playing-bars span:nth-child(1) {
|
||||||
|
animation-delay: -0.9s;
|
||||||
|
}
|
||||||
|
.playing-bars span:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.playing-bars span:nth-child(3) {
|
||||||
|
animation-delay: -0.6s;
|
||||||
|
}
|
||||||
|
.playing-bars.paused span {
|
||||||
|
animation-play-state: paused;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
@keyframes playing-bar-bounce {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
height: 30%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PLAYER BAR
|
PLAYER BAR
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -604,36 +645,6 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 12px 18px;
|
padding: 12px 12px 18px;
|
||||||
}
|
}
|
||||||
.qd-now {
|
|
||||||
display: flex;
|
|
||||||
gap: 11px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(190, 242, 100, 0.13),
|
|
||||||
rgba(190, 242, 100, 0.05)
|
|
||||||
);
|
|
||||||
border: 1px solid rgba(190, 242, 100, 0.2);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.qd-now .qt {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.qd-now .qt .t {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg-1);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.qd-now .qt .r {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--fg-3);
|
|
||||||
}
|
|
||||||
.qrow {
|
.qrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
@@ -649,6 +660,18 @@
|
|||||||
.qrow:hover {
|
.qrow:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
.qrow.current {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(190, 242, 100, 0.13),
|
||||||
|
rgba(190, 242, 100, 0.05)
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
|
||||||
|
}
|
||||||
|
.qrow.current .qt .t {
|
||||||
|
color: var(--lime);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.qrow .grip {
|
.qrow .grip {
|
||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|||||||
Reference in New Issue
Block a user