From 3057d96a8b265fb2eb5c44417e0fa3f3c3353e02 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:07:45 +0800 Subject: [PATCH] feat(ui): simplify entry and connection details --- docs/project-memory.md | 1 + docs/status.md | 9 +- docs/方案设计.md | 4 +- src/client/components/AppHeader.tsx | 2 +- .../components/ConnectionDetailsToggle.tsx | 27 +++++ src/client/components/StatusBadge.tsx | 2 +- src/client/components/connection-details.ts | 22 ++++ src/client/pages/HostPage.tsx | 102 ++++++++++++----- src/client/pages/ViewerPage.tsx | 78 +++++++++---- src/client/styles.css | 107 +++++++++++++----- tests/connection-details.test.ts | 29 +++++ 11 files changed, 297 insertions(+), 86 deletions(-) create mode 100644 src/client/components/ConnectionDetailsToggle.tsx create mode 100644 src/client/components/connection-details.ts create mode 100644 tests/connection-details.test.ts diff --git a/docs/project-memory.md b/docs/project-memory.md index c08cfef..e72394d 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -33,6 +33,7 @@ Last updated: 2026-08-19 - The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`. - Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers. - Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified. +- The stacked `feat/simple-entry-diagnostics` Draft puts equal share/join actions inside the idle 16:9 stage, defaults Host/Viewer technical details closed with one native checkbox, keeps actionable warnings outside it, and uses the bounded waiting/recovery/closed copy. Chrome 151 CDP checks pass at 320/375/390 CSS px without horizontal overflow; it is not deployed. - Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map. - With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms. - Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled. diff --git a/docs/status.md b/docs/status.md index d875935..4be93d6 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,6 +22,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share. - A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child. - Peer-assisted profile state is bounded, memory-only, absent from ordinary P2P wire, and applied to current and future relay children. +- The stacked `feat/simple-entry-diagnostics` Draft places equal share/join actions in the idle 16:9 stage, removes the duplicate weak entry, and defaults Host/Viewer technical details closed while keeping actionable warnings visible. It is not deployed. ## Verified Evidence @@ -31,6 +32,8 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding. - Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds. - Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both production builds, dependency audit, and repository hygiene. +- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media. +- The stacked UI branch passes type checking, 20 Vitest files/254 tests, both production builds, and the production dependency audit. - Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E. - Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages. - Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled. @@ -48,9 +51,9 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed ## Next Milestone -Finish ADR-0005 before UI polish: address the 2.257-second fallback, then -validate public transports, rollback, reconnect, controls, edge counts, egress, -and load. +Finish ADR-0005: address the 2.257-second fallback, then validate public +transports, rollback, reconnect, controls, edge counts, egress, and load. Keep +the stacked entry/diagnostics Draft separate and unmerged until its parent lands. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. diff --git a/docs/方案设计.md b/docs/方案设计.md index 12d1660..725d4e6 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -265,7 +265,7 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 ## 官方与参考资料 -访问日期均为 2026-08-18: +访问日期为 2026-08-18 至 2026-08-19: - [Vite Getting Started](https://vite.dev/guide/) - [React: Build a React app from Scratch](https://react.dev/learn/build-a-react-app-from-scratch) @@ -282,6 +282,8 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 - [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00) - [Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/) - [Node.js `crypto.createHmac()` and `crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html) +- [W3C WAI: Labeling Controls](https://www.w3.org/WAI/tutorials/forms/labels/) +- [W3C WCAG 2.2: Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced) MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。 diff --git a/src/client/components/AppHeader.tsx b/src/client/components/AppHeader.tsx index 8eaea5f..9d17ea5 100644 --- a/src/client/components/AppHeader.tsx +++ b/src/client/components/AppHeader.tsx @@ -10,7 +10,7 @@ export function AppHeader({ status }: { status: ReactNode }) { Screener -
{status}
+ {status &&
{status}
} ); } diff --git a/src/client/components/ConnectionDetailsToggle.tsx b/src/client/components/ConnectionDetailsToggle.tsx new file mode 100644 index 0000000..f94d5db --- /dev/null +++ b/src/client/components/ConnectionDetailsToggle.tsx @@ -0,0 +1,27 @@ +import type { ChangeEvent } from "react"; + +interface ConnectionDetailsToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; +} + +export function ConnectionDetailsToggle({ + checked, + onChange, +}: ConnectionDetailsToggleProps) { + return ( + + ); +} diff --git a/src/client/components/StatusBadge.tsx b/src/client/components/StatusBadge.tsx index 8766e09..4459061 100644 --- a/src/client/components/StatusBadge.tsx +++ b/src/client/components/StatusBadge.tsx @@ -39,7 +39,7 @@ export function PeerStatusBadge({ state: RTCPeerConnectionState | "waiting"; }) { const labels: Record = { - waiting: { tone: "neutral", label: "等待画面" }, + waiting: { tone: "neutral", label: "等待开始分享" }, new: { tone: "neutral", label: "准备中" }, connecting: { tone: "neutral", label: "正在连接" }, connected: { tone: "good", label: "已连接" }, diff --git a/src/client/components/connection-details.ts b/src/client/components/connection-details.ts new file mode 100644 index 0000000..dd51696 --- /dev/null +++ b/src/client/components/connection-details.ts @@ -0,0 +1,22 @@ +import type { PeerSnapshot } from "../types"; + +const QUALITY_LIMITATION_LABELS: Record = { + bandwidth: "带宽受限", + cpu: "编码性能受限", + other: "其他编码限制", +}; + +export function qualityLimitationSummary( + snapshots: readonly PeerSnapshot[], +): string | null { + const reasons = new Set(); + for (const snapshot of snapshots) { + const reason = snapshot.metrics.qualityLimitationReason; + if (reason && reason !== "none") { + reasons.add(QUALITY_LIMITATION_LABELS[reason] ?? reason); + } + } + return reasons.size > 0 + ? `发送画质受限:${Array.from(reasons).join("、")}` + : null; +} diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 38d268e..e8c59b8 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -17,6 +17,8 @@ import { type ServerMessage, } from "../../shared/protocol"; import { AppHeader } from "../components/AppHeader"; +import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle"; +import { qualityLimitationSummary } from "../components/connection-details"; import { PathBadge, PeerStatusBadge, @@ -133,6 +135,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [switchingSource, setSwitchingSource] = useState(false); const [changingQuality, setChangingQuality] = useState(false); const [picturePaused, setPicturePaused] = useState(false); + const [showConnectionDetails, setShowConnectionDetails] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); @@ -153,6 +156,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { () => Array.from(peerSnapshots.values()), [peerSnapshots], ); + const qualityLimitation = useMemo( + () => qualityLimitationSummary(viewers), + [viewers], + ); useEffect(() => { if (videoRef.current) { @@ -1001,7 +1008,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return (
- } /> + + ) : null + } + />
@@ -1012,13 +1025,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {phase === "live" ? `${viewers.length}/${maxViewers ?? "-"} 人正在观看` : phase === "starting" - ? "正在建立房间" - : room - ? "房间已就绪" - : "尚未开始"} + ? "正在连接" + : phase === "ended" && room + ? "已停止分享" + : room + ? "房间已就绪" + : "尚未开始"}

