From 53cab7654ce39808a456a053b8820c2c9ee5f295 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sat, 25 Apr 2026 23:38:27 +0300 Subject: [PATCH] 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. --- src-tauri/src/analysis_cache.rs | 19 ++++ src-tauri/src/audio.rs | 6 +- src-tauri/src/lib.rs | 9 ++ src/components/QueuePanel.tsx | 152 ++++++++++++++++++++++++++++++-- src/store/playerStore.ts | 79 ++++++++++++++++- src/styles/layout.css | 18 ++++ 6 files changed, 273 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index 257c9683..ac2ab4fa 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -83,6 +83,25 @@ impl AnalysisCache { Ok(Self { conn: Mutex::new(conn) }) } + /// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant). + pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result { + if track_id.trim().is_empty() { + return Ok(0); + } + let conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let mut total: u64 = 0; + for tid in track_id_cache_variants(track_id) { + let n = conn + .execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid]) + .map_err(|e| e.to_string())?; + total = total.saturating_add(n as u64); + } + Ok(total) + } + pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> { let now = now_unix_ts(); let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index afc56b1b..dacf8c47 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -4053,9 +4053,13 @@ pub fn audio_update_replay_gain( } else { None }; + // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` + // so `loudness_ui_current_gain_db` can show a number (otherwise `and_then` + // drops the requested gain entirely). let resolved_loudness_gain_db = url_for_loudness .as_deref() - .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)); + .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)) + .or(loudness_gain_db); let effective_loudness_db = if norm_mode == 2 { match url_for_loudness.as_deref() { Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2e43ff2..f99eda29 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1250,6 +1250,14 @@ fn analysis_get_loudness_for_track( }})) } +#[tauri::command] +fn analysis_delete_loudness_for_track( + track_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_loudness_for_track_id(&track_id) +} + #[tauri::command] fn analysis_enqueue_seed_from_url( track_id: String, @@ -4099,6 +4107,7 @@ pub fn run() { analysis_get_waveform, analysis_get_waveform_for_track, analysis_get_loudness_for_track, + analysis_delete_loudness_for_track, analysis_enqueue_seed_from_url, download_track_offline, delete_offline_track, diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 1e4dfa68..fa20f0b0 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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({}); + const lufsTgtBtnRef = useRef(null); + const lufsTgtMenuRef = useRef(null); const expandReplayGain = useThemeStore(s => s.expandReplayGain); const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain); const crossfadeBtnRef = useRef(null); const crossfadePopoverRef = useRef(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(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 && ( Loudness - {' · '}{liveGainLabel} + {' · '} + {' · '} TGT - {' · '}{targetLabel} + {' · '} + + {lufsTgtOpen && + createPortal( +
e.stopPropagation()} + > + {([-10, -12, -14, -16] as const).map((v) => ( + + ))} +
, + document.body, + )}
)} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 905bf9cc..68ddf437 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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; 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()( } }, + 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()( : 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, diff --git a/src/styles/layout.css b/src/styles/layout.css index d1e4c62b..32d3528f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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;