From 955a9fcbd6f70ee50a86978bce212ab259cb53f2 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:19:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(queue):=20play=20queue=20sync=20=E2=80=94?= =?UTF-8?q?=20manual=20pull,=20idle=20auto-sync,=20multi-server=20push=20(?= =?UTF-8?q?#1131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 + src/api/subsonicPlayQueue.ts | 27 ++- src/app/AppShell.tsx | 2 + src/components/ConnectionIndicator.tsx | 84 +++++--- src/config/settingsCredits.ts | 1 + src/hooks/useIdlePlayQueuePull.ts | 53 +++++ src/hooks/usePlayQueueSyncLedState.ts | 102 ++++++++++ src/locales/en/connection.ts | 7 + src/locales/ru/connection.ts | 7 + src/store/applyServerPlayQueue.test.ts | 46 +++++ src/store/applyServerPlayQueue.ts | 187 ++++++++++++++++++ src/store/miscActions.ts | 44 +---- src/store/playerStore.persistence.test.ts | 2 + src/store/queuePlaybackIdle.ts | 38 ++++ src/store/queueSync.test.ts | 80 +++----- src/store/queueSync.ts | 58 ++++-- src/store/queueSyncUiState.ts | 20 ++ src/store/resumeAction.ts | 2 + src/store/transportLightActions.ts | 3 + .../components/connection-indicator.css | 51 +++++ src/utils/playback/playbackServer.ts | 8 + src/utils/playback/trackServerScope.ts | 20 +- src/utils/server/switchActiveServer.ts | 12 +- 23 files changed, 722 insertions(+), 140 deletions(-) create mode 100644 src/hooks/useIdlePlayQueuePull.ts create mode 100644 src/hooks/usePlayQueueSyncLedState.ts create mode 100644 src/store/applyServerPlayQueue.test.ts create mode 100644 src/store/applyServerPlayQueue.ts create mode 100644 src/store/queuePlaybackIdle.ts create mode 100644 src/store/queueSyncUiState.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d6afd83d..08d32afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/api/subsonicPlayQueue.ts b/src/api/subsonicPlayQueue.ts index 7c9aaf9a..b93a6d3a 100644 --- a/src/api/subsonicPlayQueue.ts +++ b/src/api/subsonicPlayQueue.ts @@ -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 { 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 { + 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: [] }; } diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 55c08e45..22857019 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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); diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index f679cade..03dda1be 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -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(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 (
-
-
-
+
+ +
{label} {serverName} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 8b99e3d4..8b213065 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, diff --git a/src/hooks/useIdlePlayQueuePull.ts b/src/hooks/useIdlePlayQueuePull.ts new file mode 100644 index 00000000..259774be --- /dev/null +++ b/src/hooks/useIdlePlayQueuePull.ts @@ -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]); +} diff --git a/src/hooks/usePlayQueueSyncLedState.ts b/src/hooks/usePlayQueueSyncLedState.ts new file mode 100644 index 00000000..c5a7c58c --- /dev/null +++ b/src/hooks/usePlayQueueSyncLedState.ts @@ -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; +} diff --git a/src/locales/en/connection.ts b/src/locales/en/connection.ts index 272e2634..c8110b5a 100644 --- a/src/locales/en/connection.ts +++ b/src/locales/en/connection.ts @@ -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', }; diff --git a/src/locales/ru/connection.ts b/src/locales/ru/connection.ts index 592e6a76..222b43ac 100644 --- a/src/locales/ru/connection.ts +++ b/src/locales/ru/connection.ts @@ -38,4 +38,11 @@ export const connection = { switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.', manageServers: 'Управление серверами…', switchFailed: 'Не удалось переключиться — сервер недоступен.', + queueSynced: 'Очередь синхронизирована с сервером', + queuePullHint: 'Нажмите, чтобы подтянуть очередь с {{server}}', + queuePulling: 'Подтягиваем очередь…', + queuePullSuccess: 'Очередь обновлена с сервера', + queuePullEmpty: 'Очередь на сервере пуста', + queuePullFailed: 'Не удалось подтянуть очередь с сервера', + queuePullAria: 'Синхронизировать очередь с сервером', }; diff --git a/src/store/applyServerPlayQueue.test.ts b/src/store/applyServerPlayQueue.test.ts new file mode 100644 index 00000000..76735b36 --- /dev/null +++ b/src/store/applyServerPlayQueue.test.ts @@ -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, + }); + }); +}); diff --git a/src/store/applyServerPlayQueue.ts b/src/store/applyServerPlayQueue.ts new file mode 100644 index 00000000..d9871737 --- /dev/null +++ b/src/store/applyServerPlayQueue.ts @@ -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 { + 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 { + 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 { + 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'; + } +} diff --git a/src/store/miscActions.ts b/src/store/miscActions.ts index c1da0071..5f1a77a3 100644 --- a/src/store/miscActions.ts +++ b/src/store/miscActions.ts @@ -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 | ((state: PlayerState) => Partial), @@ -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) => { diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 06bc2d0a..c4f3bd25 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -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}`, diff --git a/src/store/queuePlaybackIdle.ts b/src/store/queuePlaybackIdle.ts new file mode 100644 index 00000000..efe279f1 --- /dev/null +++ b/src/store/queuePlaybackIdle.ts @@ -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; +} diff --git a/src/store/queueSync.test.ts b/src/store/queueSync.test.ts index b3b6361e..5b78c42d 100644 --- a/src/store/queueSync.test.ts +++ b/src/store/queueSync.test.ts @@ -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); diff --git a/src/store/queueSync.ts b/src/store/queueSync.ts index 98daa3c1..a6624dec 100644 --- a/src/store/queueSync.ts +++ b/src/store/queueSync.ts @@ -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 { + 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 { + 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. */ diff --git a/src/store/queueSyncUiState.ts b/src/store/queueSyncUiState.ts new file mode 100644 index 00000000..8580d291 --- /dev/null +++ b/src/store/queueSyncUiState.ts @@ -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; +} diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index 2d561cb3..5127a123 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -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". diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index 24f04a67..974aab4f 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -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: () => { diff --git a/src/styles/components/connection-indicator.css b/src/styles/components/connection-indicator.css index c48f60a9..c4d801ae 100644 --- a/src/styles/components/connection-indicator.css +++ b/src/styles/components/connection-indicator.css @@ -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); diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index d6c03199..f4011637 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -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. diff --git a/src/utils/playback/trackServerScope.ts b/src/utils/playback/trackServerScope.ts index fe257b8e..126f0f0c 100644 --- a/src/utils/playback/trackServerScope.ts +++ b/src/utils/playback/trackServerScope.ts @@ -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[] { diff --git a/src/utils/server/switchActiveServer.ts b/src/utils/server/switchActiveServer.ts index d9b311c0..1a01a689 100644 --- a/src/utils/server/switchActiveServer.ts +++ b/src/utils/server/switchActiveServer.ts @@ -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