feat(ui): add viewer volume and room codes (#47)
feat(ui): add viewer volume and room codes
This commit is contained in:
commit
352d5927fe
14 files changed
+442
-28
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? "-"} 人正在观看`
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user