diff --git a/web/src/hooks/use-webrtc-availability.ts b/web/src/hooks/use-webrtc-availability.ts index 194cbac589..4e641e115d 100644 --- a/web/src/hooks/use-webrtc-availability.ts +++ b/web/src/hooks/use-webrtc-availability.ts @@ -79,7 +79,6 @@ export function evaluateStreamWebRTCAvailability(args: { /** Console detail for a reason decided by the global availability check. */ function describeGlobalReason( reason: WebRTCUnavailableReason, - testStream: string | undefined, probeDetail: string | undefined, ): string { switch (reason) { @@ -88,7 +87,7 @@ function describeGlobalReason( case "not-configured": return "No candidates or ice_servers are set under go2rtc.webrtc."; case "unreachable": - return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`; + return `The connectivity probe failed against ${probeDetail ?? "an unknown stream"}.`; default: return ""; } @@ -98,9 +97,13 @@ function describeGlobalReason( let lastProbeSignature: string | null = null; /** - * Once-per-session: browser support, go2rtc config, and a live handshake probe. + * Browser support, go2rtc config, and a live handshake probe shared by the + * page. A failed probe is retried on a later mount or when the page becomes + * visible again once its cached result has expired. */ -export function useWebRTCGloballyAvailable(): GlobalAvailability { +export function useWebRTCGloballyAvailable( + preferredStream?: string, +): GlobalAvailability { const { data: config } = useSWR("config"); const [probe, setProbe] = useState<{ state: "pending" | "pass" | "fail"; @@ -118,11 +121,19 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability { ); }, [config]); - // Representative restreamed stream to probe against. - const testStream = useMemo(() => { - const streams = config?.go2rtc?.streams ?? {}; - return Object.keys(streams)[0]; - }, [config]); + // the stream being viewed is the one most likely to be online + const testStreams = useMemo(() => { + const streams = Object.keys(config?.go2rtc?.streams ?? {}); + + if (!preferredStream || !streams.includes(preferredStream)) { + return streams; + } + + return [preferredStream, ...streams.filter((s) => s !== preferredStream)]; + }, [config, preferredStream]); + const testStreamsKey = testStreams.join("\n"); + + const [retryToken, setRetryToken] = useState(0); const iceServers = useMemo( () => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers), @@ -136,9 +147,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability { JSON.stringify({ candidates: config?.go2rtc?.webrtc?.candidates ?? [], iceServers, - testStream, + streams: Object.keys(config?.go2rtc?.streams ?? {}), }), - [config, iceServers, testStream], + [config, iceServers], ); useEffect(() => { @@ -153,11 +164,29 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability { }, [probeSignature]); useEffect(() => { - if (!browserOk || !configured || !testStream) { + if (probe.state !== "fail") { + return; + } + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + setRetryToken((token) => token + 1); + } + }; + + document.addEventListener("visibilitychange", onVisibilityChange); + return () => + document.removeEventListener("visibilitychange", onVisibilityChange); + }, [probe.state]); + + useEffect(() => { + const streams = testStreamsKey ? testStreamsKey.split("\n") : []; + + if (!browserOk || !configured || streams.length === 0) { return; } let cancelled = false; - probeWebRTCAvailability(testStream, iceServers).then((result) => { + probeWebRTCAvailability(streams, iceServers).then((result) => { if (!cancelled) { setProbe({ state: result.ok ? "pass" : "fail", @@ -168,7 +197,7 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability { return () => { cancelled = true; }; - }, [browserOk, configured, testStream, iceServers]); + }, [browserOk, configured, testStreamsKey, iceServers, retryToken]); const availability = useMemo(() => { if (!browserOk) { @@ -196,9 +225,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability { logWebRTCUnavailable( undefined, reason, - describeGlobalReason(reason, testStream, probe.detail), + describeGlobalReason(reason, probe.detail), ); - }, [config, availability, testStream, probe.detail]); + }, [config, availability, probe.detail]); return availability; } @@ -208,7 +237,8 @@ export function useWebRTCAvailableForStream( metadata: LiveStreamMetadata | null | undefined, streamName?: string, ): StreamAvailability { - const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable(); + const { globallyAvailable, globalReason } = + useWebRTCGloballyAvailable(streamName); const availability = useMemo( () => diff --git a/web/src/utils/webrtcProbe.ts b/web/src/utils/webrtcProbe.ts index 637415c4f6..300b6c9d8a 100644 --- a/web/src/utils/webrtcProbe.ts +++ b/web/src/utils/webrtcProbe.ts @@ -1,18 +1,29 @@ import { baseUrl } from "@/api/baseUrl"; /** - * Performs a single real WebRTC handshake against go2rtc to verify that a - * media connection can actually be established (validates candidates, port - * 8555 reachability, and STUN/TURN end-to-end). Result is cached per page - * session via a module-level promise. + * Performs a real WebRTC handshake against go2rtc to verify that a media + * connection can actually be established (validates candidates, port 8555 + * reachability, and STUN/TURN end-to-end). A success is cached for the page + * session, a failure only for PROBE_FAILURE_TTL_MS. */ export type WebRTCProbeResult = { ok: boolean; detail?: string; + // go2rtc could not open the stream's source, which says nothing about + // whether WebRTC itself can connect + streamError?: boolean; }; -let probePromise: Promise | null = null; +const PROBE_FAILURE_TTL_MS = 30_000; + +// streams tried per probe when go2rtc refuses the earlier ones +const MAX_PROBE_STREAMS = 3; + +let cachedProbe: { + promise: Promise; + failedAt?: number; +} | null = null; function describeError(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -118,23 +129,67 @@ function runProbe( pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch( (err) => fail(`remote answer rejected: ${describeError(err)}`), ); + } else if (msg.type === "error") { + cleanup({ ok: false, streamError: true, detail: msg.value }); } }); }); } +async function runProbes( + testStreams: string[], + iceServers: RTCIceServer[], + timeoutMs: number, +): Promise { + let result: WebRTCProbeResult = { ok: false, detail: "no stream to probe" }; + + for (const stream of testStreams.slice(0, MAX_PROBE_STREAMS)) { + result = await runProbe(stream, iceServers, timeoutMs); + + if (!result.ok) { + result.detail = `stream '${stream}': ${result.detail}`; + } + + if (!result.streamError) { + break; + } + } + + return result; +} + +/** Probes the streams in order, moving on only when go2rtc refuses one. */ export function probeWebRTCAvailability( - testStream: string, + testStreams: string[], iceServers: RTCIceServer[], timeoutMs: number = 5000, ): Promise { - if (!probePromise) { - probePromise = runProbe(testStream, iceServers, timeoutMs); + if ( + cachedProbe && + (cachedProbe.failedAt === undefined || + Date.now() - cachedProbe.failedAt < PROBE_FAILURE_TTL_MS) + ) { + return cachedProbe.promise; } - return probePromise; + + const entry: NonNullable = { + promise: runProbes(testStreams, iceServers, timeoutMs) + .catch((err): WebRTCProbeResult => ({ + ok: false, + detail: describeError(err), + })) + .then((result) => { + if (!result.ok) { + entry.failedAt = Date.now(); + } + return result; + }), + }; + cachedProbe = entry; + return entry.promise; } /** Clears the cached probe result (e.g. when go2rtc config changes). */ export function resetWebRTCProbe(): void { - probePromise = null; + cachedProbe = null; }