feat(ui): add viewer volume and room codes (#47)

feat(ui): add viewer volume and room codes
This commit is contained in:
AAA authored and GitHub committed 2026-08-19 22:34:15 +08:00
commit 352d5927fe
14 files changed
+442 -28

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ Last updated: 2026-08-19
- Flagship media is UDP; HTTPS/WSS stays TLS/TCP. Old production retains coturn TURN for rollback. The candidate uses self-hosted STUN-only ordinary ICE and separate LiveKit SFU/UDP, with no TURN/media-TCP wire.
- Treat settings as ceilings and deployed degradation as unclassified. Use correlated Host A+B/Viewer C to compare exact `769de201f7cc` against current `main` for Host-only recovery, game FPS/load, preview cost, actual codec/encoder and one-variable rebuilds; then gate SVC before custom `LOW`. Never use UA or a composite score.
- Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`.
- Post-gate UI: local volume/mute, display name, transient roster, local-only candidate endpoints, and RTP loss rate; no accounts, database, telemetry, media, or routing effects.
- Candidate UI has local Viewer volume/mute, copyable room codes, and a favicon. Names/roster, endpoint details, and RTP loss remain pending; no wire, media, or routing effects.
- Do not add scene detection, dynamic-FPS control, or forced AV1 until stats and target hardware prove the need. Codec acceptance requires actual negotiation, power-efficient candidate evidence, interval encode cost, game FPS, CPU/GPU and sender count; Discord's native capture/hardware tuning is comparison evidence, not proof of server re-encoding or a reusable preset.
- Keep room policy deployment-driven: public/password-only rooms are random and temporary; password plus SQLite enables sequential persistent rooms and reusable stopped links.
- Deferred architecture audit: `docs/maintenance.md`.
+1 -1
View File
@@ -54,7 +54,7 @@ sync, and voice-source leakage in parallel when it does not displace that P0;
owns two-state policy. An isolated exact room later gates peer/SFU UDP and
bounded failure before replacing the old release. Selected-edge TURN, if later
justified, is a separate complete change rather than part of this canary.
Post-gate UI adds local volume/mute, names/roster, endpoint details, and RTP loss.
Candidate UI has volume, room-code copy and favicon; names/roster/diagnostics remain.
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate
+4 -4
View File
@@ -248,12 +248,12 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
## 界面结构
首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题。
首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。
- 全站 gate:仅在配置 `ACCESS_PASSWORD` 且 cookie 无效时显示一个密码输入窗口;验证后返回原目标路径。
- 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制邀请。
- 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。
- `/join`:只输入正整数形式的数字房间码,成功后进入 `/r/{code}`。
- `/r/{code}` 观看者:主区域最大化远端视频;顶部显示房间与连接状态;提供本地音量、静音、全屏和重试控制,不要求额外邀请码或 viewer token。
- `/r/{code}` 观看者:主区域最大化远端视频;标题旁显示可复制的紧凑房间号,顶部保留连接状态;提供本地音量、静音、全屏和重试控制,不要求额外邀请码或 viewer token。
- 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。
两端默认显示紧凑的在线 roster;Host 与 Viewer 均可修改本机显示名,更新只影响之后由服务端广播的纯文本 roster。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见,不能被该开关隐藏。分享者默认观看人数必须来自房间 roster,而不是只统计当前由 host 直连的媒体 child。
@@ -262,7 +262,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
按钮使用 `lucide-react` 图标并提供可访问名称和 tooltip。动态状态区域固定最小尺寸,避免连接状态变化导致布局跳动。320、375 与 390 CSS px 下两个首屏动作仍须同排等宽、文字不溢出且触控高度至少 44 px;展开详情不遮挡视频或引入横向滚动。
观看端音量条直接写入当前 `<video>.volume`,范围为 `0` 至 `1`,不进入 WebRTC sender、信令、房间状态、SQLite 或 `localStorage`。React 页面状态跨 `srcObject` 替换、换父和 ICE 恢复保留;页面刷新后恢复默认值。静音按钮只切换本地 `muted`,并保留上次非零音量;用户把音量从零调高时自动取消静音。该控件不作为音频码率、声道、采样率或来源隔离设置。
观看端使用原生 `range` 展示 `0%` 至 `100%`,并把归一化值直接写入当前 `<video>.volume`;它不进入 WebRTC sender、信令、房间状态、SQLite 或 `localStorage`。React 页面状态跨 `srcObject` 替换、换父和 ICE 恢复保留;页面刷新后恢复默认值。静音按钮只切换本地 `muted`,并保留上次非零音量;拖到零自动静音,从零调高或直接取消静音时恢复可听状态。该控件不作为音频码率、声道、采样率或来源隔离设置。
## 安全与故障边界
+2 -2
View File
@@ -12,8 +12,8 @@
### P0 - 必须实现
- [ ] 首屏双入口:通过全站验证后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;加入房间不能只藏在页头或次要链接中。点击后直接进入现有分享或房间码加入流程,不增加营销首页、模式向导或拓扑选择。
- [ ] 简单房间:分享者获得纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。启用持久受保护房间时,房间码从 `1` 开始自增;其他模式继续使用随机临时房间码防止枚举。
- [ ] 首屏双入口:通过全站验证后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;加入房间不能只藏在页头或次要链接中。点击后直接进入现有分享或房间码加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。
- [ ] 简单房间:分享者获得纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。Host 与 Viewer 标题附近都以紧凑、显眼的形式显示房间号并提供只复制房间号的动作,Host 原有复制完整邀请链接动作继续保留。启用持久受保护房间时,房间码从 `1` 开始自增;其他模式继续使用随机临时房间码防止枚举。
- [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。
- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。
- [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#121513" />
<meta name="description" content="Screener private low-latency game screen sharing" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Screener</title>
</head>
<body>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#14271b"/>
<rect x="5" y="6" width="22" height="15" rx="2.5" fill="none" stroke="#45d483" stroke-width="2"/>
<path d="M16 19V12m0 0-3 3m3-3 3 3M11 26h10m-5-5v5" fill="none" stroke="#f1f3f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

+84
View File
@@ -0,0 +1,84 @@
import { Check, CircleAlert, Copy, Hash } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { copyRoomCode } from "./room-code";
type CopyState = "idle" | "copied" | "failed";
export function RoomCode({ roomId }: { roomId: string }) {
const [copyState, setCopyState] = useState<CopyState>("idle");
const resetTimerRef = useRef<number | null>(null);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
};
}, []);
async function copy(): Promise<void> {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
resetTimerRef.current = null;
}
try {
await copyRoomCode(roomId);
if (!mountedRef.current) {
return;
}
setCopyState("copied");
resetTimerRef.current = window.setTimeout(() => {
setCopyState("idle");
resetTimerRef.current = null;
}, 1_500);
} catch {
if (mountedRef.current) {
setCopyState("failed");
resetTimerRef.current = window.setTimeout(() => {
setCopyState("idle");
resetTimerRef.current = null;
}, 1_500);
}
}
}
const copyLabel =
copyState === "copied"
? "房间号已复制"
: copyState === "failed"
? "复制失败,请手动复制房间号"
: `复制房间号 ${roomId}`;
return (
<span className="room-code" aria-label={`房间号 ${roomId}`}>
<Hash size={15} aria-hidden="true" />
<span className="room-code-label">房间号</span>
<strong>{roomId}</strong>
<button
className={`room-code-copy${copyState === "failed" ? " is-failed" : ""}`}
type="button"
title={copyLabel}
aria-label={copyLabel}
onClick={() => void copy()}
>
{copyState === "copied" ? (
<Check size={15} aria-hidden="true" />
) : copyState === "failed" ? (
<CircleAlert size={15} aria-hidden="true" />
) : (
<Copy size={15} aria-hidden="true" />
)}
</button>
<span className="visually-hidden" role="status" aria-live="polite">
{copyState === "copied"
? "房间号已复制"
: copyState === "failed"
? "房间号复制失败"
: ""}
</span>
</span>
);
}
+7
View File
@@ -0,0 +1,7 @@
export async function copyRoomCode(
roomId: string,
writeText: (value: string) => Promise<void> = (value) =>
navigator.clipboard.writeText(value),
): Promise<void> {
await writeText(roomId);
}
+63
View File
@@ -0,0 +1,63 @@
export interface ViewerVolumeState {
volumePercent: number;
muted: boolean;
lastNonZeroVolumePercent: number;
}
interface ViewerVolumeTarget {
volume: number;
muted: boolean;
}
export const DEFAULT_VIEWER_VOLUME_STATE: ViewerVolumeState = {
volumePercent: 100,
muted: false,
lastNonZeroVolumePercent: 100,
};
function normalizeVolumePercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(value)));
}
export function setViewerVolume(
current: ViewerVolumeState,
requestedPercent: number,
): ViewerVolumeState {
const volumePercent = normalizeVolumePercent(requestedPercent);
if (volumePercent === 0) {
return { ...current, volumePercent: 0, muted: true };
}
return {
volumePercent,
muted: false,
lastNonZeroVolumePercent: volumePercent,
};
}
export function toggleViewerMuted(
current: ViewerVolumeState,
): ViewerVolumeState {
if (!current.muted) {
return { ...current, muted: true };
}
const volumePercent =
current.volumePercent > 0
? current.volumePercent
: Math.max(1, current.lastNonZeroVolumePercent);
return {
volumePercent,
muted: false,
lastNonZeroVolumePercent: volumePercent,
};
}
export function applyViewerVolume(
target: ViewerVolumeTarget,
state: ViewerVolumeState,
): void {
target.volume = state.volumePercent / 100;
target.muted = state.muted;
}
+5 -1
View File
@@ -19,6 +19,7 @@ import {
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
import { RoomCode } from "../components/RoomCode";
import { qualityLimitationSummary } from "../components/connection-details";
import {
PathBadge,
@@ -1181,7 +1182,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
<section className="broadcast-area" aria-labelledby="broadcast-heading">
<div className="section-heading">
<div>
<h1 id="broadcast-heading">屏幕分享</h1>
<div className="title-line">
<h1 id="broadcast-heading">屏幕分享</h1>
{room && <RoomCode roomId={room.roomId} />}
</div>
<p className="section-meta">
{phase === "live"
? `${viewers.length}/${maxViewers ?? "-"} 人正在观看`
+64 -18
View File
@@ -16,6 +16,7 @@ import {
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
import { RoomCode } from "../components/RoomCode";
import { qualityLimitationSummary } from "../components/connection-details";
import {
PathBadge,
@@ -36,6 +37,12 @@ import {
} from "../media/viewer-quality-evidence";
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
import {
applyViewerVolume,
DEFAULT_VIEWER_VOLUME_STATE,
setViewerVolume,
toggleViewerMuted,
} from "../media/viewer-volume";
import type {
PeerSnapshot,
SignalConnectionState,
@@ -70,7 +77,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
const [relayChildEvidence, setRelayChildEvidence] =
useState<ViewerQualityEvidence | null>(null);
const [playbackBlocked, setPlaybackBlocked] = useState(false);
const [muted, setMuted] = useState(false);
const [playbackVolume, setPlaybackVolume] = useState(
DEFAULT_VIEWER_VOLUME_STATE,
);
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
const qualityLimitation = useMemo(
@@ -85,6 +94,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
const videoRef = useRef<HTMLVideoElement | null>(null);
const peerRef = useRef<ViewerPeer | null>(null);
const { muted, volumePercent } = playbackVolume;
useEffect(() => {
let active = true;
@@ -691,6 +701,14 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
}, [remoteStream]);
useEffect(() => {
const video = videoRef.current;
if (!video) {
return;
}
applyViewerVolume(video, playbackVolume);
}, [muted, remoteStream, volumePercent]);
async function playVideo(): Promise<void> {
if (!videoRef.current) {
return;
@@ -704,13 +722,24 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
function toggleMuted(): void {
const nextMuted = !muted;
setMuted(nextMuted);
const next = toggleViewerMuted(playbackVolume);
setPlaybackVolume(next);
if (videoRef.current) {
videoRef.current.muted = nextMuted;
if (!nextMuted) {
void playVideo();
}
applyViewerVolume(videoRef.current, next);
}
if (!next.muted) {
void playVideo();
}
}
function changeVolume(nextPercent: number): void {
const next = setViewerVolume(playbackVolume, nextPercent);
setPlaybackVolume(next);
if (videoRef.current) {
applyViewerVolume(videoRef.current, next);
}
if (!next.muted) {
void playVideo();
}
}
@@ -753,8 +782,10 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
<main className="viewer-workspace">
<div className="viewer-title-row">
<div>
<h1>好友屏幕</h1>
<p className="section-meta">房间 {roomId}</p>
<div className="title-line">
<h1>好友屏幕</h1>
<RoomCode roomId={roomId} />
</div>
</div>
<div className="viewer-badges">
<PeerStatusBadge state={peerSnapshot?.connectionState ?? "waiting"} />
@@ -795,15 +826,30 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
{statusText}
</div>
<div className="toolbar-actions">
<button
type="button"
className="icon-button"
title={muted ? "打开声音" : "静音"}
aria-label={muted ? "打开声音" : "静音"}
onClick={toggleMuted}
>
{muted ? <VolumeX size={19} /> : <Volume2 size={19} />}
</button>
<div className="viewer-volume-control">
<button
type="button"
className="icon-button"
title={muted ? "打开声音" : "静音"}
aria-label={muted ? "打开声音" : "静音"}
onClick={toggleMuted}
>
{muted ? <VolumeX size={19} /> : <Volume2 size={19} />}
</button>
<input
id="viewer-volume"
type="range"
min="0"
max="100"
step="1"
value={volumePercent}
title="播放音量"
aria-label="播放音量"
aria-valuetext={`${volumePercent}%${muted ? ",已静音" : ""}`}
onChange={(event) => changeVolume(Number(event.target.value))}
/>
<output htmlFor="viewer-volume">{volumePercent}%</output>
</div>
<button
type="button"
className="icon-button"
+119 -1
View File
@@ -32,7 +32,7 @@
html,
body,
#root {
min-width: 320px;
min-width: 0;
min-height: 100%;
margin: 0;
}
@@ -183,6 +183,83 @@ button:disabled {
margin-bottom: 16px;
}
.title-line {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center;
}
.room-code {
display: inline-flex;
min-width: 0;
height: 32px;
align-items: center;
gap: 6px;
overflow: hidden;
padding-left: 9px;
border: 1px solid #326645;
border-radius: 6px;
color: var(--green);
background: #14271b;
}
.room-code-label {
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.room-code strong {
overflow: hidden;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 14px;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
.room-code-copy {
display: grid;
width: 31px;
height: 30px;
flex: 0 0 31px;
padding: 0;
place-items: center;
border: 0;
border-left: 1px solid #326645;
color: var(--green);
background: transparent;
cursor: pointer;
}
.room-code-copy:hover {
color: var(--text);
background: var(--green-soft);
}
.room-code-copy.is-failed {
color: var(--red);
background: var(--red-soft);
}
.room-code-copy:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 2px var(--green);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
h1,
h2,
h3,
@@ -846,8 +923,31 @@ h3 {
.toolbar-actions {
display: flex;
min-width: 0;
flex: 0 0 auto;
gap: 8px;
align-items: center;
}
.viewer-volume-control {
display: grid;
min-width: 0;
grid-template-columns: 40px minmax(76px, 120px) 42px;
gap: 8px;
align-items: center;
}
.viewer-volume-control input {
width: 100%;
min-width: 0;
accent-color: var(--green);
}
.viewer-volume-control output {
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.viewer-stats {
@@ -937,6 +1037,24 @@ h3 {
justify-content: flex-start;
}
.viewer-toolbar {
flex-wrap: wrap;
gap: 6px;
}
.toolbar-status {
flex: 1 1 100%;
}
.toolbar-actions {
width: 100%;
}
.viewer-volume-control {
flex: 1 1 auto;
grid-template-columns: 40px minmax(58px, 1fr) 38px;
}
.segmented-control {
height: auto;
min-height: 42px;
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { copyRoomCode } from "../src/client/components/room-code.ts";
describe("room code copy boundary", () => {
it("copies only the visible room code", async () => {
const writeText = vi.fn<(value: string) => Promise<void>>().mockResolvedValue();
await copyRoomCode("123456", writeText);
expect(writeText).toHaveBeenCalledOnce();
expect(writeText).toHaveBeenCalledWith("123456");
});
it("propagates clipboard failures for the UI to report", async () => {
const failure = new Error("clipboard unavailable");
const writeText = vi.fn<(value: string) => Promise<void>>().mockRejectedValue(failure);
await expect(copyRoomCode("7", writeText)).rejects.toBe(failure);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
applyViewerVolume,
DEFAULT_VIEWER_VOLUME_STATE,
setViewerVolume,
toggleViewerMuted,
} from "../src/client/media/viewer-volume.ts";
describe("viewer page-local volume", () => {
it("starts audible at full volume for each page load", () => {
expect(DEFAULT_VIEWER_VOLUME_STATE).toEqual({
volumePercent: 100,
muted: false,
lastNonZeroVolumePercent: 100,
});
});
it("keeps the selected volume while mute is toggled", () => {
const selected = setViewerVolume(DEFAULT_VIEWER_VOLUME_STATE, 64);
const muted = toggleViewerMuted(selected);
expect(muted).toEqual({
volumePercent: 64,
muted: true,
lastNonZeroVolumePercent: 64,
});
expect(toggleViewerMuted(muted)).toEqual(selected);
});
it("mutes at zero and restores the last non-zero volume", () => {
const selected = setViewerVolume(DEFAULT_VIEWER_VOLUME_STATE, 37);
const zero = setViewerVolume(selected, 0);
expect(zero).toEqual({
volumePercent: 0,
muted: true,
lastNonZeroVolumePercent: 37,
});
expect(toggleViewerMuted(zero)).toEqual(selected);
});
it("automatically unmutes and bounds a non-zero slider value", () => {
const muted = toggleViewerMuted(DEFAULT_VIEWER_VOLUME_STATE);
expect(setViewerVolume(muted, 42)).toEqual({
volumePercent: 42,
muted: false,
lastNonZeroVolumePercent: 42,
});
expect(setViewerVolume(muted, 120).volumePercent).toBe(100);
expect(setViewerVolume(muted, Number.NaN).volumePercent).toBe(0);
});
it("synchronously applies state to the current media element", () => {
const target = { volume: 1, muted: false };
const zero = setViewerVolume(DEFAULT_VIEWER_VOLUME_STATE, 0);
applyViewerVolume(target, zero);
expect(target).toEqual({ volume: 0, muted: true });
applyViewerVolume(target, toggleViewerMuted(zero));
expect(target).toEqual({ volume: 1, muted: false });
});
});