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.
|
||||
|
||||
### 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
|
||||
|
||||
### 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)',
|
||||
'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)',
|
||||
'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 { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
||||
import { resetActiveServerConnectionSnapshot, setConnectionStatus } from '@/utils/network/activeServerReachability';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
resetActiveServerConnectionSnapshot();
|
||||
invalidateReachableEndpointCache();
|
||||
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
@@ -187,3 +189,22 @@ describe('useConnectionStatus DEV offline toggle', () => {
|
||||
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 { scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
isLanUrl,
|
||||
type ServerEndpointKind,
|
||||
} 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 {
|
||||
isDevOfflineBrowseForced,
|
||||
@@ -17,13 +23,12 @@ import {
|
||||
|
||||
// Backward-compatible re-export for call sites that still import from the hook.
|
||||
export { isLanUrl };
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
export type { ConnectionStatus };
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
||||
const status = useSyncExternalStore(subscribeConnectionStatus, getConnectionStatus, getConnectionStatus);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
// Tracks the kind of endpoint the last successful probe answered on so the
|
||||
// badge reflects the *active* connection, not just whatever the user typed
|
||||
@@ -36,20 +41,20 @@ export function useConnectionStatus() {
|
||||
const check = useCallback(async () => {
|
||||
if (isDevOfflineBrowseForced()) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
if (!server) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.onLine) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,7 +79,7 @@ export function useConnectionStatus() {
|
||||
setActiveEndpointKind(null);
|
||||
}
|
||||
setActiveServerReachable(probe.ok);
|
||||
setStatus(probe.ok ? 'connected' : 'disconnected');
|
||||
setConnectionStatus(probe.ok ? 'connected' : 'disconnected');
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
@@ -97,9 +102,7 @@ export function useConnectionStatus() {
|
||||
prevDevForceOfflineRef.current = devForceOffline;
|
||||
if (devForceOffline) {
|
||||
setActiveServerReachable(false);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -109,10 +112,12 @@ export function useConnectionStatus() {
|
||||
|
||||
if (devForceOffline) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}, [devForceOffline, check, perfFlags.disableBackgroundPolling]);
|
||||
@@ -125,15 +130,15 @@ export function useConnectionStatus() {
|
||||
}
|
||||
if (isDevOfflineBrowseForced()) {
|
||||
setActiveServerReachable(false);
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
} else {
|
||||
setActiveServerReachable(true);
|
||||
setStatus('connected');
|
||||
setConnectionStatus('connected');
|
||||
}
|
||||
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();
|
||||
intervalRef.current = setInterval(check, 120_000);
|
||||
|
||||
@@ -146,7 +151,7 @@ export function useConnectionStatus() {
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
getActiveServerReachable,
|
||||
isActiveServerReachable,
|
||||
onActiveServerBecameReachable,
|
||||
resetActiveServerConnectionSnapshot,
|
||||
setActiveServerReachable,
|
||||
} from './activeServerReachability';
|
||||
|
||||
describe('activeServerReachability', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
setActiveServerReachable(null);
|
||||
resetActiveServerConnectionSnapshot();
|
||||
});
|
||||
|
||||
it('isActiveServerReachable requires an explicit successful probe', () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
/**
|
||||
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
||||
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
|
||||
* network attempts while the browser or Subsonic endpoint is down.
|
||||
*/
|
||||
let activeServerReachable: boolean | null = null;
|
||||
let connectionStatus: ConnectionStatus = 'checking';
|
||||
|
||||
const reachableListeners = new Set<() => void>();
|
||||
const connectionStatusListeners = new Set<() => void>();
|
||||
|
||||
/** Fires when the active server transitions to reachable (`false`/`null` → `true`). */
|
||||
export function onActiveServerBecameReachable(listener: () => void): () => void {
|
||||
@@ -27,6 +31,28 @@ export function getActiveServerReachable(): boolean | null {
|
||||
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. */
|
||||
export function isActiveServerReachable(): boolean {
|
||||
if (isDevOfflineBrowseForced()) return false;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||
import { resetActiveServerConnectionSnapshot } from '../network/activeServerReachability';
|
||||
import { useOfflineBrowseActive } from './offlineBrowseMode';
|
||||
|
||||
describe('useOfflineBrowseActive', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
resetActiveServerConnectionSnapshot();
|
||||
});
|
||||
|
||||
it('enables offline browse when DEV force-offline is set', () => {
|
||||
|
||||
@@ -18,11 +18,10 @@ export function isOfflineBrowseActive(): boolean {
|
||||
*/
|
||||
export function useOfflineBrowseActive(): boolean {
|
||||
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 (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||
if (connStatus === 'disconnected') return true;
|
||||
if (connStatus === 'connected') return false;
|
||||
return !isActiveServerReachable();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user