feat(player,queue): loudness strip controls and normalization readout fixes

- Add Tauri command to delete loudness_cache rows for a track and helpers in AnalysisCache.
- Queue tech strip: click dB to reseed loudness; LUFS target picker via body portal; metric styling aligned with strip (no link chrome).
- Player store: reseed clears local cache and replays analysis seed; show loudness dB from SQLite cache when live state is still null; allow first numeric normalization-state update through the short duplicate filter.
- audio_update_replay_gain: resolve loudness from the requested gain when playback URL is not pinned yet.
This commit is contained in:
Maxim Isaev
2026-04-25 23:38:27 +03:00
parent 453151f1fc
commit 53cab7654c
6 changed files with 273 additions and 10 deletions
+146 -6
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue';
@@ -319,10 +320,17 @@ function QueuePanelHostOrSolo() {
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
useEffect(() => {
if (!showCrossfadePopover) return;
@@ -337,6 +345,64 @@ function QueuePanelHostOrSolo() {
return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]);
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]);
// Tracks which queue index is being psy-dragged for opacity visual feedback
const psyDragFromIdxRef = useRef<number | null>(null);
@@ -533,9 +599,8 @@ function QueuePanelHostOrSolo() {
const liveGainLabel = normalizationNowDb != null
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
: '—';
const targetLabel = normalizationTargetLufs != null
? `${normalizationTargetLufs} LUFS`
: '-16 LUFS';
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
const targetLabel = `${tgtNum} LUFS`;
if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
const showLufsLine = isLoudnessActive && expandReplayGain;
@@ -597,10 +662,85 @@ function QueuePanelHostOrSolo() {
{showLufsLine && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">Loudness</span>
{' · '}{liveGainLabel}
{' · '}
<button
type="button"
className="queue-current-tech-metric"
onClick={e => {
e.stopPropagation();
setLufsTgtOpen(false);
void reanalyzeLoudnessForTrack(currentTrack.id);
}}
data-tooltip="Clear cached loudness and re-analyze this track"
>
{liveGainLabel}
</button>
{' · '}
<span className="queue-current-tech-rg-label">TGT</span>
{' · '}{targetLabel}
{' · '}
<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 &&
createPortal(
<div
ref={lufsTgtMenuRef}
className="queue-lufs-tgt-menu"
style={{
...lufsTgtPopStyle,
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()}
>
{([-10, -12, -14, -16] as const).map((v) => (
<button
key={v}
type="button"
onClick={e => {
e.stopPropagation();
if (v !== authLoudnessTargetLufs) {
setLoudnessTargetLufs(v);
void reanalyzeLoudnessForTrack(currentTrack.id);
}
setLufsTgtOpen(false);
}}
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,
)}
</span>
)}
</div>