mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(player): E.19 — extract analysis-refresh helpers cluster (#583)
Two thematic cuts in one PR:
- `src/store/waveformRefresh.ts` — `refreshWaveformForTrack` plus its
`WaveformCachePayload` type. Fetches the cached waveform row and
applies bins to the player store, guarded by both the refresh
generation snapshot and the current-track check so a stale read
can't overwrite fresh data.
- `src/store/loudnessRefresh.ts` — `refreshLoudnessForTrack` plus the
`loudnessRefreshInflight` coalescing map and `LoudnessCachePayload`
type. Orchestrates the loudness fetch: dedupe concurrent calls by
(trackId, syncEngine, target), distinguish hit vs miss, enqueue
bounded backfill, suppress stale-target results by recursive retry.
Both file-private; no caller-side changes outside playerStore's own
imports. Imports of helpers that were only used by these two functions
get cleaned up out of playerStore (coerceWaveformBins, getBackfillAttempts,
forgetLoudnessGain, redactSubsonicUrlForLog, LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow, etc.).
20 tests across the two modules pin the orchestration: gen + current-track
guards on waveform; coalesce + hit/miss/backfill/stale-target/sync-flag
branches on loudness.
playerStore 3139 → 2983 LOC — first sub-3000 milestone for Phase E.
This commit is contained in:
committed by
GitHub
parent
4c64844349
commit
7dc4888a06
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* `refreshLoudnessForTrack` orchestrates the loudness analysis fetch:
|
||||||
|
* coalesce concurrent calls, distinguish hit vs miss, enqueue backfill
|
||||||
|
* within bounds, suppress stale-target results. The individual helpers
|
||||||
|
* (cache, backfill state, window predicate, debug emit) are tested in
|
||||||
|
* their own modules — this file pins the orchestration only.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const auth = {
|
||||||
|
loudnessTargetLufs: -14,
|
||||||
|
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
|
||||||
|
};
|
||||||
|
const playerState = {
|
||||||
|
queue: [] as Array<{ id: string }>,
|
||||||
|
queueIndex: 0,
|
||||||
|
currentTrack: null as { id: string } | null,
|
||||||
|
updateReplayGainForCurrentTrack: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
auth,
|
||||||
|
playerState,
|
||||||
|
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
|
||||||
|
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||||
|
redactMock: vi.fn((s: string) => s),
|
||||||
|
playerSetStateMock: vi.fn(),
|
||||||
|
emitDebugMock: vi.fn(),
|
||||||
|
forgetLoudnessMock: vi.fn(),
|
||||||
|
markLoudnessStableMock: vi.fn(),
|
||||||
|
getBackfillAttemptsMock: vi.fn(() => 0),
|
||||||
|
isBackfillInFlightMock: vi.fn(() => false),
|
||||||
|
markBackfillInFlightMock: vi.fn(),
|
||||||
|
clearBackfillInFlightMock: vi.fn(),
|
||||||
|
resetBackfillAttemptsMock: vi.fn(),
|
||||||
|
isTrackInsideWindowMock: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||||
|
vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
|
||||||
|
vi.mock('../utils/redactSubsonicUrl', () => ({ redactSubsonicUrlForLog: hoisted.redactMock }));
|
||||||
|
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
||||||
|
vi.mock('./playerStore', () => ({
|
||||||
|
usePlayerStore: {
|
||||||
|
getState: () => hoisted.playerState,
|
||||||
|
setState: hoisted.playerSetStateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./normalizationDebug', () => ({ emitNormalizationDebug: hoisted.emitDebugMock }));
|
||||||
|
vi.mock('./loudnessGainCache', () => ({
|
||||||
|
forgetLoudnessGain: hoisted.forgetLoudnessMock,
|
||||||
|
markLoudnessStable: hoisted.markLoudnessStableMock,
|
||||||
|
}));
|
||||||
|
vi.mock('./loudnessBackfillState', () => ({
|
||||||
|
MAX_BACKFILL_ATTEMPTS_PER_TRACK: 2,
|
||||||
|
clearBackfillInFlight: hoisted.clearBackfillInFlightMock,
|
||||||
|
getBackfillAttempts: hoisted.getBackfillAttemptsMock,
|
||||||
|
isBackfillInFlight: hoisted.isBackfillInFlightMock,
|
||||||
|
markBackfillInFlight: hoisted.markBackfillInFlightMock,
|
||||||
|
resetBackfillAttempts: hoisted.resetBackfillAttemptsMock,
|
||||||
|
}));
|
||||||
|
vi.mock('./loudnessBackfillWindow', () => ({
|
||||||
|
LOUDNESS_BACKFILL_WINDOW_AHEAD: 5,
|
||||||
|
isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetLoudnessRefreshInflightForTest,
|
||||||
|
refreshLoudnessForTrack,
|
||||||
|
} from './loudnessRefresh';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetLoudnessRefreshInflightForTest();
|
||||||
|
hoisted.auth.loudnessTargetLufs = -14;
|
||||||
|
hoisted.auth.normalizationEngine = 'loudness';
|
||||||
|
hoisted.playerState.queue = [];
|
||||||
|
hoisted.playerState.queueIndex = 0;
|
||||||
|
hoisted.playerState.currentTrack = null;
|
||||||
|
hoisted.invokeMock.mockReset();
|
||||||
|
hoisted.invokeMock.mockResolvedValue(null);
|
||||||
|
hoisted.playerSetStateMock.mockClear();
|
||||||
|
hoisted.emitDebugMock.mockClear();
|
||||||
|
hoisted.forgetLoudnessMock.mockClear();
|
||||||
|
hoisted.markLoudnessStableMock.mockClear();
|
||||||
|
hoisted.markBackfillInFlightMock.mockClear();
|
||||||
|
hoisted.clearBackfillInFlightMock.mockClear();
|
||||||
|
hoisted.resetBackfillAttemptsMock.mockClear();
|
||||||
|
hoisted.getBackfillAttemptsMock.mockReset();
|
||||||
|
hoisted.getBackfillAttemptsMock.mockReturnValue(0);
|
||||||
|
hoisted.isBackfillInFlightMock.mockReset();
|
||||||
|
hoisted.isBackfillInFlightMock.mockReturnValue(false);
|
||||||
|
hoisted.isTrackInsideWindowMock.mockReset();
|
||||||
|
hoisted.isTrackInsideWindowMock.mockReturnValue(true);
|
||||||
|
hoisted.playerState.updateReplayGainForCurrentTrack = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshLoudnessForTrack', () => {
|
||||||
|
it('is a no-op for empty trackId', async () => {
|
||||||
|
await refreshLoudnessForTrack('');
|
||||||
|
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces concurrent calls for the same key into one inflight promise', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValue(null);
|
||||||
|
const p1 = refreshLoudnessForTrack('t1');
|
||||||
|
const p2 = refreshLoudnessForTrack('t1');
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
// One analysis_get_loudness_for_track call shared between both awaiters.
|
||||||
|
const getCalls = hoisted.invokeMock.mock.calls.filter(c => c[0] === 'analysis_get_loudness_for_track');
|
||||||
|
expect(getCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks loudness stable on a hit row', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 123 });
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.markLoudnessStableMock).toHaveBeenCalledWith('t1', -7);
|
||||||
|
expect(hoisted.resetBackfillAttemptsMock).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets the cached value on a miss row', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enqueues a backfill when conditions are met (loudness engine, not inflight, attempts < max, in window)', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.markBackfillInFlightMock).toHaveBeenCalledWith('t1', 1);
|
||||||
|
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
|
||||||
|
expect(enqueueCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips backfill when outside the prefetch window', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||||
|
hoisted.isTrackInsideWindowMock.mockReturnValueOnce(false);
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
|
||||||
|
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
|
||||||
|
expect(enqueueCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips backfill when attempts already at max', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||||
|
hoisted.getBackfillAttemptsMock.mockReturnValueOnce(2);
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
|
||||||
|
const throttledCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'backfill:throttled');
|
||||||
|
expect(throttledCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips backfill when already inflight', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||||
|
hoisted.isBackfillInFlightMock.mockReturnValueOnce(true);
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards results and retries when the LUFS target changes mid-flight', async () => {
|
||||||
|
hoisted.invokeMock.mockImplementationOnce(async () => {
|
||||||
|
hoisted.auth.loudnessTargetLufs = -10; // target changes during await
|
||||||
|
return { recommendedGainDb: -5, targetLufs: -14, updatedAt: 1 };
|
||||||
|
});
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null); // retry returns miss
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
// markLoudnessStable should NOT have been called from the first invocation —
|
||||||
|
// result is discarded because target changed.
|
||||||
|
expect(hoisted.markLoudnessStableMock).not.toHaveBeenCalled();
|
||||||
|
const staleCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:stale-target');
|
||||||
|
expect(staleCalls).toHaveLength(1);
|
||||||
|
// Drain pending recursive retries spawned via `void refreshLoudnessForTrack(...)`
|
||||||
|
// so they don't bleed into the next test's mock queue.
|
||||||
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips engine update when syncPlayingEngine is false', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
|
||||||
|
await refreshLoudnessForTrack('t1', { syncPlayingEngine: false });
|
||||||
|
expect(hoisted.playerState.updateReplayGainForCurrentTrack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls updateReplayGainForCurrentTrack by default on hit', async () => {
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.playerState.updateReplayGainForCurrentTrack).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets cache + emits refresh:error on a thrown invoke', async () => {
|
||||||
|
hoisted.invokeMock.mockRejectedValueOnce(new Error('rust busy'));
|
||||||
|
await refreshLoudnessForTrack('t1');
|
||||||
|
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
|
||||||
|
const errCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:error');
|
||||||
|
expect(errCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { buildStreamUrl } from '../api/subsonic';
|
||||||
|
import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { emitNormalizationDebug } from './normalizationDebug';
|
||||||
|
import {
|
||||||
|
forgetLoudnessGain,
|
||||||
|
markLoudnessStable,
|
||||||
|
} from './loudnessGainCache';
|
||||||
|
import {
|
||||||
|
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
|
||||||
|
clearBackfillInFlight,
|
||||||
|
getBackfillAttempts,
|
||||||
|
isBackfillInFlight,
|
||||||
|
markBackfillInFlight,
|
||||||
|
resetBackfillAttempts,
|
||||||
|
} from './loudnessBackfillState';
|
||||||
|
import {
|
||||||
|
LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
||||||
|
isTrackInsideLoudnessBackfillWindow,
|
||||||
|
} from './loudnessBackfillWindow';
|
||||||
|
|
||||||
|
/** Subsonic-server loudness-cache row as Rust hands it back. */
|
||||||
|
type LoudnessCachePayload = {
|
||||||
|
integratedLufs: number;
|
||||||
|
truePeak: number;
|
||||||
|
recommendedGainDb: number;
|
||||||
|
targetLufs: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode
|
||||||
|
* pair. The `analysis:waveform-updated` listener fires refreshWaveform +
|
||||||
|
* refreshLoudness in parallel for every full-track analysis completion;
|
||||||
|
* without coalescing, gapless preload + current-track completion can
|
||||||
|
* stack two SQLite reads + two state writes.
|
||||||
|
*/
|
||||||
|
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the loudness gain for `trackId` from Rust and apply it to the
|
||||||
|
* loudness-gain cache + player-store debug fields. When `syncPlayingEngine`
|
||||||
|
* is false (default true), the engine is NOT asked to update its
|
||||||
|
* replay-gain — used when prefetching neighbour tracks.
|
||||||
|
*
|
||||||
|
* Coalesces by (trackId, syncEngine, target) so concurrent calls share a
|
||||||
|
* single inflight promise.
|
||||||
|
*/
|
||||||
|
export async function refreshLoudnessForTrack(
|
||||||
|
trackId: string,
|
||||||
|
opts?: { syncPlayingEngine?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!trackId) return;
|
||||||
|
const syncEngine = opts?.syncPlayingEngine !== false;
|
||||||
|
const target = useAuthStore.getState().loudnessTargetLufs;
|
||||||
|
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
|
||||||
|
const existing = loudnessRefreshInflight.get(inflightKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
|
||||||
|
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
|
||||||
|
loudnessRefreshInflight.set(inflightKey, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
|
||||||
|
emitNormalizationDebug('refresh:start', { trackId });
|
||||||
|
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
|
||||||
|
try {
|
||||||
|
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
|
||||||
|
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
|
||||||
|
trackId,
|
||||||
|
targetLufs: requestedTarget,
|
||||||
|
});
|
||||||
|
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
|
||||||
|
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
|
||||||
|
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!row || !Number.isFinite(row.recommendedGainDb)) {
|
||||||
|
forgetLoudnessGain(trackId);
|
||||||
|
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
const attempts = getBackfillAttempts(trackId);
|
||||||
|
if (auth.normalizationEngine === 'loudness'
|
||||||
|
&& !isBackfillInFlight(trackId)
|
||||||
|
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
||||||
|
const live = usePlayerStore.getState();
|
||||||
|
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) {
|
||||||
|
emitNormalizationDebug('backfill:skipped-outside-window', {
|
||||||
|
trackId,
|
||||||
|
queueIndex: live.queueIndex,
|
||||||
|
aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markBackfillInFlight(trackId, attempts + 1);
|
||||||
|
const url = buildStreamUrl(trackId);
|
||||||
|
emitNormalizationDebug('backfill:enqueue', {
|
||||||
|
trackId,
|
||||||
|
url: redactSubsonicUrlForLog(url),
|
||||||
|
attempt: attempts + 1,
|
||||||
|
});
|
||||||
|
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
|
||||||
|
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
|
||||||
|
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
|
||||||
|
.finally(() => {
|
||||||
|
clearBackfillInFlight(trackId);
|
||||||
|
});
|
||||||
|
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
||||||
|
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
|
||||||
|
}
|
||||||
|
usePlayerStore.setState({
|
||||||
|
normalizationDbgSource: 'refresh:miss',
|
||||||
|
normalizationDbgTrackId: trackId,
|
||||||
|
normalizationDbgCacheGainDb: null,
|
||||||
|
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
|
||||||
|
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markLoudnessStable(trackId, row.recommendedGainDb);
|
||||||
|
resetBackfillAttempts(trackId);
|
||||||
|
emitNormalizationDebug('refresh:hit', { trackId, row });
|
||||||
|
usePlayerStore.setState({
|
||||||
|
normalizationDbgSource: 'refresh:hit',
|
||||||
|
normalizationDbgTrackId: trackId,
|
||||||
|
normalizationDbgCacheGainDb: row.recommendedGainDb,
|
||||||
|
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
|
||||||
|
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
|
||||||
|
});
|
||||||
|
if (syncEngine) {
|
||||||
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
forgetLoudnessGain(trackId);
|
||||||
|
emitNormalizationDebug('refresh:error', { trackId });
|
||||||
|
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: drop pending refresh promises so each spec starts clean. */
|
||||||
|
export function _resetLoudnessRefreshInflightForTest(): void {
|
||||||
|
loudnessRefreshInflight.clear();
|
||||||
|
}
|
||||||
+6
-162
@@ -6,7 +6,6 @@ import { showToast } from '../utils/toast';
|
|||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
|
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
|
||||||
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||||
import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl';
|
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
@@ -30,7 +29,7 @@ import {
|
|||||||
sameQueueTrackId,
|
sameQueueTrackId,
|
||||||
shallowCloneQueueTracks,
|
shallowCloneQueueTracks,
|
||||||
} from '../utils/queueIdentity';
|
} from '../utils/queueIdentity';
|
||||||
import { coerceWaveformBins, waveformBlobLenOk } from '../utils/waveformParse';
|
import { waveformBlobLenOk } from '../utils/waveformParse';
|
||||||
import { normalizationAlmostEqual } from '../utils/normalizationCompare';
|
import { normalizationAlmostEqual } from '../utils/normalizationCompare';
|
||||||
import { isRecoverableSeekError } from '../utils/seekErrors';
|
import { isRecoverableSeekError } from '../utils/seekErrors';
|
||||||
import {
|
import {
|
||||||
@@ -49,13 +48,11 @@ import { emitNormalizationDebug } from './normalizationDebug';
|
|||||||
import { isInOrbitSession } from './orbitSession';
|
import { isInOrbitSession } from './orbitSession';
|
||||||
import {
|
import {
|
||||||
clearLoudnessCacheStateForTrackId,
|
clearLoudnessCacheStateForTrackId,
|
||||||
forgetLoudnessGain,
|
|
||||||
getCachedLoudnessGain,
|
getCachedLoudnessGain,
|
||||||
hasStableLoudness,
|
hasStableLoudness,
|
||||||
isReplayGainActive,
|
isReplayGainActive,
|
||||||
loudnessCacheStateKeysForTrackId,
|
loudnessCacheStateKeysForTrackId,
|
||||||
loudnessGainDbForEngineBind,
|
loudnessGainDbForEngineBind,
|
||||||
markLoudnessStable,
|
|
||||||
setCachedLoudnessGain,
|
setCachedLoudnessGain,
|
||||||
} from './loudnessGainCache';
|
} from './loudnessGainCache';
|
||||||
import {
|
import {
|
||||||
@@ -69,26 +66,11 @@ import {
|
|||||||
invokeAudioSetNormalizationDeduped,
|
invokeAudioSetNormalizationDeduped,
|
||||||
invokeAudioUpdateReplayGainDeduped,
|
invokeAudioUpdateReplayGainDeduped,
|
||||||
} from './normalizationIpcDedupe';
|
} from './normalizationIpcDedupe';
|
||||||
import {
|
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
|
||||||
bumpWaveformRefreshGen,
|
|
||||||
getWaveformRefreshGen,
|
|
||||||
} from './waveformRefreshGen';
|
|
||||||
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||||||
import { applySkipStarOnManualNext } from './skipStarRating';
|
import { applySkipStarOnManualNext } from './skipStarRating';
|
||||||
import {
|
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
|
||||||
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
|
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
|
||||||
clearBackfillInFlight,
|
|
||||||
getBackfillAttempts,
|
|
||||||
isBackfillInFlight,
|
|
||||||
markBackfillInFlight,
|
|
||||||
resetBackfillAttempts,
|
|
||||||
resetLoudnessBackfillStateForTrackId,
|
|
||||||
} from './loudnessBackfillState';
|
|
||||||
import {
|
|
||||||
LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
|
||||||
collectLoudnessBackfillWindowTrackIds,
|
|
||||||
isTrackInsideLoudnessBackfillWindow,
|
|
||||||
} from './loudnessBackfillWindow';
|
|
||||||
import {
|
import {
|
||||||
flushPlayQueuePosition,
|
flushPlayQueuePosition,
|
||||||
flushQueueSyncToServer,
|
flushQueueSyncToServer,
|
||||||
@@ -114,6 +96,8 @@ import {
|
|||||||
} from './seekTargetState';
|
} from './seekTargetState';
|
||||||
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
||||||
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
||||||
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||||
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||||
|
|
||||||
// Re-export so TauriEventBridge + persistence test keep their existing
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
@@ -356,24 +340,6 @@ export interface PlayerState {
|
|||||||
closeSongInfo: () => void;
|
closeSongInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WaveformCachePayload = {
|
|
||||||
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
|
|
||||||
bins: number[] | Uint8Array;
|
|
||||||
binCount: number;
|
|
||||||
isPartial: boolean;
|
|
||||||
knownUntilSec: number;
|
|
||||||
durationSec: number;
|
|
||||||
updatedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LoudnessCachePayload = {
|
|
||||||
integratedLufs: number;
|
|
||||||
truePeak: number;
|
|
||||||
recommendedGainDb: number;
|
|
||||||
targetLufs: number;
|
|
||||||
updatedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NormalizationStatePayload = {
|
type NormalizationStatePayload = {
|
||||||
engine: 'off' | 'replaygain' | 'loudness' | string;
|
engine: 'off' | 'replaygain' | 'loudness' | string;
|
||||||
currentGainDb: number | null;
|
currentGainDb: number | null;
|
||||||
@@ -625,128 +591,6 @@ radioAudio.addEventListener('suspend', () => {
|
|||||||
clearRadioReconnectTimer();
|
clearRadioReconnectTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The
|
|
||||||
* analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in
|
|
||||||
* parallel for every full-track analysis completion; without coalescing, gapless
|
|
||||||
* preload + current-track completion can stack two SQLite reads + two state writes. */
|
|
||||||
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
|
||||||
|
|
||||||
async function refreshWaveformForTrack(trackId: string) {
|
|
||||||
if (!trackId) return;
|
|
||||||
const gen = getWaveformRefreshGen(trackId);
|
|
||||||
try {
|
|
||||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
|
|
||||||
if (getWaveformRefreshGen(trackId) !== gen) return;
|
|
||||||
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
|
|
||||||
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
|
|
||||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
|
||||||
if (!bins || bins.length === 0) {
|
|
||||||
usePlayerStore.setState({
|
|
||||||
waveformBins: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
usePlayerStore.setState({
|
|
||||||
waveformBins: bins,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// best-effort; seekbar falls back to placeholder waveform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** When `syncPlayingEngine` is false, only update the loudness gain cache (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */
|
|
||||||
async function refreshLoudnessForTrack(
|
|
||||||
trackId: string,
|
|
||||||
opts?: { syncPlayingEngine?: boolean },
|
|
||||||
): Promise<void> {
|
|
||||||
if (!trackId) return;
|
|
||||||
const syncEngine = opts?.syncPlayingEngine !== false;
|
|
||||||
const target = useAuthStore.getState().loudnessTargetLufs;
|
|
||||||
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
|
|
||||||
const existing = loudnessRefreshInflight.get(inflightKey);
|
|
||||||
if (existing) return existing;
|
|
||||||
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
|
|
||||||
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
|
|
||||||
loudnessRefreshInflight.set(inflightKey, job);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
|
|
||||||
emitNormalizationDebug('refresh:start', { trackId });
|
|
||||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
|
|
||||||
try {
|
|
||||||
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
|
|
||||||
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
|
|
||||||
trackId,
|
|
||||||
targetLufs: requestedTarget,
|
|
||||||
});
|
|
||||||
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
|
|
||||||
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
|
|
||||||
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!row || !Number.isFinite(row.recommendedGainDb)) {
|
|
||||||
forgetLoudnessGain(trackId);
|
|
||||||
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
|
|
||||||
const auth = useAuthStore.getState();
|
|
||||||
const attempts = getBackfillAttempts(trackId);
|
|
||||||
if (auth.normalizationEngine === 'loudness'
|
|
||||||
&& !isBackfillInFlight(trackId)
|
|
||||||
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
|
||||||
const live = usePlayerStore.getState();
|
|
||||||
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) {
|
|
||||||
emitNormalizationDebug('backfill:skipped-outside-window', {
|
|
||||||
trackId,
|
|
||||||
queueIndex: live.queueIndex,
|
|
||||||
aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markBackfillInFlight(trackId, attempts + 1);
|
|
||||||
const url = buildStreamUrl(trackId);
|
|
||||||
emitNormalizationDebug('backfill:enqueue', {
|
|
||||||
trackId,
|
|
||||||
url: redactSubsonicUrlForLog(url),
|
|
||||||
attempt: attempts + 1,
|
|
||||||
});
|
|
||||||
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
|
|
||||||
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
|
|
||||||
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
|
|
||||||
.finally(() => {
|
|
||||||
clearBackfillInFlight(trackId);
|
|
||||||
});
|
|
||||||
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
|
||||||
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
|
|
||||||
}
|
|
||||||
usePlayerStore.setState({
|
|
||||||
normalizationDbgSource: 'refresh:miss',
|
|
||||||
normalizationDbgTrackId: trackId,
|
|
||||||
normalizationDbgCacheGainDb: null,
|
|
||||||
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
|
|
||||||
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markLoudnessStable(trackId, row.recommendedGainDb);
|
|
||||||
resetBackfillAttempts(trackId);
|
|
||||||
emitNormalizationDebug('refresh:hit', { trackId, row });
|
|
||||||
usePlayerStore.setState({
|
|
||||||
normalizationDbgSource: 'refresh:hit',
|
|
||||||
normalizationDbgTrackId: trackId,
|
|
||||||
normalizationDbgCacheGainDb: row.recommendedGainDb,
|
|
||||||
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
|
|
||||||
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
|
|
||||||
});
|
|
||||||
if (syncEngine) {
|
|
||||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
forgetLoudnessGain(trackId);
|
|
||||||
emitNormalizationDebug('refresh:error', { trackId });
|
|
||||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefetchLoudnessForEnqueuedTracks(
|
function prefetchLoudnessForEnqueuedTracks(
|
||||||
mergedQueue: Track[],
|
mergedQueue: Track[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* `refreshWaveformForTrack` fetches an analysis row from Rust and applies
|
||||||
|
* it to the player store — but only if the refresh generation hasn't been
|
||||||
|
* bumped meanwhile and the track is still current. The tests pin both
|
||||||
|
* guards and the success / null-row / empty-bins / error branches.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
|
||||||
|
coerceWaveformBinsMock: vi.fn((bins: unknown) => {
|
||||||
|
if (bins == null) return null;
|
||||||
|
if (Array.isArray(bins) && bins.length === 0) return null;
|
||||||
|
return bins as number[];
|
||||||
|
}),
|
||||||
|
playerSnapshot: {
|
||||||
|
currentTrack: null as { id: string } | null,
|
||||||
|
},
|
||||||
|
playerSetStateMock: vi.fn(),
|
||||||
|
gen: 0,
|
||||||
|
getGenMock: vi.fn(() => hoisted.gen),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||||
|
vi.mock('../utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
|
||||||
|
vi.mock('./playerStore', () => ({
|
||||||
|
usePlayerStore: {
|
||||||
|
getState: () => hoisted.playerSnapshot,
|
||||||
|
setState: hoisted.playerSetStateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./waveformRefreshGen', () => ({
|
||||||
|
getWaveformRefreshGen: hoisted.getGenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.invokeMock.mockReset();
|
||||||
|
hoisted.invokeMock.mockResolvedValue(null);
|
||||||
|
hoisted.coerceWaveformBinsMock.mockClear();
|
||||||
|
hoisted.playerSetStateMock.mockClear();
|
||||||
|
hoisted.playerSnapshot.currentTrack = null;
|
||||||
|
hoisted.gen = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshWaveformForTrack', () => {
|
||||||
|
it('is a no-op for empty trackId', async () => {
|
||||||
|
await refreshWaveformForTrack('');
|
||||||
|
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards results when the gen has been bumped since the call started', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||||
|
hoisted.invokeMock.mockImplementationOnce(async () => {
|
||||||
|
hoisted.gen = 99; // simulate concurrent bump
|
||||||
|
return { bins: [1, 2, 3] };
|
||||||
|
});
|
||||||
|
await refreshWaveformForTrack('t1');
|
||||||
|
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips when the track is no longer current after the fetch', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 'other' };
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce({ bins: [1, 2, 3] });
|
||||||
|
await refreshWaveformForTrack('t1');
|
||||||
|
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blanks bins when the row is null', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||||
|
await refreshWaveformForTrack('t1');
|
||||||
|
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blanks bins when coerceWaveformBins returns null (invalid shape)', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce({ bins: 'garbage' });
|
||||||
|
hoisted.coerceWaveformBinsMock.mockReturnValueOnce(null);
|
||||||
|
await refreshWaveformForTrack('t1');
|
||||||
|
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the coerced bins on a clean fetch', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||||
|
hoisted.invokeMock.mockResolvedValueOnce({ bins: [10, 20, 30] });
|
||||||
|
hoisted.coerceWaveformBinsMock.mockReturnValueOnce([10, 20, 30]);
|
||||||
|
await refreshWaveformForTrack('t1');
|
||||||
|
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: [10, 20, 30] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows fetch errors silently (placeholder waveform stays)', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||||
|
hoisted.invokeMock.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
await expect(refreshWaveformForTrack('t1')).resolves.toBeUndefined();
|
||||||
|
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { coerceWaveformBins } from '../utils/waveformParse';
|
||||||
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { getWaveformRefreshGen } from './waveformRefreshGen';
|
||||||
|
|
||||||
|
/** Subsonic-server waveform-cache row as Rust hands it back. */
|
||||||
|
export type WaveformCachePayload = {
|
||||||
|
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
|
||||||
|
bins: number[] | Uint8Array;
|
||||||
|
binCount: number;
|
||||||
|
isPartial: boolean;
|
||||||
|
knownUntilSec: number;
|
||||||
|
durationSec: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the cached waveform row for `trackId` from Rust and apply its bins
|
||||||
|
* to the player store — but only if (a) the refresh generation snapshot
|
||||||
|
* still matches (no newer invalidation has fired meanwhile) and (b) the
|
||||||
|
* track is still the current one. Best-effort: any failure leaves the
|
||||||
|
* seekbar with the placeholder waveform.
|
||||||
|
*/
|
||||||
|
export async function refreshWaveformForTrack(trackId: string): Promise<void> {
|
||||||
|
if (!trackId) return;
|
||||||
|
const gen = getWaveformRefreshGen(trackId);
|
||||||
|
try {
|
||||||
|
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
|
||||||
|
if (getWaveformRefreshGen(trackId) !== gen) return;
|
||||||
|
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
|
||||||
|
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
|
||||||
|
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||||
|
if (!bins || bins.length === 0) {
|
||||||
|
usePlayerStore.setState({
|
||||||
|
waveformBins: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usePlayerStore.setState({
|
||||||
|
waveformBins: bins,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort; seekbar falls back to placeholder waveform
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user