mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
a6ee0668c8
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
488 lines
20 KiB
TypeScript
488 lines
20 KiB
TypeScript
import { playbackReportStart } from './playbackReportSession';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { getMusicNetworkRuntimeOrNull } from '../music-network';
|
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
|
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
|
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
|
import {
|
|
bindQueueServerForTracks,
|
|
getPlaybackCacheServerKey,
|
|
getPlaybackIndexKey,
|
|
playbackCacheKeyForTrack,
|
|
playbackProfileIdForTrack,
|
|
shouldBindQueueServerForPlay,
|
|
} from '../utils/playback/playbackServer';
|
|
import { stampTrackServerId, stampTrackServerIds } from '../utils/playback/trackServerScope';
|
|
import {
|
|
findLocalPlaybackUrl,
|
|
hasLocalPersistentPlaybackBytes,
|
|
} from '../utils/offline/offlineLibraryHelpers';
|
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
|
import { useAuthStore } from './authStore';
|
|
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
|
|
import {
|
|
bumpPlayGeneration,
|
|
getPlayGeneration,
|
|
setIsAudioPaused,
|
|
} from './engineState';
|
|
import {
|
|
clearPreloadingIds,
|
|
getLastGaplessSwitchTime,
|
|
} from './gaplessPreloadState';
|
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
|
import {
|
|
isReplayGainActive,
|
|
loudnessGainDbForEngineBind,
|
|
} from './loudnessGainCache';
|
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
|
import { useOrbitStore } from './orbitStore';
|
|
import {
|
|
playbackSourceHintForResolvedUrl,
|
|
recordEnginePlayUrl,
|
|
} from './playbackUrlRouting';
|
|
import type { PlayerState, Track } from './playerStoreTypes';
|
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
|
import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView';
|
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
|
import { syncQueueToServer } from './queueSync';
|
|
import { playListenSessionFinalize } from './playListenSession';
|
|
import { pushQueueUndoFromGetter } from './queueUndo';
|
|
import { stopRadio } from './radioPlayer';
|
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
|
import { clearSeekDebounce } from './seekDebounce';
|
|
import {
|
|
clearSeekFallbackRetry,
|
|
getSeekFallbackVisualTarget,
|
|
setSeekFallbackRestartAt,
|
|
setSeekFallbackTrackId,
|
|
setSeekFallbackVisualTarget,
|
|
} from './seekFallbackState';
|
|
import {
|
|
clearSeekTarget,
|
|
setSeekTarget,
|
|
} from './seekTargetState';
|
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
|
|
|
type SetState = (
|
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
|
) => void;
|
|
type GetState = () => PlayerState;
|
|
|
|
/**
|
|
* Play a track, optionally replacing the queue and/or jumping to an
|
|
* explicit slot. Three guard layers run before the actual play body:
|
|
*
|
|
* 1. **Orbit bulk-gate** — when `queue.length > 1` and isn't a no-op
|
|
* replace of the current queue, prompt via `orbitBulkGuard`; on
|
|
* confirm, hosts/guests append (Orbit semantics — bulk replace
|
|
* would drop guest suggestions) and non-Orbit users replace as
|
|
* normal.
|
|
* 2. **Orbit-host single-track protection** — a `playTrack(track,
|
|
* [track])` from a host would blow away the shared queue; re-route
|
|
* to append-and-jump so guest suggestions survive.
|
|
* 3. **Ghost-command guard** — a playTrack arriving within 500 ms of
|
|
* the last gapless switch is almost certainly a stale IPC echo.
|
|
*
|
|
* The play body itself: clears all scheduled timers + seek state,
|
|
* resolves the URL, updates store + normalization snapshot
|
|
* optimistically, invokes the Rust engine, and on success seeks to
|
|
* the visual target if there was a pending one. Falls back to
|
|
* `next(false)` 500 ms after an `audio_play` failure. Same-track
|
|
* replays first flush the previous play's `stream_completed_cache`
|
|
* to hot disk so `fetch_data` doesn't re-run an HTTP range request.
|
|
*/
|
|
export function runPlayTrack(
|
|
set: SetState,
|
|
get: GetState,
|
|
track: Track,
|
|
queue: Track[] | undefined,
|
|
manual: boolean,
|
|
_orbitConfirmed: boolean,
|
|
targetQueueIndex: number | undefined,
|
|
): void {
|
|
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
|
|
// the current queue (Play All / Play Album / Play Playlist / Hero
|
|
// play buttons). Navigation calls — queue-row click, next(),
|
|
// previous() — pass the existing queue back through playTrack just
|
|
// to move the index; they are not bulk operations and must not
|
|
// trigger the confirm dialog (#234 regression).
|
|
if (!_orbitConfirmed && queue && queue.length > 1) {
|
|
const current = get().queueItems;
|
|
const sameAsCurrent = queue.length === current.length
|
|
&& queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id));
|
|
if (!sameAsCurrent) {
|
|
void orbitBulkGuard(queue.length).then(ok => {
|
|
if (!ok) return;
|
|
// Inside an Orbit session a bulk replace would discard guest
|
|
// suggestions mid-listen. Append instead — the dialog's
|
|
// "Add them all" copy already matches that semantic. Outside
|
|
// Orbit, proceed as a normal replace.
|
|
const role = useOrbitStore.getState().role;
|
|
if (role === 'host' || role === 'guest') {
|
|
get().enqueue(queue, true);
|
|
} else {
|
|
get().playTrack(track, queue, manual, true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Orbit-host single-track protection. The host's `playerStore.queue`
|
|
// *is* the shared Orbit queue. A `playTrack(track, [track])` call
|
|
// (e.g. OfflineLibrary's "Play this album" on a single-track album,
|
|
// or any other surface that explicitly passes a 1-track replacement
|
|
// queue) would otherwise blow away every guest suggestion + every
|
|
// upcoming track. Re-route to append + jump so the queue survives.
|
|
// Guest stays unguarded — a guest clicking Play locally is choosing
|
|
// to opt out of host-sync, which is the existing "guest is running
|
|
// their own show" path. `useOrbitGuest`'s `syncToHost` is also a
|
|
// guest-only call site, so it's never intercepted here.
|
|
if (!_orbitConfirmed && queue && queue.length === 1) {
|
|
const orbitRole = useOrbitStore.getState().role;
|
|
if (orbitRole === 'host') {
|
|
const currentItems = get().queueItems;
|
|
const currentTrackId = currentItems[get().queueIndex]?.trackId;
|
|
if (track.id !== currentTrackId) {
|
|
const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
|
|
if (existsAt >= 0) {
|
|
// Re-jump within the existing queue: pass undefined so playTrack keeps
|
|
// the canonical queueItems and just moves the index.
|
|
get().playTrack(track, undefined, manual, true, existsAt);
|
|
} else {
|
|
// Append the single track to the resolved current queue and jump to it.
|
|
const newQueue = [...getQueueTracksView(currentItems), track];
|
|
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
|
// this playTrack call is likely a stale IPC echo — suppress it.
|
|
if (Date.now() - getLastGaplessSwitchTime() < 500) {
|
|
return;
|
|
}
|
|
|
|
void playListenSessionFinalize('skip');
|
|
|
|
const scopedTrack = stampTrackServerId(track);
|
|
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
|
|
|
|
clearAllPlaybackScheduleTimers();
|
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
|
|
|
const gen = bumpPlayGeneration();
|
|
setIsAudioPaused(false);
|
|
clearPreloadingIds(); // new track — allow fresh preload for next
|
|
clearSeekDebounce(); clearSeekTarget();
|
|
clearSeekFallbackRetry();
|
|
setSeekFallbackRestartAt(0);
|
|
|
|
// If a radio stream is active, stop it before the new track starts so
|
|
// the PlayerBar clears radio mode immediately and the stream is released.
|
|
if (get().currentRadio) {
|
|
stopRadio();
|
|
}
|
|
|
|
const state = get();
|
|
const prevTrack = state.currentTrack;
|
|
if (prevTrack?.id !== scopedTrack.id) {
|
|
setSeekFallbackTrackId(null);
|
|
}
|
|
const visualOnEntry = getSeekFallbackVisualTarget();
|
|
if (visualOnEntry?.trackId !== scopedTrack.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
// Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
|
|
// queueItems. A no-arg navigation (next/previous/queue-row jump) keeps the
|
|
// canonical refs and just moves the index — so we never resolve the whole
|
|
// queue here (O(visible), not O(queue length)), which would hitch + churn
|
|
// every subscriber on each track change at scale.
|
|
const replacing = scopedQueue !== undefined;
|
|
const srcLen = replacing ? scopedQueue.length : state.queueItems.length;
|
|
if (replacing && shouldBindQueueServerForPlay(state.queueItems, scopedQueue, scopedQueue)) {
|
|
bindQueueServerForTracks(scopedQueue);
|
|
}
|
|
// Prefer an explicit target index from the caller (next/previous/queue-row
|
|
// click already know the exact slot). `findIndex` returns the *first*
|
|
// matching id, which jumps backwards when the queue contains the same
|
|
// track twice — breaking radio playback (issue #500).
|
|
const matchesAt = (i: number): boolean =>
|
|
replacing
|
|
? sameQueueTrackId(scopedQueue[i]?.id, scopedTrack.id)
|
|
: sameQueueTrackId(state.queueItems[i]?.trackId, scopedTrack.id);
|
|
const explicitIdxValid =
|
|
typeof targetQueueIndex === 'number'
|
|
&& targetQueueIndex >= 0
|
|
&& targetQueueIndex < srcLen
|
|
&& matchesAt(targetQueueIndex);
|
|
const idx = explicitIdxValid
|
|
? (targetQueueIndex as number)
|
|
: replacing
|
|
? scopedQueue.findIndex(t => sameQueueTrackId(t.id, scopedTrack.id))
|
|
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrack.id));
|
|
const playIdx = idx >= 0 ? idx : 0;
|
|
const playingRef = replacing ? undefined : state.queueItems[playIdx];
|
|
const prevPlayingRef = replacing ? undefined : state.queueItems[state.queueIndex];
|
|
// ±1 neighbours for replaygain normalization — resolve only these (not the
|
|
// whole queue). On replace they come from the provided Track[]; on navigation
|
|
// from the resolver cache (the bridge keeps that window warm).
|
|
const neighbourAt = (i: number): Track | null => {
|
|
if (i < 0 || i >= srcLen) return null;
|
|
if (replacing) return scopedQueue[i] ?? null;
|
|
if (i === playIdx) return scopedTrack;
|
|
const ref = state.queueItems[i];
|
|
return ref ? resolveQueueTrack(ref) : null;
|
|
};
|
|
const prevNeighbour = neighbourAt(playIdx - 1);
|
|
const nextNeighbour = neighbourAt(playIdx + 1);
|
|
// Minimal window so deriveNormalizationSnapshot reads ±1 without a full array.
|
|
const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : [];
|
|
const normIdx = normWindow.length;
|
|
normWindow.push(scopedTrack);
|
|
if (nextNeighbour) normWindow.push(nextNeighbour);
|
|
if (manual) {
|
|
pushQueueUndoFromGetter(get);
|
|
}
|
|
const visualForInitial = getSeekFallbackVisualTarget();
|
|
const pendingVisualTarget = visualForInitial?.trackId === scopedTrack.id
|
|
? visualForInitial.seconds
|
|
: null;
|
|
const initialTime = pendingVisualTarget !== null
|
|
? Math.max(0, Math.min(pendingVisualTarget, scopedTrack.duration || pendingVisualTarget))
|
|
: 0;
|
|
const initialProgress =
|
|
scopedTrack.duration && scopedTrack.duration > 0
|
|
? Math.max(0, Math.min(1, initialTime / scopedTrack.duration))
|
|
: 0;
|
|
|
|
const authState = useAuthStore.getState();
|
|
const playbackProfileId = playbackProfileIdForTrack(scopedTrack, playingRef);
|
|
const libraryLocalUrl = playbackProfileId
|
|
? findLocalPlaybackUrl(scopedTrack.id, playbackProfileId, 'library')
|
|
: null;
|
|
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
|
|
// `take()` once; a second replay would full HTTP-range again unless we flush
|
|
// RAM to hot disk first (promote was only run when switching to another track).
|
|
const needSameTrackHotPromote =
|
|
!libraryLocalUrl
|
|
&& !(playbackProfileId && hasLocalPersistentPlaybackBytes(scopedTrack.id, playbackProfileId))
|
|
&& Boolean(
|
|
prevTrack
|
|
&& sameQueueTrackId(prevTrack.id, scopedTrack.id)
|
|
&& authState.hotCacheEnabled
|
|
&& getPlaybackCacheServerKey(),
|
|
);
|
|
|
|
const runPlayTrackBody = () => {
|
|
const authStateNow = useAuthStore.getState();
|
|
const playbackSid = playbackProfileIdForTrack(scopedTrack, playingRef);
|
|
const playbackCacheSid = playbackCacheKeyForTrack(scopedTrack, playingRef);
|
|
const url = libraryLocalUrl
|
|
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'library')
|
|
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'favorite-auto')
|
|
?? resolvePlaybackUrl(scopedTrack.id, playbackCacheSid);
|
|
recordEnginePlayUrl(scopedTrack.id, url);
|
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
|
const keepPreloadHint = preloadedTrackId === scopedTrack.id;
|
|
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
|
scopedTrack.id,
|
|
playbackCacheSid,
|
|
url,
|
|
);
|
|
if (import.meta.env.DEV) {
|
|
console.info('[psysonic][playTrack-source]', {
|
|
trackId: scopedTrack.id,
|
|
resolvedUrl: url,
|
|
preloadedTrackId,
|
|
keepPreloadHint,
|
|
playbackSourceHint,
|
|
});
|
|
}
|
|
|
|
// Set state immediately so the UI updates before the download completes.
|
|
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
|
const queueSid = get().queueServerId ?? '';
|
|
// When the caller replaced the queue (explicit `queue` arg), seed the
|
|
// resolver with those tracks so the UI / hot paths resolve them without a
|
|
// network round-trip. No-arg jumps reuse already-cached refs.
|
|
if (scopedQueue) {
|
|
for (const t of scopedQueue) {
|
|
const sid = playbackCacheKeyForTrack(t);
|
|
if (sid) seedQueueResolver(sid, [t]);
|
|
}
|
|
} else if (queueSid) {
|
|
seedQueueResolver(queueSid, [scopedTrack]);
|
|
}
|
|
set({
|
|
currentTrack: scopedTrack,
|
|
currentRadio: null,
|
|
waveformBins: null,
|
|
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
|
|
// Only a replace rewrites the queue; navigation keeps the canonical refs.
|
|
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
|
|
queueIndex: idx >= 0 ? idx : 0,
|
|
progress: initialProgress,
|
|
buffered: 0,
|
|
currentTime: initialTime,
|
|
scrobbled: false,
|
|
networkLoved: false,
|
|
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
|
|
// extrapolate while RangedHttpSource / legacy reader is still buffering.
|
|
isPlaying: playbackSourceHint !== 'stream',
|
|
isPlaybackBuffering: playbackSourceHint === 'stream',
|
|
currentPlaybackSource: playbackSourceHint,
|
|
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
|
|
});
|
|
|
|
if (
|
|
prevTrack
|
|
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
|
|
&& authStateNow.hotCacheEnabled
|
|
) {
|
|
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
|
|
if (prevPromoteSid) {
|
|
void promoteCompletedStreamToHotCache(
|
|
prevTrack,
|
|
prevPromoteSid,
|
|
authStateNow.hotCacheDownloadDir || null,
|
|
);
|
|
}
|
|
}
|
|
void refreshWaveformForTrack(scopedTrack.id);
|
|
void refreshLoudnessForTrack(scopedTrack.id);
|
|
setDeferHotCachePrefetch(true);
|
|
const replayGainDb = resolveReplayGainDb(
|
|
scopedTrack, prevTrack, nextNeighbour,
|
|
isReplayGainActive(), authStateNow.replayGainMode,
|
|
);
|
|
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
|
|
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
|
|
// under crossfade, start past this track's leading silence (always, from the
|
|
// plan) and — only when the JS A-tail advance positioned this transition —
|
|
// fade over the content-driven overlap it armed. Engine-driven advances
|
|
// (plain loud→loud) leave the overlap unset and keep the normal crossfade
|
|
// length. Manual skips hard-cut and resumes keep their saved offset.
|
|
const useTrim =
|
|
!manual
|
|
&& authStateNow.crossfadeEnabled
|
|
&& authStateNow.crossfadeTrimSilence
|
|
&& !authStateNow.gaplessEnabled
|
|
&& initialTime <= 0.05;
|
|
const crossfadePlan = useTrim ? getCrossfadeTransition(scopedTrack.id) : null;
|
|
const armedOverlap = useTrim ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
|
|
const crossfadeStartSecs = crossfadePlan?.bStartSec ?? 0;
|
|
const crossfadeSecsOverride = armedOverlap ? armedOverlap.overlapSec : null;
|
|
// Scenario A: 0 ⇒ don't fade A (it rides its own recorded fade); only sent
|
|
// when JS drove this advance, so engine-driven swaps keep today's behaviour.
|
|
const outgoingFadeSecsOverride = armedOverlap ? armedOverlap.outgoingFadeSec : null;
|
|
invoke('audio_play', {
|
|
url,
|
|
volume: state.volume,
|
|
durationHint: scopedTrack.duration,
|
|
replayGainDb,
|
|
replayGainPeak,
|
|
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
|
|
preGainDb: authStateNow.replayGainPreGainDb,
|
|
fallbackDb: authStateNow.replayGainFallbackDb,
|
|
manual,
|
|
hiResEnabled: authStateNow.enableHiRes,
|
|
analysisTrackId: scopedTrack.id,
|
|
serverId: getPlaybackIndexKey() || null,
|
|
streamFormatSuffix: scopedTrack.suffix ?? null,
|
|
startPaused: false,
|
|
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
|
|
crossfadeSecsOverride,
|
|
outgoingFadeSecsOverride,
|
|
})
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
if (keepPreloadHint) {
|
|
set({ enginePreloadedTrackId: null });
|
|
}
|
|
const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null;
|
|
const seekTo = initialTime;
|
|
const canSeekAfterPlay =
|
|
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
|
|
if (canSeekAfterPlay) {
|
|
void invoke('audio_seek', { seconds: seekTo })
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setSeekTarget(seekTo);
|
|
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] audio_play failed:', err);
|
|
set({ isPlaying: false });
|
|
setTimeout(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
get().next(false);
|
|
}, 500);
|
|
});
|
|
|
|
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
|
|
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
|
|
// internally). playbackReportStart opens the live FSM on extension-capable
|
|
// servers and falls back to the legacy presence call otherwise.
|
|
playbackReportStart(scopedTrack.id, playbackSid);
|
|
const runtime = getMusicNetworkRuntimeOrNull();
|
|
void runtime?.dispatchNowPlaying({
|
|
title: scopedTrack.title,
|
|
artist: scopedTrack.artist,
|
|
album: scopedTrack.album,
|
|
duration: scopedTrack.duration,
|
|
timestamp: Date.now(),
|
|
});
|
|
if (runtime?.getEnrichmentPrimaryId()) {
|
|
void runtime
|
|
.isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist })
|
|
.then(loved => {
|
|
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
|
|
set(s => ({
|
|
networkLoved: loved,
|
|
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved },
|
|
}));
|
|
});
|
|
}
|
|
syncQueueToServer(get().queueItems, scopedTrack, initialTime);
|
|
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
|
|
};
|
|
|
|
const hotPromoteSid = getPlaybackCacheServerKey();
|
|
if (needSameTrackHotPromote && hotPromoteSid) {
|
|
void promoteCompletedStreamToHotCache(
|
|
scopedTrack,
|
|
hotPromoteSid,
|
|
authState.hotCacheDownloadDir || null,
|
|
)
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
runPlayTrackBody();
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] same-track hot promote / play body failed:', err);
|
|
set({ isPlaying: false });
|
|
});
|
|
} else {
|
|
runPlayTrackBody();
|
|
}
|
|
}
|