mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
45e0e1206f
* fix(playback): pin queue streams, cover art, and library links to queue server When the active server changes while a queue from another server is playing, keep streams and UI on queueServerId; switch back for artist/album links and queue or player-bar context menus. * fix(playback): switch to queue server when opening Now Playing Ensure active server matches queueServerId before Subsonic fetches on the Now Playing page, mobile player route, and queue info panel; scope caches by server id. * docs(credits): mention Now Playing in PR #717 contribution line * fix(playback): route scrobble and queue sync to queue server Address PR review: apiForServer for scrobble/now-playing/savePlayQueue, clear queueServerId on server removal, mini-player queueServerId sync, block cross-server enqueue with toast, and regression tests.
223 lines
9.7 KiB
TypeScript
223 lines
9.7 KiB
TypeScript
import { getSong } from '../api/subsonicLibrary';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { estimateLivePosition } from '../api/orbit';
|
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
|
import { songToTrack } from '../utils/playback/songToTrack';
|
|
import { useAuthStore } from './authStore';
|
|
import {
|
|
bumpPlayGeneration,
|
|
getIsAudioPaused,
|
|
getPlayGeneration,
|
|
setIsAudioPaused,
|
|
} from './engineState';
|
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
|
import {
|
|
isReplayGainActive,
|
|
loudnessGainDbForEngineBind,
|
|
} from './loudnessGainCache';
|
|
import { useOrbitStore } from './orbitStore';
|
|
import {
|
|
playbackSourceHintForResolvedUrl,
|
|
recordEnginePlayUrl,
|
|
} from './playbackUrlRouting';
|
|
import type { PlayerState } from './playerStoreTypes';
|
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
|
import { syncQueueToServer } from './queueSync';
|
|
import { resumeRadio } from './radioPlayer';
|
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
|
|
|
type SetState = (
|
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
|
) => void;
|
|
type GetState = () => PlayerState;
|
|
|
|
/**
|
|
* Resume playback from a paused state. Three mutually-exclusive
|
|
* branches:
|
|
*
|
|
* 1. **Orbit guest** — catches the local player up to the host's live
|
|
* position. The user hit pause at some earlier point; resuming
|
|
* shouldn't drop them back at a stale local position while the
|
|
* host is already two songs ahead. Same-track → seek + un-pause;
|
|
* different-track → `playTrack` with a deferred seek.
|
|
*
|
|
* 2. **Radio** — HTML5 audio resume; no Rust engine involved.
|
|
*
|
|
* 3. **Regular track** — two sub-branches keyed off `getIsAudioPaused`:
|
|
* - **Warm**: engine still has the stream loaded but paused;
|
|
* `audio_resume` is enough.
|
|
* - **Cold**: engine has no loaded stream (app relaunch, or track
|
|
* ended and user hit play again). Promote any
|
|
* `stream_completed_cache` to hot disk, refetch the song from
|
|
* Navidrome for fresh ReplayGain metadata, call `audio_play`,
|
|
* then seek to the persisted `currentTime`. A `getSong` failure
|
|
* falls back to the in-memory `currentTrack`.
|
|
*/
|
|
export function runResume(set: SetState, get: GetState): void {
|
|
clearAllPlaybackScheduleTimers();
|
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
|
|
|
// Orbit guest: resume means "catch up to the host's live stream".
|
|
// The user hit pause at some earlier point; resuming shouldn't drop
|
|
// them back at the stale local position while the host is already
|
|
// two songs ahead. Covers PlayerBar, media keys, MPRIS — everything
|
|
// that funnels through resume().
|
|
const orbit = useOrbitStore.getState();
|
|
const hostState = orbit.state;
|
|
if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) {
|
|
const trackId = hostState.currentTrack.trackId;
|
|
const targetMs = estimateLivePosition(hostState, Date.now());
|
|
const targetSec = Math.max(0, targetMs / 1000);
|
|
const localTrackId = get().currentTrack?.id;
|
|
void (async () => {
|
|
try {
|
|
const song = await getSong(trackId);
|
|
if (!song) return;
|
|
const track = songToTrack(song);
|
|
const fraction = Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
|
|
if (localTrackId === trackId) {
|
|
// Same track: seek + un-pause via the Rust engine directly.
|
|
// Bypasses this resume() branch re-entry via the early return below.
|
|
get().seek(fraction);
|
|
if (getIsAudioPaused()) {
|
|
invoke('audio_resume').catch(console.error);
|
|
setIsAudioPaused(false);
|
|
set({ isPlaying: true });
|
|
} else {
|
|
set({ isPlaying: true });
|
|
}
|
|
} else {
|
|
// Host has a different track — load it (`_orbitConfirmed=true`
|
|
// skips the bulk gate; single-track play isn't a bulk replace
|
|
// anyway). Seek after a short defer once the engine loads.
|
|
get().playTrack(track, [track], false, true);
|
|
window.setTimeout(() => {
|
|
if (get().currentTrack?.id === trackId) get().seek(fraction);
|
|
}, 400);
|
|
}
|
|
} catch { /* silent */ }
|
|
})();
|
|
return;
|
|
}
|
|
|
|
if (get().currentRadio) {
|
|
resumeRadio().catch(console.error);
|
|
set({ isPlaying: true });
|
|
return;
|
|
}
|
|
const { currentTrack, queue, queueIndex, currentTime } = get();
|
|
if (!currentTrack) return;
|
|
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
|
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
|
|
|
if (getIsAudioPaused()) {
|
|
// Rust engine has audio loaded but paused — just resume it.
|
|
invoke('audio_resume').catch(console.error);
|
|
setIsAudioPaused(false);
|
|
set({ isPlaying: true });
|
|
touchHotCacheOnPlayback(currentTrack.id, getPlaybackServerId());
|
|
} else {
|
|
// Engine has no loaded paused stream (app relaunch, or track ended and user
|
|
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
|
|
// `stream_completed_cache` from the prior play to hot disk before resolving URL.
|
|
const gen = bumpPlayGeneration();
|
|
const vol = get().volume;
|
|
set({ isPlaying: true });
|
|
|
|
void (async () => {
|
|
const authHot = useAuthStore.getState();
|
|
const resumePromoteSid = getPlaybackServerId();
|
|
if (authHot.hotCacheEnabled && resumePromoteSid) {
|
|
await promoteCompletedStreamToHotCache(
|
|
currentTrack,
|
|
resumePromoteSid,
|
|
authHot.hotCacheDownloadDir || null,
|
|
);
|
|
}
|
|
if (getPlayGeneration() !== gen) return;
|
|
|
|
// Fetch fresh track data from server to get replay gain metadata
|
|
getSong(currentTrack.id).then(freshSong => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
|
|
// Update store with fresh track data if available
|
|
if (freshSong) set({ currentTrack: trackToPlay });
|
|
const authStateCold = useAuthStore.getState();
|
|
const replayGainDbCold = resolveReplayGainDb(
|
|
trackToPlay, coldPrev, coldNext,
|
|
isReplayGainActive(), authStateCold.replayGainMode,
|
|
);
|
|
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
|
const coldServerId = getPlaybackServerId();
|
|
setDeferHotCachePrefetch(true);
|
|
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
|
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
|
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
|
invoke('audio_play', {
|
|
url: coldUrl,
|
|
volume: vol,
|
|
durationHint: trackToPlay.duration,
|
|
replayGainDb: replayGainDbCold,
|
|
replayGainPeak: replayGainPeakCold,
|
|
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
|
|
preGainDb: authStateCold.replayGainPreGainDb,
|
|
fallbackDb: authStateCold.replayGainFallbackDb,
|
|
manual: false,
|
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
|
analysisTrackId: trackToPlay.id,
|
|
streamFormatSuffix: trackToPlay.suffix ?? null,
|
|
}).then(() => {
|
|
if (getPlayGeneration() === gen && currentTime > 1) {
|
|
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
|
}
|
|
}).catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
|
set({ isPlaying: false });
|
|
});
|
|
syncQueueToServer(queue, trackToPlay, currentTime);
|
|
}).catch(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
// Fallback to currentTrack if fetch fails
|
|
const authStateCold = useAuthStore.getState();
|
|
const replayGainDbCold = resolveReplayGainDb(
|
|
currentTrack, coldPrev, coldNext,
|
|
isReplayGainActive(), authStateCold.replayGainMode,
|
|
);
|
|
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
|
|
const coldServerId = getPlaybackServerId();
|
|
setDeferHotCachePrefetch(true);
|
|
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
|
recordEnginePlayUrl(currentTrack.id, coldUrl);
|
|
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
|
|
invoke('audio_play', {
|
|
url: coldUrl,
|
|
volume: vol,
|
|
durationHint: currentTrack.duration,
|
|
replayGainDb: replayGainDbCold,
|
|
replayGainPeak: replayGainPeakCold,
|
|
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
|
|
preGainDb: authStateCold.replayGainPreGainDb,
|
|
fallbackDb: authStateCold.replayGainFallbackDb,
|
|
manual: false,
|
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
|
analysisTrackId: currentTrack.id,
|
|
streamFormatSuffix: currentTrack.suffix ?? null,
|
|
}).catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
|
set({ isPlaying: false });
|
|
});
|
|
syncQueueToServer(queue, currentTrack, currentTime);
|
|
});
|
|
})();
|
|
}
|
|
}
|