mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
440 lines
19 KiB
TypeScript
440 lines
19 KiB
TypeScript
import React, { useEffect, useSyncExternalStore } from 'react';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { Play } from 'lucide-react';
|
|
import type { TFunction } from 'i18next';
|
|
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
|
import { useLuckyMixStore } from '@/features/randomMix';
|
|
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
|
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
|
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
|
import { formatTrackTime } from '@/lib/format/formatDuration';
|
|
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
|
import {
|
|
getQueueResolverVersion,
|
|
subscribeQueueResolver,
|
|
resolveBatch,
|
|
} from '@/features/playback/store/queueTrackResolver';
|
|
import { collectQueueResolveRefs } from '@/features/queue/utils/collectQueueResolveRefs';
|
|
import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity';
|
|
import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows';
|
|
import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows';
|
|
import { playTimelineHistoryTrack } from '@/features/playback/utils/playTimelineHistoryTrack';
|
|
|
|
type StartDrag = (
|
|
payload: { data: string; label: string },
|
|
x: number,
|
|
y: number,
|
|
) => void;
|
|
|
|
interface Props {
|
|
queue: QueueItemRef[];
|
|
/** Timeline virtual rows; when set, `queue` is ignored for rendering. */
|
|
timelineRows?: TimelineDisplayRow[];
|
|
/** Canonical queue for history-row play / context-menu index lookup. */
|
|
canonicalQueue?: QueueItemRef[];
|
|
queueIndex: number;
|
|
displayBaseIndex: number;
|
|
queueDisplayMode: QueueDisplayMode;
|
|
emptyLabel: string;
|
|
contextMenu: PlayerState['contextMenu'];
|
|
playTrack: PlayerState['playTrack'];
|
|
activeTab: string;
|
|
queueListRef: React.RefObject<HTMLDivElement | null>;
|
|
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
|
|
isQueueDrag: boolean;
|
|
psyDragFromIdxRef: React.MutableRefObject<number | null>;
|
|
externalDropTarget: { idx: number; before: boolean } | null;
|
|
startDrag: StartDrag;
|
|
orbitAttributionLabel: (trackId: string) => string | null;
|
|
luckyRolling: boolean;
|
|
t: TFunction;
|
|
}
|
|
|
|
const INITIAL_RECT = { width: 0, height: 600 };
|
|
|
|
export function QueueList({
|
|
queue, timelineRows, canonicalQueue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel,
|
|
contextMenu, playTrack, activeTab, queueListRef,
|
|
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
|
startDrag, orbitAttributionLabel, luckyRolling, t,
|
|
}: Props) {
|
|
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
|
|
|
const usingTimeline = queueDisplayMode === 'timeline' && timelineRows != null;
|
|
const rowCount = usingTimeline ? timelineRows.length : queue.length;
|
|
|
|
// eslint-disable-next-line react-hooks/incompatible-library
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: rowCount,
|
|
getScrollElement: () => queueListRef.current,
|
|
estimateSize: () => 52,
|
|
overscan: 10,
|
|
getItemKey: i => {
|
|
if (usingTimeline) return timelineRows[i]!.key;
|
|
return `${queue[i].trackId}:${i}`;
|
|
},
|
|
initialRect: INITIAL_RECT,
|
|
});
|
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
const totalSize = rowVirtualizer.getTotalSize();
|
|
|
|
// Resolve the rows the user is actually looking at (queue thin-state). The
|
|
// queueResolverBridge only warms a window around the *playing* index; scrolling
|
|
// the queue independently would otherwise leave rows stuck on the '…'
|
|
// placeholder (cache miss / evicted from the bounded LRU). Drive resolution off
|
|
// the virtualizer's visible range so any scrolled-to row fetches on demand.
|
|
const firstVisible = virtualItems.length > 0 ? virtualItems[0]!.index : 0;
|
|
const lastVisible = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1]!.index : 0;
|
|
useEffect(() => {
|
|
if (rowCount === 0) return;
|
|
const refs = collectQueueResolveRefs({ usingTimeline, timelineRows, queue, firstVisible, lastVisible });
|
|
if (refs.length > 0) void resolveBatch(refs);
|
|
// Keyed on the visible range + mode; the resolver dedups against cache/in-flight,
|
|
// so re-running on scroll is cheap. Queue-content changes are covered by the bridge.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [firstVisible, lastVisible, rowCount, usingTimeline]);
|
|
|
|
useEffect(() => {
|
|
if (suppressNextAutoScrollRef.current) {
|
|
suppressNextAutoScrollRef.current = false;
|
|
return;
|
|
}
|
|
if (activeTab !== 'queue' || rowCount === 0 || !usingTimeline || !timelineRows) return;
|
|
|
|
const localIdx = findTimelineScrollLocalIndex(timelineRows);
|
|
if (localIdx == null) return;
|
|
|
|
const pinToTop = (index: number, scrollSelector: string) => {
|
|
rowVirtualizer.scrollToIndex(index, { align: 'start' });
|
|
const id = requestAnimationFrame(() => {
|
|
const el = queueListRef.current?.querySelector<HTMLElement>(scrollSelector);
|
|
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
};
|
|
|
|
return pinToTop(localIdx, `[data-timeline-local-idx="${localIdx}"]`);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [queueIndex, activeTab, queueDisplayMode, usingTimeline]);
|
|
|
|
useEffect(() => {
|
|
if (suppressNextAutoScrollRef.current) {
|
|
suppressNextAutoScrollRef.current = false;
|
|
return;
|
|
}
|
|
if (activeTab !== 'queue' || rowCount === 0 || usingTimeline) return;
|
|
|
|
const pinToTop = (localIndex: number, scrollSelector: string) => {
|
|
rowVirtualizer.scrollToIndex(localIndex, { align: 'start' });
|
|
const id = requestAnimationFrame(() => {
|
|
const el = queueListRef.current?.querySelector<HTMLElement>(scrollSelector);
|
|
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
};
|
|
|
|
if (queueDisplayMode === 'queue') {
|
|
if (queueIndex < 0) return;
|
|
return pinToTop(0, `[data-queue-idx="${displayBaseIndex}"]`);
|
|
}
|
|
|
|
if (queueIndex < 0) return;
|
|
const viewport = queueListRef.current;
|
|
if (viewport) {
|
|
const rowEl = viewport.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
|
|
if (rowEl) {
|
|
const rowRect = rowEl.getBoundingClientRect();
|
|
const viewRect = viewport.getBoundingClientRect();
|
|
const fullyVisible = rowRect.top >= viewRect.top && rowRect.bottom <= viewRect.bottom;
|
|
if (fullyVisible) return;
|
|
}
|
|
}
|
|
return pinToTop(queueIndex, `[data-queue-idx="${queueIndex}"]`);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [queueIndex, activeTab, queueDisplayMode, rowCount, usingTimeline]);
|
|
|
|
const playHistoryRow = (serverId: string, trackId: string) => {
|
|
suppressNextAutoScrollRef.current = true;
|
|
void playTimelineHistoryTrack(serverId, trackId, canonicalQueue);
|
|
};
|
|
|
|
const renderTrackRow = (args: {
|
|
track: ReturnType<typeof resolveQueueTrack>;
|
|
absIdx: number | null;
|
|
localIndex: number;
|
|
isPlaying: boolean;
|
|
isPast: boolean;
|
|
isHistory: boolean;
|
|
base?: QueueItemRef;
|
|
dragStyle: React.CSSProperties;
|
|
}) => {
|
|
const { track, absIdx, localIndex, isPlaying, isPast, isHistory, base, dragStyle } = args;
|
|
return (
|
|
<div
|
|
data-timeline-local-idx={localIndex}
|
|
{...(isHistory ? { 'data-timeline-kind': 'history' } : {})}
|
|
{...(absIdx != null ? { 'data-queue-idx': absIdx } : {})}
|
|
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === (absIdx != null ? 'queue-item' : 'song') && (absIdx != null ? contextMenu.queueIndex === absIdx : contextMenu.item === track) ? 'context-active' : ''}`}
|
|
onClick={() => {
|
|
if (isHistory) {
|
|
playHistoryRow(base?.serverId ?? track.serverId ?? '', track.id);
|
|
return;
|
|
}
|
|
if (absIdx == null) return;
|
|
suppressNextAutoScrollRef.current = true;
|
|
playTrack(track, undefined, undefined, undefined, absIdx);
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
if (isHistory && absIdx == null) {
|
|
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'song');
|
|
return;
|
|
}
|
|
if (absIdx == null) return;
|
|
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', absIdx);
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (isHistory || absIdx == null) return;
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
const onMove = (me: MouseEvent) => {
|
|
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
psyDragFromIdxRef.current = absIdx;
|
|
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: absIdx }), label: track.title }, me.clientX, me.clientY);
|
|
}
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}}
|
|
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' }}>
|
|
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
|
|
<span className="truncate">{track.title}</span>
|
|
</div>
|
|
<div className="queue-item-artist truncate">{track.artist}</div>
|
|
{(() => {
|
|
const label = orbitAttributionLabel(track.id);
|
|
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
|
|
})()}
|
|
</div>
|
|
<div className="queue-item-duration">
|
|
{formatTrackTime(track.duration)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<OverlayScrollArea
|
|
viewportRef={queueListRef}
|
|
className="queue-list-wrap"
|
|
viewportClassName="queue-list"
|
|
measureDeps={[activeTab, rowCount, totalSize]}
|
|
railInset="panel"
|
|
viewportScrollBehaviorAuto={isQueueDrag}
|
|
>
|
|
{rowCount === 0 ? (
|
|
emptyLabel ? (
|
|
<div className="queue-empty">{emptyLabel}</div>
|
|
) : null
|
|
) : (
|
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
|
{virtualItems.map(vi => {
|
|
const idx = vi.index;
|
|
|
|
if (usingTimeline && timelineRows) {
|
|
const row = timelineRows[idx]!;
|
|
if (row.kind === 'divider') {
|
|
return (
|
|
<div
|
|
key={row.key}
|
|
data-index={idx}
|
|
data-timeline-local-idx={row.localIndex}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
|
>
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>
|
|
{t(row.labelKey)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const base = row.kind === 'history'
|
|
? { serverId: row.ref.serverId, trackId: row.ref.trackId }
|
|
: row.ref;
|
|
const track = resolveQueueTrack(base);
|
|
const absIdx = row.kind === 'history'
|
|
? findQueueItemRefIndex(
|
|
canonicalQueue ?? usePlayerStore.getState().queueItems,
|
|
row.ref,
|
|
)
|
|
: row.queueIndex;
|
|
const isPlaying = row.kind === 'current';
|
|
const isPast = row.kind === 'history';
|
|
const prevRow = idx > 0 ? timelineRows[idx - 1] : null;
|
|
const isFirstAutoAdded = row.kind === 'upcoming' && row.ref.autoAdded
|
|
&& (prevRow?.kind !== 'upcoming' || !prevRow.ref.autoAdded);
|
|
const isFirstRadioAdded = row.kind === 'upcoming' && row.ref.radioAdded
|
|
&& (prevRow?.kind !== 'upcoming' || !prevRow.ref.radioAdded);
|
|
|
|
let dragStyle: React.CSSProperties = {};
|
|
if (row.kind !== 'history' && isQueueDrag && psyDragFromIdxRef.current === absIdx) {
|
|
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
|
} else if (row.kind !== 'history' && isQueueDrag && externalDropTarget?.idx === absIdx) {
|
|
dragStyle = externalDropTarget.before
|
|
? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }
|
|
: { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={row.key}
|
|
data-index={idx}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
|
>
|
|
{isFirstRadioAdded && (
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
|
</div>
|
|
)}
|
|
{isFirstAutoAdded && (
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
|
</div>
|
|
)}
|
|
{renderTrackRow({
|
|
track,
|
|
absIdx: row.kind === 'history' ? (absIdx >= 0 ? absIdx : null) : absIdx,
|
|
localIndex: row.localIndex,
|
|
isPlaying,
|
|
isPast,
|
|
isHistory: row.kind === 'history',
|
|
base,
|
|
dragStyle,
|
|
})}
|
|
{luckyRolling && isPlaying && (
|
|
<button
|
|
type="button"
|
|
className="queue-lucky-loading"
|
|
onClick={() => useLuckyMixStore.getState().cancel()}
|
|
data-tooltip={t('luckyMix.cancelTooltip')}
|
|
aria-label={t('luckyMix.cancelTooltip')}
|
|
>
|
|
<div className="queue-lucky-loading__dice">
|
|
<div className="queue-lucky-cube queue-lucky-cube--a">
|
|
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--tr" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--bl" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
|
</div>
|
|
<div className="queue-lucky-cube queue-lucky-cube--b">
|
|
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
|
</div>
|
|
<div className="queue-lucky-cube queue-lucky-cube--c">
|
|
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const absIdx = displayBaseIndex + idx;
|
|
const base = queue[idx];
|
|
const track = resolveQueueTrack(base);
|
|
const isPlaying = absIdx === queueIndex;
|
|
const isPast = false;
|
|
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
|
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
|
|
|
let dragStyle: React.CSSProperties = {};
|
|
if (isQueueDrag && psyDragFromIdxRef.current === absIdx) {
|
|
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
|
} else if (isQueueDrag && externalDropTarget?.idx === absIdx) {
|
|
if (externalDropTarget.before) {
|
|
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
|
|
} else {
|
|
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={vi.key}
|
|
data-index={idx}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
|
>
|
|
{isFirstRadioAdded && (
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
|
</div>
|
|
)}
|
|
{isFirstAutoAdded && (
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
|
|
</div>
|
|
)}
|
|
{renderTrackRow({
|
|
track,
|
|
absIdx,
|
|
localIndex: idx,
|
|
isPlaying,
|
|
isPast,
|
|
isHistory: false,
|
|
base,
|
|
dragStyle,
|
|
})}
|
|
{luckyRolling && isPlaying && (
|
|
<button
|
|
type="button"
|
|
className="queue-lucky-loading"
|
|
onClick={() => useLuckyMixStore.getState().cancel()}
|
|
data-tooltip={t('luckyMix.cancelTooltip')}
|
|
aria-label={t('luckyMix.cancelTooltip')}
|
|
>
|
|
<div className="queue-lucky-loading__dice">
|
|
<div className="queue-lucky-cube queue-lucky-cube--a">
|
|
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--tr" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--bl" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
|
</div>
|
|
<div className="queue-lucky-cube queue-lucky-cube--b">
|
|
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
|
</div>
|
|
<div className="queue-lucky-cube queue-lucky-cube--c">
|
|
<span className="lucky-mix-pip lucky-mix-pip--tl" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--center" />
|
|
<span className="lucky-mix-pip lucky-mix-pip--br" />
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</OverlayScrollArea>
|
|
);
|
|
}
|