mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(player): E.27 — extract initAudioListeners into module (#591)
`initAudioListeners` (~430 LOC) moves out of playerStore.ts into `src/store/initAudioListeners.ts`. The function brings together several distinct orchestrators: the 7 audio event listeners, startup Last.fm loved-sync, initial audio-settings push to Rust, the auth subscriber for live audio-settings changes, the analysis-storage change handler, MPRIS / OS media controls sync, radio ICY metadata forward, and Discord Rich Presence sync. Pure code move — no sub-orchestrator split yet (could be follow-up cuts under src/store/audio-init/ if it gets unwieldy). playerStore re-exports `initAudioListeners` so MainApp + the three characterization test files keep their `from './playerStore'` imports. Side-cleanup: 15 more imports drop from playerStore that were only fed into the now-moved code (analysisSync, normalizationDebug, normalizationIpcDedupe, normalizationCompare, NORMALIZATION_UI_*, listen, streamUrlTrackId, buildCoverArtUrl, getAlbumInfo2, normalizeAnalysisTrackId, bumpWaveformRefreshGen, clearLoudnessCacheStateForTrackId, setCachedLoudnessGain). playerStore 2394 → 1940 LOC (−454).
This commit is contained in:
committed by
GitHub
parent
0cb7fba272
commit
013d6144ca
@@ -0,0 +1,477 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import {
|
||||||
|
buildCoverArtUrl,
|
||||||
|
getAlbumInfo2,
|
||||||
|
} from '../api/subsonic';
|
||||||
|
import { streamUrlTrackId } from '../utils/resolvePlaybackUrl';
|
||||||
|
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
|
||||||
|
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.
|
||||||
|
* 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
|
||||||
|
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||||
|
*/
|
||||||
|
export function initAudioListeners(): () => 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');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
|
unsubAuth();
|
||||||
|
unsubAnalysisSync();
|
||||||
|
unsubMpris();
|
||||||
|
unsubMprisProgress();
|
||||||
|
unsubDiscordPlayer();
|
||||||
|
unsubDiscordAuth();
|
||||||
|
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||||
|
radioMetaUnlisten.then(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}
|
||||||
+8
-462
@@ -1,17 +1,15 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
|
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||||
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { useOfflineStore } from './offlineStore';
|
import { useOfflineStore } from './offlineStore';
|
||||||
import { useHotCacheStore } from './hotCacheStore';
|
import { useHotCacheStore } from './hotCacheStore';
|
||||||
import { onAnalysisStorageChanged } from './analysisSync';
|
|
||||||
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
||||||
import { useOrbitStore } from './orbitStore';
|
import { useOrbitStore } from './orbitStore';
|
||||||
import { estimateLivePosition } from '../api/orbit';
|
import { estimateLivePosition } from '../api/orbit';
|
||||||
@@ -22,13 +20,11 @@ import { shuffleArray } from '../utils/shuffleArray';
|
|||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
|
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
|
||||||
import {
|
import {
|
||||||
normalizeAnalysisTrackId,
|
|
||||||
queuesStructuralEqual,
|
queuesStructuralEqual,
|
||||||
sameQueueTrackId,
|
sameQueueTrackId,
|
||||||
shallowCloneQueueTracks,
|
shallowCloneQueueTracks,
|
||||||
} from '../utils/queueIdentity';
|
} from '../utils/queueIdentity';
|
||||||
import { waveformBlobLenOk } from '../utils/waveformParse';
|
import { waveformBlobLenOk } from '../utils/waveformParse';
|
||||||
import { normalizationAlmostEqual } from '../utils/normalizationCompare';
|
|
||||||
import { isRecoverableSeekError } from '../utils/seekErrors';
|
import { isRecoverableSeekError } from '../utils/seekErrors';
|
||||||
import {
|
import {
|
||||||
emitPlaybackProgress,
|
emitPlaybackProgress,
|
||||||
@@ -42,16 +38,13 @@ import {
|
|||||||
shouldRebindPlaybackToHotCache,
|
shouldRebindPlaybackToHotCache,
|
||||||
} from './playbackUrlRouting';
|
} from './playbackUrlRouting';
|
||||||
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||||||
import { emitNormalizationDebug } from './normalizationDebug';
|
|
||||||
import { isInOrbitSession } from './orbitSession';
|
import { isInOrbitSession } from './orbitSession';
|
||||||
import {
|
import {
|
||||||
clearLoudnessCacheStateForTrackId,
|
|
||||||
getCachedLoudnessGain,
|
getCachedLoudnessGain,
|
||||||
hasStableLoudness,
|
hasStableLoudness,
|
||||||
isReplayGainActive,
|
isReplayGainActive,
|
||||||
loudnessCacheStateKeysForTrackId,
|
loudnessCacheStateKeysForTrackId,
|
||||||
loudnessGainDbForEngineBind,
|
loudnessGainDbForEngineBind,
|
||||||
setCachedLoudnessGain,
|
|
||||||
} from './loudnessGainCache';
|
} from './loudnessGainCache';
|
||||||
import {
|
import {
|
||||||
clearAllPlaybackScheduleTimers,
|
clearAllPlaybackScheduleTimers,
|
||||||
@@ -60,11 +53,7 @@ import {
|
|||||||
schedulePauseTimer,
|
schedulePauseTimer,
|
||||||
scheduleResumeTimer,
|
scheduleResumeTimer,
|
||||||
} from './scheduleTimers';
|
} from './scheduleTimers';
|
||||||
import {
|
import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe';
|
||||||
invokeAudioSetNormalizationDeduped,
|
|
||||||
invokeAudioUpdateReplayGainDeduped,
|
|
||||||
} from './normalizationIpcDedupe';
|
|
||||||
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
|
|
||||||
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||||||
import { applySkipStarOnManualNext } from './skipStarRating';
|
import { applySkipStarOnManualNext } from './skipStarRating';
|
||||||
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
|
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
|
||||||
@@ -94,11 +83,6 @@ import {
|
|||||||
setRadioVolume,
|
setRadioVolume,
|
||||||
stopRadio,
|
stopRadio,
|
||||||
} from './radioPlayer';
|
} from './radioPlayer';
|
||||||
import {
|
|
||||||
NORMALIZATION_UI_THROTTLE_MS,
|
|
||||||
getLastNormalizationUiUpdateAtMs,
|
|
||||||
markNormalizationUiUpdate,
|
|
||||||
} from './playbackThrottles';
|
|
||||||
import {
|
import {
|
||||||
clearSeekFallbackRetry,
|
clearSeekFallbackRetry,
|
||||||
getSeekFallbackRestartAt,
|
getSeekFallbackRestartAt,
|
||||||
@@ -135,14 +119,11 @@ import {
|
|||||||
} from './infiniteQueueState';
|
} from './infiniteQueueState';
|
||||||
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
||||||
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
||||||
import {
|
import { initAudioListeners } from './initAudioListeners';
|
||||||
handleAudioEnded,
|
|
||||||
handleAudioError,
|
// Re-export so MainApp + the 3 playerStore characterization tests keep
|
||||||
handleAudioPlaying,
|
// their existing `from './playerStore'` imports.
|
||||||
handleAudioProgress,
|
export { initAudioListeners };
|
||||||
handleAudioTrackSwitched,
|
|
||||||
type NormalizationStatePayload,
|
|
||||||
} from './audioEventHandlers';
|
|
||||||
|
|
||||||
// Re-export so TauriEventBridge + persistence test keep their existing
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
@@ -387,441 +368,6 @@ export interface PlayerState {
|
|||||||
|
|
||||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up Tauri event listeners for the Rust audio engine.
|
|
||||||
* 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
|
|
||||||
* set of listeners before creating the second, avoiding duplicate handlers.
|
|
||||||
*/
|
|
||||||
export function initAudioListeners(): () => 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');
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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 () => {
|
|
||||||
unsubAuth();
|
|
||||||
unsubAnalysisSync();
|
|
||||||
unsubMpris();
|
|
||||||
unsubMprisProgress();
|
|
||||||
unsubDiscordPlayer();
|
|
||||||
unsubDiscordAuth();
|
|
||||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
|
||||||
radioMetaUnlisten.then(unlisten => unlisten());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user