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>
+76 -3
View File
@@ -209,8 +209,9 @@ interface PlayerState {
next: (manual?: boolean) => void;
previous: () => void;
seek: (progress: number) => void;
setVolume: (v: number) => void;
setVolume: (v: number) => void;
updateReplayGainForCurrentTrack: () => void;
reanalyzeLoudnessForTrack: (trackId: string) => Promise<void>;
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
@@ -593,6 +594,59 @@ function isReplayGainActive() {
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
}
function loudnessCacheStateKeysForTrackId(trackId: string): string[] {
if (!trackId) return [];
const out: string[] = [trackId];
if (trackId.startsWith('stream:')) {
const bare = trackId.slice('stream:'.length);
if (bare) out.push(bare);
} else {
out.push(`stream:${trackId}`);
}
return out;
}
function clearLoudnessCacheStateForTrackId(trackId: string) {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete cachedLoudnessGainByTrackId[k];
delete stableLoudnessGainByTrackId[k];
}
}
function resetLoudnessBackfillStateForTrackId(trackId: string) {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete analysisBackfillInFlightByTrackId[k];
analysisBackfillAttemptsByTrackId[k] = 0;
}
}
async function reseedLoudnessForTrackId(trackId: string) {
if (!trackId) return;
const auth = useAuthStore.getState();
if (auth.normalizationEngine !== 'loudness') return;
clearLoudnessCacheStateForTrackId(trackId);
resetLoudnessBackfillStateForTrackId(trackId);
if (auth.normalizationEngine === 'loudness') {
usePlayerStore.setState({
normalizationNowDb: null,
normalizationTargetLufs: auth.loudnessTargetLufs,
normalizationEngineLive: 'loudness',
});
}
try {
await invoke('analysis_delete_loudness_for_track', { trackId });
} catch (e) {
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
}
usePlayerStore.getState().updateReplayGainForCurrentTrack();
const url = buildStreamUrl(trackId);
try {
await invoke('analysis_enqueue_seed_from_url', { trackId, url });
} catch (e) {
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
}
}
async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
try {
@@ -1128,7 +1182,15 @@ export function initAudioListeners(): () => void {
return;
}
const nowMs = Date.now();
if (nowMs - lastNormalizationUiUpdateAtMs < 120 && engine === prev.normalizationEngineLive) {
const isFirstNumericGain =
engine === 'loudness'
&& nowDb != null
&& prev.normalizationNowDb == null;
if (
!isFirstNumericGain
&& nowMs - lastNormalizationUiUpdateAtMs < 120
&& engine === prev.normalizationEngineLive
) {
return;
}
lastNormalizationUiUpdateAtMs = nowMs;
@@ -2418,6 +2480,15 @@ export const usePlayerStore = create<PlayerState>()(
}
},
reanalyzeLoudnessForTrack: async (trackId: string) => {
try {
showToast('Recalculating loudness for this track…', 2000, 'info');
} catch {
// no-op
}
await reseedLoudnessForTrackId(trackId);
},
updateReplayGainForCurrentTrack: () => {
const { currentTrack, queue, queueIndex, volume } = get();
if (!currentTrack || !currentTrack.id) return;
@@ -2433,10 +2504,12 @@ export const usePlayerStore = create<PlayerState>()(
: null;
const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
const cachedLoud = cachedLoudnessGainByTrackId[currentTrack.id];
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud : null;
set(prevState => ({
normalizationNowDb:
normalization.normalizationEngineLive === 'loudness'
? prevState.normalizationNowDb
? (prevState.normalizationNowDb ?? cachedLoudDb)
: normalization.normalizationNowDb,
normalizationTargetLufs: normalization.normalizationTargetLufs,
normalizationEngineLive: normalization.normalizationEngineLive,
+18
View File
@@ -1989,6 +1989,24 @@ html[data-platform="windows"] .player-bar.floating {
text-overflow: ellipsis;
}
.queue-current-tech-metric {
margin: 0;
padding: 0;
border: none;
background: transparent;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
color: inherit;
cursor: pointer;
text-decoration: none;
border-radius: 2px;
}
.queue-current-tech-metric:hover {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.queue-divider {
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;