Files
mcma-webui/src/components/player/QueuePanel.tsx
T
Senko-san 44c8d1870f
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
feat(queue): move shuffle/loop controls into queue drawer, scoped to queue
2026-06-13 18:17:21 +03:00

307 lines
9.3 KiB
TypeScript

import {
Slider,
Badge,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
IconButton,
} from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import {
goToIndex,
removeFromQueue,
moveInQueue,
clearQueue,
toggleShuffle,
toggleLoop,
type QueueEntry,
} from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function QueuePanel() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
const hasEntries = queue.entries.length > 0;
const isRadio = queue.source === 'radio';
const sourceLabel = queue.sourceName ?? queue.source;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
dispatch(moveInQueue({ from: Number(active.id), to: Number(over.id) }));
};
return (
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className={`iconbtn sm${queue.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('queue.shuffle')}
>
<Icon name="shuffle" />
</button>
<button
type="button"
className={`iconbtn sm${queue.loop ? ' on' : ''}`}
onClick={() => dispatch(toggleLoop())}
title={t('queue.loop')}
>
<Icon name="repeat" />
</button>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title={t('queue.clear')}
>
<Icon name="trash" />
</button>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title={t('queue.close')}
>
<Icon name="x" />
</button>
</div>
<div className="qd-src">
<Icon
name={isRadio ? 'radio' : 'playlist'}
style={{ color: isRadio ? 'var(--lime)' : 'var(--fg-3)' }}
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
{t('queue.radio', { source: sourceLabel })}
</span>
) : (
<span>{t('queue.from', { source: sourceLabel })}</span>
)}
</div>
</div>
<div className="qd-scroll">
{hasEntries ? (
<>
{isRadio && (
<div className="qd-radio">
<div className="row">
<Icon name="radio" />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
{t('queue.radioActive')}
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral">{t('queue.mixing')}</Badge>
</div>
<div className="expl">
<span className="lab">{t('queue.familiar')}</span>
<Slider
className="expl-slider"
min={0}
max={100}
step={1}
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">{t('queue.new')}</span>
</div>
</div>
)}
<span
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
{t('queue.nextUp')}
</span>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.entries.map((_, index) => String(index))}
strategy={verticalListSortingStrategy}
>
{queue.entries.map((entry, index) => (
<QueueRow
key={`${entry.trackId}-${index}`}
id={String(index)}
entry={entry}
isCurrent={index === queue.currentIndex}
isPlaying={isPlaying}
onPlay={() => dispatch(goToIndex(index))}
onMoveNext={() =>
dispatch(
moveInQueue({
from: index,
to: queue.currentIndex + 1,
}),
)
}
onRemove={() => dispatch(removeFromQueue(index))}
/>
))}
</SortableContext>
</DndContext>
{isRadio && (
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
)}
</>
) : (
<div className="qd-empty">{t('queue.empty')}</div>
)}
</div>
</div>
</aside>
);
}
/** 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({
id,
entry,
isCurrent,
isPlaying,
onPlay,
onMoveNext,
onRemove,
}: {
id: string;
entry: QueueEntry;
isCurrent: boolean;
isPlaying: boolean;
onPlay: () => void;
onMoveNext: () => 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;
const artUrl =
getCoverUrl(resolved?.albumArtUrl) ??
(token && resolved?.hasCover
? getTrackCoverUrl(resolved.trackId, token, true)
: undefined);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')}
>
{isCurrent ? (
<PlayingIndicator animate={isPlaying} />
) : (
<span className="grip" {...attributes} {...listeners}>
<Icon name="dots-six-vertical" />
</span>
)}
<div className="qart">
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
{isCurrent && (
<div className="cover-playing">
<PlayingIndicator animate={isPlaying} />
</div>
)}
</div>
<div className="qt">
<div className="t">{resolved?.title ?? entry.title}</div>
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
</div>
<Menu>
<MenuTrigger asChild>
<IconButton
variant="ghost"
size="sm"
aria-label={t('queue.menu.options')}
>
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={onPlay}>{t('queue.menu.playNow')}</MenuItem>
{!isCurrent && (
<MenuItem onSelect={onMoveNext}>
{t('queue.menu.moveNext')}
</MenuItem>
)}
<MenuItem onSelect={() => dispatch(openTrackInfo(entry.trackId))}>
{t('queue.menu.info')}
</MenuItem>
<MenuItem onSelect={onRemove}>{t('queue.menu.remove')}</MenuItem>
</MenuContent>
</Menu>
</div>
);
}