mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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.
|
* 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.
|
* 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
|
## Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { api, apiForServer } from './subsonicClient';
|
import { api, apiForServer } from './subsonicClient';
|
||||||
import type { SubsonicSong } from './subsonicTypes';
|
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[] };
|
||||||
try {
|
|
||||||
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
|
function parsePlayQueueResponse(
|
||||||
|
data: { playQueue?: { current?: string; position?: number; entry?: SubsonicSong[] } },
|
||||||
|
): PlayQueueResult {
|
||||||
const pq = data.playQueue;
|
const pq = data.playQueue;
|
||||||
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
|
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');
|
||||||
|
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 {
|
} catch {
|
||||||
return { songs: [] };
|
return { songs: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilter
|
|||||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||||
import { IS_LINUX } from '../utils/platform';
|
import { IS_LINUX } from '../utils/platform';
|
||||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
|
import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import '../store/previewPlayerVolumeSync';
|
import '../store/previewPlayerVolumeSync';
|
||||||
@@ -102,6 +103,7 @@ export function AppShell() {
|
|||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||||
|
useIdlePlayQueuePull(connStatus);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const prevPathnameRef = useRef(location.pathname);
|
const prevPathnameRef = useRef(location.pathname);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { ServerProfile } from '../store/authStoreTypes';
|
import type React from 'react';
|
||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Check, ChevronDown } from 'lucide-react';
|
import { Check, ChevronDown, RefreshCw } from 'lucide-react';
|
||||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
import type { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
|
import { usePlayQueueSyncLedState } from '../hooks/usePlayQueueSyncLedState';
|
||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { switchActiveServer } from '../utils/server/switchActiveServer';
|
import { switchActiveServer } from '../utils/server/switchActiveServer';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
@@ -21,6 +23,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const servers = useAuthStore(s => s.servers);
|
const servers = useAuthStore(s => s.servers);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
|
const {
|
||||||
|
ledVariant,
|
||||||
|
pullInFlight,
|
||||||
|
syncRingVisible,
|
||||||
|
pullFromActiveServer,
|
||||||
|
} = usePlayQueueSyncLedState(status);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
||||||
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
|
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
|
||||||
@@ -51,9 +59,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
const t = e.target as Node;
|
const target = e.target as Node;
|
||||||
if (hostRef.current?.contains(t)) return;
|
if (hostRef.current?.contains(target)) return;
|
||||||
if (menuPanelRef.current?.contains(t)) return;
|
if (menuPanelRef.current?.contains(target)) return;
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -72,7 +80,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
navigate('/settings', { state: { tab: 'servers' } });
|
navigate('/settings', { state: { tab: 'servers' } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTriggerClick = () => {
|
const onMetaClick = () => {
|
||||||
if (!multi) {
|
if (!multi) {
|
||||||
goServerSettings();
|
goServerSettings();
|
||||||
return;
|
return;
|
||||||
@@ -80,6 +88,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
setMenuOpen(o => !o);
|
setMenuOpen(o => !o);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSyncClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (status !== 'connected') return;
|
||||||
|
void pullFromActiveServer();
|
||||||
|
};
|
||||||
|
|
||||||
const onPickServer = async (srv: ServerProfile) => {
|
const onPickServer = async (srv: ServerProfile) => {
|
||||||
if (srv.id === activeServerId) {
|
if (srv.id === activeServerId) {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@@ -97,7 +111,13 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const label = isLan ? 'LAN' : t('connection.extern');
|
const label = isLan ? 'LAN' : t('connection.extern');
|
||||||
const tooltip = multi
|
const tooltip = pullInFlight
|
||||||
|
? t('connection.queuePulling')
|
||||||
|
: ledVariant === 'queue-handoff'
|
||||||
|
? t('connection.queuePullHint', { server: serverName })
|
||||||
|
: ledVariant === 'connected'
|
||||||
|
? t('connection.queueSynced')
|
||||||
|
: multi
|
||||||
? t('connection.switchServerHint')
|
? t('connection.switchServerHint')
|
||||||
: status === 'connected'
|
: status === 'connected'
|
||||||
? t('connection.connectedTo', { server: serverName })
|
? t('connection.connectedTo', { server: serverName })
|
||||||
@@ -107,18 +127,28 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="connection-indicator-host" ref={hostRef}>
|
<div className="connection-indicator-host" ref={hostRef}>
|
||||||
<div
|
<div className="connection-indicator">
|
||||||
className="connection-indicator"
|
<button
|
||||||
style={{ cursor: 'pointer' }}
|
type="button"
|
||||||
onClick={onTriggerClick}
|
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={tooltip}
|
||||||
data-tooltip-pos="bottom"
|
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}
|
role={multi ? 'button' : undefined}
|
||||||
aria-haspopup={multi ? 'menu' : undefined}
|
aria-haspopup={multi ? 'menu' : undefined}
|
||||||
aria-expanded={multi ? menuOpen : undefined}
|
aria-expanded={multi ? menuOpen : undefined}
|
||||||
>
|
>
|
||||||
<div className={`connection-led connection-led--${status}`} />
|
|
||||||
<div className="connection-meta">
|
|
||||||
<span className="connection-type">{label}</span>
|
<span className="connection-type">{label}</span>
|
||||||
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
<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)',
|
'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 — 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)',
|
'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)',
|
'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.',
|
switchServerHint: 'Click to choose another saved server.',
|
||||||
manageServers: 'Manage servers…',
|
manageServers: 'Manage servers…',
|
||||||
switchFailed: 'Could not switch — server unreachable.',
|
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: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
||||||
manageServers: 'Управление серверами…',
|
manageServers: 'Управление серверами…',
|
||||||
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import {
|
import {
|
||||||
@@ -30,8 +29,6 @@ import {
|
|||||||
setSeekFallbackVisualTarget,
|
setSeekFallbackVisualTarget,
|
||||||
} from './seekFallbackState';
|
} from './seekFallbackState';
|
||||||
import { clearSeekTarget } from './seekTargetState';
|
import { clearSeekTarget } from './seekTargetState';
|
||||||
import { preparePausedRestoreOnStartup } from './pausedRestorePrepare';
|
|
||||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||||
@@ -146,42 +143,9 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
},
|
},
|
||||||
|
|
||||||
initializeFromServerQueue: async () => {
|
initializeFromServerQueue: async () => {
|
||||||
try {
|
const activeId = useAuthStore.getState().activeServerId;
|
||||||
const q = await getPlayQueue();
|
if (!activeId) return;
|
||||||
if (q.songs.length > 0) {
|
await applyServerPlayQueue(activeId, { mode: 'startup' });
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reanalyzeLoudnessForTrack: async (trackId: string) => {
|
reanalyzeLoudnessForTrack: async (trackId: string) => {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ vi.mock('@/utils/playback/playbackServer', () => ({
|
|||||||
bindQueueServerForPlayback: vi.fn(),
|
bindQueueServerForPlayback: vi.fn(),
|
||||||
clearQueueServerForPlayback: vi.fn(),
|
clearQueueServerForPlayback: vi.fn(),
|
||||||
playbackServerDiffersFromActive: () => false,
|
playbackServerDiffersFromActive: () => false,
|
||||||
|
filterQueueRefsForPlaybackServer: (refs: { serverId: string; trackId: string }[]) => refs,
|
||||||
|
playbackProfileIdForTrack: (track: { serverId?: string } | null) => track?.serverId ?? 'srv-test',
|
||||||
playbackCoverArtForId: (id: string, size: number) => ({
|
playbackCoverArtForId: (id: string, size: number) => ({
|
||||||
src: `https://mock/cover/${id}?size=${size}`,
|
src: `https://mock/cover/${id}?size=${size}`,
|
||||||
cacheKey: `mock:cover:${id}:${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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
|
|
||||||
const { savePlayQueueMock, playerState, progressSnapshot, isSubsonicServerReachableMock } = vi.hoisted(() => ({
|
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),
|
isSubsonicServerReachableMock: vi.fn((_serverId: string) => true),
|
||||||
playerState: {
|
playerState: {
|
||||||
queueItems: [] as QueueItemRef[],
|
queueItems: [] as QueueItemRef[],
|
||||||
@@ -24,6 +18,13 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('../utils/playback/playbackServer', () => ({
|
vi.mock('../utils/playback/playbackServer', () => ({
|
||||||
getPlaybackServerId: () => 'srv-a',
|
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', () => ({
|
vi.mock('./playerStore', () => ({
|
||||||
usePlayerStore: { getState: () => playerState },
|
usePlayerStore: { getState: () => playerState },
|
||||||
@@ -34,19 +35,19 @@ vi.mock('./playbackProgress', () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
_resetQueueSyncForTest,
|
_resetQueueSyncForTest,
|
||||||
|
flushPlayQueueForServer,
|
||||||
flushPlayQueuePosition,
|
flushPlayQueuePosition,
|
||||||
flushQueueSyncToServer,
|
flushQueueSyncToServer,
|
||||||
getLastQueueHeartbeatAt,
|
getLastQueueHeartbeatAt,
|
||||||
syncQueueToServer,
|
syncQueueToServer,
|
||||||
} from './queueSync';
|
} from './queueSync';
|
||||||
|
|
||||||
function track(id: string): Track {
|
function track(id: string, serverId = 'srv-a'): Track {
|
||||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100, serverId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thin-state: sync helpers take queue refs.
|
function ref(id: string, serverId = 'a.test'): QueueItemRef {
|
||||||
function ref(id: string): QueueItemRef {
|
return { serverId, trackId: id };
|
||||||
return { serverId: 'srv-a', trackId: id };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -76,35 +77,27 @@ describe('syncQueueToServer (debounced)', () => {
|
|||||||
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
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', () => {
|
it('fires once after 5 s with id list + current id + position in ms', () => {
|
||||||
syncQueueToServer(queue, track('a'), 30);
|
syncQueueToServer(queue, track('a'), 30);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels the previous timer when called again before fire', () => {
|
it('sends only refs owned by the playback server in a mixed queue', () => {
|
||||||
syncQueueToServer(queue, track('a'), 10);
|
const mixed = [ref('a', 'a.test'), ref('b', 'b.test')];
|
||||||
vi.advanceTimersByTime(3000);
|
syncQueueToServer(mixed, track('a', 'srv-a'), 12);
|
||||||
syncQueueToServer([...queue, ref('c')], track('a'), 20);
|
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('caps the queue at 1000 ids', () => {
|
describe('flushPlayQueueForServer', () => {
|
||||||
const big = Array.from({ length: 1500 }, (_, i) => ref(`t${i}`));
|
it('flushes only the target server slice', async () => {
|
||||||
syncQueueToServer(big, track('t0'), 0);
|
playerState.queueItems = [ref('a', 'srv-a'), ref('b', 'b.test')];
|
||||||
vi.advanceTimersByTime(5000);
|
playerState.currentTrack = track('a', 'srv-a');
|
||||||
const ids = savePlayQueueMock.mock.calls[0][0] as string[];
|
progressSnapshot.currentTime = 9;
|
||||||
expect(ids.length).toBe(1000);
|
await flushPlayQueueForServer('srv-a');
|
||||||
expect(ids[0]).toBe('t0');
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 9000, 'srv-a');
|
||||||
expect(ids[999]).toBe('t999');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,25 +107,6 @@ describe('flushQueueSyncToServer (immediate)', () => {
|
|||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
|
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 () => {
|
it('records the heartbeat timestamp', async () => {
|
||||||
expect(getLastQueueHeartbeatAt()).toBe(0);
|
expect(getLastQueueHeartbeatAt()).toBe(0);
|
||||||
await flushQueueSyncToServer([ref('a')], track('a'), 5);
|
await flushQueueSyncToServer([ref('a')], track('a'), 5);
|
||||||
|
|||||||
+47
-11
@@ -1,9 +1,16 @@
|
|||||||
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
||||||
import type { QueueItemRef, Track } from './playerStoreTypes';
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { isSubsonicServerReachable } from '../utils/network/subsonicNetworkGuard';
|
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 { getPlaybackProgressSnapshot } from './playbackProgress';
|
||||||
|
import { touchQueueMutationClock } from './queuePlaybackIdle';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side play-queue persistence. Subsonic's `savePlayQueue` accepts
|
* Server-side play-queue persistence. Subsonic's `savePlayQueue` accepts
|
||||||
* the current queue, the active track id, and the position in ms — so the
|
* 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
|
* called from the playback heartbeat, `pause()`, and the app-close path
|
||||||
* where the user might switch devices mid-track.
|
* 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.
|
* Queues are capped at 1000 ids to match Subsonic's max-length contract.
|
||||||
* Radio sessions skip persistence (the seed station is restored separately).
|
* Radio sessions skip persistence (the seed station is restored separately).
|
||||||
*/
|
*/
|
||||||
@@ -32,18 +40,31 @@ function isPlaybackServerReachable(): boolean {
|
|||||||
return serverId ? isSubsonicServerReachable(serverId) : false;
|
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 {
|
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
|
||||||
|
touchQueueMutationClock();
|
||||||
if (!isPlaybackServerReachable()) return;
|
if (!isPlaybackServerReachable()) return;
|
||||||
if (syncTimeout) clearTimeout(syncTimeout);
|
if (syncTimeout) clearTimeout(syncTimeout);
|
||||||
syncTimeout = setTimeout(() => {
|
syncTimeout = setTimeout(() => {
|
||||||
syncTimeout = null;
|
syncTimeout = null;
|
||||||
if (!isPlaybackServerReachable()) return;
|
if (!isPlaybackServerReachable()) return;
|
||||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
|
|
||||||
const pos = Math.floor(currentTime * 1000);
|
|
||||||
const serverId = getPlaybackServerId();
|
const serverId = getPlaybackServerId();
|
||||||
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(() => {
|
const refs = filterQueueRefsForPlaybackServer(queue);
|
||||||
// Expected when offline or the playback server is unreachable.
|
void pushRefsForServer(refs, currentTrack, currentTime, serverId);
|
||||||
});
|
|
||||||
}, SYNC_DEBOUNCE_MS);
|
}, SYNC_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +76,27 @@ export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Trac
|
|||||||
if (!isPlaybackServerReachable()) return Promise.resolve();
|
if (!isPlaybackServerReachable()) return Promise.resolve();
|
||||||
if (!currentTrack || queue.length === 0) return Promise.resolve();
|
if (!currentTrack || queue.length === 0) return Promise.resolve();
|
||||||
lastQueueHeartbeatAt = Date.now();
|
lastQueueHeartbeatAt = Date.now();
|
||||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
|
|
||||||
const pos = Math.floor(currentTime * 1000);
|
|
||||||
const serverId = getPlaybackServerId();
|
const serverId = getPlaybackServerId();
|
||||||
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(() => {
|
const refs = filterQueueRefsForPlaybackServer(queue);
|
||||||
// Expected when offline or the playback server is unreachable.
|
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. */
|
/** 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 { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
import { syncQueueToServer } from './queueSync';
|
import { syncQueueToServer } from './queueSync';
|
||||||
|
import { markPlaybackActive } from './queuePlaybackIdle';
|
||||||
import { playbackReportPlaying } from './playbackReportSession';
|
import { playbackReportPlaying } from './playbackReportSession';
|
||||||
import { resumeRadio } from './radioPlayer';
|
import { resumeRadio } from './radioPlayer';
|
||||||
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
||||||
@@ -64,6 +65,7 @@ type GetState = () => PlayerState;
|
|||||||
*/
|
*/
|
||||||
export function runResume(set: SetState, get: GetState): void {
|
export function runResume(set: SetState, get: GetState): void {
|
||||||
clearAllPlaybackScheduleTimers();
|
clearAllPlaybackScheduleTimers();
|
||||||
|
markPlaybackActive();
|
||||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||||
|
|
||||||
// Orbit guest: resume means "catch up to the host's live stream".
|
// 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 { setIsAudioPaused } from './engineState';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState } from './playerStoreTypes';
|
||||||
import { flushQueueSyncToServer } from './queueSync';
|
import { flushQueueSyncToServer } from './queueSync';
|
||||||
|
import { markPlaybackActive, markPlaybackIdle } from './queuePlaybackIdle';
|
||||||
import { playListenSessionFinalize, playListenSessionOnPause } from './playListenSession';
|
import { playListenSessionFinalize, playListenSessionOnPause } from './playListenSession';
|
||||||
import { playbackReportPaused, playbackReportStopped } from './playbackReportSession';
|
import { playbackReportPaused, playbackReportStopped } from './playbackReportSession';
|
||||||
import { pauseRadio, stopRadio } from './radioPlayer';
|
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
|
// Re-hydrate from the analysis DB in case the bins were never loaded or
|
||||||
// only partially filled while the (now stopped) track was playing.
|
// only partially filled while the (now stopped) track was playing.
|
||||||
if (keptTrackId) void refreshWaveformForTrack(keptTrackId);
|
if (keptTrackId) void refreshWaveformForTrack(keptTrackId);
|
||||||
|
markPlaybackIdle();
|
||||||
},
|
},
|
||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
@@ -89,6 +91,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||||
|
markPlaybackIdle();
|
||||||
},
|
},
|
||||||
|
|
||||||
resetAudioPause: () => {
|
resetAudioPause: () => {
|
||||||
|
|||||||
@@ -25,6 +25,52 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.connection-led {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
@@ -38,6 +84,11 @@
|
|||||||
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55);
|
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 {
|
.connection-led--disconnected {
|
||||||
background: #f38ba8;
|
background: #f38ba8;
|
||||||
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
|
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../server/serverIndexKey';
|
} from '../server/serverIndexKey';
|
||||||
import {
|
import {
|
||||||
activeServerProfileId,
|
activeServerProfileId,
|
||||||
|
filterQueueRefsForServerProfile,
|
||||||
isMultiServerQueue,
|
isMultiServerQueue,
|
||||||
profileIdFromQueueRef,
|
profileIdFromQueueRef,
|
||||||
queueItemRefAt,
|
queueItemRefAt,
|
||||||
@@ -174,6 +175,13 @@ export function queueIsMultiServer(): boolean {
|
|||||||
return isMultiServerQueue(usePlayerStore.getState().queueItems);
|
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
|
* 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.
|
* 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;
|
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);
|
const fromRef = profileIdFromQueueRef(ref);
|
||||||
if (fromRef) return fromRef;
|
if (fromRef) return fromRef === profileId;
|
||||||
const pin = usePlayerStore.getState().queueServerId;
|
const pin = usePlayerStore.getState().queueServerId;
|
||||||
if (pin) return resolveServerIdForIndexKey(pin) || pin;
|
if (pin) return (resolveServerIdForIndexKey(pin) || pin) === profileId;
|
||||||
return activeServerProfileId() ?? '';
|
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. */
|
/** Queue refs that belong to the browsed (active) server — for export/save on mixed queues. */
|
||||||
export function filterQueueRefsForActiveServer(refs: QueueItemRef[]): QueueItemRef[] {
|
export function filterQueueRefsForActiveServer(refs: QueueItemRef[]): QueueItemRef[] {
|
||||||
const activeId = activeServerProfileId();
|
const activeId = activeServerProfileId();
|
||||||
if (!activeId) return [];
|
if (!activeId) return [];
|
||||||
return refs.filter(ref => queueRefProfileId(ref) === activeId);
|
return refsForServerProfile(refs, activeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activeServerQueueTrackIds(refs: QueueItemRef[]): string[] {
|
export function activeServerQueueTrackIds(refs: QueueItemRef[]): string[] {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from '../../cover/coverTraffic';
|
} from '../../cover/coverTraffic';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useOrbitStore } from '../../store/orbitStore';
|
import { useOrbitStore } from '../../store/orbitStore';
|
||||||
|
import { flushPlayQueueForServer } from '../../store/queueSync';
|
||||||
|
import { markQueueHandoffPending } from '../../store/queueSyncUiState';
|
||||||
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
||||||
import { ensureConnectUrlResolved } from './serverEndpoint';
|
import { ensureConnectUrlResolved } from './serverEndpoint';
|
||||||
|
|
||||||
@@ -36,16 +38,24 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
|
|||||||
useOrbitStore.getState().reset();
|
useOrbitStore.getState().reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
const oldActiveId = auth.activeServerId;
|
||||||
|
if (oldActiveId && oldActiveId !== server.id) {
|
||||||
|
await flushPlayQueueForServer(oldActiveId);
|
||||||
|
}
|
||||||
|
|
||||||
const identity = {
|
const identity = {
|
||||||
type: probe.ping.type,
|
type: probe.ping.type,
|
||||||
serverVersion: probe.ping.serverVersion,
|
serverVersion: probe.ping.serverVersion,
|
||||||
openSubsonic: probe.ping.openSubsonic,
|
openSubsonic: probe.ping.openSubsonic,
|
||||||
};
|
};
|
||||||
const auth = useAuthStore.getState();
|
|
||||||
auth.setSubsonicServerIdentity(server.id, identity);
|
auth.setSubsonicServerIdentity(server.id, identity);
|
||||||
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity);
|
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity);
|
||||||
auth.setActiveServer(server.id);
|
auth.setActiveServer(server.id);
|
||||||
auth.setLoggedIn(true);
|
auth.setLoggedIn(true);
|
||||||
|
if (oldActiveId && oldActiveId !== server.id) {
|
||||||
|
markQueueHandoffPending();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user