mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
feat(queue): play queue sync — manual pull, idle auto-sync, multi-server push (#1131)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -38,4 +38,11 @@ export const connection = {
|
||||
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
||||
manageServers: 'Управление серверами…',
|
||||
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
||||
queueSynced: 'Очередь синхронизирована с сервером',
|
||||
queuePullHint: 'Нажмите, чтобы подтянуть очередь с {{server}}',
|
||||
queuePulling: 'Подтягиваем очередь…',
|
||||
queuePullSuccess: 'Очередь обновлена с сервера',
|
||||
queuePullEmpty: 'Очередь на сервере пуста',
|
||||
queuePullFailed: 'Не удалось подтянуть очередь с сервера',
|
||||
queuePullAria: 'Синхронизировать очередь с сервером',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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".
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user