feat(queue): play queue sync — manual pull, idle auto-sync, multi-server push (#1131)

This commit is contained in:
cucadmuh
2026-06-19 18:19:11 +03:00
committed by GitHub
parent c428d37e0e
commit 955a9fcbd6
23 changed files with 722 additions and 140 deletions
+8
View File
@@ -42,6 +42,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* When the target track is not buffered yet, the player briefly ducks the outgoing track while preloading; the player bar keeps showing the current song until the handoff so titles and artwork do not flicker or pause spuriously.
* During an active blend, the play/pause button shows a pulsing Blend icon.
### Play queue sync — cross-device handoff
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1131](https://github.com/Psychotoxical/psysonic/pull/1131)**, closes [#1129](https://github.com/Psychotoxical/psysonic/issues/1129)
* Manual **pull** from the header connection indicator (LED + sync ring): click to fetch the active server's play queue when it differs from the local player; no-op when already in sync. Yellow LED when browse server ≠ playback server (e.g. after switching servers).
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
## Changed
+24 -3
View File
@@ -1,11 +1,32 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicSong } from './subsonicTypes';
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
export type PlayQueueResult = { current?: string; position?: number; songs: SubsonicSong[] };
function parsePlayQueueResponse(
data: { playQueue?: { current?: string; position?: number; entry?: SubsonicSong[] } },
): PlayQueueResult {
const pq = data.playQueue;
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
}
export async function getPlayQueue(): Promise<PlayQueueResult> {
try {
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
const pq = data.playQueue;
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
return parsePlayQueueResponse(data);
} catch {
return { songs: [] };
}
}
export async function getPlayQueueForServer(serverId: string): Promise<PlayQueueResult> {
if (!serverId) return { songs: [] };
try {
const data = await apiForServer<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>(
serverId,
'getPlayQueue.view',
);
return parsePlayQueueResponse(data);
} catch {
return { songs: [] };
}
+2
View File
@@ -59,6 +59,7 @@ import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilter
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
@@ -102,6 +103,7 @@ export function AppShell() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
useIdlePlayQueuePull(connStatus);
const navigate = useNavigate();
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
+57 -27
View File
@@ -1,10 +1,12 @@
import type { ServerProfile } from '../store/authStoreTypes';
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import type React from 'react';
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { Check, ChevronDown, RefreshCw } from 'lucide-react';
import type { ConnectionStatus } from '../hooks/useConnectionStatus';
import { usePlayQueueSyncLedState } from '../hooks/usePlayQueueSyncLedState';
import type { ServerProfile } from '../store/authStoreTypes';
import { useAuthStore } from '../store/authStore';
import { switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast';
@@ -21,6 +23,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const {
ledVariant,
pullInFlight,
syncRingVisible,
pullFromActiveServer,
} = usePlayQueueSyncLedState(status);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
@@ -51,9 +59,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (hostRef.current?.contains(t)) return;
if (menuPanelRef.current?.contains(t)) return;
const target = e.target as Node;
if (hostRef.current?.contains(target)) return;
if (menuPanelRef.current?.contains(target)) return;
setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
@@ -72,7 +80,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
navigate('/settings', { state: { tab: 'servers' } });
};
const onTriggerClick = () => {
const onMetaClick = () => {
if (!multi) {
goServerSettings();
return;
@@ -80,6 +88,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
setMenuOpen(o => !o);
};
const onSyncClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (status !== 'connected') return;
void pullFromActiveServer();
};
const onPickServer = async (srv: ServerProfile) => {
if (srv.id === activeServerId) {
setMenuOpen(false);
@@ -97,28 +111,44 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
};
const label = isLan ? 'LAN' : t('connection.extern');
const tooltip = multi
? t('connection.switchServerHint')
: status === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
const tooltip = pullInFlight
? t('connection.queuePulling')
: ledVariant === 'queue-handoff'
? t('connection.queuePullHint', { server: serverName })
: ledVariant === 'connected'
? t('connection.queueSynced')
: multi
? t('connection.switchServerHint')
: status === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
return (
<div className="connection-indicator-host" ref={hostRef}>
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={onTriggerClick}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<div className="connection-indicator">
<button
type="button"
className={`connection-sync-btn${syncRingVisible ? ' connection-sync-btn--visible' : ''}${pullInFlight ? ' connection-sync-btn--busy' : ''}`}
onClick={onSyncClick}
disabled={status !== 'connected' || pullInFlight}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
aria-label={t('connection.queuePullAria')}
>
<RefreshCw size={13} className="connection-sync-icon" aria-hidden />
<div className={`connection-led connection-led--${ledVariant}`} />
</button>
<div
className="connection-meta connection-meta--clickable"
onClick={onMetaClick}
data-tooltip={multi ? t('connection.switchServerHint') : undefined}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<span className="connection-type">{label}</span>
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
+1
View File
@@ -168,6 +168,7 @@ const CONTRIBUTOR_ENTRIES = [
'Streamed Opus/Ogg seeking via on-demand HTTP Range fetches — seek mid-stream without a full pre-download (PR #1110)',
'AutoDJ — content-aware crossfade: waveform-driven silence trim, content-driven overlap, scenario-A self-fade, readiness gate + engine auto-crossfade suppression (PR #1122)',
'AutoDJ — smooth skip and interrupt blend: manual/out-of-queue crossfade, loud→loud ~2s advance, cold-target prep duck + deferred player-bar handoff (PR #1128)',
'Play queue sync — manual pull via connection indicator, idle auto-pull, multi-server push filter, flush-on-server-switch (PR #1131)',
'Niri compositor tiling WM detection (PR #1127)',
],
},
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react';
import { applyServerPlayQueue } from '../store/applyServerPlayQueue';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import {
getPlaybackIdleSinceMs,
hasRecentQueueMutation,
isPlaybackIdleLongEnough,
markPlaybackIdle,
} from '../store/queuePlaybackIdle';
import type { ConnectionStatus } from './useConnectionStatus';
import { canAutoIdlePlayQueuePull } from './usePlayQueueSyncLedState';
const IDLE_THRESHOLD_MS = 30_000;
const MUTATION_QUIET_MS = 15_000;
const POLL_INTERVAL_MS = 10_000;
/** Background pull when paused/stopped long enough on a single-server, in-sync browse context. */
export function useIdlePlayQueuePull(status: ConnectionStatus) {
const activeServerId = useAuthStore(s => s.activeServerId);
const orbitRole = useOrbitStore(s => s.role);
const isPlaying = usePlayerStore(s => s.isPlaying);
const inFlightRef = useRef(false);
useEffect(() => {
if (!isPlaying && getPlaybackIdleSinceMs() === 0) {
markPlaybackIdle();
}
}, [isPlaying]);
useEffect(() => {
if (!canAutoIdlePlayQueuePull(status, orbitRole)) return;
const tick = () => {
if (inFlightRef.current) return;
if (!canAutoIdlePlayQueuePull(status, orbitRole)) return;
if (isPlaying) return;
if (!isPlaybackIdleLongEnough(IDLE_THRESHOLD_MS)) return;
if (hasRecentQueueMutation(MUTATION_QUIET_MS)) return;
if (!activeServerId) return;
inFlightRef.current = true;
void applyServerPlayQueue(activeServerId, { mode: 'idle', preferServerPosition: true })
.finally(() => {
inFlightRef.current = false;
});
};
const id = window.setInterval(tick, POLL_INTERVAL_MS);
return () => window.clearInterval(id);
}, [activeServerId, isPlaying, orbitRole, status]);
}
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ConnectionStatus } from '../hooks/useConnectionStatus';
import { pullPlayQueueFromActiveServer } from '../store/applyServerPlayQueue';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import { getPlaybackServerId, queueIsMultiServer } from '../utils/playback/playbackServer';
import { clearQueueHandoffPending, isQueueHandoffPending } from '../store/queueSyncUiState';
import { showToast } from '../utils/ui/toast';
export function usePlayQueueSyncLedState(status: ConnectionStatus) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const orbitRole = useOrbitStore(s => s.role);
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentRadio = usePlayerStore(s => s.currentRadio);
const [pullInFlight, setPullInFlight] = useState(false);
const queueItems = usePlayerStore(s => s.queueItems);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrackId = usePlayerStore(s => s.currentTrack?.id);
const playbackServerId = useMemo(
() => getPlaybackServerId(),
[activeServerId, queueItems, queueIndex, currentTrackId],
);
useEffect(() => {
if (activeServerId && playbackServerId && activeServerId === playbackServerId) {
clearQueueHandoffPending();
}
}, [activeServerId, playbackServerId]);
const needsQueuePull = status === 'connected'
&& Boolean(activeServerId)
&& (
(Boolean(playbackServerId) && activeServerId !== playbackServerId)
|| isQueueHandoffPending()
);
const ledVariant = status === 'checking'
? 'checking'
: status === 'disconnected'
? 'disconnected'
: needsQueuePull
? 'queue-handoff'
: 'connected';
const pullFromActiveServer = useCallback(async () => {
if (status !== 'connected' || pullInFlight) return;
if (orbitRole === 'host' || orbitRole === 'guest') return;
if (currentRadio) return;
setPullInFlight(true);
try {
const result = await pullPlayQueueFromActiveServer();
switch (result) {
case 'noop':
showToast(t('connection.queueSynced'), 2500, 'info');
break;
case 'empty':
showToast(t('connection.queuePullEmpty'), 4000, 'info');
break;
case 'applied':
showToast(t('connection.queuePullSuccess'), 3000, 'info');
break;
case 'error':
showToast(t('connection.queuePullFailed'), 5000, 'error');
break;
default:
break;
}
} finally {
setPullInFlight(false);
}
}, [currentRadio, orbitRole, pullInFlight, status, t]);
const syncRingVisible = status === 'connected' && (needsQueuePull || pullInFlight);
return {
ledVariant,
needsQueuePull,
pullInFlight,
syncRingVisible,
pullFromActiveServer,
};
}
export function canAutoIdlePlayQueuePull(
status: ConnectionStatus,
orbitRole: string | null,
): boolean {
if (status !== 'connected') return false;
if (orbitRole === 'host' || orbitRole === 'guest') return false;
if (usePlayerStore.getState().currentRadio) return false;
if (queueIsMultiServer()) return false;
const activeId = useAuthStore.getState().activeServerId;
const playbackId = getPlaybackServerId();
if (!activeId || !playbackId || activeId !== playbackId) return false;
return true;
}
+7
View File
@@ -35,4 +35,11 @@ export const connection = {
switchServerHint: 'Click to choose another saved server.',
manageServers: 'Manage servers…',
switchFailed: 'Could not switch — server unreachable.',
queueSynced: 'Queue is in sync with the server',
queuePullHint: 'Click to pull the play queue from {{server}}',
queuePulling: 'Pulling play queue…',
queuePullSuccess: 'Play queue updated from server',
queuePullEmpty: 'The server play queue is empty',
queuePullFailed: 'Could not pull play queue from server',
queuePullAria: 'Sync play queue from server',
};
+7
View File
@@ -38,4 +38,11 @@ export const connection = {
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
manageServers: 'Управление серверами…',
switchFailed: 'Не удалось переключиться — сервер недоступен.',
queueSynced: 'Очередь синхронизирована с сервером',
queuePullHint: 'Нажмите, чтобы подтянуть очередь с {{server}}',
queuePulling: 'Подтягиваем очередь…',
queuePullSuccess: 'Очередь обновлена с сервера',
queuePullEmpty: 'Очередь на сервере пуста',
queuePullFailed: 'Не удалось подтянуть очередь с сервера',
queuePullAria: 'Синхронизировать очередь с сервером',
};
+46
View File
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
fingerprintFromLocalQueue,
fingerprintFromServer,
playQueueFingerprintsEqual,
} from './applyServerPlayQueue';
import { usePlayerStore } from './playerStore';
import { resetPlayerStore } from '@/test/helpers/storeReset';
describe('playQueueFingerprintsEqual', () => {
beforeEach(() => {
resetPlayerStore();
});
it('compares track order, current id, and position within tolerance', () => {
const a = { trackIds: ['1', '2'], currentId: '1', positionMs: 1000 };
const b = { trackIds: ['1', '2'], currentId: '1', positionMs: 2500 };
expect(playQueueFingerprintsEqual(a, b)).toBe(true);
expect(playQueueFingerprintsEqual(a, { ...b, positionMs: 4000 })).toBe(false);
});
it('fingerprintFromLocalQueue reads the player store', () => {
usePlayerStore.setState({
queueItems: [{ serverId: 'a.test', trackId: 't1' }],
currentTrack: { id: 't1', title: 'T', artist: '', album: 'A', albumId: 'al', duration: 60 },
currentTime: 3.5,
});
expect(fingerprintFromLocalQueue()).toEqual({
trackIds: ['t1'],
currentId: 't1',
positionMs: 3500,
});
});
it('fingerprintFromServer maps Subsonic playQueue fields', () => {
expect(fingerprintFromServer({
songs: [{ id: 'a' }, { id: 'b' }] as never,
current: 'b',
position: 1200,
})).toEqual({
trackIds: ['a', 'b'],
currentId: 'b',
positionMs: 1200,
});
});
});
+187
View File
@@ -0,0 +1,187 @@
import { getPlayQueueForServer, type PlayQueueResult } from '../api/subsonicPlayQueue';
import { songToTrack } from '../utils/playback/songToTrack';
import { bindQueueServerId } from '../utils/playback/playbackServer';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import type { Track } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { preparePausedRestoreOnStartup } from './pausedRestorePrepare';
import { pushQueueUndoFromGetter } from './queueUndo';
import { refreshWaveformForTrack } from './waveformRefresh';
import { clearQueueHandoffPending } from './queueSyncUiState';
export type ApplyPlayQueueMode = 'startup' | 'idle' | 'manual';
export type PlayQueueFingerprint = {
trackIds: string[];
currentId: string | null;
positionMs: number;
};
export type ApplyPlayQueueResult = 'applied' | 'noop' | 'empty' | 'error';
const POSITION_TOLERANCE_MS = 2000;
export function fingerprintFromServer(q: PlayQueueResult): PlayQueueFingerprint {
const trackIds = q.songs.map(s => s.id);
const currentId = q.current ?? trackIds[0] ?? null;
return {
trackIds,
currentId,
positionMs: q.position ?? 0,
};
}
export function fingerprintFromLocalQueue(): PlayQueueFingerprint {
const s = usePlayerStore.getState();
return {
trackIds: s.queueItems.map(r => r.trackId),
currentId: s.currentTrack?.id ?? null,
positionMs: Math.floor((s.currentTime ?? 0) * 1000),
};
}
export function playQueueFingerprintsEqual(
a: PlayQueueFingerprint,
b: PlayQueueFingerprint,
positionToleranceMs = POSITION_TOLERANCE_MS,
): boolean {
if (a.currentId !== b.currentId) return false;
if (a.trackIds.length !== b.trackIds.length) return false;
for (let i = 0; i < a.trackIds.length; i++) {
if (a.trackIds[i] !== b.trackIds[i]) return false;
}
return Math.abs(a.positionMs - b.positionMs) <= positionToleranceMs;
}
function resolveServerProfileId(serverId: string): string {
return resolveServerIdForIndexKey(serverId) || serverId;
}
function applyMappedQueue(
mappedTracks: Track[],
q: PlayQueueResult,
serverProfileId: string,
preferServerPosition: boolean,
localTimeFallback: number,
): void {
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) {
currentTrack = mappedTracks[idx];
queueIndex = idx;
}
}
const serverTime = q.position ? q.position / 1000 : 0;
const atSeconds = preferServerPosition
? serverTime
: (serverTime > 0 ? serverTime : localTimeFallback);
seedQueueResolver(serverProfileId, mappedTracks);
bindQueueServerId(serverProfileId);
const queueItems = toQueueItemRefs(serverProfileId, mappedTracks);
const player = usePlayerStore.getState();
const wasPlaying = player.isPlaying;
const sameCurrent = player.currentTrack?.id === currentTrack.id;
usePlayerStore.setState({
queueItems,
queueIndex,
currentTrack,
currentTime: atSeconds,
});
void refreshWaveformForTrack(currentTrack.id);
if (wasPlaying) {
if (!sameCurrent) {
player.playTrack(currentTrack, mappedTracks, true, false, queueIndex);
if (atSeconds > 0.05) {
player.seek(atSeconds / Math.max(currentTrack.duration, 1));
}
} else if (atSeconds > 0.05 && Math.abs(player.currentTime - atSeconds) > 0.5) {
player.seek(atSeconds / Math.max(currentTrack.duration, 1));
}
return;
}
preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds);
}
export async function applyServerPlayQueue(
serverId: string,
options: {
mode: ApplyPlayQueueMode;
preferServerPosition?: boolean;
pushUndo?: boolean;
},
): Promise<ApplyPlayQueueResult> {
const profileId = resolveServerProfileId(serverId);
if (!profileId) return 'error';
try {
const q = await getPlayQueueForServer(profileId);
if (q.songs.length === 0) return 'empty';
const preferServerPosition = options.preferServerPosition ?? options.mode !== 'startup';
if (options.mode === 'idle') {
const serverFp = fingerprintFromServer(q);
const localFp = fingerprintFromLocalQueue();
if (playQueueFingerprintsEqual(serverFp, localFp)) return 'noop';
}
if (options.pushUndo) {
pushQueueUndoFromGetter(usePlayerStore.getState);
}
const mappedTracks: Track[] = q.songs.map(songToTrack);
const localTime = usePlayerStore.getState().currentTime;
applyMappedQueue(mappedTracks, q, profileId, preferServerPosition, localTime);
clearQueueHandoffPending();
return 'applied';
} catch (e) {
console.error('[psysonic] applyServerPlayQueue failed', e);
return 'error';
}
}
export async function fetchActiveServerPlayQueueFingerprint(): Promise<PlayQueueFingerprint | null> {
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return null;
try {
const q = await getPlayQueueForServer(activeId);
if (q.songs.length === 0) return null;
return fingerprintFromServer(q);
} catch {
return null;
}
}
export async function pullPlayQueueFromActiveServer(): Promise<ApplyPlayQueueResult> {
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return 'error';
try {
const q = await getPlayQueueForServer(activeId);
if (q.songs.length === 0) return 'empty';
const serverFp = fingerprintFromServer(q);
const localFp = fingerprintFromLocalQueue();
if (playQueueFingerprintsEqual(serverFp, localFp)) return 'noop';
return applyServerPlayQueue(activeId, {
mode: 'manual',
preferServerPosition: true,
pushUndo: true,
});
} catch (e) {
console.error('[psysonic] pullPlayQueueFromActiveServer failed', e);
return 'error';
}
}
+4 -40
View File
@@ -1,7 +1,6 @@
import { getPlayQueue } from '../api/subsonicPlayQueue';
import { applyServerPlayQueue } from './applyServerPlayQueue';
import { invoke } from '@tauri-apps/api/core';
import i18n from '../i18n';
import { songToTrack } from '../utils/playback/songToTrack';
import { showToast } from '../utils/ui/toast';
import { useAuthStore } from './authStore';
import {
@@ -30,8 +29,6 @@ import {
setSeekFallbackVisualTarget,
} from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import { preparePausedRestoreOnStartup } from './pausedRestorePrepare';
import { refreshWaveformForTrack } from './waveformRefresh';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -146,42 +143,9 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
},
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map(songToTrack);
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
// Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
const sid = get().queueServerId ?? useAuthStore.getState().activeServerId ?? '';
// Seed the resolver with the restored tracks so the queue UI / hot
// paths resolve them without a network round-trip.
if (sid) seedQueueResolver(sid, mappedTracks);
const atSeconds = serverTime > 0 ? serverTime : localTime;
const queueItems = toQueueItemRefs(sid, mappedTracks);
set({
queueItems,
queueIndex,
currentTrack,
currentTime: atSeconds,
});
void refreshWaveformForTrack(currentTrack.id);
preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds);
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return;
await applyServerPlayQueue(activeId, { mode: 'startup' });
},
reanalyzeLoudnessForTrack: async (trackId: string) => {
@@ -57,6 +57,8 @@ vi.mock('@/utils/playback/playbackServer', () => ({
bindQueueServerForPlayback: vi.fn(),
clearQueueServerForPlayback: vi.fn(),
playbackServerDiffersFromActive: () => false,
filterQueueRefsForPlaybackServer: (refs: { serverId: string; trackId: string }[]) => refs,
playbackProfileIdForTrack: (track: { serverId?: string } | null) => track?.serverId ?? 'srv-test',
playbackCoverArtForId: (id: string, size: number) => ({
src: `https://mock/cover/${id}?size=${size}`,
cacheKey: `mock:cover:${id}:${size}`,
+38
View File
@@ -0,0 +1,38 @@
/** Timestamps for idle auto-pull guards (play queue sync). */
let playbackIdleSinceMs = 0;
let lastQueueMutationAt = 0;
export function markPlaybackIdle(): void {
if (playbackIdleSinceMs === 0) playbackIdleSinceMs = Date.now();
}
export function markPlaybackActive(): void {
playbackIdleSinceMs = 0;
}
export function getPlaybackIdleSinceMs(): number {
return playbackIdleSinceMs;
}
export function isPlaybackIdleLongEnough(thresholdMs: number): boolean {
return playbackIdleSinceMs > 0 && Date.now() - playbackIdleSinceMs >= thresholdMs;
}
export function touchQueueMutationClock(): void {
lastQueueMutationAt = Date.now();
}
export function getLastQueueMutationAt(): number {
return lastQueueMutationAt;
}
export function hasRecentQueueMutation(withinMs: number): boolean {
return lastQueueMutationAt > 0 && Date.now() - lastQueueMutationAt < withinMs;
}
/** Test-only reset. */
export function _resetQueuePlaybackIdleForTest(): void {
playbackIdleSinceMs = 0;
lastQueueMutationAt = 0;
}
+27 -53
View File
@@ -1,14 +1,8 @@
/**
* Server-queue-sync helpers: the 5-second debounce, the immediate flush,
* the queue-id cap, and the radio-skip guard inside
* `flushPlayQueuePosition`. Fake timers drive the debounce; mocks stand
* in for `savePlayQueue`, the playerStore, and the playback-progress
* snapshot.
*/
import type { QueueItemRef, Track } from './playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueueItemRef, Track } from './playerStoreTypes';
const { savePlayQueueMock, playerState, progressSnapshot, isSubsonicServerReachableMock } = vi.hoisted(() => ({
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
savePlayQueueMock: vi.fn(async () => undefined),
isSubsonicServerReachableMock: vi.fn((_serverId: string) => true),
playerState: {
queueItems: [] as QueueItemRef[],
@@ -24,6 +18,13 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackServerId: () => 'srv-a',
playbackProfileIdForTrack: (track: Track) => track.serverId ?? 'srv-a',
filterQueueRefsForPlaybackServer: (refs: QueueItemRef[]) =>
refs.filter(r => r.serverId === 'a.test' || r.serverId === 'srv-a'),
}));
vi.mock('../utils/playback/trackServerScope', () => ({
filterQueueRefsForServerProfile: (refs: QueueItemRef[], profileId: string) =>
refs.filter(r => r.serverId === profileId || (profileId === 'srv-a' && r.serverId === 'srv-a')),
}));
vi.mock('./playerStore', () => ({
usePlayerStore: { getState: () => playerState },
@@ -34,19 +35,19 @@ vi.mock('./playbackProgress', () => ({
import {
_resetQueueSyncForTest,
flushPlayQueueForServer,
flushPlayQueuePosition,
flushQueueSyncToServer,
getLastQueueHeartbeatAt,
syncQueueToServer,
} from './queueSync';
function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
function track(id: string, serverId = 'srv-a'): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100, serverId };
}
// Thin-state: sync helpers take queue refs.
function ref(id: string): QueueItemRef {
return { serverId: 'srv-a', trackId: id };
function ref(id: string, serverId = 'a.test'): QueueItemRef {
return { serverId, trackId: id };
}
beforeEach(() => {
@@ -76,35 +77,27 @@ describe('syncQueueToServer (debounced)', () => {
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('does not fire before 5 s elapse', () => {
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(4999);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('fires once after 5 s with id list + current id + position in ms', () => {
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
});
it('cancels the previous timer when called again before fire', () => {
syncQueueToServer(queue, track('a'), 10);
vi.advanceTimersByTime(3000);
syncQueueToServer([...queue, ref('c')], track('a'), 20);
it('sends only refs owned by the playback server in a mixed queue', () => {
const mixed = [ref('a', 'a.test'), ref('b', 'b.test')];
syncQueueToServer(mixed, track('a', 'srv-a'), 12);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
});
});
it('caps the queue at 1000 ids', () => {
const big = Array.from({ length: 1500 }, (_, i) => ref(`t${i}`));
syncQueueToServer(big, track('t0'), 0);
vi.advanceTimersByTime(5000);
const ids = savePlayQueueMock.mock.calls[0][0] as string[];
expect(ids.length).toBe(1000);
expect(ids[0]).toBe('t0');
expect(ids[999]).toBe('t999');
describe('flushPlayQueueForServer', () => {
it('flushes only the target server slice', async () => {
playerState.queueItems = [ref('a', 'srv-a'), ref('b', 'b.test')];
playerState.currentTrack = track('a', 'srv-a');
progressSnapshot.currentTime = 9;
await flushPlayQueueForServer('srv-a');
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 9000, 'srv-a');
});
});
@@ -114,25 +107,6 @@ describe('flushQueueSyncToServer (immediate)', () => {
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
});
it('cancels a pending debounced sync first', async () => {
syncQueueToServer([ref('a')], track('a'), 30);
await flushQueueSyncToServer([ref('a')], track('a'), 31);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
// After the flush returns, advancing past the debounce should not fire again.
vi.advanceTimersByTime(10_000);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
});
it('is a no-op when currentTrack is null', async () => {
await flushQueueSyncToServer([ref('a')], null, 5);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('is a no-op for an empty queue', async () => {
await flushQueueSyncToServer([], track('a'), 5);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('records the heartbeat timestamp', async () => {
expect(getLastQueueHeartbeatAt()).toBe(0);
await flushQueueSyncToServer([ref('a')], track('a'), 5);
+47 -11
View File
@@ -1,9 +1,16 @@
import { savePlayQueue } from '../api/subsonicPlayQueue';
import type { QueueItemRef, Track } from './playerStoreTypes';
import { isSubsonicServerReachable } from '../utils/network/subsonicNetworkGuard';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import {
filterQueueRefsForPlaybackServer,
getPlaybackServerId,
playbackProfileIdForTrack,
} from '../utils/playback/playbackServer';
import { filterQueueRefsForServerProfile } from '../utils/playback/trackServerScope';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { touchQueueMutationClock } from './queuePlaybackIdle';
import { usePlayerStore } from './playerStore';
/**
* Server-side play-queue persistence. Subsonic's `savePlayQueue` accepts
* the current queue, the active track id, and the position in ms — so the
@@ -17,6 +24,7 @@ import { usePlayerStore } from './playerStore';
* called from the playback heartbeat, `pause()`, and the app-close path
* where the user might switch devices mid-track.
*
* Mixed-server queues push only refs owned by the playback server.
* Queues are capped at 1000 ids to match Subsonic's max-length contract.
* Radio sessions skip persistence (the seed station is restored separately).
*/
@@ -32,18 +40,31 @@ function isPlaybackServerReachable(): boolean {
return serverId ? isSubsonicServerReachable(serverId) : false;
}
function pushRefsForServer(
refs: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
serverId: string,
): Promise<void> {
if (!serverId || refs.length === 0 || !currentTrack) return Promise.resolve();
if (playbackProfileIdForTrack(currentTrack) !== serverId) return Promise.resolve();
const ids = refs.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(() => {
// Expected when offline or the playback server is unreachable.
});
}
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
touchQueueMutationClock();
if (!isPlaybackServerReachable()) return;
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
syncTimeout = null;
if (!isPlaybackServerReachable()) return;
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId();
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(() => {
// Expected when offline or the playback server is unreachable.
});
const refs = filterQueueRefsForPlaybackServer(queue);
void pushRefsForServer(refs, currentTrack, currentTime, serverId);
}, SYNC_DEBOUNCE_MS);
}
@@ -55,12 +76,27 @@ export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Trac
if (!isPlaybackServerReachable()) return Promise.resolve();
if (!currentTrack || queue.length === 0) return Promise.resolve();
lastQueueHeartbeatAt = Date.now();
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId();
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(() => {
// Expected when offline or the playback server is unreachable.
});
const refs = filterQueueRefsForPlaybackServer(queue);
return pushRefsForServer(refs, currentTrack, currentTime, serverId);
}
/**
* Immediate flush of one server's queue slice (e.g. before browse switch).
* Does not mutate local player state.
*/
export function flushPlayQueueForServer(serverProfileId: string): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (!serverProfileId || !isSubsonicServerReachable(serverProfileId)) return Promise.resolve();
const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve();
const refs = filterQueueRefsForServerProfile(s.queueItems, serverProfileId);
if (refs.length === 0 || !s.currentTrack) return Promise.resolve();
const currentTime = getPlaybackProgressSnapshot().currentTime;
return pushRefsForServer(refs, s.currentTrack, currentTime, serverProfileId);
}
/** Last heartbeat timestamp (ms epoch). Used by the playback heartbeat to throttle the 15-second auto-flush cadence. */
+20
View File
@@ -0,0 +1,20 @@
/** UI hint: user switched browse server; manual pull is suggested until sync. */
let queueHandoffPending = false;
export function markQueueHandoffPending(): void {
queueHandoffPending = true;
}
export function clearQueueHandoffPending(): void {
queueHandoffPending = false;
}
export function isQueueHandoffPending(): boolean {
return queueHandoffPending;
}
/** Test-only reset. */
export function _resetQueueSyncUiForTest(): void {
queueHandoffPending = false;
}
+2
View File
@@ -31,6 +31,7 @@ import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { markPlaybackActive } from './queuePlaybackIdle';
import { playbackReportPlaying } from './playbackReportSession';
import { resumeRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
@@ -64,6 +65,7 @@ type GetState = () => PlayerState;
*/
export function runResume(set: SetState, get: GetState): void {
clearAllPlaybackScheduleTimers();
markPlaybackActive();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
// Orbit guest: resume means "catch up to the host's live stream".
+3
View File
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { setIsAudioPaused } from './engineState';
import type { PlayerState } from './playerStoreTypes';
import { flushQueueSyncToServer } from './queueSync';
import { markPlaybackActive, markPlaybackIdle } from './queuePlaybackIdle';
import { playListenSessionFinalize, playListenSessionOnPause } from './playListenSession';
import { playbackReportPaused, playbackReportStopped } from './playbackReportSession';
import { pauseRadio, stopRadio } from './radioPlayer';
@@ -70,6 +71,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
// Re-hydrate from the analysis DB in case the bins were never loaded or
// only partially filled while the (now stopped) track was playing.
if (keptTrackId) void refreshWaveformForTrack(keptTrackId);
markPlaybackIdle();
},
pause: () => {
@@ -89,6 +91,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
}
}
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
markPlaybackIdle();
},
resetAudioPause: () => {
@@ -25,6 +25,52 @@
flex-shrink: 0;
}
.connection-sync-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.connection-sync-btn:disabled {
cursor: default;
}
.connection-sync-icon {
position: absolute;
inset: 0;
margin: auto;
color: var(--text-muted);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.connection-sync-btn--visible .connection-sync-icon {
opacity: 0.85;
}
.connection-sync-btn--busy .connection-sync-icon {
opacity: 1;
animation: connection-sync-spin 0.9s linear infinite;
}
@keyframes connection-sync-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.connection-meta--clickable {
cursor: pointer;
}
.connection-led {
width: 9px;
height: 9px;
@@ -38,6 +84,11 @@
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55);
}
.connection-led--queue-handoff {
background: #f9e2af;
box-shadow: 0 0 6px 2px rgba(249, 226, 175, 0.55);
}
.connection-led--disconnected {
background: #f38ba8;
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
+8
View File
@@ -18,6 +18,7 @@ import {
} from '../server/serverIndexKey';
import {
activeServerProfileId,
filterQueueRefsForServerProfile,
isMultiServerQueue,
profileIdFromQueueRef,
queueItemRefAt,
@@ -174,6 +175,13 @@ export function queueIsMultiServer(): boolean {
return isMultiServerQueue(usePlayerStore.getState().queueItems);
}
/** Refs owned by the server that is currently playing (mixed-queue safe). */
export function filterQueueRefsForPlaybackServer(refs: QueueItemRef[]): QueueItemRef[] {
const playbackSid = getPlaybackServerId();
if (!playbackSid) return [];
return filterQueueRefsForServerProfile(refs, playbackSid);
}
/**
* True when the current queue belongs to another server (or is unpinned legacy
* state) and a browsed-server mix should clear it before enqueueing new tracks.
+15 -5
View File
@@ -50,19 +50,29 @@ export function profileIdFromQueueRef(ref: QueueItemRef | null | undefined): str
return resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
}
function queueRefProfileId(ref: QueueItemRef): string {
function refsForServerProfile(refs: QueueItemRef[], profileId: string): QueueItemRef[] {
if (!profileId) return [];
return refs.filter(ref => queueRefProfileIdForTarget(ref, profileId));
}
function queueRefProfileIdForTarget(ref: QueueItemRef, profileId: string): boolean {
const fromRef = profileIdFromQueueRef(ref);
if (fromRef) return fromRef;
if (fromRef) return fromRef === profileId;
const pin = usePlayerStore.getState().queueServerId;
if (pin) return resolveServerIdForIndexKey(pin) || pin;
return activeServerProfileId() ?? '';
if (pin) return (resolveServerIdForIndexKey(pin) || pin) === profileId;
return profileId === (activeServerProfileId() ?? '');
}
/** Queue refs that belong to a saved server profile (mixed-queue safe). */
export function filterQueueRefsForServerProfile(refs: QueueItemRef[], profileId: string): QueueItemRef[] {
return refsForServerProfile(refs, profileId);
}
/** Queue refs that belong to the browsed (active) server — for export/save on mixed queues. */
export function filterQueueRefsForActiveServer(refs: QueueItemRef[]): QueueItemRef[] {
const activeId = activeServerProfileId();
if (!activeId) return [];
return refs.filter(ref => queueRefProfileId(ref) === activeId);
return refsForServerProfile(refs, activeId);
}
export function activeServerQueueTrackIds(refs: QueueItemRef[]): string[] {
+11 -1
View File
@@ -6,6 +6,8 @@ import {
} from '../../cover/coverTraffic';
import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '../../store/orbitStore';
import { flushPlayQueueForServer } from '../../store/queueSync';
import { markQueueHandoffPending } from '../../store/queueSyncUiState';
import { endOrbitSession, leaveOrbitSession } from '../orbit';
import { ensureConnectUrlResolved } from './serverEndpoint';
@@ -36,16 +38,24 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
useOrbitStore.getState().reset();
}
const auth = useAuthStore.getState();
const oldActiveId = auth.activeServerId;
if (oldActiveId && oldActiveId !== server.id) {
await flushPlayQueueForServer(oldActiveId);
}
const identity = {
type: probe.ping.type,
serverVersion: probe.ping.serverVersion,
openSubsonic: probe.ping.openSubsonic,
};
const auth = useAuthStore.getState();
auth.setSubsonicServerIdentity(server.id, identity);
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity);
auth.setActiveServer(server.id);
auth.setLoggedIn(true);
if (oldActiveId && oldActiveId !== server.id) {
markQueueHandoffPending();
}
return true;
} catch {
return false;