feat(ui): simplify entry and connection details
This commit is contained in:
1 parent
62f6c0fd2e
commit
3057d96a8b
11 files changed
+297
-86
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 代码。
|
||||
|
||||
|
||||
@@ -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,22 @@
|
||||
import type { PeerSnapshot } from "../types";
|
||||
|
||||
const QUALITY_LIMITATION_LABELS: Record<string, string> = {
|
||||
bandwidth: "带宽受限",
|
||||
cpu: "编码性能受限",
|
||||
other: "其他编码限制",
|
||||
};
|
||||
|
||||
export function qualityLimitationSummary(
|
||||
snapshots: readonly PeerSnapshot[],
|
||||
): string | null {
|
||||
const reasons = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(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 (
|
||||
<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">
|
||||
@@ -1012,13 +1025,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" && (
|
||||
@@ -1065,13 +1082,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) && (
|
||||
@@ -1085,7 +1135,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>
|
||||
@@ -1099,12 +1149,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
{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">
|
||||
<fieldset className="control-group">
|
||||
<legend>画质</legend>
|
||||
@@ -1129,24 +1187,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{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">
|
||||
@@ -1188,9 +1228,13 @@ 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" />
|
||||
{showConnectionDetails && (
|
||||
<StatsGrid metrics={viewer.metrics} direction="send" />
|
||||
)}
|
||||
{viewer.error && <p className="inline-error">{viewer.error}</p>}
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -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<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);
|
||||
@@ -63,6 +65,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);
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -657,9 +670,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>
|
||||
|
||||
@@ -726,6 +743,11 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionDetailsToggle
|
||||
checked={showConnectionDetails}
|
||||
onChange={setShowConnectionDetails}
|
||||
/>
|
||||
|
||||
{!relayAvailable &&
|
||||
signalStatus === "connected" &&
|
||||
(hostOnline || peerSnapshot) && (
|
||||
@@ -736,13 +758,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 metrics={relaySnapshot.metrics} direction="send" />
|
||||
|
||||
+80
-27
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user