Files
Psychotoxical-psysonic/src/hooks/useConnectionStatus.ts
T

191 lines
6.8 KiB
TypeScript

import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react';
import { useAuthStore } from '../store/authStore';
import { scheduleInstantMixProbeForServer } from '@/lib/api/subsonic';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import {
ensureConnectUrlResolved,
invalidateReachableEndpointCache,
isLanUrl,
type ServerEndpointKind,
} from '@/lib/server/serverEndpoint';
import {
getConnectionStatus,
setActiveServerReachable,
setConnectionStatus,
subscribeConnectionStatus,
type ConnectionStatus,
} from '../utils/network/activeServerReachability';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import {
isDevOfflineBrowseForced,
useDevOfflineBrowseStore,
} from '@/features/offline';
// Backward-compatible re-export for call sites that still import from the hook.
export { isLanUrl };
export type { ConnectionStatus };
export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
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
// as the primary URL. A LAN-tagged primary that has fallen over to its
// public alternate must read as 'public', not 'local'.
const [activeEndpointKind, setActiveEndpointKind] = useState<ServerEndpointKind | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevDevForceOfflineRef = useRef<boolean | null>(null);
const check = useCallback(async () => {
if (isDevOfflineBrowseForced()) {
setActiveServerReachable(false);
setConnectionStatus('disconnected');
return;
}
const server = useAuthStore.getState().getActiveServer();
if (!server) {
setActiveServerReachable(false);
setConnectionStatus('disconnected');
return;
}
if (!navigator.onLine) {
setActiveServerReachable(false);
setConnectionStatus('disconnected');
return;
}
// Dual-address: probe LAN-first via the shared cache. On every poll the
// sticky entry is tried first; on failure the full sequence runs and the
// cache flips to whichever endpoint actually answers — so a laptop moving
// off WiFi smoothly transitions from LAN to public without a manual retry.
const probe = await ensureConnectUrlResolved(server);
if (probe.ok) {
const sid = useAuthStore.getState().activeServerId;
if (sid) {
const identity = {
type: probe.ping.type,
serverVersion: probe.ping.serverVersion,
openSubsonic: probe.ping.openSubsonic,
};
useAuthStore.getState().setSubsonicServerIdentity(sid, identity);
scheduleInstantMixProbeForServer(sid, probe.baseUrl, server.username, server.password, identity);
}
setActiveEndpointKind(probe.endpoint.kind);
} else {
setActiveEndpointKind(null);
}
setActiveServerReachable(probe.ok);
setConnectionStatus(probe.ok ? 'connected' : 'disconnected');
}, []);
const retry = useCallback(async () => {
setIsRetrying(true);
// Manual retry: drop the sticky cache so the next probe starts in the
// natural LAN-first order instead of revalidating whatever last worked.
const sid = useAuthStore.getState().activeServerId;
if (sid) invalidateReachableEndpointCache(sid);
await check();
setIsRetrying(false);
}, [check]);
// DEV offline toggle: react to transitions only — the polling effect already
// probes on mount; an unconditional check() here doubled probes and ignored
// disableBackgroundPolling (PlayerBar tests, perf-flagged runs).
useEffect(() => {
if (!import.meta.env.DEV) return;
if (prevDevForceOfflineRef.current === null) {
prevDevForceOfflineRef.current = devForceOffline;
if (devForceOffline) {
setActiveServerReachable(false);
setConnectionStatus('disconnected');
}
return;
}
if (prevDevForceOfflineRef.current === devForceOffline) return;
prevDevForceOfflineRef.current = devForceOffline;
if (devForceOffline) {
setActiveServerReachable(false);
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]);
useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (isDevOfflineBrowseForced()) {
setActiveServerReachable(false);
setConnectionStatus('disconnected');
} else {
setActiveServerReachable(true);
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);
const handleOnline = () => {
// Network just came back — the sticky entry is from a different network
// moment and may be wrong. Flush, then re-probe LAN-first.
const sid = useAuthStore.getState().activeServerId;
if (sid) invalidateReachableEndpointCache(sid);
check();
};
const handleOffline = () => {
setActiveServerReachable(false);
setConnectionStatus('disconnected');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [check, devForceOffline, perfFlags.disableBackgroundPolling]);
const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers);
const serverName = useMemo(
() => (server ? serverListDisplayLabel(server, servers) : ''),
[server, servers],
);
return {
status,
isRetrying,
retry,
// Active endpoint kind preferred; until the first probe completes we
// fall back to the primary url's classification so the badge has
// *something* to render at mount time. Once a probe has resolved,
// `activeEndpointKind` is the source of truth.
isLan:
activeEndpointKind !== null
? activeEndpointKind === 'local'
: server
? isLanUrl(server.url)
: false,
serverName,
};
}