mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user