feat(ui): simplify entry and connection details

This commit is contained in:
AAA committed 2026-08-19 13:07:45 +08:00
1 parent 62f6c0fd2e
commit 3057d96a8b
11 files changed
+297 -86

No files matched your search

+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 代码。
+1 -1
View File
@@ -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>
);
}
+1 -1
View File
@@ -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;
}
+73 -29
View File
@@ -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>
))}
+54 -24
View File
@@ -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
View File
@@ -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;
}
+29
View File
@@ -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();
});
});