mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
291 lines
14 KiB
TypeScript
291 lines
14 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 '../OverlayScrollArea';
|
|
import { usePlayerStore } from '../../store/playerStore';
|
|
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
|
import type { QueueItemRef, PlayerState } from '../../store/playerStoreTypes';
|
|
import type { QueueDisplayMode } from '../../store/authStoreTypes';
|
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
|
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
|
import {
|
|
getQueueResolverVersion,
|
|
subscribeQueueResolver,
|
|
} from '../../utils/library/queueTrackResolver';
|
|
|
|
type StartDrag = (
|
|
payload: { data: string; label: string },
|
|
x: number,
|
|
y: number,
|
|
) => void;
|
|
|
|
interface Props {
|
|
/** The rows to render. In queue mode this is the upcoming-only slice of the
|
|
* canonical queue; in playlist mode it is the full queue. */
|
|
queue: QueueItemRef[];
|
|
/** Absolute index of the currently playing track in the canonical queue. */
|
|
queueIndex: number;
|
|
/** Absolute index of `queue[0]` in the canonical queue (0 in playlist mode,
|
|
* `queueIndex + 1` in queue mode). Added to a row's local index to recover
|
|
* its absolute index for play / context-menu / drag / reorder. */
|
|
displayBaseIndex: number;
|
|
queueDisplayMode: QueueDisplayMode;
|
|
/** Label for the empty list (differs between "queue is empty" and "no
|
|
* upcoming tracks"). */
|
|
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;
|
|
}
|
|
|
|
// Stable reference so the virtualizer never sees a "changed" option on re-render
|
|
// (an inline object literal would be a new ref every render). Only used until the
|
|
// ResizeObserver reports the real viewport height.
|
|
const INITIAL_RECT = { width: 0, height: 600 };
|
|
|
|
export function QueueList({
|
|
queue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel,
|
|
contextMenu, playTrack, activeTab, queueListRef,
|
|
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
|
startDrag, orbitAttributionLabel, luckyRolling, t,
|
|
}: Props) {
|
|
// Thin-state: the queue prop is the canonical `QueueItemRef[]`. Each row's
|
|
// full Track comes from the resolver (cache → placeholder; F4 overrides merged
|
|
// in resolveQueueTrack). Subscribe once so the list re-renders as the cache
|
|
// fills. Pure read in render — no cache mutation (the freeze landmine).
|
|
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
|
|
|
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
|
|
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
|
|
// variable height (radio/auto dividers, lucky-mix loader) so we measure them.
|
|
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
|
// eslint-disable-next-line react-hooks/incompatible-library
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: queue.length,
|
|
getScrollElement: () => queueListRef.current,
|
|
estimateSize: () => 52,
|
|
overscan: 10,
|
|
getItemKey: i => `${queue[i].trackId}:${i}`,
|
|
// Start with a sensible viewport height so rows render before the
|
|
// ResizeObserver reports the real size (SSR / jsdom, where the observer
|
|
// never fires). The real height overrides this on first measure.
|
|
initialRect: INITIAL_RECT,
|
|
});
|
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
const totalSize = rowVirtualizer.getTotalSize();
|
|
|
|
// Auto-scroll on track change (and on mode toggle). Honours the suppression
|
|
// flag (row click / undo restore). Two-step where it scrolls: the virtualizer
|
|
// brings the target row into the rendered range (estimate-based, can land a
|
|
// few px off), then scrollIntoView snaps it flush at the top (exact).
|
|
useEffect(() => {
|
|
if (suppressNextAutoScrollRef.current) {
|
|
suppressNextAutoScrollRef.current = false;
|
|
return;
|
|
}
|
|
if (activeTab !== 'queue' || queueIndex < 0 || queue.length === 0) return;
|
|
|
|
const pinToTop = (localIndex: number, absIndex: number) => {
|
|
rowVirtualizer.scrollToIndex(localIndex, { align: 'start' });
|
|
const id = requestAnimationFrame(() => {
|
|
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${absIndex}"]`);
|
|
el?.scrollIntoView({ block: 'start', behavior: 'instant' });
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
};
|
|
|
|
if (queueDisplayMode === 'queue') {
|
|
// Upcoming-only: the next track is the first row — keep it pinned at the
|
|
// top. The played track already dropped out of the slice.
|
|
return pinToTop(0, displayBaseIndex);
|
|
}
|
|
|
|
if (queueDisplayMode === 'timeline') {
|
|
// Anchor the current track in the middle — history above, up-next below.
|
|
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
|
const id = requestAnimationFrame(() => {
|
|
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
|
|
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}
|
|
|
|
// Playlist: lazy. Let the highlight wander while the now-playing row stays
|
|
// visible; only re-pin it to the top once it has scrolled out of view.
|
|
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; // highlight just moved within view — don't yank
|
|
}
|
|
}
|
|
return pinToTop(queueIndex, queueIndex);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [queueIndex, activeTab, queueDisplayMode]);
|
|
|
|
return (
|
|
<OverlayScrollArea
|
|
viewportRef={queueListRef}
|
|
className="queue-list-wrap"
|
|
viewportClassName="queue-list"
|
|
measureDeps={[activeTab, queue.length, totalSize]}
|
|
railInset="panel"
|
|
viewportScrollBehaviorAuto={isQueueDrag}
|
|
>
|
|
{queue.length === 0 ? (
|
|
<div className="queue-empty">
|
|
{emptyLabel}
|
|
</div>
|
|
) : (
|
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
|
{virtualItems.map(vi => {
|
|
const idx = vi.index;
|
|
// Local index addresses `queue` (the displayed slice); the absolute
|
|
// index addresses the canonical queue and drives every handler that
|
|
// mutates / selects (play, context menu, drag, reorder, drop target).
|
|
const absIdx = displayBaseIndex + idx;
|
|
const base = queue[idx];
|
|
const track = resolveQueueTrack(base);
|
|
const isPlaying = absIdx === queueIndex;
|
|
const isTimeline = queueDisplayMode === 'timeline';
|
|
const isPast = isTimeline && absIdx < queueIndex;
|
|
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)` }}
|
|
>
|
|
{isTimeline && idx === 0 && queueIndex > 0 && (
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.history')}</span>
|
|
</div>
|
|
)}
|
|
{isTimeline && absIdx === queueIndex + 1 && (
|
|
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.upNext')}</span>
|
|
</div>
|
|
)}
|
|
{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>
|
|
)}
|
|
<div
|
|
data-queue-idx={absIdx}
|
|
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === absIdx ? 'context-active' : ''}`}
|
|
onClick={() => {
|
|
suppressNextAutoScrollRef.current = true;
|
|
// Same-queue jump: undefined keeps the canonical refs; the row
|
|
// index lands a click on a duplicate track on *this* slot, not
|
|
// the first occurrence (issue #500).
|
|
playTrack(track, undefined, undefined, undefined, absIdx);
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', absIdx);
|
|
}}
|
|
onMouseDown={(e) => {
|
|
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>
|
|
{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>
|
|
);
|
|
}
|