refactor(audio-listeners): split initAudioListeners into per-concern setup modules (#685)

Each listener/subscription concern moves into its own module under
store/audioListenerSetup/; initAudioListeners just composes them in the
original setup / teardown order:

- audioEngineListeners  audio:* + analysis:* Tauri listeners
- initialAudioSync      one-shot startup sync to the Rust engine
- authSyncListener      auth-store + analysis-storage subscriptions
- mprisSync             MPRIS / OS media-controls sync
- radioMprisMetadata    radio ICY StreamTitle -> MPRIS
- discordPresence       Discord Rich Presence sync

Verbatim code-move — listener registration, handler bodies, local state
and the exact cleanup order are all preserved. The MainApp call site and
the three playerStore.*.test.ts suites import initAudioListeners
unchanged.
This commit is contained in:
Frank Stellmacher
2026-05-14 13:21:54 +02:00
committed by GitHub
parent 8c4309cc8e
commit 85e9ee6a94
7 changed files with 550 additions and 463 deletions
@@ -0,0 +1,162 @@
import { listen } from '@tauri-apps/api/event';
import { streamUrlTrackId } from '../../utils/resolvePlaybackUrl';
import { normalizationAlmostEqual } from '../../utils/normalizationCompare';
import { normalizeAnalysisTrackId } from '../../utils/queueIdentity';
import {
handleAudioEnded,
handleAudioError,
handleAudioPlaying,
handleAudioProgress,
handleAudioTrackSwitched,
type NormalizationStatePayload,
} from '../audioEventHandlers';
import {
getCachedLoudnessGain,
hasStableLoudness,
setCachedLoudnessGain,
} from '../loudnessGainCache';
import { refreshLoudnessForTrack } from '../loudnessRefresh';
import { emitNormalizationDebug } from '../normalizationDebug';
import {
NORMALIZATION_UI_THROTTLE_MS,
getLastNormalizationUiUpdateAtMs,
markNormalizationUiUpdate,
} from '../playbackThrottles';
import { usePlayerStore } from '../playerStore';
import { refreshWaveformForTrack } from '../waveformRefresh';
import { bumpWaveformRefreshGen } from '../waveformRefreshGen';
/**
* Tauri event listeners for the Rust audio engine + analysis pipeline. Returns
* a cleanup function that unlistens every registered listener.
*/
export function setupAudioEngineListeners(): () => void {
// Dev-only: warn when audio:progress events arrive faster than 10/s.
// This would indicate the Rust emit interval was accidentally lowered.
let _devEventCount = 0;
let _devWindowStart = 0;
const pending = [
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
if (import.meta.env.DEV) {
_devEventCount++;
const now = Date.now();
if (_devWindowStart === 0) _devWindowStart = now;
if (now - _devWindowStart >= 1000) {
if (_devEventCount > 10) {
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
}
_devEventCount = 0;
_devWindowStart = now;
}
}
handleAudioProgress(payload.current_time, payload.duration);
}),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!Number.isFinite(payload.gainDb)) return;
if (hasStableLoudness(current.id)) return;
// Skip when the cached gain is already within ~0.05 dB of the new payload —
// float jitter from the partial-loudness heuristic would otherwise re-trigger
// updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo
// every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed.
const existing = getCachedLoudnessGain(current.id);
if (Number.isFinite(existing) && Math.abs(existing! - payload.gainDb) < 0.05) return;
setCachedLoudnessGain(current.id, payload.gainDb);
emitNormalizationDebug('partial-loudness:apply', {
trackId: current.id,
gainDb: payload.gainDb,
targetLufs: payload.targetLufs,
});
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}),
listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => {
if (!payload?.trackId) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (!payloadTrackId) return;
const currentRaw = usePlayerStore.getState().currentTrack?.id;
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
if (currentId && payloadTrackId === currentId) {
bumpWaveformRefreshGen(currentRaw!);
void refreshWaveformForTrack(currentRaw!);
void refreshLoudnessForTrack(currentId);
emitNormalizationDebug('backfill:applied', { trackId: currentId });
return;
}
// Backfill finished for another id (e.g. next in queue): refresh loudness cache only
// so the cached gain is ready before `audio_play` / gapless chain.
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
}),
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
if (!payload) return;
const engine =
payload.engine === 'loudness' || payload.engine === 'replaygain'
? payload.engine
: 'off';
const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null;
const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null;
const prev = usePlayerStore.getState();
// Avoid UI flicker from noisy duplicate emits and transient nulls.
if (
engine === prev.normalizationEngineLive
&& normalizationAlmostEqual(nowDb, prev.normalizationNowDb)
&& normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02)
) {
return;
}
if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) {
return;
}
const nowMs = Date.now();
const isFirstNumericGain =
engine === 'loudness'
&& nowDb != null
&& prev.normalizationNowDb == null;
if (
!isFirstNumericGain
&& nowMs - getLastNormalizationUiUpdateAtMs() < NORMALIZATION_UI_THROTTLE_MS
&& engine === prev.normalizationEngineLive
) {
return;
}
markNormalizationUiUpdate(nowMs);
emitNormalizationDebug('event:audio:normalization-state', {
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
payload,
});
usePlayerStore.setState({
normalizationEngineLive: engine,
normalizationNowDb: nowDb,
normalizationTargetLufs: targetLufs,
normalizationDbgSource: 'event:audio:normalization-state',
normalizationDbgLastEventAt: Date.now(),
});
}),
listen<string>('audio:preload-ready', ({ payload }) => {
const tid = streamUrlTrackId(payload);
if (import.meta.env.DEV) {
console.info('[psysonic][preload-ready]', {
payload,
parsedTrackId: tid,
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
});
}
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
else if (import.meta.env.DEV) {
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
}
}),
];
return () => {
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -0,0 +1,95 @@
import { invoke } from '@tauri-apps/api/core';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/loudnessPreAnalysisSlider';
import { useAuthStore } from '../authStore';
import { onAnalysisStorageChanged } from '../analysisSync';
import { emitNormalizationDebug } from '../normalizationDebug';
import { invokeAudioSetNormalizationDeduped } from '../normalizationIpcDedupe';
import { usePlayerStore } from '../playerStore';
import { clearLoudnessCacheStateForTrackId } from '../loudnessGainCache';
import { refreshLoudnessForTrack } from '../loudnessRefresh';
import { refreshWaveformForTrack } from '../waveformRefresh';
import { bumpWaveformRefreshGen } from '../waveformRefreshGen';
/**
* Keeps the Rust audio engine in sync whenever the auth store changes
* (crossfade / gapless / normalization), plus a cross-tab analysis-storage
* listener that refreshes waveform + loudness for the current track. Returns a
* cleanup function.
*/
export function setupAuthSync(): () => void {
const normCfg = useAuthStore.getState();
let prevNormEngine = normCfg.normalizationEngine;
let prevNormTarget = normCfg.loudnessTargetLufs;
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
const normChanged =
state.normalizationEngine !== prevNormEngine
|| state.loudnessTargetLufs !== prevNormTarget
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
if (!normChanged) return;
const onlyPreAnalysisChanged =
state.normalizationEngine === prevNormEngine
&& state.loudnessTargetLufs === prevNormTarget
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
const targetLufsChanged =
state.normalizationEngine === 'loudness'
&& state.loudnessTargetLufs !== prevNormTarget;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
usePlayerStore.setState({
normalizationEngineLive: state.normalizationEngine,
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
normalizationNowDb: state.normalizationEngine === 'loudness'
? usePlayerStore.getState().normalizationNowDb
: null,
normalizationDbgSource: 'auth:normalization-changed',
});
emitNormalizationDebug('auth:normalization-changed', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invokeAudioSetNormalizationDeduped({
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
state.loudnessPreAnalysisAttenuationDb,
state.loudnessTargetLufs,
),
});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (onlyPreAnalysisChanged) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
} else if (currentId) {
if (targetLufsChanged) {
clearLoudnessCacheStateForTrackId(currentId);
}
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
} else {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
});
const unsubAnalysisSync = onAnalysisStorageChanged(detail => {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (!currentId) return;
if (detail.trackId && detail.trackId !== currentId) return;
bumpWaveformRefreshGen(currentId);
void refreshWaveformForTrack(currentId);
void refreshLoudnessForTrack(currentId);
});
return () => {
unsubAuth();
unsubAnalysisSync();
};
}
@@ -0,0 +1,101 @@
import { invoke } from '@tauri-apps/api/core';
import { getAlbumInfo2 } from '../../api/subsonicAlbumInfo';
import { useAuthStore } from '../authStore';
import { usePlayerStore } from '../playerStore';
import { getPlaybackProgressSnapshot } from '../playbackProgress';
/**
* Discord Rich Presence sync. Updates on track change or play/pause toggle —
* no per-tick updates needed, Discord auto-counts up the elapsed timer from the
* start_timestamp we set. Returns a cleanup function.
*/
export function setupDiscordPresence(): () => void {
let discordPrevTrackId: string | null = null;
let discordPrevIsPlaying: boolean | null = null;
let discordPrevFetchCovers: boolean | null = null;
let discordPrevTemplateDetails: string | null = null;
let discordPrevTemplateState: string | null = null;
let discordPrevTemplateLargeText: string | null = null;
let discordPrevCoverSource: string | null = null;
const discordServerCoverCache = new Map<string, string | null>();
function syncDiscord() {
const { currentTrack, isPlaying } = usePlayerStore.getState();
const currentTime = getPlaybackProgressSnapshot().currentTime;
const {
discordRichPresence,
discordCoverSource,
discordTemplateDetails,
discordTemplateState,
discordTemplateLargeText,
} = useAuthStore.getState();
if (!discordRichPresence || !currentTrack) {
if (discordPrevTrackId !== null) {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
discordPrevFetchCovers = null;
discordPrevCoverSource = null;
discordPrevTemplateDetails = null;
discordPrevTemplateState = null;
discordPrevTemplateLargeText = null;
invoke('discord_clear_presence').catch(() => {});
}
return;
}
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
const coverSourceChanged = discordCoverSource !== discordPrevCoverSource;
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
discordPrevFetchCovers = discordCoverSource === 'apple';
discordPrevCoverSource = discordCoverSource;
discordPrevTemplateDetails = discordTemplateDetails;
discordPrevTemplateState = discordTemplateState;
discordPrevTemplateLargeText = discordTemplateLargeText;
const sendPresence = (coverArtUrl: string | null) => {
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
isPlaying,
elapsedSecs: isPlaying ? currentTime : null,
coverArtUrl,
fetchItunesCovers: discordCoverSource === 'apple',
detailsTemplate: discordTemplateDetails,
stateTemplate: discordTemplateState,
largeTextTemplate: discordTemplateLargeText,
}).catch(() => {});
};
if (discordCoverSource === 'server' && currentTrack.albumId) {
const cached = discordServerCoverCache.get(currentTrack.albumId);
if (cached !== undefined) {
sendPresence(cached);
} else {
getAlbumInfo2(currentTrack.albumId).then(info => {
const url = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl || null;
discordServerCoverCache.set(currentTrack.albumId, url);
sendPresence(url);
});
}
} else {
sendPresence(null);
}
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
return () => {
unsubDiscordPlayer();
unsubDiscordAuth();
};
}
@@ -0,0 +1,57 @@
import { invoke } from '@tauri-apps/api/core';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/loudnessPreAnalysisSlider';
import { useAuthStore } from '../authStore';
import { emitNormalizationDebug } from '../normalizationDebug';
import { invokeAudioSetNormalizationDeduped } from '../normalizationIpcDedupe';
import { usePlayerStore } from '../playerStore';
import { refreshLoudnessForTrack } from '../loudnessRefresh';
import { refreshWaveformForTrack } from '../waveformRefresh';
/**
* One-shot startup sync: pushes the persisted audio settings to the Rust engine
* and primes waveform / loudness caches for the boot track. No cleanup needed.
*/
export function runInitialAudioSync(): void {
// Sync Last.fm loved tracks cache on startup.
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
const normCfg = useAuthStore.getState();
usePlayerStore.setState({
normalizationEngineLive: normCfg.normalizationEngine,
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
normalizationNowDb: null,
normalizationDbgSource: 'init:set-normalization',
});
emitNormalizationDebug('init:set-normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invokeAudioSetNormalizationDeduped({
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
normCfg.loudnessPreAnalysisAttenuationDb,
normCfg.loudnessTargetLufs,
),
});
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
if (bootTrackId) {
void refreshWaveformForTrack(bootTrackId);
}
if (normCfg.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
}
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
}
+80
View File
@@ -0,0 +1,80 @@
import { invoke } from '@tauri-apps/api/core';
import { buildCoverArtUrl } from '../../api/subsonicStreamUrl';
import { usePlayerStore } from '../playerStore';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../playbackProgress';
/**
* MPRIS / OS media-controls sync. Whenever the current track or playback state
* changes, pushes updates to the Rust souvlaki MediaControls so the OS media
* overlay stays accurate. Returns a cleanup function.
*/
export function setupMprisSync(): () => void {
let prevTrackId: string | null = null;
let prevRadioId: string | null = null;
let prevIsPlaying: boolean | null = null;
let lastMprisPositionUpdate = 0;
const unsubMpris = usePlayerStore.subscribe((state) => {
const { currentTrack, currentRadio, isPlaying } = state;
// Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) {
prevTrackId = currentTrack.id;
prevRadioId = null;
const coverUrl = currentTrack.coverArt
? buildCoverArtUrl(currentTrack.coverArt, 512)
: undefined;
invoke('mpris_set_metadata', {
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
coverUrl,
durationSecs: currentTrack.duration,
}).catch(() => {});
}
// Update metadata when a radio station starts (initial push — station name as title).
// ICY StreamTitle updates are forwarded by the radio:metadata listener below.
if (currentRadio && currentRadio.id !== prevRadioId) {
prevRadioId = currentRadio.id;
prevTrackId = null;
invoke('mpris_set_metadata', {
title: currentRadio.name,
artist: null,
album: null,
coverUrl: null,
durationSecs: null,
}).catch(() => {});
}
// Update playback state on play/pause change (use live snapshot — persisted
// store currentTime is intentionally coarse between commits).
const playbackChanged = isPlaying !== prevIsPlaying;
if (playbackChanged) {
prevIsPlaying = isPlaying;
lastMprisPositionUpdate = Date.now();
const pos = getPlaybackProgressSnapshot().currentTime;
invoke('mpris_set_playback', {
playing: isPlaying,
positionSecs: pos > 0 ? pos : null,
}).catch(() => {});
invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
return;
}
});
const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
const { currentRadio, isPlaying } = usePlayerStore.getState();
if (currentRadio || !isPlaying) return;
if (Date.now() - lastMprisPositionUpdate < 1500) return;
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
positionSecs: currentTime,
}).catch(() => {});
});
return () => {
unsubMpris();
unsubMprisProgress();
};
}
@@ -0,0 +1,35 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { usePlayerStore } from '../playerStore';
/**
* Radio ICY StreamTitle → MPRIS. The Rust download task emits "radio:metadata"
* with { title, is_ad } every time an ICY metadata block changes (typically
* every 832 KB of audio). Forward each update to mpris_set_metadata so the OS
* now-playing overlay stays in sync while the stream is live. Returns a cleanup
* function.
*/
export function setupRadioMprisMetadata(): () => void {
const radioMetaUnlisten = listen<{ title: string; is_ad: boolean }>('radio:metadata', ({ payload }) => {
const { currentRadio } = usePlayerStore.getState();
if (!currentRadio) return; // guard: only forward during active radio session
if (payload.is_ad) return; // skip CDN-injected ad metadata
// Parse "Artist - Title" convention used by most ICY streams.
const sep = payload.title.indexOf(' - ');
const artist = sep !== -1 ? payload.title.slice(0, sep).trim() : null;
const title = sep !== -1 ? payload.title.slice(sep + 3).trim() : payload.title;
invoke('mpris_set_metadata', {
title: title || currentRadio.name,
artist: artist || currentRadio.name,
album: null,
coverUrl: null,
durationSecs: null,
}).catch(() => {});
});
return () => {
radioMetaUnlisten.then(unlisten => unlisten());
};
}
+20 -463
View File
@@ -1,475 +1,32 @@
import { getAlbumInfo2 } from '../api/subsonicAlbumInfo'; import { setupAudioEngineListeners } from './audioListenerSetup/audioEngineListeners';
import { buildCoverArtUrl } from '../api/subsonicStreamUrl'; import { runInitialAudioSync } from './audioListenerSetup/initialAudioSync';
import { invoke } from '@tauri-apps/api/core'; import { setupAuthSync } from './audioListenerSetup/authSyncListener';
import { listen } from '@tauri-apps/api/event'; import { setupMprisSync } from './audioListenerSetup/mprisSync';
import { streamUrlTrackId } from '../utils/resolvePlaybackUrl'; import { setupRadioMprisMetadata } from './audioListenerSetup/radioMprisMetadata';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider'; import { setupDiscordPresence } from './audioListenerSetup/discordPresence';
import { normalizationAlmostEqual } from '../utils/normalizationCompare';
import { normalizeAnalysisTrackId } from '../utils/queueIdentity';
import { useAuthStore } from './authStore';
import { onAnalysisStorageChanged } from './analysisSync';
import {
handleAudioEnded,
handleAudioError,
handleAudioPlaying,
handleAudioProgress,
handleAudioTrackSwitched,
type NormalizationStatePayload,
} from './audioEventHandlers';
import {
clearLoudnessCacheStateForTrackId,
getCachedLoudnessGain,
hasStableLoudness,
setCachedLoudnessGain,
} from './loudnessGainCache';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { emitNormalizationDebug } from './normalizationDebug';
import { invokeAudioSetNormalizationDeduped } from './normalizationIpcDedupe';
import {
getPlaybackProgressSnapshot,
subscribePlaybackProgress,
} from './playbackProgress';
import {
NORMALIZATION_UI_THROTTLE_MS,
getLastNormalizationUiUpdateAtMs,
markNormalizationUiUpdate,
} from './playbackThrottles';
import { usePlayerStore } from './playerStore';
import { refreshWaveformForTrack } from './waveformRefresh';
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
/** /**
* Set up Tauri event listeners for the Rust audio engine. * Set up Tauri event listeners for the Rust audio engine.
* Returns a cleanup function — pass it to useEffect's return value so that * Returns a cleanup function — pass it to useEffect's return value so that
* React StrictMode (which double-invokes effects in dev) tears down the first * React StrictMode (which double-invokes effects in dev) tears down the first
* set of listeners before creating the second, avoiding duplicate handlers. * set of listeners before creating the second, avoiding duplicate handlers.
*
* Each concern lives in its own module under `audioListenerSetup/`; this
* function just composes them in the original setup / teardown order.
*/ */
export function initAudioListeners(): () => void { export function initAudioListeners(): () => void {
// Dev-only: warn when audio:progress events arrive faster than 10/s. const stopEngineListeners = setupAudioEngineListeners();
// This would indicate the Rust emit interval was accidentally lowered. runInitialAudioSync();
let _devEventCount = 0; const stopAuthSync = setupAuthSync();
let _devWindowStart = 0; const stopMprisSync = setupMprisSync();
const stopRadioMprisMetadata = setupRadioMprisMetadata();
const pending = [ const stopDiscordPresence = setupDiscordPresence();
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
if (import.meta.env.DEV) {
_devEventCount++;
const now = Date.now();
if (_devWindowStart === 0) _devWindowStart = now;
if (now - _devWindowStart >= 1000) {
if (_devEventCount > 10) {
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
}
_devEventCount = 0;
_devWindowStart = now;
}
}
handleAudioProgress(payload.current_time, payload.duration);
}),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!Number.isFinite(payload.gainDb)) return;
if (hasStableLoudness(current.id)) return;
// Skip when the cached gain is already within ~0.05 dB of the new payload —
// float jitter from the partial-loudness heuristic would otherwise re-trigger
// updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo
// every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed.
const existing = getCachedLoudnessGain(current.id);
if (Number.isFinite(existing) && Math.abs(existing! - payload.gainDb) < 0.05) return;
setCachedLoudnessGain(current.id, payload.gainDb);
emitNormalizationDebug('partial-loudness:apply', {
trackId: current.id,
gainDb: payload.gainDb,
targetLufs: payload.targetLufs,
});
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}),
listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => {
if (!payload?.trackId) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (!payloadTrackId) return;
const currentRaw = usePlayerStore.getState().currentTrack?.id;
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
if (currentId && payloadTrackId === currentId) {
bumpWaveformRefreshGen(currentRaw!);
void refreshWaveformForTrack(currentRaw!);
void refreshLoudnessForTrack(currentId);
emitNormalizationDebug('backfill:applied', { trackId: currentId });
return;
}
// Backfill finished for another id (e.g. next in queue): refresh loudness cache only
// so the cached gain is ready before `audio_play` / gapless chain.
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
}),
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
if (!payload) return;
const engine =
payload.engine === 'loudness' || payload.engine === 'replaygain'
? payload.engine
: 'off';
const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null;
const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null;
const prev = usePlayerStore.getState();
// Avoid UI flicker from noisy duplicate emits and transient nulls.
if (
engine === prev.normalizationEngineLive
&& normalizationAlmostEqual(nowDb, prev.normalizationNowDb)
&& normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02)
) {
return;
}
if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) {
return;
}
const nowMs = Date.now();
const isFirstNumericGain =
engine === 'loudness'
&& nowDb != null
&& prev.normalizationNowDb == null;
if (
!isFirstNumericGain
&& nowMs - getLastNormalizationUiUpdateAtMs() < NORMALIZATION_UI_THROTTLE_MS
&& engine === prev.normalizationEngineLive
) {
return;
}
markNormalizationUiUpdate(nowMs);
emitNormalizationDebug('event:audio:normalization-state', {
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
payload,
});
usePlayerStore.setState({
normalizationEngineLive: engine,
normalizationNowDb: nowDb,
normalizationTargetLufs: targetLufs,
normalizationDbgSource: 'event:audio:normalization-state',
normalizationDbgLastEventAt: Date.now(),
});
}),
listen<string>('audio:preload-ready', ({ payload }) => {
const tid = streamUrlTrackId(payload);
if (import.meta.env.DEV) {
console.info('[psysonic][preload-ready]', {
payload,
parsedTrackId: tid,
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
});
}
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
else if (import.meta.env.DEV) {
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
}
}),
];
// Sync Last.fm loved tracks cache on startup.
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
const normCfg = useAuthStore.getState();
usePlayerStore.setState({
normalizationEngineLive: normCfg.normalizationEngine,
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
normalizationNowDb: null,
normalizationDbgSource: 'init:set-normalization',
});
emitNormalizationDebug('init:set-normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invokeAudioSetNormalizationDeduped({
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
normCfg.loudnessPreAnalysisAttenuationDb,
normCfg.loudnessTargetLufs,
),
});
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
if (bootTrackId) {
void refreshWaveformForTrack(bootTrackId);
}
if (normCfg.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
}
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
// Keep audio settings in sync whenever auth store changes.
let prevNormEngine = normCfg.normalizationEngine;
let prevNormTarget = normCfg.loudnessTargetLufs;
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
const normChanged =
state.normalizationEngine !== prevNormEngine
|| state.loudnessTargetLufs !== prevNormTarget
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
if (!normChanged) return;
const onlyPreAnalysisChanged =
state.normalizationEngine === prevNormEngine
&& state.loudnessTargetLufs === prevNormTarget
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
const targetLufsChanged =
state.normalizationEngine === 'loudness'
&& state.loudnessTargetLufs !== prevNormTarget;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
usePlayerStore.setState({
normalizationEngineLive: state.normalizationEngine,
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
normalizationNowDb: state.normalizationEngine === 'loudness'
? usePlayerStore.getState().normalizationNowDb
: null,
normalizationDbgSource: 'auth:normalization-changed',
});
emitNormalizationDebug('auth:normalization-changed', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invokeAudioSetNormalizationDeduped({
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
state.loudnessPreAnalysisAttenuationDb,
state.loudnessTargetLufs,
),
});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (onlyPreAnalysisChanged) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
} else if (currentId) {
if (targetLufsChanged) {
clearLoudnessCacheStateForTrackId(currentId);
}
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
} else {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
});
const unsubAnalysisSync = onAnalysisStorageChanged(detail => {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (!currentId) return;
if (detail.trackId && detail.trackId !== currentId) return;
bumpWaveformRefreshGen(currentId);
void refreshWaveformForTrack(currentId);
void refreshLoudnessForTrack(currentId);
});
// ── MPRIS / OS media controls sync ───────────────────────────────────────
// Whenever the current track or playback state changes, push updates to the
// Rust souvlaki MediaControls so the OS media overlay stays accurate.
let prevTrackId: string | null = null;
let prevRadioId: string | null = null;
let prevIsPlaying: boolean | null = null;
let lastMprisPositionUpdate = 0;
const unsubMpris = usePlayerStore.subscribe((state) => {
const { currentTrack, currentRadio, isPlaying } = state;
// Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) {
prevTrackId = currentTrack.id;
prevRadioId = null;
const coverUrl = currentTrack.coverArt
? buildCoverArtUrl(currentTrack.coverArt, 512)
: undefined;
invoke('mpris_set_metadata', {
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
coverUrl,
durationSecs: currentTrack.duration,
}).catch(() => {});
}
// Update metadata when a radio station starts (initial push — station name as title).
// ICY StreamTitle updates are forwarded by the radio:metadata listener below.
if (currentRadio && currentRadio.id !== prevRadioId) {
prevRadioId = currentRadio.id;
prevTrackId = null;
invoke('mpris_set_metadata', {
title: currentRadio.name,
artist: null,
album: null,
coverUrl: null,
durationSecs: null,
}).catch(() => {});
}
// Update playback state on play/pause change (use live snapshot — persisted
// store currentTime is intentionally coarse between commits).
const playbackChanged = isPlaying !== prevIsPlaying;
if (playbackChanged) {
prevIsPlaying = isPlaying;
lastMprisPositionUpdate = Date.now();
const pos = getPlaybackProgressSnapshot().currentTime;
invoke('mpris_set_playback', {
playing: isPlaying,
positionSecs: pos > 0 ? pos : null,
}).catch(() => {});
invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
return;
}
});
const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
const { currentRadio, isPlaying } = usePlayerStore.getState();
if (currentRadio || !isPlaying) return;
if (Date.now() - lastMprisPositionUpdate < 1500) return;
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
positionSecs: currentTime,
}).catch(() => {});
});
// ── Radio ICY StreamTitle → MPRIS ─────────────────────────────────────────
// The Rust download task emits "radio:metadata" with { title, is_ad } every
// time an ICY metadata block changes (typically every 832 KB of audio).
// Forward each update to mpris_set_metadata so the OS now-playing overlay
// stays in sync while the stream is live.
const radioMetaUnlisten = listen<{ title: string; is_ad: boolean }>('radio:metadata', ({ payload }) => {
const { currentRadio } = usePlayerStore.getState();
if (!currentRadio) return; // guard: only forward during active radio session
if (payload.is_ad) return; // skip CDN-injected ad metadata
// Parse "Artist - Title" convention used by most ICY streams.
const sep = payload.title.indexOf(' - ');
const artist = sep !== -1 ? payload.title.slice(0, sep).trim() : null;
const title = sep !== -1 ? payload.title.slice(sep + 3).trim() : payload.title;
invoke('mpris_set_metadata', {
title: title || currentRadio.name,
artist: artist || currentRadio.name,
album: null,
coverUrl: null,
durationSecs: null,
}).catch(() => {});
});
// ── Discord Rich Presence sync ────────────────────────────────────────────
// Updates on track change or play/pause toggle. No per-tick updates needed —
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
let discordPrevTrackId: string | null = null;
let discordPrevIsPlaying: boolean | null = null;
let discordPrevFetchCovers: boolean | null = null;
let discordPrevTemplateDetails: string | null = null;
let discordPrevTemplateState: string | null = null;
let discordPrevTemplateLargeText: string | null = null;
let discordPrevCoverSource: string | null = null;
const discordServerCoverCache = new Map<string, string | null>();
function syncDiscord() {
const { currentTrack, isPlaying } = usePlayerStore.getState();
const currentTime = getPlaybackProgressSnapshot().currentTime;
const {
discordRichPresence,
discordCoverSource,
discordTemplateDetails,
discordTemplateState,
discordTemplateLargeText,
} = useAuthStore.getState();
if (!discordRichPresence || !currentTrack) {
if (discordPrevTrackId !== null) {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
discordPrevFetchCovers = null;
discordPrevCoverSource = null;
discordPrevTemplateDetails = null;
discordPrevTemplateState = null;
discordPrevTemplateLargeText = null;
invoke('discord_clear_presence').catch(() => {});
}
return;
}
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
const coverSourceChanged = discordCoverSource !== discordPrevCoverSource;
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
discordPrevFetchCovers = discordCoverSource === 'apple';
discordPrevCoverSource = discordCoverSource;
discordPrevTemplateDetails = discordTemplateDetails;
discordPrevTemplateState = discordTemplateState;
discordPrevTemplateLargeText = discordTemplateLargeText;
const sendPresence = (coverArtUrl: string | null) => {
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
isPlaying,
elapsedSecs: isPlaying ? currentTime : null,
coverArtUrl,
fetchItunesCovers: discordCoverSource === 'apple',
detailsTemplate: discordTemplateDetails,
stateTemplate: discordTemplateState,
largeTextTemplate: discordTemplateLargeText,
}).catch(() => {});
};
if (discordCoverSource === 'server' && currentTrack.albumId) {
const cached = discordServerCoverCache.get(currentTrack.albumId);
if (cached !== undefined) {
sendPresence(cached);
} else {
getAlbumInfo2(currentTrack.albumId).then(info => {
const url = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl || null;
discordServerCoverCache.set(currentTrack.albumId, url);
sendPresence(url);
});
}
} else {
sendPresence(null);
}
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
return () => { return () => {
unsubAuth(); stopAuthSync();
unsubAnalysisSync(); stopMprisSync();
unsubMpris(); stopDiscordPresence();
unsubMprisProgress(); stopEngineListeners();
unsubDiscordPlayer(); stopRadioMprisMetadata();
unsubDiscordAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
radioMetaUnlisten.then(unlisten => unlisten());
}; };
} }