307 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|