mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(playback): pin queue playback to source server when browsing another library (#717)
* 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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { reportNowPlaying } from '../api/subsonicScrobble';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { getPlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { useAuthStore } from './authStore';
|
||||
import {
|
||||
@@ -129,9 +130,9 @@ export function applyQueueHistorySnapshot(
|
||||
PlayerState,
|
||||
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
|
||||
>);
|
||||
const authSnap = useAuthStore.getState();
|
||||
const playbackSid = getPlaybackServerId();
|
||||
const playbackSourceUndo = nextTrack
|
||||
? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null)
|
||||
? getPlaybackSourceKind(nextTrack.id, playbackSid, null)
|
||||
: null;
|
||||
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
|
||||
? prior.currentPlaybackSource
|
||||
@@ -186,7 +187,7 @@ export function applyQueueHistorySnapshot(
|
||||
|
||||
if (!keepPlaybackFromPrior) {
|
||||
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
|
||||
if (npUndo) reportNowPlaying(nextTrack.id);
|
||||
if (npUndo) reportNowPlaying(nextTrack.id, getPlaybackServerId());
|
||||
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: gen,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
@@ -160,7 +161,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
scrobbleSong(track.id, Date.now());
|
||||
scrobbleSong(track.id, Date.now(), getPlaybackServerId());
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (scrobblingEnabled && lastfmSessionKey) {
|
||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||
@@ -236,7 +237,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi
|
||||
const shouldBytePreloadForGaplessBackup =
|
||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const serverId = getPlaybackServerId();
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||
@@ -322,7 +323,7 @@ export function handleAudioEnded(): void {
|
||||
void (async () => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
const authState = useAuthStore.getState();
|
||||
const repeatPromoteSid = authState.activeServerId;
|
||||
const repeatPromoteSid = getPlaybackServerId();
|
||||
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
||||
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
||||
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
||||
@@ -373,7 +374,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
|
||||
if (!nextTrack) return;
|
||||
|
||||
const switchServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
const switchServerId = getPlaybackServerId();
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||
|
||||
@@ -403,7 +404,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
|
||||
// Report Now Playing to Navidrome + Last.fm
|
||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
|
||||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, getPlaybackServerId());
|
||||
if (lastfmSessionKey) {
|
||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||
@@ -415,7 +416,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
});
|
||||
}
|
||||
syncQueueToServer(queue, nextTrack, 0);
|
||||
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
|
||||
touchHotCacheOnPlayback(nextTrack.id, getPlaybackServerId());
|
||||
}
|
||||
|
||||
export function handleAudioError(message: string): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildCoverArtUrl } from '../../api/subsonicStreamUrl';
|
||||
import { playbackCoverArtForId } from '../../utils/playback/playbackServer';
|
||||
import { usePlayerStore } from '../playerStore';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../playbackProgress';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function setupMprisSync(): () => void {
|
||||
prevTrackId = currentTrack.id;
|
||||
prevRadioId = null;
|
||||
const coverUrl = currentTrack.coverArt
|
||||
? buildCoverArtUrl(currentTrack.coverArt, 512)
|
||||
? playbackCoverArtForId(currentTrack.coverArt, 512).src
|
||||
: undefined;
|
||||
invoke('mpris_set_metadata', {
|
||||
title: currentTrack.title,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
import { generateId } from './authStoreHelpers';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
||||
@@ -38,6 +40,9 @@ export function createServerProfileActions(set: SetState): Pick<
|
||||
},
|
||||
|
||||
removeServer: (id) => {
|
||||
if (usePlayerStore.getState().queueServerId === id) {
|
||||
clearQueueServerForPlayback();
|
||||
}
|
||||
set(s => {
|
||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||
const switchedAway = s.activeServerId === id;
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import { makeServer } from '@/test/helpers/factories';
|
||||
|
||||
function addThree(): { a: string; b: string; c: string } {
|
||||
@@ -24,6 +26,7 @@ function addThree(): { a: string; b: string; c: string } {
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
resetPlayerStore();
|
||||
});
|
||||
|
||||
describe('addServer / updateServer', () => {
|
||||
@@ -122,6 +125,18 @@ describe('removeServer', () => {
|
||||
useAuthStore.getState().removeServer(a);
|
||||
expect(useAuthStore.getState().activeServerId).toBe(b);
|
||||
});
|
||||
|
||||
it('clears queueServerId when the removed server owned the playback queue', () => {
|
||||
const { a, b } = addThree();
|
||||
useAuthStore.getState().setActiveServer(b);
|
||||
usePlayerStore.setState({
|
||||
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
|
||||
queueServerId: a,
|
||||
queueIndex: 0,
|
||||
});
|
||||
useAuthStore.getState().removeServer(a);
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectors — getBaseUrl / getActiveServer', () => {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
||||
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
||||
import {
|
||||
bindQueueServerForPlayback,
|
||||
getPlaybackServerId,
|
||||
shouldBindQueueServerForPlay,
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { useAuthStore } from './authStore';
|
||||
@@ -173,6 +178,9 @@ export function runPlayTrack(
|
||||
setSeekFallbackVisualTarget(null);
|
||||
}
|
||||
const newQueue = queue ?? state.queue;
|
||||
if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) {
|
||||
bindQueueServerForPlayback();
|
||||
}
|
||||
// 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
|
||||
@@ -207,18 +215,19 @@ export function runPlayTrack(
|
||||
prevTrack
|
||||
&& sameQueueTrackId(prevTrack.id, track.id)
|
||||
&& authState.hotCacheEnabled
|
||||
&& authState.activeServerId,
|
||||
&& getPlaybackServerId(),
|
||||
);
|
||||
|
||||
const runPlayTrackBody = () => {
|
||||
const authStateNow = useAuthStore.getState();
|
||||
const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? '');
|
||||
const playbackSid = getPlaybackServerId();
|
||||
const url = resolvePlaybackUrl(track.id, playbackSid);
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === track.id;
|
||||
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
||||
track.id,
|
||||
authStateNow.activeServerId ?? '',
|
||||
playbackSid,
|
||||
url,
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -255,7 +264,7 @@ export function runPlayTrack(
|
||||
&& !sameQueueTrackId(prevTrack.id, track.id)
|
||||
&& authStateNow.hotCacheEnabled
|
||||
) {
|
||||
const prevPromoteSid = authStateNow.activeServerId;
|
||||
const prevPromoteSid = getPlaybackServerId();
|
||||
if (prevPromoteSid) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
prevTrack,
|
||||
@@ -326,7 +335,7 @@ export function runPlayTrack(
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
if (npEnabled) reportNowPlaying(track.id);
|
||||
if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId());
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||
@@ -338,10 +347,10 @@ export function runPlayTrack(
|
||||
});
|
||||
}
|
||||
syncQueueToServer(newQueue, track, initialTime);
|
||||
touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? '');
|
||||
touchHotCacheOnPlayback(track.id, playbackSid);
|
||||
};
|
||||
|
||||
const hotPromoteSid = authState.activeServerId;
|
||||
const hotPromoteSid = getPlaybackServerId();
|
||||
if (needSameTrackHotPromote && hotPromoteSid) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
track,
|
||||
|
||||
@@ -44,6 +44,16 @@ vi.mock('@/api/subsonicScrobble', () => ({
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock('@/utils/playback/playbackServer', () => ({
|
||||
getPlaybackServerId: () => 'srv-test',
|
||||
bindQueueServerForPlayback: vi.fn(),
|
||||
clearQueueServerForPlayback: vi.fn(),
|
||||
playbackServerDiffersFromActive: () => false,
|
||||
playbackCoverArtForId: (id: string, size: number) => ({
|
||||
src: `https://mock/cover/${id}?size=${size}`,
|
||||
cacheKey: `mock:cover:${id}:${size}`,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/api/subsonicStarRating', () => ({
|
||||
setRating: vi.fn(async () => undefined),
|
||||
probeEntityRatingSupport: vi.fn(async () => 'track_only'),
|
||||
@@ -116,6 +126,7 @@ describe('flushPlayQueuePosition', () => {
|
||||
[t1.id, t2.id, t3.id],
|
||||
t2.id,
|
||||
12345, // Math.floor(12.345 * 1000)
|
||||
'srv-test',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
queue: [],
|
||||
queueServerId: null,
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
@@ -81,6 +82,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
repeatMode: state.repeatMode,
|
||||
currentTrack: state.currentTrack,
|
||||
queue: state.queue,
|
||||
queueServerId: state.queueServerId,
|
||||
queueIndex: state.queueIndex,
|
||||
isQueueVisible: state.isQueueVisible,
|
||||
// currentTime is intentionally NOT persisted here.
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface PlayerState {
|
||||
*/
|
||||
enginePreloadedTrackId: string | null;
|
||||
queue: Track[];
|
||||
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
|
||||
queueServerId: string | null;
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
progress: number; // 0–1
|
||||
@@ -171,8 +173,20 @@ export interface PlayerState {
|
||||
* list/grid to copy a `composer` link from the otherwise artist-typed
|
||||
* context menu, so paste lands on /composer/:id instead of /artist/:id. */
|
||||
shareKindOverride?: 'track' | 'album' | 'artist' | 'composer';
|
||||
/** Menu actions target {@link queueServerId} (set for queue-item and player-sourced album menus). */
|
||||
pinToPlaybackServer?: boolean;
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number, shareKindOverride?: 'track' | 'album' | 'artist' | 'composer') => void;
|
||||
openContextMenu: (
|
||||
x: number,
|
||||
y: number,
|
||||
item: any,
|
||||
type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist',
|
||||
queueIndex?: number,
|
||||
playlistId?: string,
|
||||
playlistSongIndex?: number,
|
||||
shareKindOverride?: 'track' | 'album' | 'artist' | 'composer',
|
||||
pinToPlaybackServer?: boolean,
|
||||
) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
|
||||
@@ -13,7 +13,10 @@ const { invokeMock, setEntryMock, buildStreamUrlMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
||||
vi.mock('../api/subsonicStreamUrl', () => ({ buildStreamUrl: buildStreamUrlMock }));
|
||||
vi.mock('../api/subsonicStreamUrl', () => ({
|
||||
buildStreamUrl: buildStreamUrlMock,
|
||||
buildStreamUrlForServer: (_serverId: string, id: string) => buildStreamUrlMock(id),
|
||||
}));
|
||||
vi.mock('./hotCacheStore', () => ({
|
||||
useHotCacheStore: { getState: () => ({ setEntry: setEntryMock }) },
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildStreamUrl } from '../api/subsonicStreamUrl';
|
||||
import { buildStreamUrlForServer } from '../api/subsonicStreamUrl';
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useHotCacheStore } from './hotCacheStore';
|
||||
@@ -25,7 +25,7 @@ export async function promoteCompletedStreamToHotCache(
|
||||
{
|
||||
trackId: track.id,
|
||||
serverId,
|
||||
url: buildStreamUrl(track.id),
|
||||
url: buildStreamUrlForServer(serverId, track.id),
|
||||
suffix: track.suffix || 'mp3',
|
||||
customDir,
|
||||
},
|
||||
|
||||
@@ -17,12 +17,21 @@ import {
|
||||
import { clearSeekDebounce } from './seekDebounce';
|
||||
import { clearSeekFallbackRetry } from './seekFallbackState';
|
||||
import { clearSeekTarget } from './seekTargetState';
|
||||
import i18n from '../i18n';
|
||||
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => void;
|
||||
type GetState = () => PlayerState;
|
||||
|
||||
function blockCrossServerEnqueue(): boolean {
|
||||
if (!playbackServerDiffersFromActive()) return false;
|
||||
showToast(i18n.t('queue.crossServerEnqueueBlocked'), 4500, 'error');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eleven queue-mutation actions. Shared invariant: every action except
|
||||
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
||||
@@ -44,6 +53,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
> {
|
||||
return {
|
||||
enqueue: (tracks, _orbitConfirmed = false) => {
|
||||
if (blockCrossServerEnqueue()) return;
|
||||
if (!_orbitConfirmed && tracks.length > 1) {
|
||||
void orbitBulkGuard(tracks.length).then(ok => {
|
||||
if (ok) get().enqueue(tracks, true);
|
||||
@@ -129,6 +139,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
},
|
||||
|
||||
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
|
||||
if (blockCrossServerEnqueue()) return;
|
||||
if (!_orbitConfirmed && tracks.length > 1) {
|
||||
void orbitBulkGuard(tracks.length).then(ok => {
|
||||
if (ok) get().enqueueAt(tracks, insertIndex, true);
|
||||
@@ -154,6 +165,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
|
||||
playNext: (tracks) => {
|
||||
if (tracks.length === 0) return;
|
||||
if (blockCrossServerEnqueue()) return;
|
||||
const state = get();
|
||||
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
|
||||
if (!state.currentTrack) {
|
||||
@@ -197,6 +209,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
clearSeekDebounce(); clearSeekTarget();
|
||||
clearRadioSessionSeenIds();
|
||||
setCurrentRadioArtistId(null);
|
||||
clearQueueServerForPlayback();
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
|
||||
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number) => undefined),
|
||||
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
|
||||
playerState: {
|
||||
queue: [] as Track[],
|
||||
currentTrack: null as Track | null,
|
||||
@@ -18,6 +18,9 @@ const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('../api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock }));
|
||||
vi.mock('../utils/playback/playbackServer', () => ({
|
||||
getPlaybackServerId: () => 'srv-a',
|
||||
}));
|
||||
vi.mock('./playerStore', () => ({
|
||||
usePlayerStore: { getState: () => playerState },
|
||||
}));
|
||||
@@ -65,7 +68,7 @@ describe('syncQueueToServer (debounced)', () => {
|
||||
it('fires once after 5 s with id list + current id + position in ms', () => {
|
||||
syncQueueToServer(queue, queue[0], 30);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
|
||||
});
|
||||
|
||||
it('cancels the previous timer when called again before fire', () => {
|
||||
@@ -74,7 +77,7 @@ describe('syncQueueToServer (debounced)', () => {
|
||||
syncQueueToServer([...queue, track('c')], queue[0], 20);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
|
||||
});
|
||||
|
||||
it('caps the queue at 1000 ids', () => {
|
||||
@@ -91,7 +94,7 @@ describe('syncQueueToServer (debounced)', () => {
|
||||
describe('flushQueueSyncToServer (immediate)', () => {
|
||||
it('fires synchronously with no debounce', async () => {
|
||||
await flushQueueSyncToServer([track('a')], track('a'), 12);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
|
||||
});
|
||||
|
||||
it('cancels a pending debounced sync first', async () => {
|
||||
@@ -126,7 +129,7 @@ describe('flushPlayQueuePosition', () => {
|
||||
playerState.currentTrack = playerState.queue[0];
|
||||
progressSnapshot.currentTime = 42;
|
||||
await flushPlayQueuePosition();
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000);
|
||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
|
||||
});
|
||||
|
||||
it('is a no-op when a radio session is active', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
/**
|
||||
@@ -31,7 +32,8 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu
|
||||
syncTimeout = null;
|
||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
||||
const pos = Math.floor(currentTime * 1000);
|
||||
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
|
||||
const serverId = getPlaybackServerId();
|
||||
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => {
|
||||
console.error('Failed to sync play queue to server', err);
|
||||
});
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
@@ -46,7 +48,8 @@ export function flushQueueSyncToServer(queue: Track[], currentTrack: Track | nul
|
||||
lastQueueHeartbeatAt = Date.now();
|
||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
||||
const pos = Math.floor(currentTime * 1000);
|
||||
return savePlayQueue(ids, currentTrack.id, pos).catch(err => {
|
||||
const serverId = getPlaybackServerId();
|
||||
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => {
|
||||
console.error('Failed to flush play queue to server', err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 { useAuthStore } from './authStore';
|
||||
@@ -38,10 +39,11 @@ export function queueUndoRestoreAudioEngine(opts: {
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const playbackSid = getPlaybackServerId();
|
||||
const url = resolvePlaybackUrl(track.id, playbackSid);
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
usePlayerStore.setState({
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url),
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackSid, url),
|
||||
});
|
||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||
setDeferHotCachePrefetch(true);
|
||||
@@ -91,5 +93,5 @@ export function queueUndoRestoreAudioEngine(opts: {
|
||||
.finally(() => {
|
||||
setDeferHotCachePrefetch(false);
|
||||
});
|
||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
||||
touchHotCacheOnPlayback(track.id, getPlaybackServerId());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -117,7 +118,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
invoke('audio_resume').catch(console.error);
|
||||
setIsAudioPaused(false);
|
||||
set({ isPlaying: true });
|
||||
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
|
||||
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
|
||||
@@ -128,7 +129,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
|
||||
void (async () => {
|
||||
const authHot = useAuthStore.getState();
|
||||
const resumePromoteSid = authHot.activeServerId;
|
||||
const resumePromoteSid = getPlaybackServerId();
|
||||
if (authHot.hotCacheEnabled && resumePromoteSid) {
|
||||
await promoteCompletedStreamToHotCache(
|
||||
currentTrack,
|
||||
@@ -150,7 +151,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
const coldServerId = getPlaybackServerId();
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||
@@ -189,7 +190,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
const coldServerId = getPlaybackServerId();
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isRecoverableSeekError } from '../utils/audio/seekErrors';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
@@ -47,8 +48,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
||||
armSeekDebounce(100, () => {
|
||||
const s0 = get();
|
||||
if (!s0.currentTrack) return;
|
||||
const authSeek = useAuthStore.getState();
|
||||
const sidSeek = authSeek.activeServerId ?? '';
|
||||
const sidSeek = getPlaybackServerId();
|
||||
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: s0.currentTrack.id,
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
persistQueueVisibility,
|
||||
} from './queueVisibilityStorage';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
import {
|
||||
ensurePlaybackServerActive,
|
||||
playbackServerDiffersFromActive,
|
||||
} from '../utils/playback/playbackServer';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
@@ -41,10 +45,31 @@ export function createUiStateActions(set: SetState): Pick<
|
||||
};
|
||||
}),
|
||||
|
||||
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride) =>
|
||||
set({
|
||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride },
|
||||
}),
|
||||
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride, pinToPlaybackServer) => {
|
||||
const pin = pinToPlaybackServer ?? type === 'queue-item';
|
||||
const open = () =>
|
||||
set({
|
||||
contextMenu: {
|
||||
isOpen: true,
|
||||
x,
|
||||
y,
|
||||
item,
|
||||
type,
|
||||
queueIndex,
|
||||
playlistId,
|
||||
playlistSongIndex,
|
||||
shareKindOverride,
|
||||
pinToPlaybackServer: pin,
|
||||
},
|
||||
});
|
||||
if (pin && playbackServerDiffersFromActive()) {
|
||||
void ensurePlaybackServerActive().then(ok => {
|
||||
if (ok) open();
|
||||
});
|
||||
return;
|
||||
}
|
||||
open();
|
||||
},
|
||||
|
||||
closeContextMenu: () =>
|
||||
set(state => ({
|
||||
|
||||
Reference in New Issue
Block a user