From eb926f89b34562f35430d2a40dcd0205e67ee69d Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:37:11 +0800 Subject: [PATCH] feat: implement v9 viewer entry and presentation --- src/client/App.tsx | 32 +- src/client/components/RoomCodeEntry.tsx | 7 +- src/client/components/StageEntryActions.tsx | 48 ++ src/client/lib/diagnostic-export.ts | 75 ++- src/client/lib/session.ts | 33 +- src/client/lib/signaling.ts | 1 + src/client/media/video-frame-proof.ts | 97 +++ src/client/media/viewer-presentation.ts | 626 ++++++++++++++++++ src/client/media/viewer-sfu-route.ts | 29 +- src/client/pages/HostPage.tsx | 48 +- src/client/pages/ViewerPage.tsx | 673 +++++++++++++++----- src/client/styles.css | 32 +- src/server/hybrid-media-router.ts | 12 +- src/server/signaling.ts | 23 +- src/shared/protocol.ts | 3 +- tests/client-state.test.ts | 40 +- tests/diagnostic-export.test.ts | 73 +++ tests/hybrid-media-router.test.ts | 181 +++++- tests/protocol.test.ts | 83 ++- tests/server-signal.test.ts | 121 +++- tests/stage-entry-actions.test.ts | 56 ++ tests/video-frame-proof.test.ts | 86 +++ tests/viewer-presentation.test.ts | 204 ++++++ 23 files changed, 2301 insertions(+), 282 deletions(-) create mode 100644 src/client/components/StageEntryActions.tsx create mode 100644 src/client/media/video-frame-proof.ts create mode 100644 src/client/media/viewer-presentation.ts create mode 100644 tests/stage-entry-actions.test.ts create mode 100644 tests/video-frame-proof.test.ts create mode 100644 tests/viewer-presentation.test.ts diff --git a/src/client/App.tsx b/src/client/App.tsx index cfb9252..755855a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -6,14 +6,13 @@ import { getSiteAccess, type SiteAccessStatus, } from "./lib/api"; -import { readViewerRoute } from "./lib/session"; +import { parseAppRoute, readViewerRoute } from "./lib/session"; import { HostPage } from "./pages/HostPage"; import { JoinPage } from "./pages/JoinPage"; import { ViewerPage } from "./pages/ViewerPage"; -const viewerRoute = readViewerRoute(); -const isJoinRoute = /^\/join\/?$/.test(window.location.pathname); -const isHostRoute = /^\/?$/.test(window.location.pathname); +const appRoute = parseAppRoute(window.location.pathname); +const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null; type AccessState = | { kind: "checking" } @@ -34,20 +33,24 @@ function readableError(error: unknown): string { } export function App() { - if (viewerRoute) { + if (appRoute.kind === "viewer" && viewerRoute) { return viewerRoute.viewerGrant ? ( ) : ( ); } - if (isHostRoute) { + if (appRoute.kind === "host") { return ; } - if (isJoinRoute) { + if (appRoute.kind === "join") { return ; } - return ; + return appRoute.kind === "malformed-room" ? ( + + ) : ( + + ); } function SiteAccessGate({ @@ -196,3 +199,16 @@ function UnavailableRoute() { ); } + +function MalformedRoomRoute() { + return ( +
+
+
+

房间号格式不正确

+

房间号必须是 1000..9999 的四位数字

+
+
+
+ ); +} diff --git a/src/client/components/RoomCodeEntry.tsx b/src/client/components/RoomCodeEntry.tsx index 1e6a0c3..42c6493 100644 --- a/src/client/components/RoomCodeEntry.tsx +++ b/src/client/components/RoomCodeEntry.tsx @@ -1,6 +1,5 @@ import { Hash, LogIn } from "lucide-react"; import { useState, type FormEvent } from "react"; -import { ROOM_CODE_LENGTH } from "../../shared/protocol"; import { roomRouteFromInput } from "../lib/session"; interface RoomCodeEntryProps { @@ -18,7 +17,7 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod event.preventDefault(); const route = roomRouteFromInput(roomId); if (!route) { - setError("房间码格式不正确"); + setError("房间号必须是 1000..9999 的四位数字"); return; } window.location.assign(route); @@ -46,14 +45,12 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod id={inputId} value={roomId} inputMode="numeric" - pattern="[0-9]*" - maxLength={ROOM_CODE_LENGTH} autoComplete="off" autoFocus={autoFocus} placeholder={inline ? "房间码" : undefined} aria-invalid={error ? "true" : undefined} onChange={(event) => { - setRoomId(event.target.value.replace(/\D/g, "")); + setRoomId(event.target.value); setError(null); }} /> diff --git a/src/client/components/StageEntryActions.tsx b/src/client/components/StageEntryActions.tsx new file mode 100644 index 0000000..da1efd7 --- /dev/null +++ b/src/client/components/StageEntryActions.tsx @@ -0,0 +1,48 @@ +import { Hash, MonitorUp } from "lucide-react"; + +import { RoomCodeEntry } from "./RoomCodeEntry"; + +interface StageEntryActionsProps { + joiningRoom: boolean; + onJoinToggle: () => void; + onStartSharing: () => void; +} + +export function StageEntryActions({ + joiningRoom, + onJoinToggle, + onStartSharing, +}: StageEntryActionsProps) { + return ( + <> +
+ + + +
+ {joiningRoom && ( + + )} + + ); +} diff --git a/src/client/lib/diagnostic-export.ts b/src/client/lib/diagnostic-export.ts index 032fbf5..41eb902 100644 --- a/src/client/lib/diagnostic-export.ts +++ b/src/client/lib/diagnostic-export.ts @@ -1,5 +1,12 @@ import type { RouteDiagnosticSnapshot } from "../../shared/protocol"; import type { ConnectionMetrics } from "../types"; +import type { + ViewerFailureCode, + ViewerHostState, + ViewerRouteKind, + ViewerStage, +} from "../media/viewer-presentation"; +import type { SignalConnectionState } from "../types"; export const DIAGNOSTIC_SCHEMA_VERSION = 2; @@ -75,13 +82,33 @@ export interface DiagnosticConnectionInput { metrics: ConnectionMetrics; } +export interface ViewerDiagnosticInput { + authenticated: boolean; + stage: ViewerStage; + revision: number | null; + failureCode: ViewerFailureCode | null; + signalState: SignalConnectionState; + hostState: ViewerHostState; + routeKind: ViewerRouteKind; + frameProof: "none" | "previous" | "current"; +} + export function createDiagnosticReport( role: "host" | "viewer", connections: readonly DiagnosticConnectionInput[], exportedAt = new Date(), - routeSnapshot: RouteDiagnosticSnapshot | null = null, + context: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null = null, ) { - const route = role === "host" ? routeSnapshot : null; + const route = + role === "host" && isRouteDiagnosticSnapshot(context) ? context : null; + const viewerState = + role === "viewer" && isViewerDiagnosticInput(context) + ? context + : undefined; + const safeConnections = + role === "viewer" && viewerState && !viewerState.authenticated + ? [] + : connections; return { schemaVersion: DIAGNOSTIC_SCHEMA_VERSION, exportedAt: exportedAt.toISOString(), @@ -95,7 +122,10 @@ export function createDiagnosticReport( ]), ) : null, - connections: connections.map((connection) => ({ + ...(role === "viewer" && viewerState + ? { viewer: serializeViewerState(viewerState) } + : {}), + connections: safeConnections.map((connection) => ({ scope: connection.scope, route: connection.route, direction: connection.direction, @@ -114,14 +144,14 @@ export function createDiagnosticReport( export function downloadDiagnosticReport( role: "host" | "viewer", connections: readonly DiagnosticConnectionInput[], - routeSnapshot: RouteDiagnosticSnapshot | null = null, + context: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null = null, ): void { const exportedAt = new Date(); const report = createDiagnosticReport( role, connections, exportedAt, - routeSnapshot, + context, ); const blob = new Blob([`${JSON.stringify(report, null, 2)}\n`], { type: "application/json;charset=utf-8", @@ -138,3 +168,38 @@ export function downloadDiagnosticReport( link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); } + +function isRouteDiagnosticSnapshot( + value: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null, +): value is RouteDiagnosticSnapshot { + return value !== null && "children" in value; +} + +function isViewerDiagnosticInput( + value: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null, +): value is ViewerDiagnosticInput { + return value !== null && "authenticated" in value; +} + +function serializeViewerState(state: ViewerDiagnosticInput) { + if (!state.authenticated) { + return { + stage: state.stage, + failureCode: state.failureCode, + }; + } + return { + stage: state.stage, + revision: + state.revision === null + ? "none" + : state.revision === 0 + ? "initial" + : "changed", + failureCode: state.failureCode, + signalState: state.signalState, + hostState: state.hostState, + routeKind: state.routeKind, + frameProof: state.frameProof, + }; +} diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index 7db66c6..73f568b 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -31,13 +31,36 @@ export interface ViewerRoute { viewerGrant?: string; } +export type AppRoute = + | { kind: "host" } + | { kind: "join" } + | { kind: "viewer"; roomId: string } + | { kind: "malformed-room" } + | { kind: "unknown" }; + export function isValidRoomId(value: string): boolean { return roomCodeSchema.safeParse(value).success; } export function roomRouteFromInput(value: string): string | null { - const roomId = value.trim(); - return isValidRoomId(roomId) ? `/r/${roomId}` : null; + return isValidRoomId(value) ? `/r/${value}` : null; +} + +export function parseAppRoute(pathname: string): AppRoute { + if (pathname === "/") { + return { kind: "host" }; + } + if (/^\/join\/?$/.test(pathname)) { + return { kind: "join" }; + } + const viewerMatch = pathname.match(/^\/r\/([1-9]\d{3})\/?$/); + if (viewerMatch) { + return { kind: "viewer", roomId: viewerMatch[1]! }; + } + if (pathname === "/r" || pathname.startsWith("/r/")) { + return { kind: "malformed-room" }; + } + return { kind: "unknown" }; } export function clearHostRoom(): void { @@ -124,12 +147,12 @@ function writeSessionValue(key: string, value: string): void { } export function readViewerRoute(): ViewerRoute | null { - const match = window.location.pathname.match(/^\/r\/(\d+)\/?$/); - if (!match || !isValidRoomId(match[1])) { + const route = parseAppRoute(window.location.pathname); + if (route.kind !== "viewer") { return null; } - const roomId = match[1]; + const roomId = route.roomId; const fragment = window.location.hash; if (fragment) { const fragmentMatch = fragment.match(/^#v=(.+)$/); diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 0125306..6218e44 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -27,6 +27,7 @@ interface SignalingEvents { const FATAL_SIGNAL_ERRORS = new Set([ "AUTH_REQUIRED", "INVALID_TOKEN", + "ROOM_ACCESS_DENIED", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", diff --git a/src/client/media/video-frame-proof.ts b/src/client/media/video-frame-proof.ts new file mode 100644 index 0000000..82c650a --- /dev/null +++ b/src/client/media/video-frame-proof.ts @@ -0,0 +1,97 @@ +type VideoWithFrameCallback = HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: (now: DOMHighResTimeStamp) => void, + ) => number; + cancelVideoFrameCallback?: (handle: number) => void; + webkitDecodedFrameCount?: number; +}; + +export function isAutoplayPolicyRejection(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "NotAllowedError" + ); +} + +export function observeCompositedVideoFrame( + video: HTMLVideoElement, + expectedStream: MediaStream, + onFrame: () => void, +): () => void { + const target = video as VideoWithFrameCallback; + let active = true; + let frameCallbackHandle: number | null = null; + const fallbackEvents = ["loadeddata", "timeupdate", "playing"] as const; + const removeFallbackListeners = (): void => { + for (const event of fallbackEvents) { + video.removeEventListener(event, checkFallback); + } + }; + const baselineDecodedFrames = decodedFrameCount(target); + const baselineCurrentTime = video.currentTime; + + function checkFallback(): void { + if ( + !active || + video.srcObject !== expectedStream || + video.readyState < 2 + ) { + return; + } + const currentDecodedFrames = decodedFrameCount(target); + if ( + (baselineDecodedFrames !== null && + currentDecodedFrames !== null && + currentDecodedFrames > baselineDecodedFrames) || + video.currentTime > baselineCurrentTime + ) { + finish(); + } + } + + function finish(): void { + if ( + !active || + video.srcObject !== expectedStream || + video.readyState < 2 + ) { + return; + } + active = false; + removeFallbackListeners(); + onFrame(); + } + + if (target.requestVideoFrameCallback) { + frameCallbackHandle = target.requestVideoFrameCallback(() => finish()); + return () => { + active = false; + if (frameCallbackHandle !== null) { + target.cancelVideoFrameCallback?.(frameCallbackHandle); + } + }; + } + + for (const event of fallbackEvents) { + video.addEventListener(event, checkFallback); + } + checkFallback(); + + return () => { + active = false; + removeFallbackListeners(); + }; +} + +function decodedFrameCount(video: VideoWithFrameCallback): number | null { + if (typeof video.getVideoPlaybackQuality === "function") { + const count = video.getVideoPlaybackQuality().totalVideoFrames; + return Number.isFinite(count) ? count : null; + } + return typeof video.webkitDecodedFrameCount === "number" && + Number.isFinite(video.webkitDecodedFrameCount) + ? video.webkitDecodedFrameCount + : null; +} diff --git a/src/client/media/viewer-presentation.ts b/src/client/media/viewer-presentation.ts new file mode 100644 index 0000000..86bbce5 --- /dev/null +++ b/src/client/media/viewer-presentation.ts @@ -0,0 +1,626 @@ +import type { SignalConnectionState } from "../types"; + +export type ViewerHostState = + | "unknown" + | "online" + | "paused" + | "offline" + | "stopped"; + +export type ViewerRouteKind = "none" | "p2p" | "sfu"; + +export type ViewerFailureCode = + | "ROOM_ACCESS_DENIED" + | "INVALID_TOKEN" + | "ROOM_CLOSED" + | "ROOM_EXPIRED" + | "ROOM_FULL" + | "ROOM_LOST" + | "STALE_CLIENT" + | "SERVER_ERROR" + | "SESSION_REPLACED" + | "SIGNAL_TERMINATED" + | "HOST_STOPPED" + | "HOST_OFFLINE" + | "ROUTE_EXHAUSTED" + | "AUTOPLAY_BLOCKED" + | "PLAYBACK_FAILED"; + +export type ViewerStage = + | "joining" + | "access-denied" + | "invalid-invite" + | "room-closed" + | "room-expired" + | "room-full" + | "room-lost" + | "stale-client" + | "server-error" + | "session-replaced" + | "signal-terminated" + | "host-paused" + | "needs-play" + | "playing" + | "recovering" + | "route-failed" + | "playback-failed" + | "waiting-host" + | "host-offline" + | "waiting-sfu" + | "preparing-p2p" + | "preparing-sfu" + | "receiving" + | "allocating"; + +interface ViewerRouteFact { + revision: number; + phase: "prepare" | "active"; + kind: ViewerRouteKind; +} + +interface ViewerRouteStatusFact { + revision: number; + state: "waiting" | "failed"; +} + +interface ViewerMediaFact { + generation: number; + revision: number; + framePresented: boolean; +} + +export interface ViewerPresentationState { + access: "checking" | "ready" | "denied"; + signal: SignalConnectionState; + host: ViewerHostState; + revision: number | null; + route: ViewerRouteFact | null; + routeStatus: ViewerRouteStatusFact | null; + connection: + | "idle" + | "connecting" + | "connected" + | "reconnecting" + | "failed"; + media: ViewerMediaFact | null; + retainedFrame: boolean; + autoplayBlockedGeneration: number | null; + failure: ViewerFailureCode | null; + retryAvailable: boolean; +} + +export type ViewerPresentationAction = + | { + type: "access"; + access: ViewerPresentationState["access"]; + failure?: ViewerFailureCode | null; + } + | { type: "signal"; signal: SignalConnectionState } + | { type: "host"; host: ViewerHostState } + | { + type: "route"; + revision: number; + phase: "prepare" | "active"; + kind: ViewerRouteKind; + } + | { + type: "route-status"; + revision: number; + state: "waiting" | "failed"; + } + | { + type: "connection"; + revision: number; + connection: ViewerPresentationState["connection"]; + } + | { type: "media-bound"; generation: number; revision: number } + | { type: "frame-presented"; generation: number; revision: number } + | { type: "autoplay-blocked"; generation: number; revision: number } + | { type: "autoplay-cleared"; generation: number } + | { type: "playback-failed"; generation: number; revision: number } + | { type: "media-cleared" } + | { + type: "failure"; + failure: ViewerFailureCode | null; + revision?: number; + } + | { type: "retry-available"; available: boolean }; + +export interface ViewerPresentation { + stage: ViewerStage; + message: string; + notice: string | null; + overlay: "none" | "status" | "blocking"; + showPlay: boolean; + retryAvailable: boolean; + hasCurrentFrame: boolean; + hasRetainedFrame: boolean; + failureCode: ViewerFailureCode | null; +} + +export const INITIAL_VIEWER_PRESENTATION_STATE: ViewerPresentationState = { + access: "checking", + signal: "offline", + host: "unknown", + revision: null, + route: null, + routeStatus: null, + connection: "idle", + media: null, + retainedFrame: false, + autoplayBlockedGeneration: null, + failure: null, + retryAvailable: false, +}; + +export function reduceViewerPresentation( + state: ViewerPresentationState, + action: ViewerPresentationAction, +): ViewerPresentationState { + switch (action.type) { + case "access": + return { + ...state, + access: action.access, + failure: + action.failure !== undefined + ? action.failure + : action.access === "ready" + ? null + : state.failure, + }; + case "signal": + return { + ...state, + signal: action.signal, + failure: + action.signal === "connected" && + (state.failure === "SIGNAL_TERMINATED" || + state.failure === "SESSION_REPLACED") + ? null + : state.failure, + }; + case "host": { + const failure = + action.host === "offline" + ? "HOST_OFFLINE" + : action.host === "stopped" + ? "HOST_STOPPED" + : state.failure === "HOST_OFFLINE" || + state.failure === "HOST_STOPPED" + ? null + : state.failure; + return { ...state, host: action.host, failure }; + } + case "route": { + if (state.revision !== null && action.revision < state.revision) { + return state; + } + const revisionChanged = state.revision !== action.revision; + const currentFrame = hasCurrentFrame(state); + return { + ...state, + revision: action.revision, + route: { + revision: action.revision, + phase: action.phase, + kind: action.kind, + }, + routeStatus: + state.routeStatus?.revision === action.revision && + state.routeStatus.state === "waiting" + ? state.routeStatus + : null, + retainedFrame: + state.retainedFrame || (revisionChanged && currentFrame), + failure: + revisionChanged && + (state.failure === "ROUTE_EXHAUSTED" || + state.failure === "PLAYBACK_FAILED") + ? null + : state.failure, + }; + } + case "route-status": { + if (state.revision !== null && action.revision < state.revision) { + return state; + } + const revisionChanged = state.revision !== action.revision; + return { + ...state, + revision: action.revision, + routeStatus: { + revision: action.revision, + state: action.state, + }, + retainedFrame: + state.retainedFrame || (revisionChanged && hasCurrentFrame(state)), + failure: + action.state === "failed" + ? "ROUTE_EXHAUSTED" + : state.failure === "ROUTE_EXHAUSTED" + ? null + : state.failure, + }; + } + case "connection": + if (state.revision !== null && action.revision < state.revision) { + return state; + } + return { + ...state, + revision: Math.max(state.revision ?? 0, action.revision), + connection: action.connection, + }; + case "media-bound": { + if ( + (state.revision !== null && action.revision < state.revision) || + (state.media && action.generation <= state.media.generation) + ) { + return state; + } + return { + ...state, + revision: Math.max(state.revision ?? 0, action.revision), + media: { + generation: action.generation, + revision: action.revision, + framePresented: false, + }, + retainedFrame: state.retainedFrame || hasCurrentFrame(state), + autoplayBlockedGeneration: null, + failure: + state.failure === "AUTOPLAY_BLOCKED" || + state.failure === "PLAYBACK_FAILED" || + state.failure === "ROUTE_EXHAUSTED" + ? null + : state.failure, + }; + } + case "frame-presented": + if ( + !state.media || + state.media.generation !== action.generation || + state.media.revision !== action.revision || + state.revision !== action.revision + ) { + return state; + } + return { + ...state, + media: { ...state.media, framePresented: true }, + retainedFrame: false, + failure: + state.failure === "PLAYBACK_FAILED" ? null : state.failure, + }; + case "autoplay-blocked": + if ( + !state.media || + state.media.generation !== action.generation || + state.media.revision !== action.revision + ) { + return state; + } + return { + ...state, + autoplayBlockedGeneration: action.generation, + failure: "AUTOPLAY_BLOCKED", + }; + case "autoplay-cleared": + if (state.autoplayBlockedGeneration !== action.generation) { + return state; + } + return { + ...state, + autoplayBlockedGeneration: null, + failure: + state.failure === "AUTOPLAY_BLOCKED" ? null : state.failure, + }; + case "playback-failed": + if ( + !state.media || + state.media.generation !== action.generation || + state.media.revision !== action.revision + ) { + return state; + } + return { ...state, failure: "PLAYBACK_FAILED" }; + case "media-cleared": + return { + ...state, + media: null, + retainedFrame: false, + autoplayBlockedGeneration: null, + }; + case "failure": + if ( + action.revision !== undefined && + state.revision !== null && + action.revision < state.revision + ) { + return state; + } + return { + ...state, + revision: + action.revision === undefined + ? state.revision + : Math.max(state.revision ?? 0, action.revision), + failure: action.failure, + }; + case "retry-available": + return { ...state, retryAvailable: action.available }; + } +} + +export function deriveViewerPresentation( + state: ViewerPresentationState, +): ViewerPresentation { + const currentFrame = hasCurrentFrame(state); + const retainedFrame = state.retainedFrame; + const frameOverlay = retainedFrame ? "status" : "blocking"; + + if (state.access === "checking") { + return presentation("joining", "正在加入房间", "blocking", state); + } + if (state.access === "denied") { + switch (state.failure) { + case "ROOM_ACCESS_DENIED": + return presentation( + "access-denied", + "当前无法通过房间号加入", + "blocking", + state, + ); + case "INVALID_TOKEN": + return presentation( + "invalid-invite", + "邀请链接无效或已失效", + "blocking", + state, + ); + case "ROOM_EXPIRED": + return presentation("room-expired", "房间已过期", "blocking", state); + case "ROOM_CLOSED": + return presentation("room-closed", "房间已关闭", "blocking", state); + case "ROOM_FULL": + return presentation("room-full", "当前无法加入房间", "blocking", state); + case "ROOM_LOST": + return presentation( + "room-lost", + "服务器已重启,房间已失效", + "blocking", + state, + ); + case "STALE_CLIENT": + return presentation( + "stale-client", + "页面版本已更新,请刷新后重试", + "blocking", + state, + ); + case "SESSION_REPLACED": + return presentation( + "session-replaced", + "此页面的会话已被另一个标签页接管", + "blocking", + state, + ); + case "SIGNAL_TERMINATED": + return presentation( + "signal-terminated", + "连接已终止,请刷新后重试", + "blocking", + state, + ); + default: + return presentation( + "server-error", + "暂时无法加入房间", + "blocking", + state, + ); + } + } + + if (state.host === "paused") { + return presentation( + "host-paused", + "分享者已暂停", + currentFrame || retainedFrame ? "status" : "blocking", + state, + ); + } + + if ( + state.media && + state.autoplayBlockedGeneration === state.media.generation + ) { + return { + ...presentation("needs-play", "需要点击播放", "blocking", state), + showPlay: true, + }; + } + + if (currentFrame) { + const signalRecovering = + state.signal === "reconnecting" || state.signal === "connecting"; + const mediaRecovering = + state.connection === "reconnecting" || state.connection === "failed"; + return { + ...presentation( + signalRecovering || mediaRecovering ? "recovering" : "playing", + signalRecovering || mediaRecovering ? "正在恢复连接" : "正在播放", + "none", + state, + ), + notice: state.host === "offline" + ? "分享者连接已中断,画面仍然可用" + : signalRecovering + ? "信令正在恢复,画面仍在播放" + : mediaRecovering + ? "媒体连接正在恢复" + : null, + }; + } + + switch (state.failure) { + case "ROUTE_EXHAUSTED": + return presentation( + "route-failed", + "没有可用的媒体线路", + frameOverlay, + state, + ); + case "PLAYBACK_FAILED": + return presentation( + "playback-failed", + "浏览器无法播放当前画面", + frameOverlay, + state, + ); + case "SERVER_ERROR": + return presentation( + "server-error", + "连接服务暂时不可用", + frameOverlay, + state, + ); + case "STALE_CLIENT": + return presentation( + "stale-client", + "页面版本已更新,请刷新后重试", + frameOverlay, + state, + ); + case "SESSION_REPLACED": + return presentation( + "session-replaced", + "此页面的会话已被另一个标签页接管", + frameOverlay, + state, + ); + case "SIGNAL_TERMINATED": + return presentation( + "signal-terminated", + "连接已终止,请刷新后重试", + frameOverlay, + state, + ); + case "HOST_OFFLINE": + return presentation( + "host-offline", + "分享者连接已中断", + frameOverlay, + state, + ); + case "HOST_STOPPED": + return presentation( + "waiting-host", + "等待开始分享", + frameOverlay, + state, + ); + } + + if (state.routeStatus?.state === "waiting") { + return presentation( + "waiting-sfu", + "正在连接备用线路", + frameOverlay, + state, + ); + } + if (state.media && state.media.revision === state.revision) { + return presentation( + "receiving", + "正在接收画面", + frameOverlay, + state, + ); + } + if (state.route?.phase === "prepare") { + return presentation( + state.route.kind === "sfu" ? "preparing-sfu" : "preparing-p2p", + state.route.kind === "sfu" ? "正在连接备用线路" : "正在建立 P2P", + frameOverlay, + state, + ); + } + if ( + state.signal === "reconnecting" || + state.connection === "reconnecting" || + state.connection === "failed" + ) { + return presentation( + "recovering", + "正在恢复连接", + frameOverlay, + state, + ); + } + if (state.host === "stopped" || state.host === "unknown") { + return presentation( + "waiting-host", + "等待开始分享", + frameOverlay, + state, + ); + } + if (state.host === "offline") { + return presentation( + "host-offline", + "分享者连接已中断", + frameOverlay, + state, + ); + } + if (state.route?.kind === "p2p") { + return presentation( + "preparing-p2p", + "正在建立 P2P", + frameOverlay, + state, + ); + } + if (state.route?.kind === "sfu") { + return presentation( + "preparing-sfu", + "正在连接备用线路", + frameOverlay, + state, + ); + } + return presentation( + "allocating", + "正在分配线路", + frameOverlay, + state, + ); +} + +function hasCurrentFrame(state: ViewerPresentationState): boolean { + return Boolean( + state.media?.framePresented && state.media.revision === state.revision, + ); +} + +function presentation( + stage: ViewerStage, + message: string, + overlay: ViewerPresentation["overlay"], + state: ViewerPresentationState, +): ViewerPresentation { + return { + stage, + message, + notice: null, + overlay, + showPlay: false, + retryAvailable: + state.access === "ready" && state.host !== "paused" && state.retryAvailable, + hasCurrentFrame: hasCurrentFrame(state), + hasRetainedFrame: state.retainedFrame, + failureCode: state.failure, + }; +} diff --git a/src/client/media/viewer-sfu-route.ts b/src/client/media/viewer-sfu-route.ts index 2fda69d..1858171 100644 --- a/src/client/media/viewer-sfu-route.ts +++ b/src/client/media/viewer-sfu-route.ts @@ -61,10 +61,14 @@ interface ViewerSfuRouteEvents { stream: MediaStream, assignment: ParticipantRouteAssignment, initialVideoStream: boolean, + revision: number, + ) => void; + onSfuVideoAvailability?: (available: boolean, revision: number) => void; + onSfuUpdate?: (metrics: ConnectionMetrics | null, revision: number) => void; + onSfuState?: ( + state: "connected" | "reconnecting", + revision: number, ) => void; - onSfuVideoAvailability?: (available: boolean) => void; - onSfuUpdate?: (metrics: ConnectionMetrics | null) => void; - onSfuState?: (state: "connected" | "reconnecting") => void; send: (message: ClientMessage) => boolean; createSubscriber?: ( events: { @@ -309,12 +313,12 @@ export class ViewerSfuRoute { void this.queueTransition(() => this.promotePendingSfu(slot)); } } else if (this.active === slot && !slot.failed) { - this.events.onSfuUpdate?.(metrics); + this.events.onSfuUpdate?.(metrics, slot.revision); } }, onState: (state: "connected" | "reconnecting") => { if (this.active === slot && !slot.failed) { - this.events.onSfuState?.(state); + this.events.onSfuState?.(state, slot.revision); } }, onDisconnected: () => this.handleFailure(slot), @@ -393,7 +397,10 @@ export class ViewerSfuRoute { const active = this.active; this.pending = null; this.active = null; - this.events.onSfuUpdate?.(null); + this.events.onSfuUpdate?.( + null, + active?.revision ?? pending?.revision ?? this.route.getRevision() ?? 0, + ); if (active?.activated) { try { active.subscriber.deactivate(); @@ -513,7 +520,7 @@ export class ViewerSfuRoute { const assignment = this.route.getMediaAssignment(); if (assignment?.upstream.kind === "sfu") { slot.mediaAvailable = true; - this.events.onSfuStream(stream, assignment, false); + this.events.onSfuStream(stream, assignment, false, slot.revision); } return; } @@ -543,7 +550,7 @@ export class ViewerSfuRoute { } this.recovery = null; slot.mediaAvailable = true; - this.events.onSfuStream(slot.stream, assignment, true); + this.events.onSfuStream(slot.stream, assignment, true, slot.revision); if (previous && previous !== slot) { try { previous.subscriber.deactivate(); @@ -569,7 +576,7 @@ export class ViewerSfuRoute { } const changed = slot.mediaAvailable !== available; slot.mediaAvailable = available; - this.events.onSfuVideoAvailability?.(available); + this.events.onSfuVideoAvailability?.(available, slot.revision); if (!changed || this.paused) return; } @@ -595,7 +602,7 @@ export class ViewerSfuRoute { } const active = this.active; this.active = null; - this.events.onSfuUpdate?.(null); + this.events.onSfuUpdate?.(null, active.revision); if (active.activated) { try { active.subscriber.deactivate(); @@ -617,7 +624,7 @@ export class ViewerSfuRoute { } if (wasActive) { this.active = null; - this.events.onSfuUpdate?.(null); + this.events.onSfuUpdate?.(null, slot.revision); } const assignment = this.route.getPlannedAssignment(); diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index d4a2292..f3d48f8 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -1,7 +1,6 @@ import { Check, Copy, - Hash, KeyRound, LockKeyhole, Maximize2, @@ -34,7 +33,7 @@ import { AppHeader } from "../components/AppHeader"; import { ConnectionSelfCheck } from "../components/ConnectionSelfCheck"; import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle"; import { RoomCode } from "../components/RoomCode"; -import { RoomCodeEntry } from "../components/RoomCodeEntry"; +import { StageEntryActions } from "../components/StageEntryActions"; import { qualityLimitationSummary } from "../components/connection-details"; import { MediaRouteBadge, @@ -2062,41 +2061,16 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {phase === "ended" && room && ( 已停止分享 )} -
- - - - {joiningRoom && ( - - )} -
+ { + setJoiningRoom(false); + void startSharing(); + }} + onJoinToggle={() => + setJoiningRoom((current) => !current) + } + /> ) : (
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 1f3c1a0..3b6dfe8 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -1,4 +1,5 @@ import { + Download, KeyRound, LoaderCircle, Maximize2, @@ -12,7 +13,7 @@ import { VolumeX, X, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import { DEFAULT_QUALITY_SETTINGS, MAX_VIEWER_PASSWORD_LENGTH, @@ -40,7 +41,11 @@ import { readDisplayName, saveDisplayName } from "../lib/display-name"; import { clearViewerGrant, getStableClientId } from "../lib/session"; import { SignalingClient } from "../lib/signaling"; import { labelParticipantSnapshot } from "../lib/viewer-presence"; -import { downloadDiagnosticReport, type DiagnosticConnectionInput } from "../lib/diagnostic-export"; +import { + downloadDiagnosticReport, + type DiagnosticConnectionInput, + type ViewerDiagnosticInput, +} from "../lib/diagnostic-export"; import { DecodedFrameStallDetector } from "../media/decoded-frame-stall"; import type { QualitySettings } from "../media/quality"; import { relayCapacityMessageForBrowser } from "../media/relay-capability"; @@ -57,6 +62,17 @@ import { ViewerQualityEvidenceReporter, } from "../media/viewer-quality-evidence"; import { ViewerMessageAuthority } from "../media/viewer-message-authority"; +import { + INITIAL_VIEWER_PRESENTATION_STATE, + deriveViewerPresentation, + reduceViewerPresentation, + type ViewerFailureCode, + type ViewerRouteKind, +} from "../media/viewer-presentation"; +import { + isAutoplayPolicyRejection, + observeCompositedVideoFrame, +} from "../media/video-frame-proof"; import { exactPeerSignalOwner } from "../media/route-transition"; import { ViewerSfuRoute } from "../media/viewer-sfu-route"; import { @@ -68,7 +84,6 @@ import { import type { ConnectionMetrics, PeerSnapshot, - SignalConnectionState, } from "../types"; import { limitMediaAssignment, @@ -102,16 +117,26 @@ interface PendingPeerRoute { snapshot: PeerSnapshot | null; } +interface RemoteMediaBinding { + stream: MediaStream; + generation: number; + revision: number; + videoTrackKey: string; +} + export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { - const [accessState, setAccessState] = useState< - "checking" | "ready" | "denied" - >("checking"); - const [signalStatus, setSignalStatus] = - useState("offline"); - const [statusText, setStatusText] = useState("正在连接"); + const [presentationState, dispatchPresentation] = useReducer( + reduceViewerPresentation, + INITIAL_VIEWER_PRESENTATION_STATE, + ); + const presentation = deriveViewerPresentation(presentationState); + const accessState = presentationState.access; + const signalStatus = presentationState.signal; const [hostOnline, setHostOnline] = useState(false); - const [hostPaused, setHostPaused] = useState(false); - const [remoteStream, setRemoteStream] = useState(null); + const [remoteMedia, setRemoteMedia] = useState( + null, + ); + const remoteStream = remoteMedia?.stream ?? null; const [peerSnapshot, setPeerSnapshot] = useState(null); const [sfuUpstream, setSfuUpstream] = useState(null); const [assignedRoute, setAssignedRoute] = useState<{ @@ -122,7 +147,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { const [relaySnapshot, setRelaySnapshot] = useState(null); const [relayChildEvidence, setRelayChildEvidence] = useState(null); - const [playbackBlocked, setPlaybackBlocked] = useState(false); const [playbackVolume, setPlaybackVolume] = useState( DEFAULT_VIEWER_VOLUME_STATE, ); @@ -143,6 +167,10 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { password: string; sequence: number; } | null>(null); + const [viewerPasswordExpanded, setViewerPasswordExpanded] = useState(false); + const [localNotice, setLocalNotice] = useState< + "fullscreen-unavailable" | null + >(null); const qualityLimitation = useMemo( () => @@ -170,20 +198,20 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { function clearParticipantPresence(): void { setParticipantPresence(null); - setHostPaused(false); } function clearHostPresence(): void { setParticipantPresence((current) => current?.filter((participant) => participant.role !== "host") ?? null, ); - setHostPaused(false); } const videoRef = useRef(null); const peerRef = useRef(null); const signalRef = useRef(null); const displayNameRef = useRef(displayName); + const remoteMediaRef = useRef(null); + const mediaGenerationRef = useRef(0); const { muted, volumePercent } = playbackVolume; const routePresentation = viewerRouteEvidence( assignedRoute?.upstream ?? null, @@ -228,6 +256,58 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { metrics: metricsFromQualityEvidence(freshRelayChildEvidence), }); } + const viewerDiagnosticState: ViewerDiagnosticInput = { + authenticated: presentationState.revision !== null, + stage: presentation.stage, + revision: presentationState.revision, + failureCode: presentation.failureCode, + signalState: presentationState.signal, + hostState: presentationState.host, + routeKind: + assignedRoute === null + ? "none" + : routeKindFromAssignment(assignedRoute.upstream), + frameProof: presentation.hasCurrentFrame + ? "current" + : presentation.hasRetainedFrame + ? "previous" + : "none", + }; + + function bindRemoteStream(stream: MediaStream, revision: number): void { + const videoTrackKey = stream + .getVideoTracks() + .map((track) => track.id) + .sort() + .join(":"); + const current = remoteMediaRef.current; + if ( + current?.stream === stream && + current.revision === revision && + current.videoTrackKey === videoTrackKey + ) { + return; + } + const next: RemoteMediaBinding = { + stream, + revision, + videoTrackKey, + generation: ++mediaGenerationRef.current, + }; + remoteMediaRef.current = next; + setRemoteMedia(next); + dispatchPresentation({ + type: "media-bound", + generation: next.generation, + revision: next.revision, + }); + } + + function clearRemoteMedia(): void { + remoteMediaRef.current = null; + setRemoteMedia(null); + dispatchPresentation({ type: "media-cleared" }); + } function acceptAssignedRoute( revision: number, @@ -239,11 +319,17 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ? current : { revision, phase, upstream }, ); + dispatchPresentation({ + type: "route", + revision, + phase, + kind: routeKindFromAssignment(upstream), + }); } useEffect(() => { let active = true; - let viewerAuthenticated = false; + let hadAuthenticated = false; let currentIceConfig: IceConfig | null = null; let currentHostOnline = false; let currentHostPaused = false; @@ -295,7 +381,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { { onStatus: (status) => { if (active) { - setSignalStatus(status); + dispatchPresentation({ type: "signal", signal: status }); } }, onTerminated: (message) => { @@ -306,12 +392,13 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); setAssignedRoute(null); clearViewerSfuRoute(); - clearPeerState(); + clearPeerState(true); clearParticipantPresence(); - if (!viewerAuthenticated) { - setAccessState("denied"); - } - setStatusText(message); + dispatchPresentation({ + type: "access", + access: "denied", + failure: viewerTerminationFailure(message), + }); }, onAccessRequired: () => { if (active) { @@ -319,11 +406,13 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); setAssignedRoute(null); clearViewerSfuRoute(); - clearPeerState(); + clearPeerState(true); clearParticipantPresence(); - viewerAuthenticated = false; - setAccessState("denied"); - setStatusText("邀请无效或已失效"); + dispatchPresentation({ + type: "access", + access: "denied", + failure: "INVALID_TOKEN", + }); } }, onMessage: (message) => { @@ -561,10 +650,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { true, ); ensureViewerRelay()?.setStream(probe.stream); - setRemoteStream(probe.stream); + bindRemoteStream(probe.stream, revision); setPeerSnapshot(probe.snapshot); setSfuUpstream(null); - setStatusText("正在播放"); return true; } applyMediaAssignment({ @@ -639,12 +727,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ); reconcileRelayChildren(previousChildPeerIds, revision); }, - onSfuUpdate: (metrics) => { + onSfuUpdate: (metrics, revision) => { if (active && viewerSfuRoute === route) { if (metrics) { observeActiveDecodedFrames( "sfu", - String(currentRouteRevision), + String(revision), metrics.intervalFramesDecoded, ); } @@ -653,25 +741,37 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ); } }, - onSfuState: (state) => { + onSfuState: (state, revision) => { if (active && viewerSfuRoute === route) { setSfuUpstream((current) => current ? { ...current, connectionState: state } : null, ); + dispatchPresentation({ + type: "connection", + revision, + connection: state, + }); } }, - onSfuVideoAvailability: (available) => { + onSfuVideoAvailability: (available, revision) => { if (!active || viewerSfuRoute !== route || available) { return; } setSfuUpstream((current) => current ? { ...current, connectionState: "reconnecting" } : current, ); - setStatusText( - currentHostOnline ? "正在恢复连接" : "等待开始分享", - ); + dispatchPresentation({ + type: "connection", + revision, + connection: "reconnecting", + }); }, - onSfuStream: (nextStream, assignment, initialVideoStream) => { + onSfuStream: ( + nextStream, + assignment, + initialVideoStream, + revision, + ) => { if (!active || viewerSfuRoute !== route) { return; } @@ -683,12 +783,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { reconcileRelayChildren(previousChildPeerIds); const relay = ensureViewerRelay(); relay?.setStream(nextStream); - setRemoteStream(nextStream); + bindRemoteStream(nextStream, revision); setSfuUpstream( (current) => current ?? { connectionState: "connected", metrics: null }, ); - setStatusText("正在播放"); if (initialVideoStream) { if (!pendingPeer) { peerRef.current?.dispose(); @@ -741,18 +840,25 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } } - function clearUpstreamState(): void { + function clearUpstreamState(clearMedia = false): void { qualityEvidenceReporter.reset(); const peer = peerRef.current; peer?.dispose(); peerRef.current = null; - setRemoteStream(null); setPeerSnapshot(null); - setPlaybackBlocked(false); + if (clearMedia) { + clearRemoteMedia(); + } else { + dispatchPresentation({ + type: "connection", + revision: currentRouteRevision, + connection: "reconnecting", + }); + } } - function clearPeerState(): void { - clearUpstreamState(); + function clearPeerState(clearMedia = false): void { + clearUpstreamState(clearMedia); clearRelayChildEvidence(); viewerRelay?.stop(); } @@ -768,7 +874,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { if (!preserveUpstream && previousParentId !== nextAssignment.parentPeerId) { clearUpstreamState(); - setStatusText("正在恢复连接"); } reconcileRelayChildren(previousChildPeerIds); } @@ -788,9 +893,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { probe.stream = stream; provePendingPeer(); } else if (active && peerRef.current === peer) { - setRemoteStream(stream); + bindRemoteStream(stream, currentRouteRevision); ensureViewerRelay()?.setStream(stream); - setStatusText("正在播放"); } }, onUpdate: (snapshot) => { @@ -807,11 +911,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ); qualityEvidenceReporter.offer(snapshot, currentRouteRevision); setPeerSnapshot(snapshot); - setStatusText( - snapshot.connectionState === "connected" - ? "已连接" - : "正在恢复连接", - ); + dispatchPresentation({ + type: "connection", + revision: currentRouteRevision, + connection: connectionFact(snapshot.connectionState), + }); } }, onRecoveryExhausted: (parentPeerId, connectionId): boolean => { @@ -864,8 +968,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ), onStream: (nextStream) => { if (active) { - setRemoteStream(nextStream); - setStatusText("正在播放"); + bindRemoteStream(nextStream, currentRouteRevision); // Audio and video can arrive as separate track events on the // same MediaStream, so refresh both downstream senders each time. ensureViewerRelay()?.setStream(nextStream); @@ -885,25 +988,25 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { snapshot.connectionState === "failed" ) { clearPeerState(); - setStatusText("等待开始分享"); return; } setPeerSnapshot(snapshot); - if (snapshot.connectionState === "connected") { - setStatusText("已连接"); - } else if ( - snapshot.connectionState === "failed" || - snapshot.connectionState === "disconnected" - ) { - setStatusText("正在恢复连接"); - } + dispatchPresentation({ + type: "connection", + revision: currentRouteRevision, + connection: connectionFact(snapshot.connectionState), + }); } }, onRecoveryExhausted: (parentPeerId, connectionId) => { if (viewerSfuRoute) { return viewerSfuRoute.reportPeerFailure(parentPeerId, connectionId); } - setStatusText("无法建立媒体连接"); + dispatchPresentation({ + type: "failure", + failure: "ROUTE_EXHAUSTED", + revision: currentRouteRevision, + }); return true; }, }, @@ -917,8 +1020,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { authorityToken: number, ): Promise { if (message.type === "authenticated") { - viewerAuthenticated = true; - setAccessState("ready"); + hadAuthenticated = true; + dispatchPresentation({ type: "access", access: "ready" }); setViewerPasswordDraft(""); setViewerPasswordError(null); clearRelayChildEvidence(); @@ -947,15 +1050,20 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { clearRelayChildEvidence(); } currentRouteRevision = nextRouteRevision; - setAssignedRoute( - nextPeerAssisted && "routeAssignment" in message - ? { - revision: message.routeRevision, - phase: "active", - upstream: message.routeAssignment.upstream, - } - : null, - ); + if (nextPeerAssisted && "routeAssignment" in message) { + acceptAssignedRoute( + message.routeRevision, + message.routeAssignment.upstream, + ); + } else { + setAssignedRoute(null); + dispatchPresentation({ + type: "route", + revision: 0, + phase: "active", + kind: message.hostOnline ? "p2p" : "none", + }); + } const relayCapacity = relayCapacityMessageForBrowser(nextPeerAssisted); if (relayCapacity) { signal.send(relayCapacity); @@ -966,7 +1074,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { const sharingPaused = message.hostPaused ?? false; currentHostPaused = sharingPaused; decodedFrameStall.setPaused(sharingPaused); - setHostPaused(sharingPaused); + dispatchPresentation({ + type: "host", + host: sharingPaused + ? "paused" + : message.hostOnline + ? "online" + : "stopped", + }); if (nextPeerAssisted && "qualitySettings" in message) { currentQualitySettings = message.qualitySettings; void viewerRelay?.updateProfile(currentQualitySettings); @@ -990,7 +1105,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ) { clearPeerState(); } - setStatusText(message.hostOnline ? "正在连接" : "等待开始分享"); const peer = peerRef.current; peer?.updateIceConfig(message.iceConfig); viewerRelay?.updateIceConfig(message.iceConfig); @@ -1040,6 +1154,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } return; } + if (message.type === "route-status") { + dispatchPresentation({ + type: "route-status", + revision: message.revision, + state: message.state, + }); + return; + } if (message.type === "viewer-quality-evidence") { acceptRelayChildEvidence(message); return; @@ -1123,7 +1245,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } const peer = ensurePeer(); if (!peer) { - setStatusText("正在连接"); return; } await peer.acceptSignal(message.fromPeerId, message.payload); @@ -1144,13 +1265,15 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { currentHostPaused = message.paused; decodedFrameStall.setPaused(message.paused); setHostOnline(message.online); - setHostPaused(message.paused); + dispatchPresentation({ + type: "host", + host: message.paused + ? "paused" + : message.online + ? "online" + : "offline", + }); viewerSfuRoute?.setPaused(message.paused); - if (!message.online && !peerRef.current?.isConnected()) { - setStatusText("等待开始分享"); - } else if (message.online && !peerRef.current?.isConnected()) { - setStatusText("正在连接"); - } return; } if (message.type === "viewer-presence") { @@ -1164,10 +1287,10 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { currentHostPaused = false; decodedFrameStall.reset(); clearViewerSfuRoute(); - clearPeerState(); + clearPeerState(true); clearHostPresence(); setHostOnline(false); - setStatusText("等待开始分享"); + dispatchPresentation({ type: "host", host: "stopped" }); return; } if (message.type === "viewer-grant-revoked") { @@ -1178,27 +1301,32 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } viewerAuthorizationGeneration = null; - viewerAuthenticated = false; - setAccessState("denied"); clearViewerGrant(roomId); setSfuStandbyUrl(null); setAssignedRoute(null); clearViewerSfuRoute(); - clearPeerState(); + clearPeerState(true); clearParticipantPresence(); - setStatusText("邀请已失效,请向分享者获取新链接"); + dispatchPresentation({ + type: "access", + access: "denied", + failure: "INVALID_TOKEN", + }); signal.stop(); return; } if (message.type === "room-closed") { - viewerAuthenticated = false; - setAccessState("denied"); setSfuStandbyUrl(null); setAssignedRoute(null); clearViewerSfuRoute(); - clearPeerState(); + clearPeerState(true); clearParticipantPresence(); - setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭"); + dispatchPresentation({ + type: "access", + access: "denied", + failure: + message.reason === "expired" ? "ROOM_EXPIRED" : "ROOM_CLOSED", + }); signal.stop(); return; } @@ -1206,13 +1334,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) { clearPeerState(); clearHostPresence(); - setStatusText("等待开始分享"); + dispatchPresentation({ type: "host", host: "offline" }); return; } if ( [ "AUTH_REQUIRED", "INVALID_TOKEN", + "ROOM_ACCESS_DENIED", "ROOM_EXPIRED", "ROOM_FULL", ].includes(message.code) @@ -1220,20 +1349,40 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); setAssignedRoute(null); clearViewerSfuRoute(); - clearPeerState(); + clearPeerState(true); clearParticipantPresence(); - viewerAuthenticated = false; - setAccessState("denied"); + const failure = + hadAuthenticated && + (message.code === "INVALID_TOKEN" || + message.code === "ROOM_ACCESS_DENIED") + ? "ROOM_LOST" + : viewerFailureFromServerCode(message.code); + dispatchPresentation({ + type: "access", + access: "denied", + failure, + }); } if (message.code === "INVALID_TOKEN") { clearViewerGrant(roomId); if (!viewerGrant && viewerPasswordAttempt) { setViewerPasswordError("无法加入房间,请重试"); } - setStatusText("邀请无效或已失效"); return; } - setStatusText(message.message); + if (message.code === "ROOM_ACCESS_DENIED") { + if (viewerPasswordAttempt) { + setViewerPasswordError("当前无法通过房间号加入"); + setViewerPasswordExpanded(true); + } + return; + } + const failure = viewerFailureFromServerCode(message.code); + if (failure) { + dispatchPresentation({ type: "failure", failure }); + } else if (message.code !== "PEER_NOT_FOUND") { + dispatchPresentation({ type: "failure", failure: "SERVER_ERROR" }); + } } } @@ -1262,17 +1411,47 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { useEffect(() => { const video = videoRef.current; - if (!video) { + if (!video || !remoteMedia) { + if (video) { + video.srcObject = null; + } return; } - video.srcObject = remoteStream; - if (remoteStream) { - void video.play().then( - () => setPlaybackBlocked(false), - () => setPlaybackBlocked(true), - ); - } - }, [remoteStream]); + video.srcObject = remoteMedia.stream; + const stopObserving = observeCompositedVideoFrame( + video, + remoteMedia.stream, + () => + dispatchPresentation({ + type: "frame-presented", + generation: remoteMedia.generation, + revision: remoteMedia.revision, + }), + ); + void video.play().then( + () => + dispatchPresentation({ + type: "autoplay-cleared", + generation: remoteMedia.generation, + }), + (error: unknown) => { + if (isAutoplayPolicyRejection(error)) { + dispatchPresentation({ + type: "autoplay-blocked", + generation: remoteMedia.generation, + revision: remoteMedia.revision, + }); + } else { + dispatchPresentation({ + type: "playback-failed", + generation: remoteMedia.generation, + revision: remoteMedia.revision, + }); + } + }, + ); + return stopObserving; + }, [remoteMedia]); useEffect(() => { const video = videoRef.current; @@ -1280,17 +1459,44 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } applyViewerVolume(video, playbackVolume); - }, [muted, remoteStream, volumePercent]); + }, [muted, remoteMedia, volumePercent]); + + useEffect(() => { + dispatchPresentation({ + type: "retry-available", + available: Boolean( + peerSnapshot || + (assignedRoute?.phase === "active" && + assignedRoute.upstream.kind === "sfu"), + ), + }); + }, [assignedRoute, peerSnapshot]); async function playVideo(): Promise { - if (!videoRef.current) { + const binding = remoteMediaRef.current; + if (!videoRef.current || !binding) { return; } try { await videoRef.current.play(); - setPlaybackBlocked(false); - } catch { - setPlaybackBlocked(true); + dispatchPresentation({ + type: "autoplay-cleared", + generation: binding.generation, + }); + } catch (error) { + if (isAutoplayPolicyRejection(error)) { + dispatchPresentation({ + type: "autoplay-blocked", + generation: binding.generation, + revision: binding.revision, + }); + } else { + dispatchPresentation({ + type: "playback-failed", + generation: binding.generation, + revision: binding.revision, + }); + } } } @@ -1330,7 +1536,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { video.webkitEnterFullscreen?.(); } } catch { - setStatusText("当前浏览器无法进入全屏"); + setLocalNotice("fullscreen-unavailable"); } } @@ -1341,9 +1547,17 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ? signalRef.current?.reconnect() === true : peerRef.current?.requestRecovery() === true; if (!requested) { - setStatusText(hostOnline ? "正在连接" : "等待开始分享"); + dispatchPresentation({ + type: "connection", + revision: assignedRoute?.revision ?? 0, + connection: hostOnline ? "connecting" : "idle", + }); } else { - setStatusText("正在恢复连接"); + dispatchPresentation({ + type: "connection", + revision: assignedRoute?.revision ?? 0, + connection: "reconnecting", + }); } } @@ -1370,7 +1584,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return; } setViewerPasswordError(null); - setAccessState("checking"); + dispatchPresentation({ + type: "access", + access: "checking", + failure: null, + }); setViewerPasswordAttempt((current) => ({ password: viewerPasswordDraft, sequence: (current?.sequence ?? 0) + 1, @@ -1378,6 +1596,15 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } if (accessState !== "ready") { + const codeOnlyDenied = + !viewerGrant && presentationState.failure === "ROOM_ACCESS_DENIED"; + const canRefresh = [ + "ROOM_LOST", + "STALE_CLIENT", + "SERVER_ERROR", + "SESSION_REPLACED", + "SIGNAL_TERMINATED", + ].includes(presentationState.failure ?? ""); return (
@@ -1386,42 +1613,89 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
- ) : viewerGrant ? ( -
-

无法访问

-
) : ( -
+
-

加入房间

-

请输入当前房间的密码

-
- - {viewerPasswordError && ( -

- {viewerPasswordError} +

{presentation.message}

+

+ {codeOnlyDenied + ? "请使用分享者提供的邀请链接,或尝试房间密码。" + : viewerGrant + ? "请向分享者获取新的邀请链接。" + : "请检查入口后重试。"}

+
+ {codeOnlyDenied && !viewerPasswordExpanded && ( + )} - + + )} + {canRefresh && ( + + )} + - + )} @@ -1543,15 +1817,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { autoPlay playsInline muted={muted} - onPlaying={() => setPlaybackBlocked(false)} /> - {!remoteStream && ( -
+ {presentation.overlay === "blocking" && !presentation.showPlay && ( +
)} - {playbackBlocked && remoteStream && !hostPaused && ( + {presentation.showPlay && (