chore: merge feat/simple-entry-diagnostics into feat/clarity-quality-controls
# Conflicts: # src/client/pages/HostPage.tsx # src/client/styles.css
This commit is contained in:
commit
fb19e59c27
12 files changed
+311
-109
No files matched your search
@@ -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.
|
||||
|
||||
+6
-3
@@ -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.
|
||||
|
||||
+3
-1
@@ -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 代码。
|
||||
|
||||
|
||||
@@ -1416,6 +1416,20 @@ async function runQualityControlSmoke(
|
||||
.map((page) => page.label),
|
||||
);
|
||||
const sendingPages = pages.filter((page) => sendingLabels.has(page.label));
|
||||
await Promise.all(
|
||||
sendingPages.map((page) =>
|
||||
evaluate(
|
||||
cdp,
|
||||
page,
|
||||
`(() => {
|
||||
const toggle = document.querySelector('.connection-details-toggle input');
|
||||
if (!(toggle instanceof HTMLInputElement)) return false;
|
||||
if (!toggle.checked) toggle.click();
|
||||
return true;
|
||||
})()`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const waitForReadback = async (label: "平衡" | "清晰"): Promise<void> => {
|
||||
const expected = JSON.stringify(`${label} / ${label}`);
|
||||
await Promise.all(
|
||||
|
||||
@@ -10,7 +10,7 @@ export function AppHeader({ status }: { status: ReactNode }) {
|
||||
</span>
|
||||
<span>Screener</span>
|
||||
</a>
|
||||
<div className="header-status">{status}</div>
|
||||
{status && <div className="header-status">{status}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
interface ConnectionDetailsToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConnectionDetailsToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: ConnectionDetailsToggleProps) {
|
||||
return (
|
||||
<label
|
||||
className="connection-details-toggle"
|
||||
title="展开或隐藏连接的技术指标"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<span>显示连接详情</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function PeerStatusBadge({
|
||||
state: RTCPeerConnectionState | "waiting";
|
||||
}) {
|
||||
const labels: Record<RTCPeerConnectionState | "waiting", BadgeProps> = {
|
||||
waiting: { tone: "neutral", label: "等待画面" },
|
||||
waiting: { tone: "neutral", label: "等待开始分享" },
|
||||
new: { tone: "neutral", label: "准备中" },
|
||||
connecting: { tone: "neutral", label: "正在连接" },
|
||||
connected: { tone: "good", label: "已连接" },
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PeerSnapshot } from "../types";
|
||||
|
||||
export function qualityLimitationSummary(
|
||||
snapshots: readonly PeerSnapshot[],
|
||||
): string | null {
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.qualityWarning) {
|
||||
return snapshot.qualityWarning;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -143,6 +145,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<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
@@ -167,6 +170,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
() => matchingQualityProfileId(qualitySettings),
|
||||
[qualitySettings],
|
||||
);
|
||||
const qualityLimitation = useMemo(
|
||||
() => qualityLimitationSummary(viewers),
|
||||
[viewers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
@@ -1024,7 +1031,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<AppHeader status={<SignalStatusBadge state={signalStatus} />} />
|
||||
<AppHeader
|
||||
status={
|
||||
showConnectionDetails ? (
|
||||
<SignalStatusBadge state={signalStatus} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="host-workspace">
|
||||
<section className="broadcast-area" aria-labelledby="broadcast-heading">
|
||||
@@ -1035,13 +1048,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
{phase === "live"
|
||||
? `${viewers.length}/${maxViewers ?? "-"} 人正在观看`
|
||||
: phase === "starting"
|
||||
? "正在建立房间"
|
||||
: room
|
||||
? "房间已就绪"
|
||||
: "尚未开始"}
|
||||
? "正在连接"
|
||||
: phase === "ended" && room
|
||||
? "已停止分享"
|
||||
: room
|
||||
? "房间已就绪"
|
||||
: "尚未开始"}
|
||||
</p>
|
||||
</div>
|
||||
{forceRelay && <span className="diagnostic-badge">强制中继</span>}
|
||||
{showConnectionDetails && forceRelay && (
|
||||
<span className="diagnostic-badge">强制中继</span>
|
||||
)}
|
||||
{(phase === "live" || phase === "starting") && (
|
||||
<div className="broadcast-actions">
|
||||
{phase === "live" && (
|
||||
@@ -1088,13 +1105,46 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="video-stage local-stage">
|
||||
<div
|
||||
className="video-stage local-stage"
|
||||
role="group"
|
||||
aria-label="分享或加入房间"
|
||||
>
|
||||
{stream ? (
|
||||
<video ref={videoRef} autoPlay muted playsInline />
|
||||
) : phase === "idle" ||
|
||||
phase === "ended" ||
|
||||
phase === "error" ? (
|
||||
<div className="stage-placeholder stage-entry">
|
||||
{phase === "ended" && room && (
|
||||
<span className="stage-status">已停止分享</span>
|
||||
)}
|
||||
<div className="entry-actions">
|
||||
<button
|
||||
className="entry-action"
|
||||
type="button"
|
||||
title="开始分享屏幕"
|
||||
onClick={() => void startSharing()}
|
||||
>
|
||||
<MonitorUp size={18} aria-hidden="true" />
|
||||
开始分享
|
||||
</button>
|
||||
<span className="entry-divider" aria-hidden="true">
|
||||
或
|
||||
</span>
|
||||
<a
|
||||
className="entry-action"
|
||||
href="/join"
|
||||
title="输入房间码加入观看"
|
||||
>
|
||||
<Hash size={18} aria-hidden="true" />
|
||||
加入房间
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stage-placeholder">
|
||||
<MonitorUp size={36} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>{phase === "ended" ? "等待开始分享" : "未分享画面"}</span>
|
||||
</div>
|
||||
)}
|
||||
{(phase === "starting" || switchingSource || picturePaused) && (
|
||||
@@ -1108,7 +1158,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{details && stream && (
|
||||
{showConnectionDetails && details && stream && (
|
||||
<div className="capture-strip" aria-label="实际捕获参数">
|
||||
<span>{details.resolution}</span>
|
||||
<span>{details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"}</span>
|
||||
@@ -1119,15 +1169,26 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
{!details?.hasAudio && stream && (
|
||||
<WarningBanner>当前来源没有可共享音频</WarningBanner>
|
||||
)}
|
||||
{room && signalStatus === "connected" && !relayAvailable && (
|
||||
{showConnectionDetails &&
|
||||
room &&
|
||||
signalStatus === "connected" &&
|
||||
!relayAvailable && (
|
||||
<WarningBanner>TURN 未配置,严格网络可能无法连接</WarningBanner>
|
||||
)}
|
||||
{qualityLimitation && (
|
||||
<WarningBanner>{qualityLimitation}</WarningBanner>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="notice" role="status" aria-live="polite">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionDetailsToggle
|
||||
checked={showConnectionDetails}
|
||||
onChange={setShowConnectionDetails}
|
||||
/>
|
||||
|
||||
<div className="setup-controls">
|
||||
<div className="quality-controls">
|
||||
<fieldset className="control-group">
|
||||
@@ -1272,24 +1333,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{phase !== "live" && (
|
||||
<button
|
||||
className="button button-primary start-button"
|
||||
type="button"
|
||||
disabled={phase === "starting"}
|
||||
onClick={() => void startSharing()}
|
||||
>
|
||||
<MonitorUp size={18} aria-hidden="true" />
|
||||
{phase === "starting" ? "正在启动" : "开始分享"}
|
||||
</button>
|
||||
)}
|
||||
{phase !== "live" && (
|
||||
<a className="join-room-link" href="/join">
|
||||
<Hash size={15} aria-hidden="true" />
|
||||
输入房间码观看
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{room && (
|
||||
<div className="invite-bar">
|
||||
@@ -1331,17 +1374,18 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<h3>朋友 {index + 1}</h3>
|
||||
<PeerStatusBadge state={viewer.connectionState} />
|
||||
</div>
|
||||
<PathBadge path={viewer.metrics.path} />
|
||||
{showConnectionDetails && (
|
||||
<PathBadge path={viewer.metrics.path} />
|
||||
)}
|
||||
</div>
|
||||
<StatsGrid
|
||||
metrics={viewer.metrics}
|
||||
direction="send"
|
||||
senderParameters={viewer.senderParameters}
|
||||
/>
|
||||
{viewer.error && <p className="inline-error">{viewer.error}</p>}
|
||||
{viewer.qualityWarning && (
|
||||
<p className="inline-warning">{viewer.qualityWarning}</p>
|
||||
{showConnectionDetails && (
|
||||
<StatsGrid
|
||||
metrics={viewer.metrics}
|
||||
direction="send"
|
||||
senderParameters={viewer.senderParameters}
|
||||
/>
|
||||
)}
|
||||
{viewer.error && <p className="inline-error">{viewer.error}</p>}
|
||||
</article>
|
||||
))}
|
||||
{viewers.length === 0 && (
|
||||
|
||||
@@ -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,
|
||||
@@ -52,7 +54,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
);
|
||||
const [signalStatus, setSignalStatus] =
|
||||
useState<SignalConnectionState>("offline");
|
||||
const [statusText, setStatusText] = useState("正在进入房间");
|
||||
const [statusText, setStatusText] = useState("正在连接");
|
||||
const [hostOnline, setHostOnline] = useState(false);
|
||||
const [relayAvailable, setRelayAvailable] = useState(false);
|
||||
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
||||
@@ -60,6 +62,17 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
const [relaySnapshot, setRelaySnapshot] = useState<PeerSnapshot | null>(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<HTMLVideoElement | null>(null);
|
||||
const peerRef = useRef<ViewerPeer | null>(null);
|
||||
@@ -296,11 +309,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
|
||||
if (previousParentId !== nextAssignment.parentPeerId) {
|
||||
clearUpstreamState();
|
||||
setStatusText(
|
||||
nextAssignment.parentPeerId
|
||||
? "正在切换媒体来源"
|
||||
: "等待可用的媒体来源",
|
||||
);
|
||||
setStatusText("正在恢复连接");
|
||||
}
|
||||
ensureViewerRelay()?.setChild(nextAssignment.childPeerIds[0] ?? null);
|
||||
}
|
||||
@@ -344,7 +353,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
snapshot.connectionState === "failed"
|
||||
) {
|
||||
clearPeerState();
|
||||
setStatusText("等待分享者再次开始");
|
||||
setStatusText("等待开始分享");
|
||||
return;
|
||||
}
|
||||
setPeerSnapshot(snapshot);
|
||||
@@ -354,7 +363,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
snapshot.connectionState === "failed" ||
|
||||
snapshot.connectionState === "disconnected"
|
||||
) {
|
||||
setStatusText("正在恢复媒体连接");
|
||||
setStatusText("正在恢复连接");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -411,9 +420,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);
|
||||
@@ -492,7 +499,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
const peer = ensurePeer();
|
||||
if (!peer) {
|
||||
setStatusText("尚未收到可用的 ICE 配置");
|
||||
setStatusText("正在连接");
|
||||
return;
|
||||
}
|
||||
await peer.acceptSignal(message.fromPeerId, message.payload);
|
||||
@@ -519,9 +526,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;
|
||||
}
|
||||
@@ -530,20 +537,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 (
|
||||
@@ -633,15 +640,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
|
||||
function retryConnection(): void {
|
||||
if (!peerRef.current?.requestRecovery()) {
|
||||
setStatusText(hostOnline ? "等待分享画面" : "等待分享者开始分享");
|
||||
setStatusText(hostOnline ? "正在连接" : "等待开始分享");
|
||||
} else {
|
||||
setStatusText("正在恢复媒体连接");
|
||||
setStatusText("正在恢复连接");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell viewer-shell">
|
||||
<AppHeader status={<SignalStatusBadge state={signalStatus} />} />
|
||||
<AppHeader
|
||||
status={
|
||||
showConnectionDetails ? (
|
||||
<SignalStatusBadge state={signalStatus} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="viewer-workspace">
|
||||
<div className="viewer-title-row">
|
||||
@@ -650,9 +663,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
<p className="section-meta">房间 {roomId}</p>
|
||||
</div>
|
||||
<div className="viewer-badges">
|
||||
{forceRelay && <span className="diagnostic-badge">强制中继</span>}
|
||||
{showConnectionDetails && forceRelay && (
|
||||
<span className="diagnostic-badge">强制中继</span>
|
||||
)}
|
||||
<PeerStatusBadge state={peerSnapshot?.connectionState ?? "waiting"} />
|
||||
<PathBadge path={peerSnapshot?.metrics.path ?? "unknown"} />
|
||||
{showConnectionDetails && (
|
||||
<PathBadge path={peerSnapshot?.metrics.path ?? "unknown"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -719,7 +736,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!relayAvailable &&
|
||||
<ConnectionDetailsToggle
|
||||
checked={showConnectionDetails}
|
||||
onChange={setShowConnectionDetails}
|
||||
/>
|
||||
|
||||
{showConnectionDetails &&
|
||||
!relayAvailable &&
|
||||
signalStatus === "connected" &&
|
||||
(hostOnline || peerSnapshot) && (
|
||||
<WarningBanner>TURN 未配置,严格网络可能无法连接</WarningBanner>
|
||||
@@ -729,13 +752,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
{peerSnapshot.error}
|
||||
</div>
|
||||
)}
|
||||
{peerSnapshot && (
|
||||
{relaySnapshot?.error && (
|
||||
<div className="notice notice-error" role="status">
|
||||
{relaySnapshot.error}
|
||||
</div>
|
||||
)}
|
||||
{qualityLimitation && (
|
||||
<WarningBanner>{qualityLimitation}</WarningBanner>
|
||||
)}
|
||||
{showConnectionDetails && peerSnapshot && (
|
||||
<section className="viewer-stats" aria-labelledby="stats-heading">
|
||||
<h2 id="stats-heading">连接数据</h2>
|
||||
<StatsGrid metrics={peerSnapshot.metrics} direction="receive" />
|
||||
</section>
|
||||
)}
|
||||
{relaySnapshot && (
|
||||
{showConnectionDetails && relaySnapshot && (
|
||||
<section className="viewer-stats" aria-labelledby="relay-stats-heading">
|
||||
<h2 id="relay-stats-heading">转发数据</h2>
|
||||
<StatsGrid
|
||||
@@ -743,12 +774,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
direction="send"
|
||||
senderParameters={relaySnapshot.senderParameters}
|
||||
/>
|
||||
{relaySnapshot.error && (
|
||||
<p className="inline-error">{relaySnapshot.error}</p>
|
||||
)}
|
||||
{relaySnapshot.qualityWarning && (
|
||||
<p className="inline-warning">{relaySnapshot.qualityWarning}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
+80
-35
@@ -254,6 +254,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;
|
||||
@@ -293,10 +357,6 @@ h3 {
|
||||
}
|
||||
|
||||
.setup-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(310px, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
@@ -375,21 +435,31 @@ h3 {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -724,13 +794,6 @@ h3 {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inline-warning {
|
||||
margin-top: 10px;
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.viewer-workspace {
|
||||
width: min(1380px, 100%);
|
||||
margin: 0 auto;
|
||||
@@ -817,10 +880,6 @@ h3 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setup-controls {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.advanced-quality-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -828,11 +887,6 @@ h3 {
|
||||
.quality-priority {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.viewer-stats .stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -859,15 +913,6 @@ h3 {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.setup-controls {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.remote-stage {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@@ -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(qualityWarning: string | null): PeerSnapshot {
|
||||
return {
|
||||
peerId: "viewer-1",
|
||||
connectionId: "connection-1",
|
||||
connectionState: "connected",
|
||||
iceConnectionState: "connected",
|
||||
metrics: { ...EMPTY_METRICS, qualityLimitationReason: "bandwidth" },
|
||||
error: null,
|
||||
qualityWarning,
|
||||
};
|
||||
}
|
||||
|
||||
describe("progressive connection details", () => {
|
||||
it("keeps one debounced quality warning outside the details panel", () => {
|
||||
expect(
|
||||
qualityLimitationSummary([
|
||||
snapshot(null),
|
||||
snapshot("持续受带宽限制,浏览器正在降低画面质量"),
|
||||
snapshot("持续受编码性能限制,浏览器正在降低画面质量"),
|
||||
]),
|
||||
).toBe("持续受带宽限制,浏览器正在降低画面质量");
|
||||
expect(qualityLimitationSummary([snapshot(null)])).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user