mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
8c4309cc8e
commit
85e9ee6a94
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 8–32 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
@@ -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 8–32 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());
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user