- {forceRelay && 强制中继} + {showConnectionDetails && forceRelay && ( + 强制中继 + )} {(phase === "live" || phase === "starting") && (
{phase === "live" && ( @@ -1065,13 +1082,46 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )}
-
+
{stream ? (
- {details && stream && ( + {showConnectionDetails && details && stream && (
{details.resolution} {details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"} @@ -1099,12 +1149,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {room && signalStatus === "connected" && !relayAvailable && ( TURN 未配置,严格网络可能无法连接 )} + {qualityLimitation && ( + {qualityLimitation} + )} {notice && (
{notice}
)} + +
画质 @@ -1129,24 +1187,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )}
- - {phase !== "live" && ( - - )} - {phase !== "live" && ( - - - )}
{room && (
@@ -1188,9 +1228,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {

朋友 {index + 1}

- + {showConnectionDetails && ( + + )}
- + {showConnectionDetails && ( + + )} {viewer.error &&

{viewer.error}

} ))} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index c25be61..91da1f2 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -14,6 +14,8 @@ import { type ServerMessage, } from "../../shared/protocol"; import { AppHeader } from "../components/AppHeader"; +import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle"; +import { qualityLimitationSummary } from "../components/connection-details"; import { PathBadge, PeerStatusBadge, @@ -55,7 +57,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) ); const [signalStatus, setSignalStatus] = useState("offline"); - const [statusText, setStatusText] = useState("正在进入房间"); + const [statusText, setStatusText] = useState("正在连接"); const [hostOnline, setHostOnline] = useState(false); const [relayAvailable, setRelayAvailable] = useState(false); const [remoteStream, setRemoteStream] = useState(null); @@ -63,6 +65,17 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) const [relaySnapshot, setRelaySnapshot] = useState(null); const [playbackBlocked, setPlaybackBlocked] = useState(false); const [muted, setMuted] = useState(false); + const [showConnectionDetails, setShowConnectionDetails] = useState(false); + + const qualityLimitation = useMemo( + () => + qualityLimitationSummary( + [peerSnapshot, relaySnapshot].filter( + (snapshot): snapshot is PeerSnapshot => snapshot !== null, + ), + ), + [peerSnapshot, relaySnapshot], + ); const videoRef = useRef(null); const peerRef = useRef(null); @@ -299,11 +312,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) if (previousParentId !== nextAssignment.parentPeerId) { clearUpstreamState(); - setStatusText( - nextAssignment.parentPeerId - ? "正在切换媒体来源" - : "等待可用的媒体来源", - ); + setStatusText("正在恢复连接"); } ensureViewerRelay()?.setChild(nextAssignment.childPeerIds[0] ?? null); } @@ -347,7 +356,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) snapshot.connectionState === "failed" ) { clearPeerState(); - setStatusText("等待分享者再次开始"); + setStatusText("等待开始分享"); return; } setPeerSnapshot(snapshot); @@ -357,7 +366,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) snapshot.connectionState === "failed" || snapshot.connectionState === "disconnected" ) { - setStatusText("正在恢复媒体连接"); + setStatusText("正在恢复连接"); } } }, @@ -416,9 +425,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) ) { clearPeerState(); } - setStatusText( - message.hostOnline ? "等待分享画面" : "等待分享者开始分享", - ); + setStatusText(message.hostOnline ? "正在连接" : "等待开始分享"); const peer = peerRef.current; peer?.updateIceConfig(message.iceConfig); viewerRelay?.updateIceConfig(message.iceConfig); @@ -499,7 +506,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) } const peer = ensurePeer(); if (!peer) { - setStatusText("尚未收到可用的 ICE 配置"); + setStatusText("正在连接"); return; } await peer.acceptSignal(message.fromPeerId, message.payload); @@ -526,9 +533,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) currentHostOnline = message.online; setHostOnline(message.online); if (!message.online && !peerRef.current?.isConnected()) { - setStatusText("等待分享者开始分享"); + setStatusText("等待开始分享"); } else if (message.online && !peerRef.current?.isConnected()) { - setStatusText("等待分享画面"); + setStatusText("正在连接"); } return; } @@ -537,20 +544,20 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) clearViewerSfuRoute(); clearPeerState(); setHostOnline(false); - setStatusText("分享已停止,等待分享者再次开始"); + setStatusText("等待开始分享"); return; } if (message.type === "room-closed") { clearViewerSfuRoute(); clearPeerState(); - setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束"); + setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭"); signal.stop(); return; } if (message.type === "error") { if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) { clearPeerState(); - setStatusText("等待分享者再次开始"); + setStatusText("等待开始分享"); return; } if ( @@ -640,15 +647,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) function retryConnection(): void { if (!peerRef.current?.requestRecovery()) { - setStatusText(hostOnline ? "等待分享画面" : "等待分享者开始分享"); + setStatusText(hostOnline ? "正在连接" : "等待开始分享"); } else { - setStatusText("正在恢复媒体连接"); + setStatusText("正在恢复连接"); } } return (
- } /> + + ) : null + } + />
@@ -657,9 +670,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)

