fix(ui): align sharing pause and display names (#102)

This commit is contained in:
AAA authored and GitHub committed 2026-08-21 04:28:12 +08:00
1 parent 39b3415d8e
commit d2b66fef9f
8 files changed
+192 -85

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 的实验门槛。
+4 -2
View File
@@ -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;
}
+86 -41
View File
@@ -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>
)}
+70 -29
View File
@@ -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}
+14
View File
@@ -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
View File
@@ -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);
});
});