refactor(queue-panel): H4 — split QueuePanel.tsx 1256 → 383 LOC across 11 files (#667)

* refactor(queue-panel): H4 — extract helpers + Save/LoadPlaylistModal

Pure code-move: formatTime, formatQueueReplayGainParts, renderStars and the
DurationMode type → utils/queuePanelHelpers.tsx; the two playlist modals → own
files under components/queuePanel/.

QueuePanel.tsx: 1256 → 1104 LOC.

* refactor(queue-panel): H4 — extract QueueHeader

Pure code-move: the title/count/duration/collapse-button header → its own
component file. No prop or behaviour changes.

QueuePanel.tsx: 1104 → 1004 LOC.

* refactor(queue-panel): H4 — extract QueueCurrentTrack + QueueLufsTargetMenu

The currently-playing track block (cover, info, replay-gain / LUFS badge with
its target-listbox portal) moves into two own files. Pure code-move via prop
plumbing. setLoudnessTargetLufs is typed as LoudnessLufsPreset throughout the
new components.

QueuePanel.tsx: 1004 → 812 LOC.

* refactor(queue-panel): H4 — extract useQueuePanelDrag hook

Moves the psy-drag wiring (hit-test registration, drop-inside dispatch
for song/songs/album/queue_reorder payloads, drop-outside removal) into
its own hook. Drops the dead isRadioDrag variable since the
parsedData.type === 'radio' guard inside onPsyDrop already handles that
case.

QueuePanel.tsx: 812 → 715 LOC.

* refactor(queue-panel): H4 — extract useQueueLufsTgtPopover hook

Pulls the LUFS-target popover open-state, button/menu refs, fixed-position
recompute on open/resize/scroll, and auto-close-when-RG-collapses out of
QueuePanel.

QueuePanel.tsx: 715 → 662 LOC.

* refactor(queue-panel): H4 — extract QueueToolbar

The toolbar-button switch (shuffle/save/load/share/clear/gapless/crossfade/
infinite) and the crossfade popover (with its close-on-outside-click effect)
move into one component. crossfadeBtnRef / crossfadePopoverRef and
showCrossfadePopover state are now component-local — QueuePanel no longer
sees them.

QueuePanel.tsx: 662 → 541 LOC.

* refactor(queue-panel): H4 — extract QueueList

The OverlayScrollArea + queue.map block (with track rows, lucky-mix dice
overlay, and radio/auto-added section dividers) moves into its own
component. PlayerState['contextMenu'] + PlayerState['playTrack'] are
re-used for prop typing, the local StartDrag alias matches the
DragDropContext signature.

QueuePanel.tsx: 541 → 434 LOC.

* refactor(queue-panel): H4 — extract QueueTabBar + useQueueAutoScroll, final cleanup

QueueTabBar is the bottom queue/lyrics/info tab switcher. useQueueAutoScroll
groups the three list-scroll effects (publish scrollTop reader, restore
pending snapshot, scroll next track into view on advance). Drops the dead
toggleQueue and replayGainMode selectors plus the now-unused Play, Radio,
MicVocal, ListMusic, Info imports and OverlayScrollArea.

QueuePanel.tsx: 434 → 383 LOC. Every new file under 400.
This commit is contained in:
Frank Stellmacher
2026-05-13 22:33:02 +02:00
committed by GitHub
parent 34cc311b4d
commit 8ff630cb5c
13 changed files with 1339 additions and 980 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { Play, X, Trash2, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getPlaylists, deletePlaylist } from '../../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../../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(() => {
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(--ctp-surface1)', 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(--ctp-red)' }}><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,223 @@
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/resolvePlaybackUrl';
import {
formatQueueReplayGainParts,
renderStars,
} from '../../utils/queuePanelHelpers';
import { loudnessGainPlaceholderUntilCacheDb } from '../../utils/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/loudnessPreAnalysisSlider';
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
interface Props {
currentTrack: Track;
currentCoverSrc: string;
userRatingOverrides: Record<string, number>;
orbitAttributionLabel: (trackId: string) => string | null;
navigate: (to: string) => 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, currentCoverSrc, userRatingOverrides, orbitAttributionLabel,
navigate, playbackSource, normalizationEngine, normalizationEngineLive,
normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs,
loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain,
reanalyzeLoudnessForTrack, setLoudnessTargetLufs, lufsTgtOpen, setLufsTgtOpen,
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
}: Props) {
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;
})(),
].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) 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">
{currentTrack.coverArt ? (
<img src={currentCoverSrc} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</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>
)}
{(() => {
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>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useMemo } from 'react';
import { ChevronDown, ListMusic } from 'lucide-react';
import type { TFunction } from 'i18next';
import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes';
import type { DurationMode } from '../../utils/queuePanelHelpers';
interface Props {
queue: Track[];
queueIndex: number;
activePlaylist: { id: string; name: string } | null;
isNowPlayingCollapsed: boolean;
setIsNowPlayingCollapsed: (v: boolean) => void;
durationMode: DurationMode;
setDurationMode: (m: DurationMode) => void;
t: TFunction;
}
export function QueueHeader({
queue, queueIndex, activePlaylist, isNowPlayingCollapsed,
setIsNowPlayingCollapsed, durationMode, setDurationMode, t,
}: Props) {
const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const totalSecs = useMemo(() =>
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
[queue]
);
const futureTracksDuration = useMemo(() =>
queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
[queue, queueIndex]
);
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
const fmt = (secs: number) => {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
};
const fmtEta = (secs: number) => {
const finishTime = new Date(Date.now() + secs * 1000);
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime);
};
let dur: string | null = null;
if (queue.length > 0) {
if (durationMode === 'total') dur = fmt(Math.floor(totalSecs));
else if (durationMode === 'remaining') dur = `-${fmt(Math.floor(remainingSecs))}`;
else dur = fmtEta(remainingSecs);
}
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';
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 }}>
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</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>
);
}
+164
View File
@@ -0,0 +1,164 @@
import React from 'react';
import { Play } from 'lucide-react';
import type { TFunction } from 'i18next';
import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore';
import { useLuckyMixStore } from '../../store/luckyMixStore';
import type { Track, PlayerState } from '../../store/playerStoreTypes';
import { formatTime } from '../../utils/queuePanelHelpers';
type StartDrag = (
payload: { data: string; label: string },
x: number,
y: number,
) => void;
interface Props {
queue: Track[];
queueIndex: number;
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;
}
export function QueueList({
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
startDrag, orbitAttributionLabel, luckyRolling, t,
}: Props) {
return (
<OverlayScrollArea
viewportRef={queueListRef}
className="queue-list-wrap"
viewportClassName="queue-list"
measureDeps={[activeTab, queue.length]}
railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag}
>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
</div>
) : (
<>
{queue.map((track, idx) => {
const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {};
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isQueueDrag && externalDropTarget?.idx === idx) {
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 (
<React.Fragment key={`${track.id}-${idx}`}>
{isFirstRadioAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
</div>
)}
{isFirstAutoAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
</div>
)}
<div
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => {
suppressNextAutoScrollRef.current = true;
// Pass the row index so a click on a duplicate track lands on
// *this* slot, not the first occurrence (issue #500).
playTrack(track, queue, undefined, undefined, idx);
}}
onContextMenu={(e) => {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = idx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: idx }), 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={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">
{formatTime(track.duration)}
</div>
</div>
{luckyRolling && isPlaying && (
<button
type="button"
className="queue-lucky-loading"
onClick={() => useLuckyMixStore.getState().cancel()}
data-tooltip={t('luckyMix.cancelTooltip')}
aria-label={t('luckyMix.cancelTooltip')}
>
<div className="queue-lucky-loading__dice">
<div className="queue-lucky-cube queue-lucky-cube--a">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--tr" />
<span className="lucky-mix-pip lucky-mix-pip--bl" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--b">
<span className="lucky-mix-pip lucky-mix-pip--center" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--c">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--center" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
</div>
</button>
)}
</React.Fragment>
);
})}
</>
)}
</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,
);
}
+41
View File
@@ -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>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from 'react';
import {
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
} from 'lucide-react';
import type { TFunction } from 'i18next';
import type { Track } from '../../store/playerStoreTypes';
import type {
QueueToolbarButtonConfig,
QueueToolbarButtonId,
} from '../../store/queueToolbarStore';
interface Props {
queue: Track[];
activePlaylist: { id: string; name: string } | null;
saveState: 'idle' | 'saving' | 'saved';
toolbarButtons: QueueToolbarButtonConfig[];
shuffleQueue: () => void;
handleSave: () => void;
handleLoad: () => void;
handleCopyQueueShare: () => void;
handleClear: () => void;
gaplessEnabled: boolean;
setGaplessEnabled: (v: boolean) => void;
crossfadeEnabled: boolean;
setCrossfadeEnabled: (v: boolean) => void;
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, setGaplessEnabled, crossfadeEnabled, setCrossfadeEnabled,
crossfadeSecs, setCrossfadeSecs, infiniteQueueEnabled, setInfiniteQueueEnabled,
t,
}: Props) {
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showCrossfadePopover) return;
const handle = (e: MouseEvent) => {
if (
crossfadeBtnRef.current?.contains(e.target as Node) ||
crossfadePopoverRef.current?.contains(e.target as Node)
) return;
setShowCrossfadePopover(false);
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]);
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 'save':
return (
<button
key={btn.id}
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
onClick={handleSave}
disabled={saveState === 'saving'}
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
aria-label={t('queue.savePlaylist')}
>
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
</button>
);
case 'load':
return (
<button key={btn.id} className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
</button>
);
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${gaplessEnabled ? ' active' : ''}`}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
data-tooltip={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${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
onClick={() => {
if (crossfadeEnabled) {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
}}
data-tooltip={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));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>0.1s</span><span>10s</span>
</div>
</div>
)}
</div>
);
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')}
>
<Infinity 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>
);
}
+51
View File
@@ -0,0 +1,51 @@
import React, { useEffect, useLayoutEffect } from 'react';
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
import type { Track } from '../store/playerStoreTypes';
interface Args {
queue: Track[];
queueIndex: number;
currentTrack: Track | null;
activeTab: string;
queueListRef: React.RefObject<HTMLDivElement | null>;
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
}
/** Queue auto-scroll: keeps the next track in view as playback progresses,
* publishes the list's scrollTop to the undo store, and restores any pending
* scrollTop snapshot (set when an undo restores a prior queue state). */
export function useQueueAutoScroll({
queue, queueIndex, currentTrack, activeTab, 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]);
useEffect(function queueAutoScroll() {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
return;
}
if (!queueListRef.current || queueIndex < 0) return;
if (activeTab !== 'queue') return;
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
const nextSong = songs[queueIndex + 1];
if (!nextSong) return;
nextSong.scrollIntoView({ block: 'start', behavior: 'instant' });
requestAnimationFrame(() => {
queueListRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTrack, activeTab]);
}
+78
View File
@@ -0,0 +1,78 @@
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(() => {
if (!expandReplayGain) setLufsTgtOpen(false);
}, [expandReplayGain]);
return {
lufsTgtOpen,
setLufsTgtOpen,
lufsTgtPopStyle,
lufsTgtBtnRef,
lufsTgtMenuRef,
};
}
+134
View File
@@ -0,0 +1,134 @@
import React, { useEffect, useRef, useState } from 'react';
import { getAlbum } from '../api/subsonicLibrary';
import { useDragDrop, registerQueueDragHitTest } from '../contexts/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;
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: any = null;
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().queue.length;
if (parsedData.type === 'queue_reorder') {
const fromIdx: number = parsedData.index;
psyDragFromIdxRef.current = null;
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
} else if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'songs') {
enqueueAt(parsedData.tracks as Track[], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueueAt(tracks, 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 = 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,
};
}
+43
View File
@@ -0,0 +1,43 @@
import { Star } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { Track } from '../store/playerStoreTypes';
export type DurationMode = 'total' | 'remaining' | 'eta';
export function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
const parts: string[] = [];
const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
if (track.replayGainTrackDb != null) {
parts.push(t('queue.rgTrack', { db: fmtDb(track.replayGainTrackDb) }));
}
if (track.replayGainAlbumDb != null) {
parts.push(t('queue.rgAlbum', { db: fmtDb(track.replayGainAlbumDb) }));
}
if (track.replayGainPeak != null) {
parts.push(t('queue.rgPeak', { pk: track.replayGainPeak.toFixed(3) }));
}
return parts;
}
export function renderStars(rating?: number) {
if (!rating) return null;
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<Star
key={i}
size={12}
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
/>
);
}
return <div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>{stars}</div>;
}