fix(ui): align sharing pause and display names (#102)
This commit is contained in:
1 parent
39b3415d8e
commit
d2b66fef9f
8 files changed
+192
-85
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
+2
-2
@@ -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` 显示“房间已关闭”或“房间已过期”,不得误写成“分享已结束”。房间寿命、媒体代次、换父和恢复等内部状态不得作为额外产品文案暴露。
|
||||
|
||||
|
||||
+2
-2
@@ -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 的实验门槛。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [editingDisplayName, setEditingDisplayName] = useState(false);
|
||||
const [viewerQualityEvidence, setViewerQualityEvidence] = useState<
|
||||
Map<string, ViewerQualityEvidence>
|
||||
>(() => 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<HTMLVideoElement | null>(null);
|
||||
@@ -255,7 +258,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const sourceSwitchRef = useRef<object | null>(null);
|
||||
const qualityChangeRef = useRef<object | null>(null);
|
||||
const qualitySettingsRef = useRef<QualitySettings>(DEFAULT_QUALITY_SETTINGS);
|
||||
const picturePausedRef = useRef(false);
|
||||
const sharingPausedRef = useRef(false);
|
||||
const retiringStreamRef = useRef<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
const sfuStandbyPrewarmerRef = useRef<SfuStandbyPrewarmer | null>(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 ? (
|
||||
<Play size={16} fill="currentColor" aria-hidden="true" />
|
||||
) : (
|
||||
<Pause size={16} fill="currentColor" aria-hidden="true" />
|
||||
)}
|
||||
{picturePaused ? "恢复画面" : "暂停画面"}
|
||||
{sharingPaused ? "恢复分享" : "暂停分享"}
|
||||
</button>
|
||||
)}
|
||||
{phase === "live" && (
|
||||
@@ -1539,34 +1543,75 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="viewer-name-control host-name-control"
|
||||
className={`viewer-name-control host-name-control${
|
||||
editingDisplayName ? " is-editing" : ""
|
||||
}`}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commitDisplayName();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="host-display-name">显示名</label>
|
||||
<input
|
||||
id="host-display-name"
|
||||
type="text"
|
||||
value={displayNameDraft}
|
||||
maxLength={96}
|
||||
autoComplete="nickname"
|
||||
aria-invalid={displayNameError ? "true" : undefined}
|
||||
onChange={(event) => {
|
||||
setDisplayNameDraft(event.target.value);
|
||||
setDisplayNameError(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="icon-button"
|
||||
title="保存显示名"
|
||||
aria-label="保存显示名"
|
||||
disabled={displayNameDraft === displayName}
|
||||
<label
|
||||
htmlFor={editingDisplayName ? "host-display-name" : undefined}
|
||||
>
|
||||
<Save size={17} />
|
||||
</button>
|
||||
显示名
|
||||
</label>
|
||||
{editingDisplayName ? (
|
||||
<>
|
||||
<input
|
||||
id="host-display-name"
|
||||
type="text"
|
||||
value={displayNameDraft}
|
||||
maxLength={96}
|
||||
autoComplete="nickname"
|
||||
autoFocus
|
||||
aria-invalid={displayNameError ? "true" : undefined}
|
||||
onChange={(event) => {
|
||||
setDisplayNameDraft(event.target.value);
|
||||
setDisplayNameError(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="icon-button"
|
||||
title="保存显示名"
|
||||
aria-label="保存显示名"
|
||||
disabled={displayNameDraft === displayName}
|
||||
>
|
||||
<Save size={17} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="取消编辑"
|
||||
aria-label="取消编辑显示名"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(false);
|
||||
}}
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="viewer-name-value">{displayName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="编辑显示名"
|
||||
aria-label="编辑显示名"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={17} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{displayNameError && (
|
||||
<span className="viewer-name-error" role="alert">
|
||||
{displayNameError}
|
||||
@@ -1616,12 +1661,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<MonitorUp size={36} strokeWidth={1.5} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
{(phase === "starting" || switchingSource || picturePaused) && (
|
||||
{(phase === "starting" || switchingSource || sharingPaused) && (
|
||||
<div className="stage-overlay" role="status">
|
||||
{switchingSource
|
||||
? "正在切换来源"
|
||||
: picturePaused
|
||||
? "画面已暂停"
|
||||
: sharingPaused
|
||||
? "音视频分享已暂停"
|
||||
: "正在连接"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,12 +2,14 @@ import {
|
||||
KeyRound,
|
||||
LoaderCircle,
|
||||
Maximize2,
|
||||
Pencil,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
VideoOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -101,6 +103,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const [displayName, setDisplayName] = useState(() => readDisplayName());
|
||||
const [displayNameDraft, setDisplayNameDraft] = useState(displayName);
|
||||
const [displayNameError, setDisplayNameError] = useState<string | null>(null);
|
||||
const [editingDisplayName, setEditingDisplayName] = useState(false);
|
||||
const [hostPresence, setHostPresence] = useState<Extract<
|
||||
ParticipantPresenceEntry,
|
||||
{ role: "host" }
|
||||
@@ -977,6 +980,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
setDisplayName(saved);
|
||||
setDisplayNameDraft(saved);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(false);
|
||||
signalRef.current?.setViewerDisplayName(saved);
|
||||
}
|
||||
|
||||
@@ -1061,7 +1065,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
<div className="viewer-title-row">
|
||||
<div>
|
||||
<div className="title-line">
|
||||
<h1>好友屏幕</h1>
|
||||
<h1>
|
||||
{hostPresence ? `${hostPresence.displayName} 的屏幕` : "好友屏幕"}
|
||||
</h1>
|
||||
<RoomCode roomId={roomId} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1073,41 +1079,76 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hostPresence && (
|
||||
<p className="section-meta viewer-host-name">
|
||||
{hostPresence.displayName} 的屏幕
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="viewer-name-control"
|
||||
className={`viewer-name-control${
|
||||
editingDisplayName ? " is-editing" : ""
|
||||
}`}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commitDisplayName();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="viewer-display-name">显示名</label>
|
||||
<input
|
||||
id="viewer-display-name"
|
||||
type="text"
|
||||
value={displayNameDraft}
|
||||
maxLength={96}
|
||||
autoComplete="nickname"
|
||||
aria-invalid={displayNameError ? "true" : undefined}
|
||||
onChange={(event) => {
|
||||
setDisplayNameDraft(event.target.value);
|
||||
setDisplayNameError(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="icon-button"
|
||||
title="保存显示名"
|
||||
aria-label="保存显示名"
|
||||
disabled={displayNameDraft === displayName}
|
||||
<label
|
||||
htmlFor={editingDisplayName ? "viewer-display-name" : undefined}
|
||||
>
|
||||
<Save size={17} />
|
||||
</button>
|
||||
显示名
|
||||
</label>
|
||||
{editingDisplayName ? (
|
||||
<>
|
||||
<input
|
||||
id="viewer-display-name"
|
||||
type="text"
|
||||
value={displayNameDraft}
|
||||
maxLength={96}
|
||||
autoComplete="nickname"
|
||||
autoFocus
|
||||
aria-invalid={displayNameError ? "true" : undefined}
|
||||
onChange={(event) => {
|
||||
setDisplayNameDraft(event.target.value);
|
||||
setDisplayNameError(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="icon-button"
|
||||
title="保存显示名"
|
||||
aria-label="保存显示名"
|
||||
disabled={displayNameDraft === displayName}
|
||||
>
|
||||
<Save size={17} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="取消编辑"
|
||||
aria-label="取消编辑显示名"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(false);
|
||||
}}
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="viewer-name-value">{displayName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="编辑显示名"
|
||||
aria-label="编辑显示名"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={17} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{displayNameError && (
|
||||
<span className="viewer-name-error" role="alert">
|
||||
{displayNameError}
|
||||
|
||||
@@ -960,6 +960,10 @@ h3 {
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.viewer-name-control.is-editing {
|
||||
grid-template-columns: auto minmax(0, 1fr) 40px 40px;
|
||||
}
|
||||
|
||||
.viewer-name-control label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -982,6 +986,16 @@ h3 {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.viewer-name-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-name-error {
|
||||
grid-column: 2 / -1;
|
||||
color: #ff9797;
|
||||
|
||||
+13
-8
@@ -8,7 +8,7 @@ import {
|
||||
QUALITY_PROFILES,
|
||||
screenShareLowBitrate,
|
||||
senderParameterWarning,
|
||||
setVideoPaused,
|
||||
setMediaPaused,
|
||||
} from "../src/client/media/quality.ts";
|
||||
|
||||
function createVideoStream() {
|
||||
@@ -18,12 +18,15 @@ function createVideoStream() {
|
||||
applyConstraints: vi.fn(async () => undefined),
|
||||
getSettings: () => ({ width: 1920, height: 1080, frameRate: 60 }),
|
||||
} as unknown as MediaStreamTrack;
|
||||
const audioTrack = {
|
||||
enabled: true,
|
||||
} as unknown as MediaStreamTrack;
|
||||
const stream = {
|
||||
getTracks: () => [videoTrack],
|
||||
getTracks: () => [videoTrack, audioTrack],
|
||||
getVideoTracks: () => [videoTrack],
|
||||
getAudioTracks: () => [],
|
||||
getAudioTracks: () => [audioTrack],
|
||||
} as unknown as MediaStream;
|
||||
return { stream, videoTrack };
|
||||
return { stream, videoTrack, audioTrack };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -304,12 +307,14 @@ describe("realtime quality controls", () => {
|
||||
).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it("pauses only the video track and can resume it", () => {
|
||||
const { stream, videoTrack } = createVideoStream();
|
||||
it("pauses and resumes the shared video and audio tracks together", () => {
|
||||
const { stream, videoTrack, audioTrack } = createVideoStream();
|
||||
|
||||
expect(setVideoPaused(stream, true)).toBe(true);
|
||||
expect(setMediaPaused(stream, true)).toBe(true);
|
||||
expect(videoTrack.enabled).toBe(false);
|
||||
expect(setVideoPaused(stream, false)).toBe(true);
|
||||
expect(audioTrack.enabled).toBe(false);
|
||||
expect(setMediaPaused(stream, false)).toBe(true);
|
||||
expect(videoTrack.enabled).toBe(true);
|
||||
expect(audioTrack.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user