房间 {roomId}

- {forceRelay && 强制中继} + {showConnectionDetails && forceRelay && ( + 强制中继 + )} - + {showConnectionDetails && ( + + )}
@@ -726,6 +743,11 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) + + {!relayAvailable && signalStatus === "connected" && (hostOnline || peerSnapshot) && ( @@ -736,13 +758,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) {peerSnapshot.error} )} - {peerSnapshot && ( + {relaySnapshot?.error && ( +
+ {relaySnapshot.error} +
+ )} + {qualityLimitation && ( + {qualityLimitation} + )} + {showConnectionDetails && peerSnapshot && (

连接数据

)} - {relaySnapshot && ( + {showConnectionDetails && relaySnapshot && (

转发数据

diff --git a/src/client/styles.css b/src/client/styles.css index 5cc02c6..c7c3fca 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -252,6 +252,70 @@ h3 { background: rgba(5, 7, 6, 0.72); } +.stage-entry { + gap: 13px; + width: 100%; +} + +.stage-status { + color: var(--muted); +} + +.entry-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); + width: min(520px, calc(100% - 20px)); + gap: 8px; + align-items: stretch; +} + +.entry-action { + display: inline-flex; + min-width: 0; + min-height: 44px; + gap: 7px; + align-items: center; + justify-content: center; + padding: 0 9px; + border: 1px solid #326645; + border-radius: 6px; + color: var(--text); + background: #14271b; + font-size: 13px; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.entry-action svg { + flex: 0 0 auto; + color: var(--green); +} + +.entry-action:hover { + border-color: var(--green-strong); + background: var(--green-soft); +} + +.entry-divider { + display: grid; + grid-template-rows: minmax(3px, 1fr) auto minmax(3px, 1fr); + gap: 3px; + justify-items: center; + align-items: center; + color: var(--faint); + font-size: 11px; +} + +.entry-divider::before, +.entry-divider::after { + width: 1px; + height: 100%; + background: var(--border-strong); + content: ""; +} + .capture-strip { display: flex; min-height: 34px; @@ -291,28 +355,34 @@ h3 { } .setup-controls { - display: grid; - grid-template-columns: minmax(310px, 1fr) auto; - gap: 18px; - align-items: end; margin-top: 22px; } -.join-room-link { +.connection-details-toggle { display: inline-flex; - grid-column: 1 / -1; - width: fit-content; - gap: 7px; + min-height: 44px; + max-width: 100%; + gap: 9px; align-items: center; + margin-top: 8px; + padding: 0 2px; color: var(--muted); font-size: 13px; - text-decoration: none; + cursor: pointer; } -.join-room-link:hover { +.connection-details-toggle:hover { color: var(--text); } +.connection-details-toggle input { + width: 18px; + height: 18px; + flex: 0 0 18px; + margin: 0; + accent-color: var(--green-strong); +} + .control-group { min-width: 0; margin: 0; @@ -733,14 +803,6 @@ h3 { flex-wrap: wrap; } - .setup-controls { - grid-template-columns: minmax(0, 1fr) auto; - } - - .start-button { - grid-column: auto; - } - .viewer-stats .stats-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -767,15 +829,6 @@ h3 { border-left: 0; } - .setup-controls { - grid-template-columns: 1fr; - gap: 14px; - } - - .start-button { - grid-column: auto; - } - .remote-stage { max-height: none; } diff --git a/tests/connection-details.test.ts b/tests/connection-details.test.ts new file mode 100644 index 0000000..586a443 --- /dev/null +++ b/tests/connection-details.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { qualityLimitationSummary } from "../src/client/components/connection-details.ts"; +import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts"; + +function snapshot(reason: string | null): PeerSnapshot { + return { + peerId: "viewer-1", + connectionId: "connection-1", + connectionState: "connected", + iceConnectionState: "connected", + metrics: { ...EMPTY_METRICS, qualityLimitationReason: reason }, + error: null, + }; +} + +describe("progressive connection details", () => { + it("keeps active quality limitations available outside the details panel", () => { + expect( + qualityLimitationSummary([ + snapshot("none"), + snapshot("bandwidth"), + snapshot("cpu"), + snapshot("bandwidth"), + ]), + ).toBe("发送画质受限:带宽受限、编码性能受限"); + expect(qualityLimitationSummary([snapshot(null)])).toBeNull(); + }); +});