feat: v1.32.0 — The Big Easter Update

Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal,
cover upload), Backup/Restore, Albums year filter, Statistics Library
Insights (playtime/genres/formats), Playlist cover upload, resizable
tracklist columns for Playlists & Favorites, crossfade fine control,
Settings Storage tab redesign, various fixes and UI polish.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-05 19:34:26 +02:00
parent 67f31b0700
commit 9be0d8dfa9
36 changed files with 4108 additions and 1211 deletions
+57 -248
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -46,85 +47,25 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
}
// ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const COLUMNS = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true, fixed: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 100, defaultWidth: 220, required: true, fixed: false },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 160, required: false, fixed: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false, fixed: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 100, required: false, fixed: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 60, required: false, fixed: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
] as const;
const COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
];
type ColKey = (typeof COLUMNS)[number]['key'];
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
const DEFAULT_WIDTHS: Record<ColKey, number> = Object.fromEntries(
COLUMNS.map(c => [c.key, c.defaultWidth])
) as Record<ColKey, number>;
const DEFAULT_VISIBLE = new Set<ColKey>([
'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre',
]);
function loadColPrefs(): { widths: Record<ColKey, number>; visible: Set<ColKey> } {
try {
const raw = localStorage.getItem('psysonic_tracklist_columns');
if (!raw) return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
const parsed = JSON.parse(raw);
const visible = new Set<ColKey>((parsed.visible as ColKey[]) ?? [...DEFAULT_VISIBLE]);
COLUMNS.filter(c => c.required).forEach(c => visible.add(c.key as ColKey));
return {
widths: { ...DEFAULT_WIDTHS, ...(parsed.widths ?? {}) },
visible,
};
} catch {
return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
}
}
function saveColPrefs(widths: Record<ColKey, number>, visible: Set<ColKey>) {
localStorage.setItem('psysonic_tracklist_columns', JSON.stringify({
widths,
visible: [...visible],
}));
}
/** Scale flexible (non-fixed) visible columns proportionally to fill `targetW` exactly.
* Fixed columns (e.g. 'num') keep their width unchanged.
* Each flexible column is clamped to its minWidth; rounding error is absorbed by 'title'. */
function fitColumnsToWidth(
widths: Record<ColKey, number>,
vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[],
targetW: number,
gapPx: number
): Record<ColKey, number> {
if (vCols.length === 0 || targetW <= 0) return widths;
const next = { ...widths };
const fixedCols = vCols.filter(c => c.fixed);
const flexCols = vCols.filter(c => !c.fixed);
if (flexCols.length === 0) return next;
const totalGaps = Math.max(0, vCols.length - 1) * gapPx;
const fixedTotal = fixedCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
const available = targetW - totalGaps - fixedTotal;
if (available <= 0) return next;
const currentFlexTotal = flexCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
if (currentFlexTotal === 0) return next;
const ratio = available / currentFlexTotal;
flexCols.forEach(c => {
const key = c.key as ColKey;
next[key] = Math.max(c.minWidth, Math.round((next[key] ?? c.minWidth) * ratio));
});
// Correct rounding drift in 'title' column
const newFlexTotal = flexCols.reduce((s, c) => s + next[c.key as ColKey], 0);
const diff = available - newFlexTotal;
if (flexCols.some(c => c.key === 'title') && diff !== 0) {
const titleDef = COLUMNS.find(c => c.key === 'title')!;
next['title'] = Math.max(titleDef.minWidth, next['title'] + diff);
}
return next;
}
// Columns where cell content should be centred (both header and rows)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
@@ -164,142 +105,12 @@ export default function AlbumTrackList({
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state ──────────────────────────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<ColKey, number>>(() => loadColPrefs().widths);
const [colVisible, setColVisible] = useState<Set<ColKey>>(() => loadColPrefs().visible);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const tracklistRef = useRef<HTMLDivElement>(null);
const prevContainerW = useRef(0);
const colVisibleRef = useRef(colVisible);
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
// Stores the user's last intentional column widths + the container W they match.
// ResizeObserver always scales FROM this base — never from intermediate scaled values.
// This prevents drift when the window is shrunk and enlarged again.
const baseWidthsRef = useRef<{ widths: Record<ColKey, number>; containerW: number } | null>(null);
// Tracks current colWidths without a useEffect dependency in callbacks
const colWidthsRef = useRef(colWidths);
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
// On mount: fit saved (or default) widths to current container; establish base.
useLayoutEffect(() => {
const el = tracklistRef.current;
if (!el) return;
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
prevContainerW.current = containerW;
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
setColWidths(prev => {
const fitted = fitColumnsToWidth(prev, vCols, containerW, 12);
baseWidthsRef.current = { widths: fitted, containerW };
return fitted;
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// When the container resizes, scale all columns proportionally FROM the base.
// Using the base (not prev) means shrink → grow always returns to exact original widths.
useEffect(() => {
const el = tracklistRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const newW = el.clientWidth - paddingH;
if (Math.abs(newW - prevContainerW.current) < 2) return;
prevContainerW.current = newW;
const base = baseWidthsRef.current;
if (!base) return;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 12) : 12;
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
// Always scale from base.widths, never from current state → no drift
setColWidths(() => fitColumnsToWidth(base.widths, vCols, newW, gapPx));
});
observer.observe(el);
return () => observer.disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// All visible columns in order
const visibleCols = useMemo(
() => COLUMNS.filter(c => colVisible.has(c.key)),
[colVisible]
);
// Grid template: all fixed px — bidirectional resize works correctly
const gridTemplate = useMemo(
() => visibleCols.map(c => `${colWidths[c.key]}px`).join(' '),
[colWidths, visibleCols]
);
const colStyle = { gridTemplateColumns: gridTemplate };
// ── Bidirectional resize ─────────────────────────────────────────────────
// Dragging the divider between col[colIndex] and col[colIndex+1]:
// → right: colA grows, colB shrinks (clamped to minWidth)
// → left: colA shrinks, colB grows (clamped to minWidth)
// Excel-style resize: only the dragged column changes width.
// Clamped so total never exceeds container width — no overflow, no scrollbar.
const startResize = (e: React.MouseEvent, colIndex: number) => {
e.preventDefault();
e.stopPropagation();
const colA = visibleCols[colIndex];
const defA = COLUMNS.find(c => c.key === colA.key)!;
const startX = e.clientX;
const startW = colWidths[colA.key as ColKey];
const snapshotVisible = colVisible;
// Measure container once at drag start
let maxW = Infinity;
const el = tracklistRef.current;
if (el) {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 0) : 12;
const sumOthers = visibleCols
.filter((_, i) => i !== colIndex)
.reduce((s, c) => s + colWidths[c.key as ColKey], 0);
maxW = Math.max(defA.minWidth, containerW - sumOthers - (visibleCols.length - 1) * gapPx);
}
const onMove = (me: MouseEvent) => {
const newW = Math.min(Math.max(defA.minWidth, startW + me.clientX - startX), maxW);
setColWidths(prev => ({ ...prev, [colA.key]: newW }));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Save final state and update base so future window resizes scale from here
const finalWidths = colWidthsRef.current;
baseWidthsRef.current = { widths: { ...finalWidths }, containerW: prevContainerW.current };
saveColPrefs(finalWidths, snapshotVisible);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const toggleColumn = (key: ColKey) => {
const def = COLUMNS.find(c => c.key === key)!;
if (def.required) return;
setColVisible(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
saveColPrefs(colWidths, next);
return next;
});
};
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
@@ -333,17 +144,6 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
@@ -356,13 +156,13 @@ export default function AlbumTrackList({
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
@@ -376,21 +176,31 @@ export default function AlbumTrackList({
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
// direction=1: drag right → this column grows, title (1fr) shrinks
const isResizable = !isLastCol;
return (
<div
key={key}
className={isCentered ? 'col-center' : undefined}
style={{ position: 'relative' }}
>
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
{/* Resize handle on all non-fixed columns except the last */}
{!isLastCol && !colDef.fixed && (
<div
className="col-resize-handle"
onMouseDown={e => startResize(e, colIndex)}
/>
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
@@ -521,7 +331,7 @@ export default function AlbumTrackList({
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={colStyle}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
@@ -538,14 +348,13 @@ export default function AlbumTrackList({
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{COLUMNS.filter(c => !c.required).map(c => {
const key = c.key as ColKey;
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : key;
const isOn = colVisible.has(key);
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={key}
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(key)}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">
{isOn && <Check size={13} />}
@@ -574,7 +383,7 @@ export default function AlbumTrackList({
<div
key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={colStyle}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
+9 -4
View File
@@ -20,7 +20,7 @@ function isNewer(a: string, b: string): boolean {
type State =
| { phase: 'idle' }
| { phase: 'available'; version: string; update: Update | null }
| { phase: 'available'; version: string; update: Update | null; error?: string }
| { phase: 'downloading'; pct: number }
| { phase: 'installing' }
| { phase: 'done' };
@@ -82,9 +82,11 @@ export default function AppUpdater() {
}
});
await invoke('relaunch_after_update');
} catch (e) {
console.error('Update failed', e);
setState({ phase: 'available', version: savedVersion, update });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Update failed:', msg);
// Surface the error so the user (and developer) can see what went wrong
setState({ phase: 'available', version: savedVersion, update, error: msg });
}
};
@@ -122,6 +124,9 @@ export default function AppUpdater() {
{state.phase === 'available' && (
<div className="app-updater-actions">
{state.error && (
<div className="app-updater-error">{state.error}</div>
)}
{canInstall && (
<>
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
+19 -26
View File
@@ -148,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
@@ -189,30 +189,24 @@ interface AutoEqVariant { form: string; rig: string | null; source: string; }
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
function parseGraphicEqString(graphicEqStr: string): number[] {
const line = graphicEqStr.replace(/^GraphicEQ:\s*/i, '');
const points: [number, number][] = line
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => { const [f, g] = s.split(/\s+/).map(Number); return [f, g] as [number, number]; })
.filter(([f, g]) => !isNaN(f) && !isNaN(g));
/** Parses AutoEQ FixedBandEQ.txt format.
* Expected lines:
* Preamp: -5.5 dB
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
* ...
* Returns all 10 band gains as exact floats and the preamp value.
*/
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
if (points.length === 0) return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
return [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(targetFreq => {
if (targetFreq <= points[0][0]) return points[0][1];
if (targetFreq >= points[points.length - 1][0]) return points[points.length - 1][1];
for (let i = 0; i < points.length - 1; i++) {
if (points[i][0] <= targetFreq && points[i + 1][0] >= targetFreq) {
const lo = points[i], hi = points[i + 1];
if (lo[0] === hi[0]) return lo[1];
const t = (Math.log10(targetFreq) - Math.log10(lo[0])) / (Math.log10(hi[0]) - Math.log10(lo[0]));
return Math.round((lo[1] + t * (hi[1] - lo[1])) / 0.1) * 0.1;
}
}
return 0;
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
allFilters.slice(0, 10).forEach((m, i) => {
gains[i] = parseFloat(m[1]);
});
return { gains, preamp };
}
// ─── Main component ───────────────────────────────────────────────────────────
@@ -303,9 +297,8 @@ export default function Equalizer() {
form: result.form,
});
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const newGains = parseGraphicEqString(text);
// autoeq.app normalizes gains (preamp baked in) — apply with 0 pre-gain
applyAutoEq(result.name, newGains, 0);
const { gains: newGains, preamp } = parseFixedBandEqString(text);
applyAutoEq(result.name, newGains, preamp);
setAutoEqApplied(result.name);
setAutoEqQuery('');
setAutoEqResults([]);
+4 -3
View File
@@ -61,11 +61,12 @@ export default function PlayerBar() {
const duration = currentTrack?.duration ?? 0;
// Cover art: prefer radio station art, fall back to track art.
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
const radioCoverSrc = useMemo(
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
[currentRadio?.coverArt]
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
[currentRadio?.coverArt, currentRadio?.id]
);
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
+14 -8
View File
@@ -216,7 +216,10 @@ export default function QueuePanel() {
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
})();
useEffect(() => {
if (!isPsyDragging) {
@@ -240,6 +243,9 @@ export default function QueuePanel() {
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);
@@ -304,9 +310,9 @@ export default function QueuePanel() {
return (
<aside
ref={asideRef}
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
@@ -469,22 +475,22 @@ export default function QueuePanel() {
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={1}
min={0.1}
max={10}
step={0.5}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(Number(e.target.value));
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>1s</span><span>10s</span>
<span>0.1s</span><span>10s</span>
</div>
</div>
)}
+1 -1
View File
@@ -24,7 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
// radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};