From d2b66fef9f514457de231df75e062a1b58a0dd4d Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:28:12 +0800 Subject: [PATCH] fix(ui): align sharing pause and display names (#102) --- docs/status.md | 2 +- docs/方案设计.md | 4 +- docs/需求理解.md | 4 +- src/client/media/quality.ts | 6 +- src/client/pages/HostPage.tsx | 127 +++++++++++++++++++++----------- src/client/pages/ViewerPage.tsx | 99 +++++++++++++++++-------- src/client/styles.css | 14 ++++ tests/quality.test.ts | 21 ++++-- 8 files changed, 192 insertions(+), 85 deletions(-) diff --git a/docs/status.md b/docs/status.md index 82bd7e6..17b3e94 100644 --- a/docs/status.md +++ b/docs/status.md @@ -15,7 +15,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo ## Current Snapshot -- Capture precedes room creation; source/quality changes preserve peers and pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. +- Capture precedes room creation; source/quality changes preserve peers, while pause disables audio/video tracks together and keeps connections. Quality defaults to `maintain-resolution` with balanced/fluid options. - Production access is `screener-v2`: a valid private fragment grant enters directly; every code-only Viewer must first hold site access, after which public-watch accepts the code and private rooms still require their room password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and the site-access cookie are deployed atomically; raw credentials are never stored. - Web Hosts can set their local/session display name, and the current release advertises it through the existing presence wire. The name is not an account identity. - Native v2 remains source-only (memory rooms, 300s reclaim); production does not run it. diff --git a/docs/方案设计.md b/docs/方案设计.md index 90343a3..9589fe4 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -318,12 +318,12 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。 - 访问入口:未鉴权根路径、`/join` 和 code-only `/r/{code}` 只显示中性的站点访问验证,未知路径只显示“无法访问”。有效 `/r/{code}#v={grant}` 直接进入;其余 Viewer 通过 site access 后,public-watch 进入房间,private room 再显示中性房间密码输入。无效、过期、错误密码或满员只显示通用拒绝,不泄露房间存在性或 policy。 -- 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。 +- 分享者根页面:顶部显示“{分享者显示名} 的屏幕”和房间状态,并在标题旁显示可单独复制的紧凑房间号;Viewer 同一位置显示权威 Host 名称,不再在标题下重复。主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、同步暂停音视频、质量档位和复制完整邀请链接;暂停只禁用当前 capture stream 轨道并保留连接。 - `/join`:通过 site access 后只输入正整数房间码并进入 `/r/{code}`;public-watch 直接通过,private room 缺失 grant 时显示中性房间密码表单。 - `/r/{code}` 观看者:private room 首次解析合法 fragment grant 后立即清理地址栏并直达;仅有 code 时先通过 site access,再按 public/private policy 进入。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。 - 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 -两端后续默认显示紧凑在线 roster;Host 与 Viewer 均可修改本机显示名,展示为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。用户名/roster 是独立 PR,不进入 access credential 或 SQLite。 +两端后续默认显示紧凑在线 roster;Host 与 Viewer 的本机显示名默认只读,点击铅笔按钮才展开输入、保存和取消,展示仍为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。用户名/roster 不进入 access credential 或 SQLite。 用户可见的非直播状态只使用“已停止分享”(分享端已结束当前发布)、“等待开始分享”(观看端等待发布)、“正在连接”与“正在恢复连接”。`room-closed` 显示“房间已关闭”或“房间已过期”,不得误写成“分享已结束”。房间寿命、媒体代次、换父和恢复等内部状态不得作为额外产品文案暴露。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 4c1429a..5aff677 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -19,10 +19,10 @@ - [ ] 首屏双入口:分享入口通过站点访问后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;持合法房间 grant 的观看者不需要知道站点访问密码。点击后直接进入现有分享或房间加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。 - [ ] 简单房间:分享者获得纯数字房间码,但房间码只负责定位。现有 `/r/{code}#v={grant}` 合法未过期邀请继续直接进入;仅有 `/r/{code}` 或 `/join` 房间码的 Viewer 必须先通过站点访问验证,再按房间 policy 进入:public-watch 接受房间码,private room 还需正确观看密码。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。 - [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。 -- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。 +- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时同步暂停音视频和切换分享来源;暂停保留现有连接并禁用当前 capture stream 的全部媒体轨道。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。 - [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。 - [ ] 观看端本地音量:播放控制栏提供 `0%` 至 `100%` 音量条和静音按钮,只改变当前浏览器的媒体元素,不修改 Host 音轨、编码、房间状态或其他观看者。切流、换父和 ICE 恢复保留当前页面内的值;刷新后不持久化。静音保留上次非零音量,拖动到非零值自动取消静音。 -- [ ] 本地显示名与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名;信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Host 必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份使用“名称 + 当前房间 `peerId` 的至少六字符短后缀”,房内碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称,Host 可编辑或清除本地名称;Native 不发送 capability,wire 保持不变。 +- [ ] 本地显示名与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名;默认以只读文本显示,点击铅笔按钮才进入编辑。Viewer 顶部标题直接显示“{Host 名称} 的屏幕”,不得在下方重复一行 Host 名称。信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Host 必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份使用“名称 + 当前房间 `peerId` 的至少六字符短后缀”,房内碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称;Native 不发送 capability,wire 保持不变。 - [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。 - [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。 - [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。 diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts index da9e148..a6417c3 100644 --- a/src/client/media/quality.ts +++ b/src/client/media/quality.ts @@ -152,12 +152,14 @@ export async function applyCaptureProfile( await videoTrack.applyConstraints(captureConstraints(profile)); } -export function setVideoPaused(stream: MediaStream, paused: boolean): boolean { +export function setMediaPaused(stream: MediaStream, paused: boolean): boolean { const videoTrack = stream.getVideoTracks()[0]; if (!videoTrack) { return false; } - videoTrack.enabled = !paused; + for (const track of stream.getTracks()) { + track.enabled = !paused; + } return true; } diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index ddcf6d7..1e79875 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -7,12 +7,14 @@ import { LockKeyhole, MonitorUp, Pause, + Pencil, Play, RefreshCw, Save, Square, Trash2, Users, + X, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -73,7 +75,7 @@ import { QUALITY_PROFILE_LABELS, QUALITY_RESOLUTIONS, qualitySettingsLabel, - setVideoPaused, + setMediaPaused, type DegradationPreference, type QualityProfileId, type QualitySettings, @@ -219,6 +221,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [displayName, setDisplayName] = useState(() => readDisplayName()); const [displayNameDraft, setDisplayNameDraft] = useState(displayName); const [displayNameError, setDisplayNameError] = useState(null); + const [editingDisplayName, setEditingDisplayName] = useState(false); const [viewerQualityEvidence, setViewerQualityEvidence] = useState< Map >(() => new Map()); @@ -226,7 +229,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [copied, setCopied] = useState(false); const [switchingSource, setSwitchingSource] = useState(false); const [changingQuality, setChangingQuality] = useState(false); - const [picturePaused, setPicturePaused] = useState(false); + const [sharingPaused, setSharingPaused] = useState(false); const [showConnectionDetails, setShowConnectionDetails] = useState(false); const videoRef = useRef(null); @@ -255,7 +258,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const sourceSwitchRef = useRef(null); const qualityChangeRef = useRef(null); const qualitySettingsRef = useRef(DEFAULT_QUALITY_SETTINGS); - const picturePausedRef = useRef(false); + const sharingPausedRef = useRef(false); const retiringStreamRef = useRef(null); const hostSfuRouteRef = useRef(null); const sfuStandbyPrewarmerRef = useRef(null); @@ -449,8 +452,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setSignalStatus("offline"); setSwitchingSource(false); setChangingQuality(false); - picturePausedRef.current = false; - setPicturePaused(false); + sharingPausedRef.current = false; + setSharingPaused(false); } function forgetRoom(): void { @@ -667,19 +670,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } } - function togglePicturePause(): void { + function toggleSharingPause(): void { const activeStream = streamRef.current; if (phase !== "live" || !activeStream) { return; } - const nextPaused = !picturePausedRef.current; - if (!setVideoPaused(activeStream, nextPaused)) { - setNotice("当前分享没有可暂停的视频轨道"); + const nextPaused = !sharingPausedRef.current; + if (!setMediaPaused(activeStream, nextPaused)) { + setNotice("当前分享没有可暂停的媒体轨道"); return; } - picturePausedRef.current = nextPaused; - setPicturePaused(nextPaused); - setNotice(nextPaused ? "画面已暂停,音频不受影响" : "画面已恢复"); + sharingPausedRef.current = nextPaused; + setSharingPaused(nextPaused); + setNotice(nextPaused ? "音视频分享已暂停" : "音视频分享已恢复"); } function removePeer(peerId: string): void { @@ -1278,7 +1281,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } retiringStreamRef.current = previousStream; - setVideoPaused(captured, picturePausedRef.current); + setMediaPaused(captured, sharingPausedRef.current); streamRef.current = captured; setStream(captured); setDetails(captureDetails(captured)); @@ -1446,6 +1449,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setDisplayName(saved); setDisplayNameDraft(saved); setDisplayNameError(null); + setEditingDisplayName(false); if (!signalRef.current?.setDisplayName(saved)) { setNotice("开始分享并连接后才能修改显示名"); } @@ -1499,14 +1503,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { className="button button-secondary" type="button" disabled={switchingSource || changingQuality} - onClick={togglePicturePause} + onClick={toggleSharingPause} > - {picturePaused ? ( + {sharingPaused ? (