mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
This commit is contained in:
@@ -297,6 +297,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
|
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
|
||||||
|
|
||||||
|
### Sidebar — offline nav gating after manual reconnect Retry
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1190](https://github.com/Psychotoxical/psysonic/pull/1190)**, closes [#1160](https://github.com/Psychotoxical/psysonic/issues/1160)
|
||||||
|
|
||||||
|
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
|
||||||
|
|
||||||
## Under the Hood
|
## Under the Hood
|
||||||
|
|
||||||
### ESLint setup and a strict lint pass over the frontend
|
### ESLint setup and a strict lint pass over the frontend
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'CI: ESLint strict workflow and path-aware ci-ok merge gate (PR #1170)',
|
'CI: ESLint strict workflow and path-aware ci-ok merge gate (PR #1170)',
|
||||||
'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)',
|
'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)',
|
||||||
'AutoDJ overlap cap — Auto (12 s content cap) or Limit (2–30 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)',
|
'AutoDJ overlap cap — Auto (12 s content cap) or Limit (2–30 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)',
|
||||||
|
'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ vi.mock('@/utils/perf/perfFlags', () => ({
|
|||||||
|
|
||||||
import { pingWithCredentialsForProfile } from '@/api/subsonic';
|
import { pingWithCredentialsForProfile } from '@/api/subsonic';
|
||||||
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
||||||
|
import { resetActiveServerConnectionSnapshot, setConnectionStatus } from '@/utils/network/activeServerReachability';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
import { useConnectionStatus } from './useConnectionStatus';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAuthStore();
|
resetAuthStore();
|
||||||
|
resetActiveServerConnectionSnapshot();
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
||||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||||
@@ -187,3 +189,22 @@ describe('useConnectionStatus DEV offline toggle', () => {
|
|||||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle);
|
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useConnectionStatus shared status', () => {
|
||||||
|
it('keeps all hook instances in sync when connection status changes', () => {
|
||||||
|
const shell = renderHook(() => useConnectionStatus());
|
||||||
|
const sidebar = renderHook(() => useConnectionStatus());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
});
|
||||||
|
expect(shell.result.current.status).toBe('disconnected');
|
||||||
|
expect(sidebar.result.current.status).toBe('disconnected');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
});
|
||||||
|
expect(shell.result.current.status).toBe('connected');
|
||||||
|
expect(sidebar.result.current.status).toBe('connected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
isLanUrl,
|
isLanUrl,
|
||||||
type ServerEndpointKind,
|
type ServerEndpointKind,
|
||||||
} from '../utils/server/serverEndpoint';
|
} from '../utils/server/serverEndpoint';
|
||||||
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
|
import {
|
||||||
|
getConnectionStatus,
|
||||||
|
setActiveServerReachable,
|
||||||
|
setConnectionStatus,
|
||||||
|
subscribeConnectionStatus,
|
||||||
|
type ConnectionStatus,
|
||||||
|
} from '../utils/network/activeServerReachability';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import {
|
import {
|
||||||
isDevOfflineBrowseForced,
|
isDevOfflineBrowseForced,
|
||||||
@@ -17,13 +23,12 @@ import {
|
|||||||
|
|
||||||
// Backward-compatible re-export for call sites that still import from the hook.
|
// Backward-compatible re-export for call sites that still import from the hook.
|
||||||
export { isLanUrl };
|
export { isLanUrl };
|
||||||
|
export type { ConnectionStatus };
|
||||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
|
||||||
|
|
||||||
export function useConnectionStatus() {
|
export function useConnectionStatus() {
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||||
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
const status = useSyncExternalStore(subscribeConnectionStatus, getConnectionStatus, getConnectionStatus);
|
||||||
const [isRetrying, setIsRetrying] = useState(false);
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
// Tracks the kind of endpoint the last successful probe answered on so the
|
// Tracks the kind of endpoint the last successful probe answered on so the
|
||||||
// badge reflects the *active* connection, not just whatever the user typed
|
// badge reflects the *active* connection, not just whatever the user typed
|
||||||
@@ -36,20 +41,20 @@ export function useConnectionStatus() {
|
|||||||
const check = useCallback(async () => {
|
const check = useCallback(async () => {
|
||||||
if (isDevOfflineBrowseForced()) {
|
if (isDevOfflineBrowseForced()) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setConnectionStatus('disconnected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = useAuthStore.getState().getActiveServer();
|
const server = useAuthStore.getState().getActiveServer();
|
||||||
if (!server) {
|
if (!server) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setConnectionStatus('disconnected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setConnectionStatus('disconnected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ export function useConnectionStatus() {
|
|||||||
setActiveEndpointKind(null);
|
setActiveEndpointKind(null);
|
||||||
}
|
}
|
||||||
setActiveServerReachable(probe.ok);
|
setActiveServerReachable(probe.ok);
|
||||||
setStatus(probe.ok ? 'connected' : 'disconnected');
|
setConnectionStatus(probe.ok ? 'connected' : 'disconnected');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const retry = useCallback(async () => {
|
const retry = useCallback(async () => {
|
||||||
@@ -97,9 +102,7 @@ export function useConnectionStatus() {
|
|||||||
prevDevForceOfflineRef.current = devForceOffline;
|
prevDevForceOfflineRef.current = devForceOffline;
|
||||||
if (devForceOffline) {
|
if (devForceOffline) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
setConnectionStatus('disconnected');
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setStatus('disconnected');
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -109,10 +112,12 @@ export function useConnectionStatus() {
|
|||||||
|
|
||||||
if (devForceOffline) {
|
if (devForceOffline) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setConnectionStatus('disconnected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!perfFlags.disableBackgroundPolling) {
|
if (!perfFlags.disableBackgroundPolling) {
|
||||||
|
// React Compiler set-state-in-effect rule: probe after DEV offline toggle clears.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void check();
|
void check();
|
||||||
}
|
}
|
||||||
}, [devForceOffline, check, perfFlags.disableBackgroundPolling]);
|
}, [devForceOffline, check, perfFlags.disableBackgroundPolling]);
|
||||||
@@ -125,15 +130,15 @@ export function useConnectionStatus() {
|
|||||||
}
|
}
|
||||||
if (isDevOfflineBrowseForced()) {
|
if (isDevOfflineBrowseForced()) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
setConnectionStatus('disconnected');
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setStatus('disconnected');
|
|
||||||
} else {
|
} else {
|
||||||
setActiveServerReachable(true);
|
setActiveServerReachable(true);
|
||||||
setStatus('connected');
|
setConnectionStatus('connected');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// React Compiler set-state-in-effect rule: initial probe + polling interval on mount.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
check();
|
check();
|
||||||
intervalRef.current = setInterval(check, 120_000);
|
intervalRef.current = setInterval(check, 120_000);
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export function useConnectionStatus() {
|
|||||||
};
|
};
|
||||||
const handleOffline = () => {
|
const handleOffline = () => {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setConnectionStatus('disconnected');
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
window.addEventListener('online', handleOnline);
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
getActiveServerReachable,
|
getActiveServerReachable,
|
||||||
isActiveServerReachable,
|
isActiveServerReachable,
|
||||||
onActiveServerBecameReachable,
|
onActiveServerBecameReachable,
|
||||||
|
resetActiveServerConnectionSnapshot,
|
||||||
setActiveServerReachable,
|
setActiveServerReachable,
|
||||||
} from './activeServerReachability';
|
} from './activeServerReachability';
|
||||||
|
|
||||||
describe('activeServerReachability', () => {
|
describe('activeServerReachability', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||||
setActiveServerReachable(null);
|
resetActiveServerConnectionSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isActiveServerReachable requires an explicit successful probe', () => {
|
it('isActiveServerReachable requires an explicit successful probe', () => {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
||||||
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
|
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
|
||||||
* network attempts while the browser or Subsonic endpoint is down.
|
* network attempts while the browser or Subsonic endpoint is down.
|
||||||
*/
|
*/
|
||||||
let activeServerReachable: boolean | null = null;
|
let activeServerReachable: boolean | null = null;
|
||||||
|
let connectionStatus: ConnectionStatus = 'checking';
|
||||||
|
|
||||||
const reachableListeners = new Set<() => void>();
|
const reachableListeners = new Set<() => void>();
|
||||||
|
const connectionStatusListeners = new Set<() => void>();
|
||||||
|
|
||||||
/** Fires when the active server transitions to reachable (`false`/`null` → `true`). */
|
/** Fires when the active server transitions to reachable (`false`/`null` → `true`). */
|
||||||
export function onActiveServerBecameReachable(listener: () => void): () => void {
|
export function onActiveServerBecameReachable(listener: () => void): () => void {
|
||||||
@@ -27,6 +31,28 @@ export function getActiveServerReachable(): boolean | null {
|
|||||||
return activeServerReachable;
|
return activeServerReachable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared across all `useConnectionStatus` hook instances (manual retry, polling). */
|
||||||
|
export function getConnectionStatus(): ConnectionStatus {
|
||||||
|
return connectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConnectionStatus(next: ConnectionStatus): void {
|
||||||
|
if (connectionStatus === next) return;
|
||||||
|
connectionStatus = next;
|
||||||
|
for (const listener of connectionStatusListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeConnectionStatus(listener: () => void): () => void {
|
||||||
|
connectionStatusListeners.add(listener);
|
||||||
|
return () => connectionStatusListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper — resets module-level connection snapshot. */
|
||||||
|
export function resetActiveServerConnectionSnapshot(): void {
|
||||||
|
activeServerReachable = null;
|
||||||
|
connectionStatus = 'checking';
|
||||||
|
}
|
||||||
|
|
||||||
/** True only when the browser is online and the last active-server probe succeeded. */
|
/** True only when the browser is online and the last active-server probe succeeded. */
|
||||||
export function isActiveServerReachable(): boolean {
|
export function isActiveServerReachable(): boolean {
|
||||||
if (isDevOfflineBrowseForced()) return false;
|
if (isDevOfflineBrowseForced()) return false;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { describe, expect, it, beforeEach } from 'vitest';
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||||
|
import { resetActiveServerConnectionSnapshot } from '../network/activeServerReachability';
|
||||||
import { useOfflineBrowseActive } from './offlineBrowseMode';
|
import { useOfflineBrowseActive } from './offlineBrowseMode';
|
||||||
|
|
||||||
describe('useOfflineBrowseActive', () => {
|
describe('useOfflineBrowseActive', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||||
|
resetActiveServerConnectionSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables offline browse when DEV force-offline is set', () => {
|
it('enables offline browse when DEV force-offline is set', () => {
|
||||||
|
|||||||
@@ -18,11 +18,10 @@ export function isOfflineBrowseActive(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function useOfflineBrowseActive(): boolean {
|
export function useOfflineBrowseActive(): boolean {
|
||||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||||
const { status: connStatus } = useConnectionStatus();
|
// Shared status — all hook instances stay in sync after manual retry.
|
||||||
|
useConnectionStatus();
|
||||||
|
|
||||||
if (import.meta.env.DEV && devForceOffline) return true;
|
if (import.meta.env.DEV && devForceOffline) return true;
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||||
if (connStatus === 'disconnected') return true;
|
|
||||||
if (connStatus === 'connected') return false;
|
|
||||||
return !isActiveServerReachable();
|
return !isActiveServerReachable();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user