mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
cb1a110afb
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
import { invoke } from '@tauri-apps/api/core';
|
|
import { playbackReportSeek } from '@/features/playback/store/playbackReportSession';
|
|
import { isRecoverableSeekError } from '@/features/playback/utils/audio/seekErrors';
|
|
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
|
import { maybeCrossfadeBytePreload } from '@/features/playback/store/crossfadePreload';
|
|
import { shouldRebindPlaybackToHotCache } from '@/features/playback/store/playbackUrlRouting';
|
|
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
|
import { armSeekDebounce } from '@/features/playback/store/seekDebounce';
|
|
import {
|
|
clearSeekFallbackRetry,
|
|
getSeekFallbackRestartAt,
|
|
getSeekFallbackTrackId,
|
|
scheduleSeekFallbackRetry,
|
|
setSeekFallbackRestartAt,
|
|
setSeekFallbackTrackId,
|
|
setSeekFallbackVisualTarget,
|
|
} from '@/features/playback/store/seekFallbackState';
|
|
import {
|
|
clearSeekTarget,
|
|
setSeekTarget,
|
|
} from '@/features/playback/store/seekTargetState';
|
|
|
|
type SetState = (
|
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
|
) => void;
|
|
type GetState = () => PlayerState;
|
|
|
|
/**
|
|
* Seek to a 0..1 fraction of the current track. 100 ms debounce
|
|
* collapses rapid slider drags into one actual engine seek; when the
|
|
* resolved playback source is about to be replaced by a hot-cache
|
|
* rebind, we re-issue `playTrack` with a visual target instead of
|
|
* a raw `audio_seek` (otherwise the seek would land in the old
|
|
* source and snap the progress UI back).
|
|
*
|
|
* Recoverable backend errors (streaming start not yet seekable / busy)
|
|
* are translated into a bounded retry burst: keep the UI's visual
|
|
* target pinned at the requested position, schedule another seek
|
|
* attempt, and on the "not seekable" subset restart the track from
|
|
* the same position via `playTrack`.
|
|
*/
|
|
export function runSeek(set: SetState, get: GetState, progress: number): void {
|
|
const { currentTrack } = get();
|
|
if (!currentTrack) return;
|
|
const dur = currentTrack.duration;
|
|
if (!dur || !isFinite(dur)) return;
|
|
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
|
|
set({ progress: time / dur, currentTime: time });
|
|
armSeekDebounce(100, () => {
|
|
const s0 = get();
|
|
if (!s0.currentTrack) return;
|
|
// Report the new position once the drag settles so live now-playing jumps to
|
|
// the seeked point instead of waiting for the next heartbeat.
|
|
playbackReportSeek(time, s0.isPlaying);
|
|
const sidSeek = getPlaybackServerId();
|
|
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
|
setSeekFallbackVisualTarget({
|
|
trackId: s0.currentTrack.id,
|
|
seconds: time,
|
|
setAtMs: Date.now(),
|
|
});
|
|
clearSeekFallbackRetry();
|
|
s0.playTrack(s0.currentTrack, undefined, true);
|
|
return;
|
|
}
|
|
invoke('audio_seek', { seconds: time }).then(() => {
|
|
// Arm stale-progress guard only after backend acknowledged seek.
|
|
setSeekTarget(time);
|
|
setSeekFallbackVisualTarget(null);
|
|
clearSeekFallbackRetry();
|
|
// Seeking straight into the crossfade pre-buffer window must kick off the
|
|
// next-track download now — the progress-tick path is gated by the seek
|
|
// settle guard, which would otherwise delay the buffer past the fade.
|
|
maybeCrossfadeBytePreload(time, dur);
|
|
}).catch((err: unknown) => {
|
|
// Release the progress-tick guard so the UI doesn't freeze
|
|
// waiting for a target the engine will never reach.
|
|
clearSeekTarget();
|
|
const msg = String(err ?? '');
|
|
if (!isRecoverableSeekError(msg)) {
|
|
console.error(err);
|
|
setSeekFallbackVisualTarget(null);
|
|
clearSeekFallbackRetry();
|
|
return;
|
|
}
|
|
// Streaming-start path can be temporarily non-seekable or busy.
|
|
// Keep UI at target and retry seek for a short bounded window.
|
|
const s = get();
|
|
if (!s.currentTrack) return;
|
|
const now = Date.now();
|
|
const sameBurst =
|
|
getSeekFallbackTrackId() === s.currentTrack.id
|
|
&& now - getSeekFallbackRestartAt() < 600;
|
|
setSeekFallbackVisualTarget({
|
|
trackId: s.currentTrack.id,
|
|
seconds: time,
|
|
setAtMs: Date.now(),
|
|
});
|
|
// Keep stale progress ticks from snapping UI back to start while
|
|
// recoverable seek retries are still in flight.
|
|
setSeekTarget(time);
|
|
if (msg.includes('not seekable') && !sameBurst) {
|
|
setSeekFallbackTrackId(s.currentTrack.id);
|
|
setSeekFallbackRestartAt(now);
|
|
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
|
s.playTrack(s.currentTrack, undefined, true);
|
|
}
|
|
scheduleSeekFallbackRetry(s.currentTrack.id, time);
|
|
});
|
|
});
|
|
}
|