mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(queue): co-locate QueuePanel UI into features/queue
Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).
Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.
Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
This commit is contained in:
@@ -13,7 +13,7 @@ import { useArtistFanart } from '@/cover/useArtistFanart';
|
||||
import { backdropFromConfig } from '@/cover/artistBackdrop';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useFsIdleFade } from '@/hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '@/hooks/useQueueTracks';
|
||||
import { useQueueTrackAt } from '@/features/queue';
|
||||
import { WaveformSeek } from '@/features/waveform';
|
||||
import { FsQueueModal } from '@/features/fullscreenPlayer/components/FsQueueModal';
|
||||
import { FsLyricsApple } from '@/features/fullscreenPlayer/components/FsLyricsApple';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Play, X, Trash2, ListPlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getPlaylists, deletePlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onLoad: (id: string, name: string, mode: 'replace' | 'append') => void;
|
||||
}
|
||||
|
||||
export function LoadPlaylistModal({ onClose, onLoad }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists().then(data => {
|
||||
setPlaylists(data);
|
||||
setLoading(false);
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchPlaylists();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
setConfirmDelete({ id, name });
|
||||
};
|
||||
|
||||
const confirmDeletePlaylist = async () => {
|
||||
if (!confirmDelete) return;
|
||||
await deletePlaylist(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
fetchPlaylists();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '560px', width: '90vw' }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
|
||||
{!loading && playlists.length > 0 && (
|
||||
<input
|
||||
type="text"
|
||||
className="live-search-field"
|
||||
placeholder={t('queue.filterPlaylists')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
autoFocus
|
||||
style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }}
|
||||
/>
|
||||
)}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
|
||||
) : playlists.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--bg-hover)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span style={{ fontWeight: 500 }} className="truncate" data-tooltip={p.name}>{p.name}</span>
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id, p.name, 'replace')} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id, p.name, 'append')} data-tooltip={t('queue.appendToQueue')} style={{ width: '28px', height: '28px', background: 'transparent' }}><ListPlus size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--danger)' }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="modal-overlay" onClick={() => setConfirmDelete(null)} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '360px' }}>
|
||||
<button className="modal-close" onClick={() => setConfirmDelete(null)}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{t('queue.delete')}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
|
||||
{t('queue.deleteConfirm', { name: confirmDelete.name })}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>{t('queue.cancel')}</button>
|
||||
<button className="btn btn-primary" style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={confirmDeletePlaylist}>
|
||||
{t('queue.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, FolderOpen, HardDrive, Music, Waves } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { LoudnessLufsPreset, NormalizationEngine } from '@/store/authStoreTypes';
|
||||
import type { PlaybackSourceKind } from '@/utils/playback/resolvePlaybackUrl';
|
||||
import {
|
||||
formatQueueReplayGainParts,
|
||||
renderStars,
|
||||
} from '@/utils/componentHelpers/queuePanelHelpers';
|
||||
import { loudnessGainPlaceholderUntilCacheDb } from '@/utils/audio/loudnessPlaceholder';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
|
||||
import { formatQueueBpmTech, formatQueueMoodLabels } from '@/utils/library/trackEnrichment';
|
||||
import { useQueueTrackEnrichment } from '@/features/queue/hooks/useQueueTrackEnrichment';
|
||||
import { QueueLufsTargetMenu } from '@/features/queue/components/QueueLufsTargetMenu';
|
||||
import { PlaybackBufferingOverlay } from '@/components/playback/PlaybackBufferingOverlay';
|
||||
import { CoverArtImage } from '@/cover/CoverArtImage';
|
||||
import { OpenArtistRefInline } from '@/features/artist';
|
||||
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
orbitAttributionLabel: (trackId: string) => string | null;
|
||||
navigate: (to: string) => void | Promise<void>;
|
||||
playbackSource: PlaybackSourceKind | null;
|
||||
normalizationEngine: NormalizationEngine;
|
||||
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
|
||||
normalizationNowDb: number | null;
|
||||
normalizationTargetLufs: number | null;
|
||||
authLoudnessTargetLufs: LoudnessLufsPreset;
|
||||
loudnessPreAnalysisAttenuationDb: number;
|
||||
expandReplayGain: boolean;
|
||||
setExpandReplayGain: (v: boolean) => void;
|
||||
reanalyzeLoudnessForTrack: (id: string) => void | Promise<void>;
|
||||
setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
|
||||
lufsTgtOpen: boolean;
|
||||
setLufsTgtOpen: (v: boolean | ((p: boolean) => boolean)) => void;
|
||||
lufsTgtBtnRef: React.RefObject<HTMLButtonElement | null>;
|
||||
lufsTgtMenuRef: React.RefObject<HTMLDivElement | null>;
|
||||
lufsTgtPopStyle: React.CSSProperties;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function QueueCurrentTrack({
|
||||
currentTrack, userRatingOverrides, orbitAttributionLabel,
|
||||
navigate, playbackSource, normalizationEngine, normalizationEngineLive,
|
||||
normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs,
|
||||
loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain,
|
||||
reanalyzeLoudnessForTrack, setLoudnessTargetLufs, lufsTgtOpen, setLufsTgtOpen,
|
||||
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
||||
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
||||
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||
const bpmTech = formatQueueBpmTech(enrichment, t);
|
||||
const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t);
|
||||
return (
|
||||
<div className="queue-current-track">
|
||||
{(() => {
|
||||
const baseParts = [
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
const bd = currentTrack.bitDepth;
|
||||
const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
|
||||
if (bd && sr) return `${bd}/${sr}`;
|
||||
if (bd) return `${bd}-bit`;
|
||||
if (sr) return sr;
|
||||
return undefined;
|
||||
})(),
|
||||
bpmTech ?? undefined,
|
||||
].filter(Boolean) as string[];
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
const baseLine = baseParts.join(' · ');
|
||||
const rgLine = rgParts.join(' · ');
|
||||
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
|
||||
const liveGainLabel = (() => {
|
||||
if (normalizationNowDb != null && Number.isFinite(normalizationNowDb)) {
|
||||
return `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`;
|
||||
}
|
||||
if (isLoudnessActive && Number.isFinite(loudnessPreAnalysisAttenuationDb)) {
|
||||
const preEff = effectiveLoudnessPreAnalysisAttenuationDb(
|
||||
loudnessPreAnalysisAttenuationDb,
|
||||
authLoudnessTargetLufs,
|
||||
);
|
||||
const ph = loudnessGainPlaceholderUntilCacheDb(
|
||||
authLoudnessTargetLufs,
|
||||
preEff,
|
||||
);
|
||||
return `${ph >= 0 ? '+' : ''}${ph.toFixed(2)} dB`;
|
||||
}
|
||||
return '—';
|
||||
})();
|
||||
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
|
||||
const targetLabel = `${tgtNum} LUFS`;
|
||||
if (!baseLine && !rgLine && !playbackSource && !bpmTech) return null;
|
||||
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
|
||||
const showLufsLine = isLoudnessActive && expandReplayGain;
|
||||
return (
|
||||
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
|
||||
<div className="queue-current-tech-stack">
|
||||
<div className="queue-current-tech-row">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
|
||||
{!isLoudnessActive && rgLine && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
data-tooltip={`${t('queue.replayGain')} · ${rgLine}`}
|
||||
aria-expanded={showRgLine}
|
||||
aria-label={t('queue.replayGain')}
|
||||
onClick={() => setExpandReplayGain(!expandReplayGain)}
|
||||
>
|
||||
RG
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
{isLoudnessActive && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showLufsLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
data-tooltip={`LUFS · ${liveGainLabel} · TGT · ${targetLabel}`}
|
||||
aria-expanded={showLufsLine}
|
||||
aria-label="LUFS"
|
||||
onClick={() => setExpandReplayGain(!expandReplayGain)}
|
||||
>
|
||||
LUFS
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRgLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
<span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span>
|
||||
{' · '}{rgLine}
|
||||
</span>
|
||||
)}
|
||||
{showLufsLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
<span className="queue-current-tech-rg-label">Loudness</span>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className="queue-current-tech-metric queue-current-tech-metric--lufs-reanalyze"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setLufsTgtOpen(false);
|
||||
void reanalyzeLoudnessForTrack(currentTrack.id);
|
||||
}}
|
||||
data-tooltip={t('queue.clearCachedLoudnessWaveform')}
|
||||
aria-label={t('queue.clearCachedLoudnessWaveform')}
|
||||
>
|
||||
{liveGainLabel}
|
||||
</button>
|
||||
{' · '}
|
||||
<span className="queue-current-tech-rg-label">TGT</span>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
ref={lufsTgtBtnRef}
|
||||
className="queue-current-tech-metric"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setLufsTgtOpen(v => !v);
|
||||
}}
|
||||
data-tooltip="Change target integrated loudness"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={lufsTgtOpen}
|
||||
>
|
||||
{targetLabel}
|
||||
</button>
|
||||
{lufsTgtOpen && (
|
||||
<QueueLufsTargetMenu
|
||||
menuRef={lufsTgtMenuRef}
|
||||
popStyle={lufsTgtPopStyle}
|
||||
authLoudnessTargetLufs={authLoudnessTargetLufs}
|
||||
setLoudnessTargetLufs={setLoudnessTargetLufs}
|
||||
onClose={() => setLufsTgtOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||
{coverRef ? (
|
||||
<CoverArtImage
|
||||
coverRef={coverRef}
|
||||
displayCssPx={128}
|
||||
surface="sparse"
|
||||
ensurePriority="high"
|
||||
alt=""
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
{showBufferingOverlay && <PlaybackBufferingOverlay />}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate">{currentTrack.title}</h3>
|
||||
<div className="queue-current-sub truncate">
|
||||
<OpenArtistRefInline
|
||||
refs={artistRefs}
|
||||
fallbackName={currentTrack.artist}
|
||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="is-link"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
{moodLine && (
|
||||
<div className="queue-current-sub queue-current-enrichment">{moodLine}</div>
|
||||
)}
|
||||
{(() => {
|
||||
const label = orbitAttributionLabel(currentTrack.id);
|
||||
return label ? <div className="queue-current-sub queue-current-attribution">{label}</div> : null;
|
||||
})()}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
|
||||
import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { QueueItemRef } from '@/store/playerStoreTypes';
|
||||
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
import type { DurationMode } from '@/utils/componentHelpers/queuePanelHelpers';
|
||||
import { formatLongDuration } from '@/lib/format/formatDuration';
|
||||
import { formatClockTime } from '@/lib/format/formatClockTime';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
|
||||
interface Props {
|
||||
queue: QueueItemRef[];
|
||||
queueIndex: number;
|
||||
activePlaylist: { id: string; name: string } | null;
|
||||
isNowPlayingCollapsed: boolean;
|
||||
setIsNowPlayingCollapsed: (v: boolean) => void;
|
||||
durationMode: DurationMode;
|
||||
setDurationMode: (m: DurationMode) => void;
|
||||
queueDisplayMode: QueueDisplayMode;
|
||||
setQueueDisplayMode: (v: QueueDisplayMode) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function QueueHeader({
|
||||
queue, queueIndex, activePlaylist, isNowPlayingCollapsed,
|
||||
setIsNowPlayingCollapsed, durationMode, setDurationMode,
|
||||
queueDisplayMode, setQueueDisplayMode, t,
|
||||
}: Props) {
|
||||
const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const clockFormat = useAuthStore((s) => s.clockFormat);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Thin-state: durations come from the resolver cache. The totals re-derive as
|
||||
// the cache fills (version) and on queue change; tracks past the cache window
|
||||
// contribute 0 until they resolve. Pure read (no cache mutation) in the memo.
|
||||
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
|
||||
// bumps `version` dozens of times in one frame; useDeferredValue coalesces
|
||||
// the burst into a single low-priority commit so long queues do not block
|
||||
// the main thread on every cache tick.
|
||||
//
|
||||
// The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`.
|
||||
// A skip moves only `queueIndex`, so it must not re-walk the whole queue: that
|
||||
// synchronous O(n) pass on every track change froze the UI for seconds on very
|
||||
// large queues (#1072, and the device-switch fallback in #1090). Instead we
|
||||
// build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks
|
||||
// [0, i)) once per queue/cache change, then derive the future total as an O(1)
|
||||
// lookup below. A 50k-track queue costs one walk per queue/cache change, zero
|
||||
// per track change.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
const deferredVersion = useDeferredValue(version);
|
||||
const { totalSecs, cumSecs } = useMemo(() => {
|
||||
const cum = new Float64Array(queue.length + 1);
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0);
|
||||
}
|
||||
return { totalSecs: cum[queue.length], cumSecs: cum };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue, deferredVersion]);
|
||||
// Tracks strictly after the current index — O(1) per skip.
|
||||
const futureTracksDuration = Math.max(
|
||||
0,
|
||||
totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)],
|
||||
);
|
||||
|
||||
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
|
||||
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
|
||||
|
||||
let dur: string | null = null;
|
||||
if (queue.length > 0) {
|
||||
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
|
||||
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
|
||||
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
else dur = formatClockTime(Date.now() + remainingSecs * 1000, clockFormat, i18n.language);
|
||||
}
|
||||
|
||||
const nextMode: DurationMode =
|
||||
durationMode === 'total' ? 'remaining' :
|
||||
durationMode === 'remaining' ? 'eta' : 'total';
|
||||
const nextTooltipKey =
|
||||
nextMode === 'total' ? 'queue.showTotal' :
|
||||
nextMode === 'remaining' ? 'queue.showRemaining' : 'queue.showEta';
|
||||
|
||||
const isEta = durationMode === 'eta';
|
||||
|
||||
// Cycle the panel through the three render modes; icon + title show the active
|
||||
// one, tooltip names the one a click switches to.
|
||||
const DISPLAY_MODE_CYCLE: QueueDisplayMode[] = ['queue', 'timeline', 'playlist'];
|
||||
const nextDisplayMode =
|
||||
DISPLAY_MODE_CYCLE[(DISPLAY_MODE_CYCLE.indexOf(queueDisplayMode) + 1) % DISPLAY_MODE_CYCLE.length];
|
||||
const displayModeLabel = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? t('queue.modePlaylist') : m === 'timeline' ? t('queue.modeTimeline') : t('queue.title');
|
||||
const displayModeIcon = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? <ListMusic size={15} /> : m === 'timeline' ? <AlignCenterVertical size={15} /> : <ListOrdered size={15} />;
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
|
||||
{/* Small icon button to flip the display mode without leaving the
|
||||
panel. Icon reflects the active mode; the title (next to it) and
|
||||
the tooltip name the modes. Mirrors the Settings toggle. */}
|
||||
<button
|
||||
type="button"
|
||||
className="queue-action-btn"
|
||||
onClick={() => setQueueDisplayMode(nextDisplayMode)}
|
||||
data-tooltip={displayModeLabel(nextDisplayMode)}
|
||||
aria-label={displayModeLabel(nextDisplayMode)}
|
||||
style={{ width: 24, height: 24, alignSelf: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{displayModeIcon(queueDisplayMode)}
|
||||
</button>
|
||||
{/* Title doubles as the mode indicator so the panel names the active
|
||||
mode rather than always reading "Queue". */}
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>
|
||||
{displayModeLabel(queueDisplayMode)}
|
||||
</h2>
|
||||
{queue.length > 0 && (
|
||||
<span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
|
||||
({queueIndex + 1}/{queue.length})
|
||||
</span>
|
||||
)}
|
||||
{dur !== null && (
|
||||
<span
|
||||
onClick={() => setDurationMode(nextMode)}
|
||||
data-tooltip={t(nextTooltipKey)}
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: isEta ? (isPlaying ? "var(--accent)" : "var(--text-muted)") : "var(--accent)",
|
||||
opacity: isEta && !isPlaying ? 0.5 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
· {dur}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activePlaylist && (
|
||||
<div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<ListMusic size={10} style={{ flexShrink: 0 }} />
|
||||
<span className="truncate">{activePlaylist.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="queue-action-btn"
|
||||
onClick={() => queue.length > 0 && setIsNowPlayingCollapsed(!isNowPlayingCollapsed)}
|
||||
disabled={queue.length === 0}
|
||||
data-tooltip={queue.length === 0 ? t('queue.emptyQueue') : (isNowPlayingCollapsed ? t('queue.showNowPlaying') : t('queue.hideNowPlaying'))}
|
||||
aria-label={queue.length === 0 ? t('queue.emptyQueue') : (isNowPlayingCollapsed ? t('queue.showNowPlaying') : t('queue.hideNowPlaying'))}
|
||||
aria-expanded={!isNowPlayingCollapsed}
|
||||
style={{ marginLeft: '8px', opacity: queue.length === 0 ? 0.3 : 1, cursor: queue.length === 0 ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<ChevronDown size={18} style={{ transform: isNowPlayingCollapsed ? 'rotate(-90deg)' : 'none', transition: 'transform 0.2s ease' }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
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 '@/store/playerStore';
|
||||
import { useLuckyMixStore } from '@/store/luckyMixStore';
|
||||
import type { QueueItemRef, PlayerState } from '@/store/playerStoreTypes';
|
||||
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
import { findQueueItemRefIndex } from '@/utils/playback/queueIdentity';
|
||||
import type { TimelineDisplayRow } from '@/utils/queue/buildTimelineDisplayRows';
|
||||
import { findTimelineScrollLocalIndex } from '@/utils/queue/buildTimelineDisplayRows';
|
||||
import { playTimelineHistoryTrack } from '@/utils/queue/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();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { LoudnessLufsPreset } from '@/store/authStoreTypes';
|
||||
|
||||
interface Props {
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
popStyle: React.CSSProperties;
|
||||
authLoudnessTargetLufs: LoudnessLufsPreset;
|
||||
setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LUFS_TARGETS: readonly LoudnessLufsPreset[] = [-10, -12, -14, -16] as const;
|
||||
|
||||
export function QueueLufsTargetMenu({
|
||||
menuRef, popStyle, authLoudnessTargetLufs, setLoudnessTargetLufs, onClose,
|
||||
}: Props) {
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="queue-lufs-tgt-menu"
|
||||
style={{
|
||||
...popStyle,
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border, rgba(255,255,255,0.12))',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.35)',
|
||||
padding: 6,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="LUFS target"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{LUFS_TARGETS.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (v !== authLoudnessTargetLufs) {
|
||||
setLoudnessTargetLufs(v);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: v === authLoudnessTargetLufs ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{v} LUFS
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* `QueuePanel` characterization (Phase F5b).
|
||||
*
|
||||
* Includes the §4.4 regression test from the v2 plan — queue DnD must
|
||||
* NOT use HTML5 native `dataTransfer.setData`/`draggable=true`. The
|
||||
* project's custom `psy-drop` system sidesteps WebView2's
|
||||
* `text/plain`-only restriction by avoiding HTML5 DnD entirely; a
|
||||
* refactor that re-introduces native DnD would silently break Windows.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
|
||||
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import QueuePanel from '@/features/queue/components/QueuePanel';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
onInvoke('library_get_recent_play_sessions', () => []);
|
||||
});
|
||||
|
||||
describe('QueuePanel — render surface', () => {
|
||||
// jsdom has no layout, so the virtualized QueueList sees a 0px viewport and
|
||||
// renders nothing. @tanstack/virtual-core measures via offsetHeight, so give
|
||||
// the scroll viewport a height and rows a fixed height — then the virtualizer
|
||||
// produces rows the way it does in the browser.
|
||||
let offsetSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
offsetSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('queue-list') ? 600 : 52;
|
||||
});
|
||||
// These characterize the full-queue rendering (one row per track), which is
|
||||
// playlist mode. The default mode is 'queue' (upcoming-only), so pin it.
|
||||
useAuthStore.getState().setQueueDisplayMode('playlist');
|
||||
});
|
||||
afterEach(() => offsetSpy.mockRestore());
|
||||
|
||||
it('renders an empty-queue affordance when the queue is empty', () => {
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
expect(container.querySelector('.queue-panel')).not.toBeNull();
|
||||
// No queue rows present.
|
||||
expect(container.querySelectorAll('[data-queue-idx]').length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders one row per queue track with the matching data-queue-idx', () => {
|
||||
const tracks = makeTracks(3);
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows[0]?.getAttribute('data-queue-idx')).toBe('0');
|
||||
expect(rows[2]?.getAttribute('data-queue-idx')).toBe('2');
|
||||
});
|
||||
|
||||
it('renders each queue row with the track title text', () => {
|
||||
const t1 = makeTrack({ id: 'q1', title: 'Test Song A' });
|
||||
const t2 = makeTrack({ id: 'q2', title: 'Test Song B' });
|
||||
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
|
||||
const { getAllByText, getByText } = renderWithProviders(<QueuePanel />);
|
||||
// Title A appears both in the now-playing section and in the row;
|
||||
// assert at least one match. Title B only lives in its row.
|
||||
expect(getAllByText('Test Song A').length).toBeGreaterThan(0);
|
||||
expect(getByText('Test Song B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueuePanel — display mode', () => {
|
||||
// Same virtualizer layout shim as the render-surface block: jsdom has no
|
||||
// layout, so give the scroll viewport a height and rows a fixed height.
|
||||
let offsetSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
offsetSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('queue-list') ? 600 : 52;
|
||||
});
|
||||
});
|
||||
afterEach(() => offsetSpy.mockRestore());
|
||||
|
||||
it('playlist mode: header reads "Playlist", full queue renders, no "Next Tracks" divider', () => {
|
||||
const tracks = makeTracks(4);
|
||||
useAuthStore.getState().setQueueDisplayMode('playlist');
|
||||
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
expect(container.querySelector('.queue-header h2')?.textContent).toBe('Playlist');
|
||||
const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx'));
|
||||
expect(idxs).toEqual(['0', '1', '2', '3']);
|
||||
expect(container.textContent).not.toContain('Next Tracks');
|
||||
});
|
||||
|
||||
it('queue mode: header reads "Queue", only upcoming rows render with absolute indices + titles', () => {
|
||||
const tracks = makeTracks(5);
|
||||
useAuthStore.getState().setQueueDisplayMode('queue');
|
||||
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
expect(container.querySelector('.queue-header h2')?.textContent).toBe('Queue');
|
||||
const rows = [...container.querySelectorAll<HTMLElement>('[data-queue-idx]')];
|
||||
// Played (0) + current (1) are gone; only 2,3,4 remain, with absolute idx.
|
||||
expect(rows.map(r => r.getAttribute('data-queue-idx'))).toEqual(['2', '3', '4']);
|
||||
// The first displayed row maps to the absolute track at index 2, not 0.
|
||||
expect(rows[0]?.textContent).toContain(tracks[2].title);
|
||||
expect(container.textContent).toContain('Next Tracks');
|
||||
});
|
||||
|
||||
it('queue mode with the current track last: shows the "no upcoming" empty state, no rows', () => {
|
||||
const tracks = makeTracks(3);
|
||||
useAuthStore.getState().setQueueDisplayMode('queue');
|
||||
seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
expect(container.querySelectorAll('[data-queue-idx]').length).toBe(0);
|
||||
expect(container.textContent).toContain('No upcoming tracks');
|
||||
});
|
||||
|
||||
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
|
||||
toggle!.click();
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
|
||||
});
|
||||
|
||||
it('timeline mode: renders current + upcoming only (not played queue prefix)', () => {
|
||||
const tracks = makeTracks(4);
|
||||
useAuthStore.getState().setQueueDisplayMode('timeline');
|
||||
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx'));
|
||||
expect(idxs).toEqual(['1', '2', '3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueuePanel — toolbar', () => {
|
||||
it('exposes Shuffle / Playlist / Share Queue / Clear / AutoDJ via aria-label', () => {
|
||||
const tracks = makeTracks(3);
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||
expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
|
||||
expect(getByLabelText('Playlist')).toBeInTheDocument();
|
||||
expect(getByLabelText('Copy queue share link')).toBeInTheDocument();
|
||||
expect(getByLabelText('Clear queue')).toBeInTheDocument();
|
||||
expect(getByLabelText('AutoDJ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save and Load live inside the Playlist submenu, not directly on the toolbar', () => {
|
||||
const tracks = makeTracks(3);
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { getByLabelText, container } = renderWithProviders(<QueuePanel />);
|
||||
// The submenu is closed initially.
|
||||
expect(container.querySelector('.queue-menu')).toBeNull();
|
||||
fireEvent.click(getByLabelText('Playlist'));
|
||||
const menu = container.querySelector('.queue-menu');
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu?.textContent).toContain('Save Playlist');
|
||||
expect(menu?.textContent).toContain('Load Playlist');
|
||||
});
|
||||
|
||||
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
|
||||
seedQueue([makeTrack()], { index: 0, currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||
const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement;
|
||||
expect(shuffle.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueuePanel — DnD architecture pin (§4.4 of v2 plan)', () => {
|
||||
// The custom `psy-drop` event system in DragDropContext sidesteps
|
||||
// WebView2's `text/plain`-only DnD restriction by avoiding HTML5 native
|
||||
// DnD entirely. These tests make sure a refactor that "modernises" the
|
||||
// queue back to native HTML5 DnD breaks loudly.
|
||||
|
||||
it('queue rows do not declare draggable=true (no HTML5 native drag)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
for (const row of rows) {
|
||||
expect(row.getAttribute('draggable')).not.toBe('true');
|
||||
}
|
||||
});
|
||||
|
||||
it('the source file has no `dataTransfer.setData` / `dataTransfer.getData` / `onDragStart` / `onDrop` JSX usage', () => {
|
||||
// Static check — protect against re-introducing native DnD. The
|
||||
// `dragenter` / `dragover` props are allowed because the document
|
||||
// listens for them to render the drop indicator without acting as
|
||||
// a sink for HTML5 payloads.
|
||||
const source = readFileSync(join(process.cwd(), 'src/features/queue/components/QueuePanel.tsx'), 'utf8');
|
||||
expect(source).not.toMatch(/dataTransfer\.setData/);
|
||||
expect(source).not.toMatch(/dataTransfer\.getData/);
|
||||
expect(source).not.toMatch(/\bonDragStart\s*=/);
|
||||
expect(source).not.toMatch(/\bonDrop\s*=/);
|
||||
});
|
||||
|
||||
it('the source file does not use `application/json` MIME anywhere (WebView2 restriction)', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/features/queue/components/QueuePanel.tsx'), 'utf8');
|
||||
expect(source).not.toMatch(/application\/json/);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().closeContextMenu();
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { updatePlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import { resolvePlaylist, resolveMediaServerId } from '@/features/offline';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import { OrbitGuestQueue, OrbitQueueHead } from '@/features/orbit';
|
||||
import HostApprovalQueue from '@/components/HostApprovalQueue';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { encodeSharePayload } from '@/utils/share/shareLink';
|
||||
import { serverShareBaseUrl } from '@/utils/server/serverEndpoint';
|
||||
import { copyTextToClipboard } from '@/utils/server/serverMagicString';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useLyricsStore } from '@/store/lyricsStore';
|
||||
import LyricsPane from '@/components/LyricsPane';
|
||||
import { NowPlayingInfo } from '@/features/nowPlaying';
|
||||
import { useLuckyMixStore } from '@/store/luckyMixStore';
|
||||
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
|
||||
import { SavePlaylistModal } from '@/features/queue/components/SavePlaylistModal';
|
||||
import { LoadPlaylistModal } from '@/features/queue/components/LoadPlaylistModal';
|
||||
import { QueueHeader } from '@/features/queue/components/QueueHeader';
|
||||
import { QueueCurrentTrack } from '@/features/queue/components/QueueCurrentTrack';
|
||||
import { useQueuePanelDrag } from '@/features/queue/hooks/useQueuePanelDrag';
|
||||
import { useQueueLufsTgtPopover } from '@/features/queue/hooks/useQueueLufsTgtPopover';
|
||||
import { QueueToolbar } from '@/features/queue/components/QueueToolbar';
|
||||
import { QueueList } from '@/features/queue/components/QueueList';
|
||||
import { QueueTabBar } from '@/features/queue/components/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '@/features/queue/hooks/useQueueAutoScroll';
|
||||
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/hooks/useTimelinePlayHistory';
|
||||
import { buildTimelineDisplayRows } from '@/utils/queue/buildTimelineDisplayRows';
|
||||
import { activeServerQueueTrackIds } from '@/utils/playback/trackServerScope';
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
if (orbitRole === 'guest') {
|
||||
return (
|
||||
<aside className="queue-panel queue-panel--orbit-guest">
|
||||
<OrbitGuestQueue />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
return <QueuePanelHostOrSolo />;
|
||||
}
|
||||
|
||||
function QueuePanelHostOrSolo() {
|
||||
const { t } = useTranslation();
|
||||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const orbitState = useOrbitStore(s => s.state);
|
||||
/** trackId → addedBy (host username or guest username) — only populated while
|
||||
* hosting an Orbit session, so the queue rows can surface attribution. */
|
||||
const orbitAddedByByTrack = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (orbitRole !== 'host' || !orbitState) return map;
|
||||
if (orbitState.currentTrack) {
|
||||
map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy);
|
||||
}
|
||||
for (const q of orbitState.queue) map.set(q.trackId, q.addedBy);
|
||||
return map;
|
||||
}, [orbitRole, orbitState]);
|
||||
const orbitHostUsername = orbitState?.host ?? '';
|
||||
/** Attribution label for a queue row / current track while hosting. Null when
|
||||
* not in a hosted session. Bulk-adds (album / playlist enqueue) bypass
|
||||
* `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we
|
||||
* default those to "Added by you" rather than showing nothing. */
|
||||
const orbitAttributionLabel = (trackId: string): string | null => {
|
||||
if (orbitRole !== 'host' || !orbitState) return null;
|
||||
const addedBy = orbitAddedByByTrack.get(trackId);
|
||||
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
|
||||
return t('orbit.queueAddedByUser', { user: addedBy });
|
||||
};
|
||||
// Thin-state: the queue is the canonical `QueueItemRef[]`; rows resolve their
|
||||
// Track from the resolver. List, header, toolbar and id/length reads (save /
|
||||
// share / playlist) all read off the refs.
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const removeTrack = usePlayerStore(s => s.removeTrack);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
// When the user picks a track *from* the queue list, suppress the
|
||||
// upcoming auto-scroll so their click target stays in view instead of
|
||||
// the list rebasing onto the next track. Auto-advance (natural playback)
|
||||
// never sets this flag, so it keeps its original "show what's next" behavior.
|
||||
const suppressNextAutoScrollRef = useRef(false);
|
||||
|
||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
|
||||
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
|
||||
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const crossfadeTrimSilence = useAuthStore(s => s.crossfadeTrimSilence);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
const luckyRolling = useLuckyMixStore(s => s.isRolling);
|
||||
|
||||
const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
|
||||
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
|
||||
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
|
||||
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
|
||||
useTimelineBootstrapOnMode(queueDisplayMode === 'timeline');
|
||||
const timelineHistoryRefs = useTimelinePlayHistory();
|
||||
useTimelineHistoryResolver(timelineHistoryRefs, queueDisplayMode === 'timeline');
|
||||
const timelineRows = useMemo(() => {
|
||||
if (queueDisplayMode !== 'timeline') return undefined;
|
||||
return buildTimelineDisplayRows({
|
||||
historyRefs: timelineHistoryRefs,
|
||||
queueItems,
|
||||
queueIndex,
|
||||
});
|
||||
}, [queueDisplayMode, timelineHistoryRefs, queueItems, queueIndex]);
|
||||
const toolbarButtons = useQueueToolbarStore(s => s.buttons);
|
||||
const durationMode = useAuthStore(s => s.queueDurationDisplayMode);
|
||||
const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode);
|
||||
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
|
||||
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
|
||||
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
|
||||
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
|
||||
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
|
||||
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
|
||||
|
||||
const {
|
||||
lufsTgtOpen,
|
||||
setLufsTgtOpen,
|
||||
lufsTgtPopStyle,
|
||||
lufsTgtBtnRef,
|
||||
lufsTgtMenuRef,
|
||||
} = useQueueLufsTgtPopover(expandReplayGain);
|
||||
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
const {
|
||||
psyDragFromIdxRef,
|
||||
externalDropTarget,
|
||||
externalDropTargetRef,
|
||||
setExternalDropTarget,
|
||||
isQueueDrag,
|
||||
startDrag,
|
||||
} = useQueuePanelDrag({
|
||||
asideRef,
|
||||
isQueueVisible,
|
||||
reorderQueue,
|
||||
enqueueAt,
|
||||
removeTrack,
|
||||
});
|
||||
|
||||
useQueueAutoScroll({
|
||||
queue: queueItems,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
queueListRef,
|
||||
suppressNextAutoScrollRef,
|
||||
});
|
||||
|
||||
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
const exportTrackIds = activeServerQueueTrackIds(queueItems);
|
||||
if (exportTrackIds.length === 0) return;
|
||||
if (activePlaylist) {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await updatePlaylist(activePlaylist.id, exportTrackIds);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 1500);
|
||||
} catch (e) {
|
||||
console.error('Failed to update playlist', e);
|
||||
setSaveState('idle');
|
||||
}
|
||||
} else {
|
||||
setSaveModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearQueue();
|
||||
setActivePlaylist(null);
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
const ids = activeServerQueueTrackIds(queueItems);
|
||||
if (ids.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
// Queue share goes to remote recipients — use the share URL, not the
|
||||
// connect URL the active app is currently bound to (would leak the LAN
|
||||
// host on a dual-address profile).
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
if (!active) return;
|
||||
const srv = serverShareBaseUrl(active);
|
||||
if (!srv) return;
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
};
|
||||
|
||||
// Queue mode shows upcoming tracks only — the current track lives in the
|
||||
// header and drops out of the list once played. Playlist mode keeps the full
|
||||
// timeline. `queueItems` stays the canonical list either way; the slice is a
|
||||
// view. `displayBaseIndex` maps a displayed row back to its absolute queue
|
||||
// index for every index-based handler (play / remove / reorder / drag).
|
||||
const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0;
|
||||
const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems;
|
||||
const queueEmptyLabel = queueDisplayMode === 'timeline'
|
||||
? (timelineRows && timelineRows.length > 0 ? '' : t('queue.emptyQueue'))
|
||||
: queueDisplayMode === 'queue' && queueItems.length > 0
|
||||
? t('queue.noUpcoming')
|
||||
: t('queue.emptyQueue');
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={`queue-panel${isQueueDrag ? ' queue-drop-active' : ''}`}
|
||||
onMouseMove={e => {
|
||||
if (!isQueueDrag || !queueListRef.current) return;
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
let found = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const rect = items[i].getBoundingClientRect();
|
||||
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
const idx = parseInt(items[i].dataset.queueIdx!);
|
||||
const target = { idx, before };
|
||||
externalDropTargetRef.current = target;
|
||||
setExternalDropTarget(target);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{orbitRole === 'host' && orbitState && (
|
||||
<>
|
||||
<OrbitQueueHead state={orbitState} />
|
||||
<HostApprovalQueue />
|
||||
</>
|
||||
)}
|
||||
<QueueHeader
|
||||
queue={queueItems}
|
||||
queueIndex={queueIndex}
|
||||
activePlaylist={activePlaylist}
|
||||
isNowPlayingCollapsed={isNowPlayingCollapsed}
|
||||
setIsNowPlayingCollapsed={setIsNowPlayingCollapsed}
|
||||
durationMode={durationMode}
|
||||
setDurationMode={setDurationMode}
|
||||
queueDisplayMode={queueDisplayMode}
|
||||
setQueueDisplayMode={setQueueDisplayMode}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{currentTrack && !isNowPlayingCollapsed && (
|
||||
<QueueCurrentTrack
|
||||
currentTrack={currentTrack}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
orbitAttributionLabel={orbitAttributionLabel}
|
||||
navigate={navigatePlaybackLibrary}
|
||||
playbackSource={playbackSource}
|
||||
normalizationEngine={normalizationEngine}
|
||||
normalizationEngineLive={normalizationEngineLive}
|
||||
normalizationNowDb={normalizationNowDb}
|
||||
normalizationTargetLufs={normalizationTargetLufs}
|
||||
authLoudnessTargetLufs={authLoudnessTargetLufs}
|
||||
loudnessPreAnalysisAttenuationDb={loudnessPreAnalysisAttenuationDb}
|
||||
expandReplayGain={expandReplayGain}
|
||||
setExpandReplayGain={setExpandReplayGain}
|
||||
reanalyzeLoudnessForTrack={reanalyzeLoudnessForTrack}
|
||||
setLoudnessTargetLufs={setLoudnessTargetLufs}
|
||||
lufsTgtOpen={lufsTgtOpen}
|
||||
setLufsTgtOpen={setLufsTgtOpen}
|
||||
lufsTgtBtnRef={lufsTgtBtnRef}
|
||||
lufsTgtMenuRef={lufsTgtMenuRef}
|
||||
lufsTgtPopStyle={lufsTgtPopStyle}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Queue mode hides the current track from the list, so a collapsed
|
||||
now-playing card would leave nothing showing what's playing. This
|
||||
slim strip fills that gap; clicking it re-expands the full card. */}
|
||||
{currentTrack && isNowPlayingCollapsed && queueDisplayMode === 'queue' && (
|
||||
<button
|
||||
type="button"
|
||||
className="queue-now-playing-mini"
|
||||
onClick={() => setIsNowPlayingCollapsed(false)}
|
||||
data-tooltip={t('queue.showNowPlaying')}
|
||||
aria-label={t('queue.showNowPlaying')}
|
||||
>
|
||||
<Play size={11} fill="currentColor" style={{ flexShrink: 0 }} />
|
||||
<span className="truncate" style={{ minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 600 }}>{currentTrack.title}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> · {currentTrack.artist}</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeTab === 'queue' ? (<>
|
||||
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
|
||||
<QueueToolbar
|
||||
queue={queueItems}
|
||||
activePlaylist={activePlaylist}
|
||||
saveState={saveState}
|
||||
toolbarButtons={toolbarButtons}
|
||||
shuffleQueue={shuffleQueue}
|
||||
handleSave={handleSave}
|
||||
handleLoad={handleLoad}
|
||||
handleCopyQueueShare={handleCopyQueueShare}
|
||||
handleClear={handleClear}
|
||||
gaplessEnabled={gaplessEnabled}
|
||||
crossfadeEnabled={crossfadeEnabled}
|
||||
crossfadeTrimSilence={crossfadeTrimSilence}
|
||||
crossfadeSecs={crossfadeSecs}
|
||||
setCrossfadeSecs={setCrossfadeSecs}
|
||||
infiniteQueueEnabled={infiniteQueueEnabled}
|
||||
setInfiniteQueueEnabled={setInfiniteQueueEnabled}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Next Tracks" only labels the upcoming-only list in queue mode. In
|
||||
playlist mode the list also holds the current + played rows, so the
|
||||
divider would be misleading — hide it. */}
|
||||
{queueDisplayMode === 'queue' && displayItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<QueueList
|
||||
queue={displayItems}
|
||||
timelineRows={timelineRows}
|
||||
canonicalQueue={queueItems}
|
||||
queueIndex={queueIndex}
|
||||
displayBaseIndex={displayBaseIndex}
|
||||
queueDisplayMode={queueDisplayMode}
|
||||
emptyLabel={queueEmptyLabel}
|
||||
contextMenu={contextMenu}
|
||||
playTrack={playTrack}
|
||||
activeTab={activeTab}
|
||||
queueListRef={queueListRef}
|
||||
suppressNextAutoScrollRef={suppressNextAutoScrollRef}
|
||||
isQueueDrag={isQueueDrag}
|
||||
psyDragFromIdxRef={psyDragFromIdxRef}
|
||||
externalDropTarget={externalDropTarget}
|
||||
startDrag={startDrag}
|
||||
orbitAttributionLabel={orbitAttributionLabel}
|
||||
luckyRolling={luckyRolling}
|
||||
t={t}
|
||||
/>
|
||||
</>) : activeTab === 'lyrics' ? (
|
||||
<LyricsPane currentTrack={currentTrack} />
|
||||
) : (
|
||||
<NowPlayingInfo />
|
||||
)}
|
||||
|
||||
<QueueTabBar activeTab={activeTab} setTab={setTab} t={t} />
|
||||
|
||||
{saveModalOpen && (
|
||||
<SavePlaylistModal
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
|
||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to save playlist', e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async (id, name, mode) => {
|
||||
try {
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, id);
|
||||
if (!data) return;
|
||||
const tracks: Track[] = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
if (mode === 'append') {
|
||||
enqueue(tracks);
|
||||
} else {
|
||||
clearQueue();
|
||||
playTrack(tracks[0], tracks);
|
||||
}
|
||||
}
|
||||
setActivePlaylist({ id, name });
|
||||
setLoadModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to load playlist', e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Info, ListMusic, MicVocal } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
type LyricsTab = 'queue' | 'lyrics' | 'info';
|
||||
|
||||
interface Props {
|
||||
activeTab: LyricsTab;
|
||||
setTab: (tab: LyricsTab) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function QueueTabBar({ activeTab, setTab, t }: Props) {
|
||||
return (
|
||||
<div className="queue-tab-bar">
|
||||
<button
|
||||
className={`queue-tab-btn${activeTab === 'queue' ? ' active' : ''}`}
|
||||
onClick={() => setTab('queue')}
|
||||
aria-label={t('queue.title')}
|
||||
>
|
||||
<ListMusic size={14} />
|
||||
{t('queue.title')}
|
||||
</button>
|
||||
<button
|
||||
className={`queue-tab-btn${activeTab === 'lyrics' ? ' active' : ''}`}
|
||||
onClick={() => setTab('lyrics')}
|
||||
aria-label={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
{t('player.lyrics')}
|
||||
</button>
|
||||
<button
|
||||
className={`queue-tab-btn${activeTab === 'info' ? ' active' : ''}`}
|
||||
onClick={() => setTab('info')}
|
||||
aria-label={t('nowPlayingInfo.tab')}
|
||||
>
|
||||
<Info size={14} />
|
||||
{t('nowPlayingInfo.tab')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
||||
} from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { QueueItemRef } from '@/store/playerStoreTypes';
|
||||
import type {
|
||||
QueueToolbarButtonConfig,
|
||||
QueueToolbarButtonId,
|
||||
} from '@/store/queueToolbarStore';
|
||||
import { getTransitionMode, setTransitionMode } from '@/utils/playback/playbackTransition';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
|
||||
interface Props {
|
||||
queue: QueueItemRef[];
|
||||
activePlaylist: { id: string; name: string } | null;
|
||||
saveState: 'idle' | 'saving' | 'saved';
|
||||
toolbarButtons: QueueToolbarButtonConfig[];
|
||||
shuffleQueue: () => void;
|
||||
handleSave: () => void;
|
||||
handleLoad: () => void;
|
||||
handleCopyQueueShare: () => void;
|
||||
handleClear: () => void;
|
||||
gaplessEnabled: boolean;
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeTrimSilence: boolean;
|
||||
crossfadeSecs: number;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
infiniteQueueEnabled: boolean;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function QueueToolbar({
|
||||
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
|
||||
handleSave, handleLoad, handleCopyQueueShare, handleClear,
|
||||
gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence,
|
||||
crossfadeSecs, setCrossfadeSecs,
|
||||
infiniteQueueEnabled, setInfiniteQueueEnabled,
|
||||
t,
|
||||
}: Props) {
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const [showPlaylistMenu, setShowPlaylistMenu] = useState(false);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
const playlistBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const playlistMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mode = getTransitionMode({ gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence });
|
||||
// Transitions are host-controlled while a guest in a live session — disable
|
||||
// the quick-toggles so the user can't fight the per-tick sync.
|
||||
const transitionsLocked = useOrbitStore(
|
||||
s => s.role === 'guest' && (s.phase === 'active' || s.phase === 'joining'),
|
||||
);
|
||||
const transitionLockTip = transitionsLocked ? t('settings.transitionsHostControlled') : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCrossfadePopover && !showPlaylistMenu) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
showCrossfadePopover &&
|
||||
!crossfadeBtnRef.current?.contains(target) &&
|
||||
!crossfadePopoverRef.current?.contains(target)
|
||||
) setShowCrossfadePopover(false);
|
||||
if (
|
||||
showPlaylistMenu &&
|
||||
!playlistBtnRef.current?.contains(target) &&
|
||||
!playlistMenuRef.current?.contains(target)
|
||||
) setShowPlaylistMenu(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover, showPlaylistMenu]);
|
||||
|
||||
return (
|
||||
<div className="queue-toolbar">
|
||||
{toolbarButtons.map((btn) => {
|
||||
if (!btn.visible) return null;
|
||||
|
||||
switch (btn.id as QueueToolbarButtonId) {
|
||||
case 'shuffle':
|
||||
return (
|
||||
<button key={btn.id} className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
|
||||
<Shuffle size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'playlist':
|
||||
return (
|
||||
<div key={btn.id} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={playlistBtnRef}
|
||||
className={`queue-round-btn${showPlaylistMenu ? ' active' : ''}`}
|
||||
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(v => !v); }}
|
||||
data-tooltip={showPlaylistMenu ? undefined : t('queue.playlist')}
|
||||
aria-label={t('queue.playlist')}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
</button>
|
||||
{showPlaylistMenu && (
|
||||
<div className="crossfade-popover queue-menu" ref={playlistMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="queue-menu-item"
|
||||
onClick={() => { handleSave(); setShowPlaylistMenu(false); }}
|
||||
disabled={saveState === 'saving'}
|
||||
>
|
||||
{saveState === 'saved' ? <Check size={14} /> : <Save size={14} />}
|
||||
{activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="queue-menu-item"
|
||||
onClick={() => { handleLoad(); setShowPlaylistMenu(false); }}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('queue.loadPlaylist')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'share':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className="queue-round-btn"
|
||||
onClick={() => void handleCopyQueueShare()}
|
||||
data-tooltip={t('queue.shareQueue')}
|
||||
aria-label={t('queue.shareQueue')}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'clear':
|
||||
return (
|
||||
<button key={btn.id} className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'separator':
|
||||
return <div key={btn.id} className="queue-toolbar-sep" />;
|
||||
case 'gapless':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${mode === 'gapless' ? ' active' : ''}`}
|
||||
onClick={() => { setShowCrossfadePopover(false); setTransitionMode(mode === 'gapless' ? 'none' : 'gapless'); }}
|
||||
disabled={transitionsLocked}
|
||||
data-tooltip={transitionLockTip ?? t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<MoveRight size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'crossfade':
|
||||
return (
|
||||
<div key={btn.id} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${mode === 'crossfade' || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
// Left click toggles classic crossfade on/off. Right click
|
||||
// opens the seconds popover.
|
||||
setShowPlaylistMenu(false);
|
||||
setTransitionMode(mode === 'crossfade' ? 'none' : 'crossfade');
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setShowPlaylistMenu(false);
|
||||
setShowCrossfadePopover(v => !v);
|
||||
}}
|
||||
disabled={transitionsLocked}
|
||||
data-tooltip={transitionLockTip ?? (showCrossfadePopover ? undefined : t('queue.crossfade'))}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{showCrossfadePopover && (
|
||||
<div className="crossfade-popover" ref={crossfadePopoverRef}>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setTransitionMode('crossfade');
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
aria-label={t('queue.crossfade')}
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>0.1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'autodj':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${mode === 'autodj' ? ' active' : ''}`}
|
||||
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(false); setTransitionMode(mode === 'autodj' ? 'none' : 'autodj'); }}
|
||||
disabled={transitionsLocked}
|
||||
data-tooltip={transitionLockTip ?? t('queue.autoDj')}
|
||||
aria-label={t('queue.autoDj')}
|
||||
>
|
||||
<Blend size={13} />
|
||||
</button>
|
||||
);
|
||||
case 'infinite':
|
||||
return (
|
||||
<button
|
||||
key={btn.id}
|
||||
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
|
||||
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
|
||||
data-tooltip={t('queue.infiniteQueue')}
|
||||
aria-label={t('queue.infiniteQueue')}
|
||||
>
|
||||
<InfinityIcon size={13} />
|
||||
</button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSave: (name: string) => void;
|
||||
}
|
||||
|
||||
export function SavePlaylistModal({ onClose, onSave }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="live-search-field"
|
||||
placeholder={t('queue.playlistName')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
|
||||
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '@/store/queueUndo';
|
||||
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
|
||||
|
||||
interface Args {
|
||||
queue: QueueItemRef[];
|
||||
queueIndex: number;
|
||||
currentTrack: Track | null;
|
||||
queueListRef: React.RefObject<HTMLDivElement | null>;
|
||||
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
/** Queue scroll-position bridge for undo: publishes the list's scrollTop to the
|
||||
* undo store and restores any pending scrollTop snapshot (set when an undo
|
||||
* restores a prior queue state). The "scroll the next track into view" path
|
||||
* lives in `QueueList` now that the list is virtualized (scrollToIndex). */
|
||||
export function useQueueAutoScroll({
|
||||
queue, queueIndex, currentTrack, queueListRef, suppressNextAutoScrollRef,
|
||||
}: Args) {
|
||||
useLayoutEffect(() => {
|
||||
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
|
||||
return () => registerQueueListScrollTopReader(null);
|
||||
}, [queueListRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const top = consumePendingQueueListScrollTop();
|
||||
if (top === undefined) return;
|
||||
const el = queueListRef.current;
|
||||
if (!el) return;
|
||||
suppressNextAutoScrollRef.current = true;
|
||||
el.scrollTop = top;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}, [queue, queueIndex, currentTrack?.id, queueListRef, suppressNextAutoScrollRef]);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Manages the open/closed state, button + menu refs, and positioning of the
|
||||
* LUFS-target listbox popover inside the queue panel's current-track header.
|
||||
* The popover collapses automatically when the parent replay-gain expansion
|
||||
* is toggled off. */
|
||||
export function useQueueLufsTgtPopover(expandReplayGain: boolean) {
|
||||
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
|
||||
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
|
||||
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (
|
||||
lufsTgtBtnRef.current?.contains(e.target as Node) ||
|
||||
lufsTgtMenuRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setLufsTgtOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
const updateLufsTgtPopStyle = () => {
|
||||
if (!lufsTgtBtnRef.current) return;
|
||||
const rect = lufsTgtBtnRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 160;
|
||||
const MAX_H = 220;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - WIDTH, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setLufsTgtPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
updateLufsTgtPopStyle();
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const onResize = () => updateLufsTgtPopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!expandReplayGain) setLufsTgtOpen(false);
|
||||
}, [expandReplayGain]);
|
||||
|
||||
return {
|
||||
lufsTgtOpen,
|
||||
setLufsTgtOpen,
|
||||
lufsTgtPopStyle,
|
||||
lufsTgtBtnRef,
|
||||
lufsTgtMenuRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { useDragDrop, registerQueueDragHitTest } from '@/lib/dnd/DragDropContext';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
|
||||
/** Drag types that may be dropped into the queue panel. */
|
||||
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
|
||||
|
||||
interface Args {
|
||||
asideRef: React.RefObject<HTMLElement | null>;
|
||||
isQueueVisible: boolean;
|
||||
reorderQueue: (from: number, to: number) => void;
|
||||
enqueueAt: (tracks: Track[], idx: number) => void;
|
||||
removeTrack: (idx: number) => void;
|
||||
}
|
||||
|
||||
/** Queue drag/drop wiring: hit-test registration, psy-drop dispatch for drops
|
||||
* inside the panel, removal-on-drop-outside, and visual feedback refs. */
|
||||
export function useQueuePanelDrag({
|
||||
asideRef, isQueueVisible, reorderQueue, enqueueAt, removeTrack,
|
||||
}: Args) {
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
const isQueueDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return QUEUE_DROP_TYPES.has(JSON.parse(psyPayload.data).type); } catch { return false; }
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const hitTest = (cx: number, cy: number) => {
|
||||
const el = asideRef.current;
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||
};
|
||||
return registerQueueDragHitTest(hitTest);
|
||||
}, [asideRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
externalDropTargetRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setExternalDropTarget(null);
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const aside = asideRef.current;
|
||||
if (!aside) return;
|
||||
|
||||
const onPsyDrop = async (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
|
||||
let parsedData: {
|
||||
type?: string;
|
||||
index?: number;
|
||||
track?: Track;
|
||||
tracks?: Track[];
|
||||
serverId?: string;
|
||||
id?: string;
|
||||
};
|
||||
try { parsedData = JSON.parse(detail.data); } catch { return; }
|
||||
|
||||
// Radio streams are not tracks — reject silently
|
||||
if (parsedData.type === 'radio') return;
|
||||
|
||||
const dropTarget = externalDropTargetRef.current;
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
|
||||
const insertIdx = dropTarget
|
||||
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
|
||||
: usePlayerStore.getState().queueItems.length;
|
||||
|
||||
if (parsedData.type === 'queue_reorder') {
|
||||
const fromIdx = parsedData.index as number;
|
||||
psyDragFromIdxRef.current = null;
|
||||
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
|
||||
} else if (parsedData.type === 'song') {
|
||||
enqueueAt([parsedData.track as Track], insertIdx);
|
||||
} else if (parsedData.type === 'songs') {
|
||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const serverId = resolveMediaServerId(parsedData.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, parsedData.id as string);
|
||||
if (!albumData) return;
|
||||
enqueueAt(albumData.songs.map(songToTrack), insertIdx);
|
||||
}
|
||||
};
|
||||
|
||||
aside.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => aside.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [asideRef, enqueueAt, reorderQueue]);
|
||||
|
||||
// Drag a queue row outside the panel → remove (drop never reaches `aside`).
|
||||
useEffect(() => {
|
||||
const onDocPsyDrop = (e: Event) => {
|
||||
if (!isQueueVisible) return;
|
||||
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
|
||||
if (!d?.data) return;
|
||||
const cx = d.clientX;
|
||||
const cy = d.clientY;
|
||||
if (typeof cx !== 'number' || typeof cy !== 'number') return;
|
||||
let parsed: { type?: string; index?: number } | null;
|
||||
try {
|
||||
parsed = JSON.parse(d.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
|
||||
const aside = asideRef.current;
|
||||
if (!aside) return;
|
||||
const r = aside.getBoundingClientRect();
|
||||
const inside =
|
||||
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||
if (inside) return;
|
||||
psyDragFromIdxRef.current = null;
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
removeTrack(parsed.index);
|
||||
};
|
||||
document.addEventListener('psy-drop', onDocPsyDrop);
|
||||
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
|
||||
}, [asideRef, isQueueVisible, removeTrack]);
|
||||
|
||||
return {
|
||||
psyDragFromIdxRef,
|
||||
externalDropTarget,
|
||||
externalDropTargetRef,
|
||||
setExternalDropTarget,
|
||||
isPsyDragging,
|
||||
isQueueDrag,
|
||||
startDrag,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseQueueResizerArgs {
|
||||
isMobile: boolean;
|
||||
isSidebarCollapsed: boolean;
|
||||
isQueueVisible: boolean;
|
||||
toggleQueue: () => void;
|
||||
}
|
||||
|
||||
interface UseQueueResizerResult {
|
||||
queueWidth: number;
|
||||
isDraggingQueue: boolean;
|
||||
setIsDraggingQueue: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
queueHandleTop: number | null;
|
||||
handleQueueHandleMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
const QUEUE_MIN_WIDTH = 310;
|
||||
const QUEUE_MAX_WIDTH = 500;
|
||||
const QUEUE_DEFAULT_WIDTH = 340;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
/**
|
||||
* State + handlers for the queue panel's vertical resizer:
|
||||
* - `queueWidth` follows the resizer drag (clamped 310..500 px).
|
||||
* - `queueHandleTop` tracks the y-center of the sidebar's collapse button
|
||||
* so the queue handle visually aligns with it across resizes and
|
||||
* sidebar collapse changes.
|
||||
* - `handleQueueHandleMouseDown` distinguishes a click from a drag with a
|
||||
* 4-pixel threshold so the handle can both toggle the queue and resize
|
||||
* it without conflicting interaction modes.
|
||||
*/
|
||||
export function useQueueResizer({
|
||||
isMobile,
|
||||
isSidebarCollapsed,
|
||||
isQueueVisible,
|
||||
toggleQueue,
|
||||
}: UseQueueResizerArgs): UseQueueResizerResult {
|
||||
const [queueWidth, setQueueWidth] = useState(QUEUE_DEFAULT_WIDTH);
|
||||
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
|
||||
const [queueHandleTop, setQueueHandleTop] = useState<number | null>(null);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDraggingQueue) return;
|
||||
const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - e.clientX, QUEUE_MAX_WIDTH));
|
||||
setQueueWidth(newWidth);
|
||||
}, [isDraggingQueue]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingQueue(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingQueue) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.classList.add('is-dragging');
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.classList.remove('is-dragging');
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.classList.remove('is-dragging');
|
||||
};
|
||||
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const syncQueueHandleTop = useCallback(() => {
|
||||
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
|
||||
if (!leftBtn) return;
|
||||
const r = leftBtn.getBoundingClientRect();
|
||||
setQueueHandleTop(r.top + r.height / 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
|
||||
if (!leftBtn) return;
|
||||
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
syncQueueHandleTop();
|
||||
const raf = requestAnimationFrame(syncQueueHandleTop);
|
||||
|
||||
const onResize = () => syncQueueHandleTop();
|
||||
window.addEventListener('resize', onResize);
|
||||
const observer = new ResizeObserver(onResize);
|
||||
observer.observe(leftBtn);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isMobile, isSidebarCollapsed, syncQueueHandleTop]);
|
||||
|
||||
const handleQueueHandleMouseDown = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let didDrag = false;
|
||||
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp, true);
|
||||
document.body.style.cursor = '';
|
||||
document.body.classList.remove('is-dragging');
|
||||
};
|
||||
|
||||
const applyWidthFromClientX = (clientX: number) => {
|
||||
const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - clientX, QUEUE_MAX_WIDTH));
|
||||
setQueueWidth(newWidth);
|
||||
};
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const movedEnough = Math.hypot(me.clientX - startX, me.clientY - startY) >= DRAG_THRESHOLD_PX;
|
||||
if (!didDrag && movedEnough) {
|
||||
didDrag = true;
|
||||
if (!isQueueVisible) toggleQueue();
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.classList.add('is-dragging');
|
||||
}
|
||||
if (!didDrag) return;
|
||||
applyWidthFromClientX(me.clientX);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
cleanup();
|
||||
if (!didDrag) toggleQueue();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp, true);
|
||||
}, [isQueueVisible, toggleQueue]);
|
||||
|
||||
return { queueWidth, isDraggingQueue, setIsDraggingQueue, queueHandleTop, handleQueueHandleMouseDown };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { libraryGetFacts, libraryGetTrack } from '@/lib/api/library';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import {
|
||||
enrichmentDisplayComplete,
|
||||
OXIMEDIA_MOOD_UI_ENABLED,
|
||||
parseTrackEnrichmentFacts,
|
||||
type ParsedTrackEnrichment,
|
||||
} from '@/utils/library/trackEnrichment';
|
||||
import { libraryIsReady } from '@/utils/library/libraryReady';
|
||||
import { normalizeAnalysisTrackId } from '@/utils/playback/queueIdentity';
|
||||
|
||||
const EMPTY: ParsedTrackEnrichment = {
|
||||
serverBpm: null,
|
||||
measuredBpm: null,
|
||||
moodLabels: [],
|
||||
};
|
||||
|
||||
/** Enrichment may finish several seconds after CPU seed / playback start. */
|
||||
const REFETCH_MS = [3_000, 8_000, 15_000, 30_000, 60_000] as const;
|
||||
|
||||
const ENRICHMENT_FACT_KINDS = OXIMEDIA_MOOD_UI_ENABLED
|
||||
? (['bpm', 'moods', 'mood_tag', 'mood_labels', 'valence', 'arousal'] as const)
|
||||
: (['bpm'] as const);
|
||||
|
||||
/**
|
||||
* Loads server BPM + oximedia mood facts for the queue "now playing" block.
|
||||
* Uses the playback server id (queue scope), not the browsed server.
|
||||
*/
|
||||
export function useQueueTrackEnrichment(trackId: string | undefined): ParsedTrackEnrichment {
|
||||
const serverId = usePlaybackServerId();
|
||||
const indexEnabled = useLibraryIndexStore(s =>
|
||||
serverId ? s.isIndexEnabled(serverId) : false,
|
||||
);
|
||||
const [data, setData] = useState<ParsedTrackEnrichment>(EMPTY);
|
||||
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !trackId || !indexEnabled) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setData(EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const load = async () => {
|
||||
if (!(await libraryIsReady(serverId))) return;
|
||||
try {
|
||||
const [track, facts] = await Promise.all([
|
||||
libraryGetTrack(serverId, trackId),
|
||||
libraryGetFacts(serverId, trackId, [...ENRICHMENT_FACT_KINDS]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const parsed = parseTrackEnrichmentFacts(facts, track?.bpm ?? null);
|
||||
setData(parsed);
|
||||
if (enrichmentDisplayComplete(parsed)) {
|
||||
for (const id of timers) clearTimeout(id);
|
||||
timers.length = 0;
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setData(EMPTY);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
for (const ms of REFETCH_MS) {
|
||||
timers.push(setTimeout(() => { void load(); }, ms));
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const id of timers) clearTimeout(id);
|
||||
};
|
||||
}, [serverId, trackId, indexEnabled, refreshNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !trackId || !indexEnabled) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
void listen<{ trackId: string; serverId: string }>('analysis:enrichment-updated', ({ payload }) => {
|
||||
if (!payload?.trackId) return;
|
||||
const eventTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||
const currentId = normalizeAnalysisTrackId(trackId);
|
||||
if (!eventTrackId || eventTrackId !== currentId) return;
|
||||
if (payload.serverId && payload.serverId !== serverId) return;
|
||||
setRefreshNonce(n => n + 1);
|
||||
}).then(fn => {
|
||||
unlisten = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, [serverId, trackId, indexEnabled]);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
|
||||
import { seedQueue } from '@/test/helpers/factories';
|
||||
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from '@/features/queue/hooks/useQueueTracks';
|
||||
|
||||
const track = (id: string, over: Partial<Track> = {}): Track =>
|
||||
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
|
||||
|
||||
describe('useQueueTracks selectors', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
usePlayerStore.setState({
|
||||
queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null,
|
||||
starredOverrides: {}, userRatingOverrides: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('useQueueTrackAt returns the track at the index, or null', () => {
|
||||
// seedQueue seeds the resolver under serverId 's1' and sets the refs.
|
||||
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
|
||||
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
|
||||
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('useQueueTrackAt merges session star/rating overrides', () => {
|
||||
seedQueue([track('t1')], { serverId: 's1', currentTrack: null });
|
||||
usePlayerStore.setState({
|
||||
starredOverrides: { t1: true },
|
||||
userRatingOverrides: { t1: 5 },
|
||||
});
|
||||
const { result } = renderHook(() => useQueueTrackAt(0));
|
||||
expect(!!result.current?.starred).toBe(true);
|
||||
expect(result.current?.userRating).toBe(5);
|
||||
});
|
||||
|
||||
it('useCurrentTrack returns the current track', () => {
|
||||
usePlayerStore.setState({ currentTrack: track('cur') });
|
||||
expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur');
|
||||
});
|
||||
|
||||
it('useQueueItems returns the canonical thin refs (serverId + flags)', () => {
|
||||
seedQueue([track('t1'), track('t2', { radioAdded: true })], { serverId: 's1', currentTrack: null });
|
||||
const { result } = renderHook(() => useQueueItems());
|
||||
expect(result.current).toEqual([
|
||||
{ serverId: 's1', trackId: 't1' },
|
||||
{ serverId: 's1', trackId: 't2', radioAdded: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedQueue resolver seeding', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
usePlayerStore.setState({ queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
|
||||
});
|
||||
|
||||
it('seeds the resolver cache with the queue tracks under the queue server', () => {
|
||||
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
|
||||
/**
|
||||
* Stable queue selectors (queue thin-state). The store is refs-canonical now:
|
||||
* full `Track`s come from the resolver cache (placeholder until a fetch lands),
|
||||
* with session star/rating overrides (F4) merged on read via resolveQueueTrack.
|
||||
*/
|
||||
|
||||
/** The track at a queue index, or null. */
|
||||
export function useQueueTrackAt(idx: number): Track | null {
|
||||
const ref = usePlayerStore(s => s.queueItems[idx] ?? null);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
return useMemo(() => {
|
||||
if (!ref) return null;
|
||||
return resolveQueueTrack(ref);
|
||||
// version drives re-resolution as the cache fills; overrides drive the merge.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, starredOverrides, userRatingOverrides, version]);
|
||||
}
|
||||
|
||||
/** The currently playing track, or null. */
|
||||
export function useCurrentTrack(): Track | null {
|
||||
return usePlayerStore(s => s.currentTrack);
|
||||
}
|
||||
|
||||
/** The whole queue as thin refs (the canonical list). */
|
||||
export function useQueueItems(): QueueItemRef[] {
|
||||
return usePlayerStore(s => s.queueItems);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Public API of the queue feature (QueuePanel UI + its hooks). Consumes the
|
||||
// playback store; holds no audio-engine state itself.
|
||||
export { default } from './components/QueuePanel';
|
||||
export { useQueueResizer } from './hooks/useQueueResizer';
|
||||
export { useQueueTrackAt } from './hooks/useQueueTracks';
|
||||
Reference in New Issue
Block a user