feat(ui): integrate living-room UI (#321)

This commit is contained in:
AAA authored and GitHub committed 2026-08-29 03:22:30 +08:00
1 parent 0bfbad63e6
commit 045b1d758e
58 files changed
+9758 -3978

No files matched your search

+27
View File
@@ -17,6 +17,33 @@ room identity, current state, roster, and one manual media-reconnect action.
Controls and text must remain usable without overlap or horizontal overflow on
desktop and mobile viewports.
## Visual Language
The interface presents one small shared living room rather than an operations
dashboard. The television is the media stage, the couch and pawns make presence
spatial, the shelf separates watching from controls, and the room display keeps
the four-digit code prominent. This metaphor must clarify the same product
state on Host and Viewer pages; decoration never creates a second state model.
Chinese, English, and pure-visual modes are three expressions of the same typed
state and command catalog. A fresh Browser starts in visual mode and persists
an explicit later choice. Text modes use concise copy. Visual mode uses one
consistent stroke language for single concepts, small panel comics for causes
and sequences, and literal scene objects for people, rooms, and media. Glyphs do
not form sentences. Universal digits, transport symbols, URLs, and measured
values remain literal when users need the data.
Motion explains entry, transition, progress, and control feedback. Stable state
stays still; ambient animation and motion added only for ornament are outside
the language. All meaning remains available with reduced motion. Functional
controls remain native buttons or inputs with localized accessible names, and
hover, keyboard focus, and touch receive equivalent guidance.
The visual hierarchy, interaction ownership, and information order remain the
same across themes and viewport sizes. Responsive layout may reflow or scroll a
bounded visualization, but it must not hide a primary action, truncate an
essential value, overlap controls, or change product semantics.
## Playback Ownership
One native `<video>` element owns Viewer play/pause, volume, mute, and fullscreen.
+9 -1
View File
@@ -3,9 +3,17 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#121513" />
<meta name="theme-color" content="#141e29" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#e9f2ee" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Screener</title>
<script>
try {
var theme = localStorage.getItem("screener:ui-theme") ||
(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = theme;
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
-10
View File
@@ -10,7 +10,6 @@
"dependencies": {
"livekit-client": "2.22.0",
"livekit-server-sdk": "2.17.0",
"lucide-react": "1.31.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"sdp-transform": "2.15.0",
@@ -1501,15 +1500,6 @@
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lucide-react": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
"integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
-1
View File
@@ -25,7 +25,6 @@
"dependencies": {
"livekit-client": "2.22.0",
"livekit-server-sdk": "2.17.0",
"lucide-react": "1.31.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"sdp-transform": "2.15.0",
+113 -85
View File
@@ -1,4 +1,3 @@
import { KeyRound, LoaderCircle } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import {
ApiError,
@@ -10,6 +9,11 @@ import { parseAppRoute, readViewerRoute } from "./lib/session";
import { HostPage } from "./pages/HostPage";
import { JoinPage } from "./pages/JoinPage";
import { ViewerPage } from "./pages/ViewerPage";
import { AppHeader } from "./components/living/Header";
import { Btn, Pill } from "./components/living/primitives";
import { ComicTooltip } from "./components/living/ComicTooltip";
import { Glyph, type GlyphName } from "./ui/icons";
import { useCopy } from "./ui/copy";
const appRoute = parseAppRoute(window.location.pathname);
const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null;
@@ -27,10 +31,8 @@ function stateFromStatus(status: SiteAccessStatus): AccessState {
: { kind: "required", error: null };
}
function readableError(error: unknown): string {
return error instanceof ApiError
? error.message
: "无法连接站点访问服务,请重试";
function readableError(error: unknown, t: (key: "gate.connectFailed") => string): string {
return error instanceof ApiError ? error.message : t("gate.connectFailed");
}
export function App() {
@@ -48,9 +50,49 @@ export function App() {
return <SiteAccessGate surface="join" />;
}
return appRoute.kind === "malformed-room" ? (
<MalformedRoomRoute />
<StaticRoute icon="door" titleKey="gate.malformed" hintKey="gate.malformedHint" />
) : (
<UnavailableRoute />
<StaticRoute icon="alert" titleKey="gate.unavailableRoute" />
);
}
function StaticRoute({
icon,
titleKey,
hintKey,
}: {
icon: GlyphName;
titleKey: "gate.malformed" | "gate.unavailableRoute";
hintKey?: "gate.malformedHint";
}) {
const { t, vis } = useCopy();
const panelIcon = (
<span className="lr-tv-big" style={{ borderColor: "var(--ink)", color: "var(--ink)", background: "var(--paper)" }}>
<Glyph name={icon} size={30} />
</span>
);
return (
<div className="lr-app">
<AppHeader />
<main className="lr-join">
<div className="lr-join-panel">
{vis ? (
<ComicTooltip kind="warning">{panelIcon}</ComicTooltip>
) : (
panelIcon
)}
{vis ? null : (
<div className="lr-access-text">
<h1>{t(titleKey)}</h1>
{hintKey ? <p>{t(hintKey)}</p> : null}
</div>
)}
<span className="visually-hidden" role="alert">
{t(titleKey)}
</span>
</div>
</main>
</div>
);
}
@@ -59,6 +101,7 @@ function SiteAccessGate({
}: {
surface: "host" | "join" | "viewer";
}) {
const { t, vis } = useCopy();
const [access, setAccess] = useState<AccessState>({ kind: "checking" });
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -69,7 +112,7 @@ function SiteAccessGate({
(status) => active && setAccess(stateFromStatus(status)),
(error: unknown) =>
active &&
setAccess({ kind: "unavailable", message: readableError(error) }),
setAccess({ kind: "unavailable", message: readableError(error, t) }),
);
return () => {
active = false;
@@ -111,7 +154,7 @@ function SiteAccessGate({
try {
setAccess(stateFromStatus(await getSiteAccess()));
} catch (error) {
setAccess({ kind: "unavailable", message: readableError(error) });
setAccess({ kind: "unavailable", message: readableError(error, t) });
}
}
@@ -120,7 +163,7 @@ function SiteAccessGate({
const submittedPassword = password;
setPassword("");
if (!submittedPassword.trim()) {
setAccess({ kind: "required", error: "请输入站点口令" });
setAccess({ kind: "required", error: t("gate.hint") });
return;
}
@@ -134,8 +177,8 @@ function SiteAccessGate({
kind: "required",
error:
error instanceof ApiError && error.status === 401
? "站点口令不正确,请重试"
: readableError(error),
? t("gate.wrong")
: readableError(error, t),
});
} finally {
setSubmitting(false);
@@ -152,94 +195,79 @@ function SiteAccessGate({
return (
<HostPage
onAuthorizationRequired={() =>
setAccess({ kind: "required", error: "站点访问已失效,请重新验证" })
setAccess({ kind: "required", error: t("gate.expired") })
}
/>
);
}
return (
<div className="app-shell">
<main className="access-workspace access-workspace-full">
<div className="lr-app">
<AppHeader />
<main className="lr-join">
{access.kind === "checking" ? (
<div className="access-loading" role="status">
<LoaderCircle size={20} className="spin" aria-hidden="true" />
正在验证站点访问
<div className="lr-loading" role="status">
<Glyph name="loader" size={22} className="lr-spin" />
{vis ? null : (
<span className="lr-tv-msg" style={{ color: "var(--ink)", textShadow: "none" }}>
{t("gate.checking")}
</span>
)}
</div>
) : access.kind === "unavailable" ? (
<section className="access-panel" aria-labelledby="access-heading">
<h1 id="access-heading">暂时无法验证站点访问</h1>
<p className="access-error" role="alert">
{access.message}
</p>
<button
className="button button-secondary"
type="button"
onClick={() => void retry()}
>
重试
</button>
</section>
) : (
<form className="access-panel" onSubmit={(event) => void submit(event)}>
<div>
<h1>站点访问</h1>
<p className="section-meta">请输入站点口令</p>
</div>
<label className="token-field">
<span>站点口令</span>
<span className="input-with-icon">
<KeyRound size={16} aria-hidden="true" />
<input
type="password"
value={password}
disabled={submitting}
autoComplete="current-password"
autoFocus
onChange={(event) => setPassword(event.target.value)}
/>
</span>
</label>
{access.error && (
<p className="access-error" role="alert">
{access.error}
</p>
<div className="lr-join-panel">
<span className="lr-tv-big" style={{ borderColor: "var(--ink)", color: "var(--ink)", background: "var(--paper)" }}>
<Glyph name="wifiOff" size={30} />
</span>
{vis ? (
<span className="visually-hidden" role="alert">{access.message}</span>
) : (
<div className="lr-access-text">
<h1>{t("gate.unavailable")}</h1>
<p role="alert">{access.message}</p>
</div>
)}
<button
className="button button-primary"
<Btn icon="refresh" title="common.retry" cap="common.retry" onClick={() => void retry()} />
</div>
) : (
<form className="lr-join-panel" onSubmit={(event) => void submit(event)}>
<span className="lr-tv-big" style={{ borderColor: "var(--ink)", color: "var(--ink)", background: "var(--paper)" }}>
<Glyph name="key" size={30} draw="gate-key" />
</span>
{vis ? null : (
<div className="lr-access-text">
<h1>{t("gate.title")}</h1>
<p>{t("gate.hint")}</p>
</div>
)}
<span className="lr-input" style={{ minWidth: 240 }}>
<Glyph name="lock" size={17} />
<input
type="password"
value={password}
disabled={submitting}
autoComplete="current-password"
autoFocus
aria-label={t("gate.password")}
placeholder={vis ? "····" : t("gate.password")}
onChange={(event) => setPassword(event.target.value)}
/>
</span>
{access.error ? (
<Pill icon="alert" tone="bad" label={access.error} alert comic="warning" />
) : null}
<Btn
icon="arrowRight"
title="gate.submit"
cap="gate.submit"
tone="primary"
type="submit"
hint="hint-password"
disabled={submitting}
>
{submitting ? "正在验证" : "进入站点"}
</button>
/>
</form>
)}
</main>
</div>
);
}
function UnavailableRoute() {
return (
<div className="app-shell">
<main className="access-workspace access-workspace-full">
<section className="access-panel">
<h1>无法访问</h1>
</section>
</main>
</div>
);
}
function MalformedRoomRoute() {
return (
<div className="app-shell">
<main className="access-workspace access-workspace-full">
<section className="access-panel">
<h1>房间号格式不正确</h1>
<p className="section-meta">请输入 1000 至 9999 的四位房间号</p>
</section>
</main>
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { MonitorUp } from "lucide-react";
import type { ReactNode } from "react";
export function AppHeader({ status }: { status: ReactNode }) {
return (
<header className="app-header">
<a className="brand" href="/" aria-label="Screener 首页">
<span className="brand-mark" aria-hidden="true">
<MonitorUp size={19} />
</span>
<span>Screener</span>
</a>
{status && <div className="header-status">{status}</div>}
</header>
);
}
@@ -1,28 +0,0 @@
import type { ChangeEvent } from "react";
interface ConnectionDetailsToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
}
export function ConnectionDetailsToggle({
checked,
onChange,
}: ConnectionDetailsToggleProps) {
return (
<div className="connection-details-toggle">
<label
title="展开或隐藏连接的技术指标"
>
<input
type="checkbox"
checked={checked}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange(event.currentTarget.checked)
}
/>
<span>显示连接详情</span>
</label>
</div>
);
}
-154
View File
@@ -1,154 +0,0 @@
import { Check, CircleAlert, Copy, Hash, RefreshCw, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { copyRoomCode } from "./room-code";
type CopyState = "idle" | "copied" | "failed";
export function RoomCode({
roomId,
onReplace,
replaceDisabled = false,
}: {
roomId: string;
onReplace?: () => void | Promise<void>;
replaceDisabled?: boolean;
}) {
const [copyState, setCopyState] = useState<CopyState>("idle");
const [confirmingReplace, setConfirmingReplace] = useState(false);
const [replacePending, setReplacePending] = useState(false);
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);
}
}
}
async function replace(): Promise<void> {
if (!onReplace || replacePending || replaceDisabled) {
return;
}
setReplacePending(true);
try {
await onReplace();
} finally {
if (mountedRef.current) {
setReplacePending(false);
setConfirmingReplace(false);
}
}
}
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>
<span className="room-code-actions">
{onReplace && confirmingReplace ? (
<>
<button
className="room-code-copy"
type="button"
title={replacePending ? "正在更换房间号" : "确认更换房间号"}
aria-label={replacePending ? "正在更换房间号" : "确认更换房间号"}
disabled={replacePending || replaceDisabled}
onClick={() => void replace()}
>
{replacePending ? (
<RefreshCw size={15} className="spin" aria-hidden="true" />
) : (
<Check size={15} aria-hidden="true" />
)}
</button>
<button
className="room-code-copy"
type="button"
title="取消更换房间号"
aria-label="取消更换房间号"
disabled={replacePending}
onClick={() => setConfirmingReplace(false)}
>
<X size={15} aria-hidden="true" />
</button>
</>
) : (
<>
{onReplace && (
<button
className="room-code-copy"
type="button"
title="更换房间号"
aria-label="更换房间号"
disabled={replaceDisabled}
onClick={() => setConfirmingReplace(true)}
>
<RefreshCw size={15} aria-hidden="true" />
</button>
)}
<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>
<span className="visually-hidden" role="status" aria-live="polite">
{copyState === "copied"
? "房间号已复制"
: copyState === "failed"
? "房间号复制失败"
: ""}
</span>
</span>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { ArrowRight, Hash } from "lucide-react";
import { useState, type FormEvent } from "react";
import { roomRouteForExplicitEntry } from "../lib/session";
interface RoomCodeEntryProps {
id: string;
autoFocus?: boolean;
inline?: boolean;
}
export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCodeEntryProps) {
const [roomId, setRoomId] = useState("");
const [error, setError] = useState<string | null>(null);
const inputId = `${id}-input`;
function join(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
const route = roomRouteForExplicitEntry(roomId);
if (!route) {
setError("请输入 1000 至 9999 的四位房间号");
return;
}
window.location.assign(route);
}
return (
<form
id={id}
className={inline ? "room-code-entry is-inline" : "access-panel"}
onSubmit={join}
>
{!inline && (
<div>
<h1>加入房间</h1>
<p className="section-meta">输入分享者提供的房间号</p>
</div>
)}
<div className="token-field">
<label className={inline ? "visually-hidden" : "field-label"} htmlFor={inputId}>
房间号
</label>
<span className="input-with-icon">
<Hash size={16} aria-hidden="true" />
<input
id={inputId}
value={roomId}
inputMode="numeric"
autoComplete="off"
autoFocus={autoFocus}
placeholder={inline ? "房间号" : undefined}
aria-invalid={error ? "true" : undefined}
onChange={(event) => {
setRoomId(event.target.value);
setError(null);
}}
/>
</span>
</div>
{error && (
<p className="access-error" role="alert">
{error}
</p>
)}
<button
className={inline ? "icon-button room-code-submit" : "button button-primary"}
type="submit"
title={inline ? "加入房间" : undefined}
aria-label={inline ? "加入房间" : undefined}
>
{inline ? <ArrowRight size={18} aria-hidden="true" /> : "加入"}
</button>
</form>
);
}
@@ -1,51 +0,0 @@
import { Hash, MonitorUp } from "lucide-react";
import { RoomCodeEntry } from "./RoomCodeEntry";
interface StageEntryActionsProps {
joiningRoom: boolean;
startSharingDisabled?: boolean;
onJoinToggle: () => void;
onStartSharing: () => void;
}
export function StageEntryActions({
joiningRoom,
startSharingDisabled = false,
onJoinToggle,
onStartSharing,
}: StageEntryActionsProps) {
return (
<>
<div className="entry-actions">
<button
className="entry-action"
type="button"
disabled={startSharingDisabled}
title="开始分享屏幕"
onClick={onStartSharing}
>
<MonitorUp size={18} aria-hidden="true" />
开始分享
</button>
<span className="entry-divider" aria-hidden="true">
或
</span>
<button
className="entry-action"
type="button"
title="输入房间号加入观看"
aria-expanded={joiningRoom}
aria-controls="host-room-code-entry"
onClick={onJoinToggle}
>
<Hash size={18} aria-hidden="true" />
加入房间
</button>
</div>
{joiningRoom && (
<RoomCodeEntry id="host-room-code-entry" autoFocus inline />
)}
</>
);
}
-279
View File
@@ -1,279 +0,0 @@
import { ChevronDown } from "lucide-react";
import { useId, useState } from "react";
import type { ConnectionMetrics } from "../types";
import { formatPacketLossPercent } from "./connection-details";
interface MetricValue {
label: string;
value: string;
title?: string;
}
function readableNumber(value: number | null, digits = 0): string {
return value === null || !Number.isFinite(value) ? "未知" : value.toFixed(digits);
}
function readableWithUnit(
value: number | null,
unit: string,
digits = 0,
): string {
const readable = readableNumber(value, digits);
return readable === "未知" ? readable : `${readable} ${unit}`;
}
function qualityReason(value: string): string {
const labels: Record<string, string> = {
none: "正常",
bandwidth: "带宽受限",
cpu: "编码受限",
other: "其他限制",
};
return labels[value] ?? "未分类限制";
}
function Metric({ label, value, title }: MetricValue) {
return (
<div className="metric" title={title}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function codecName(codec: string): string {
return codec.split("/").at(-1) ?? codec;
}
export function codecContractWarnings(metrics: ConnectionMetrics): string[] {
const warnings: string[] = [];
if (
metrics.codec &&
!["video/h264", "video/vp8"].includes(metrics.codec.toLowerCase())
) {
warnings.push(
`视频编码为 ${codecName(metrics.codec)},预期 H264 或 VP8`,
);
}
if (metrics.audioCodec && metrics.audioCodec.toLowerCase() !== "audio/opus") {
warnings.push(`音频编码为 ${codecName(metrics.audioCodec)},预期 Opus`);
}
return warnings;
}
function secondaryMetrics(
metrics: ConnectionMetrics,
direction: "send" | "receive",
): MetricValue[] {
const values: MetricValue[] = [];
const addNumber = (
label: string,
value: number | null,
unit: string,
digits = 0,
title?: string,
) => {
if (value !== null && Number.isFinite(value)) {
values.push({ label, value: readableWithUnit(value, unit, digits), title });
}
};
addNumber("RTT", metrics.rttMs, "ms", 0, "网络往返时间");
if (metrics.codec) {
values.push({ label: "视频编码", value: codecName(metrics.codec) });
}
if (direction === "send") {
addNumber("可用上行", metrics.availableOutgoingKbps, "kbps");
if (metrics.qualityLimitationReason) {
values.push({
label: "质量状态",
value: qualityReason(metrics.qualityLimitationReason),
});
}
const captureParts: string[] = [];
if (metrics.captureWidth !== null && metrics.captureHeight !== null) {
captureParts.push(`${metrics.captureWidth}x${metrics.captureHeight}`);
}
if (metrics.captureFramesPerSecond !== null) {
captureParts.push(readableWithUnit(metrics.captureFramesPerSecond, "fps", 1));
}
if (captureParts.length > 0) {
values.push({
label: "捕获设置",
value: captureParts.join(" · "),
title: "MediaStreamTrack 当前捕获设置",
});
}
addNumber(
"编码输入帧率",
metrics.mediaSourceFramesPerSecond,
"fps",
1,
);
if (metrics.encoderImplementation) {
values.push({
label: "编码器",
value: `${metrics.encoderImplementation}${
metrics.powerEfficientEncoder === true
? " · 节能"
: metrics.powerEfficientEncoder === false
? " · 非节能"
: ""
}`,
});
}
addNumber("编码耗时/帧", metrics.intervalEncodeMs, "ms", 1);
} else {
addNumber("网络抖动", metrics.jitterMs, "ms", 1);
addNumber("丢帧", metrics.framesDropped, "帧");
addNumber("解码耗时/帧", metrics.intervalDecodeMs, "ms", 1);
addNumber("画面冻结", metrics.intervalFreezeCount, "次");
addNumber("冻结时长", metrics.intervalFreezeDurationMs, "ms", 1);
}
addNumber("音频码率", metrics.audioBitrateKbps, "kbps");
if (metrics.audioPacketLossPercent !== null) {
values.push({
label: "音频丢包率",
value: formatPacketLossPercent(metrics.audioPacketLossPercent),
});
}
addNumber("音频抖动", metrics.audioJitterMs, "ms", 1);
if (direction === "receive") {
addNumber(
"音视频播放差",
metrics.audioVideoPlayoutDeltaMs,
"ms",
1,
"音频 estimatedPlayoutTimestamp 减视频;正值表示音频时间线领先",
);
addNumber(
"视频缓冲",
metrics.videoJitterBufferDelayMs,
"ms",
1,
"最近统计区间内已播放视频帧的平均抖动缓冲延迟",
);
addNumber(
"音频缓冲",
metrics.audioJitterBufferDelayMs,
"ms",
1,
"最近统计区间内已播放音频样本的平均抖动缓冲延迟",
);
if (metrics.audioConcealedSamplesPercent !== null) {
values.push({
label: "音频补偿率",
value: formatPacketLossPercent(metrics.audioConcealedSamplesPercent),
title: "最近统计区间内由接收端合成补偿的音频样本比例",
});
}
addNumber(
"音频补偿",
metrics.intervalAudioConcealmentEvents,
"次",
0,
"最近统计区间内开始连续音频样本补偿的次数",
);
}
return values;
}
export function StatsGrid({
metrics,
direction,
progressive = false,
}: {
metrics: ConnectionMetrics;
direction: "send" | "receive";
progressive?: boolean;
}) {
const [secondaryExpanded, setSecondaryExpanded] = useState(false);
const secondaryId = useId();
const warnings = codecContractWarnings(metrics);
const primary: MetricValue[] = [
{ label: "分辨率", value: metrics.resolution ?? "未知" },
{
label: "帧率",
value: readableWithUnit(metrics.framesPerSecond, "fps", 1),
},
{
label: "码率",
value: readableWithUnit(metrics.bitrateKbps, "kbps"),
},
{
label: "丢包率",
value: formatPacketLossPercent(metrics.packetLossPercent),
},
];
const secondary = secondaryMetrics(metrics, direction);
const summary = (
<>
<dl className="stats-grid">
{primary.map((metric) => (
<Metric key={metric.label} {...metric} />
))}
</dl>
{warnings.map((warning) => (
<p className="stats-contract-warning" key={warning}>
{warning}
</p>
))}
</>
);
if (!progressive) {
return (
<>
<dl className="stats-grid">
{[...primary, ...secondary].map((metric) => (
<Metric key={metric.label} {...metric} />
))}
</dl>
{warnings.map((warning) => (
<p className="stats-contract-warning" key={warning}>
{warning}
</p>
))}
</>
);
}
const toggleLabel = secondaryExpanded ? "收起详细指标" : "展开详细指标";
return (
<div className="stats-disclosure">
{summary}
{secondary.length > 0 && (
<>
<div className="stats-detail-actions">
<span>详细指标</span>
<button
className={`icon-button stats-detail-toggle${secondaryExpanded ? " is-expanded" : ""}`}
type="button"
title={toggleLabel}
aria-label={toggleLabel}
aria-expanded={secondaryExpanded}
aria-controls={secondaryId}
onClick={() => setSecondaryExpanded((current) => !current)}
>
<ChevronDown size={16} aria-hidden="true" />
</button>
</div>
<dl
id={secondaryId}
className="stats-grid stats-grid-secondary"
hidden={!secondaryExpanded}
>
{secondary.map((metric) => (
<Metric key={metric.label} {...metric} />
))}
</dl>
</>
)}
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
import { CircleAlert, Network, Server } from "lucide-react";
import type { SignalConnectionState } from "../types";
import {
MEDIA_ROUTE_PRESENTATION,
ROUTING_STATUS_PRESENTATION,
} from "./status-badge-model";
interface BadgeProps {
tone: "good" | "warning" | "danger" | "neutral";
label: string;
title?: string;
}
function Badge({ tone, label, title }: BadgeProps) {
return (
<span className={`status-badge status-${tone}`} title={title}>
<span className="status-dot" aria-hidden="true" />
{label}
</span>
);
}
export function SignalStatusBadge({
state,
}: {
state: SignalConnectionState;
}) {
const values: Record<SignalConnectionState, BadgeProps> = {
connecting: { tone: "neutral", label: "正在连接" },
connected: { tone: "good", label: "服务器已连接" },
reconnecting: { tone: "warning", label: "正在恢复" },
offline: { tone: "neutral", label: "服务器未连接" },
};
return <Badge {...values[state]} />;
}
export function PeerStatusBadge({
state,
}: {
state: RTCPeerConnectionState | "reconnecting" | "waiting" | "routing";
}) {
const labels: Record<
RTCPeerConnectionState | "reconnecting" | "waiting" | "routing",
BadgeProps
> = {
waiting: { tone: "neutral", label: "等待开始分享" },
reconnecting: { tone: "warning", label: "正在恢复" },
routing: ROUTING_STATUS_PRESENTATION,
new: { tone: "neutral", label: "准备中" },
connecting: { tone: "neutral", label: "正在连接" },
connected: { tone: "good", label: "已连接" },
disconnected: { tone: "warning", label: "连接中断" },
failed: { tone: "danger", label: "连接失败" },
closed: { tone: "neutral", label: "已关闭" },
};
return <Badge {...labels[state]} />;
}
export function MediaRouteBadge({ route }: { route: "p2p" | "sfu" }) {
const presentation = MEDIA_ROUTE_PRESENTATION[route];
const Icon = presentation.icon === "network" ? Network : Server;
return (
<span
className={`status-badge status-${presentation.tone}`}
title="当前媒体路径"
>
<Icon size={14} aria-hidden="true" />
{presentation.label}
</span>
);
}
export function WarningBanner({ children }: { children: React.ReactNode }) {
return (
<div className="notice notice-warning" role="status">
<CircleAlert size={16} aria-hidden="true" />
<span>{children}</span>
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
import type { LabeledViewerPresence } from "../lib/viewer-presence";
import { deriveParticipantTopology, type TopologyBranch } from "../lib/participant-topology";
function ViewerBranch({ branch, edge }: { branch: TopologyBranch; edge: "P2P" | "SFU" }) {
return (
<li>
<div className="topology-node">
<span className="topology-edge">{edge}</span><strong>{branch.viewer.label}</strong>
</div>
{branch.children.length > 0 && (
<ul>
{branch.children.map((child) => (
<ViewerBranch key={child.viewer.peerId} branch={child} edge="P2P" />
))}
</ul>
)}
</li>
);
}
export function TopologyView({ hostPeerId, hostLabel, viewers }: { hostPeerId: string | null; hostLabel: string; viewers: readonly LabeledViewerPresence[] }) {
const topology = deriveParticipantTopology(hostPeerId, viewers);
return (
<div className="topology-view" id="room-topology">
<ul className="topology-tree" aria-label="当前连接拓扑">
<li>
<div className="topology-node">
<span className="topology-role">Host</span><strong>{hostLabel}</strong>
</div>
<ul>
{topology.peerRoots.map((root) => (
<ViewerBranch key={root.viewer.peerId} branch={root} edge="P2P" />
))}
{topology.sfuRoots.length > 0 && (
<li>
<div className="topology-node">
<span className="topology-role">SFU</span><strong>媒体服务器</strong>
</div>
<ul>
{topology.sfuRoots.map((root) => (
<ViewerBranch key={root.viewer.peerId} branch={root} edge="SFU" />
))}
</ul>
</li>
)}
</ul>
</li>
</ul>
{topology.pending.length > 0 && (
<div className="topology-pending">
<span>连接中</span>
{topology.pending.map((viewer) => (
<strong key={viewer.peerId}>{viewer.label}</strong>
))}
</div>
)}
</div>
);
}
+5 -2
View File
@@ -1,8 +1,11 @@
import type { PeerSnapshot } from "../types";
export function formatPacketLossPercent(value: number | null): string {
export function formatPacketLossPercent(
value: number | null,
unknownLabel: string,
): string {
return value === null || !Number.isFinite(value)
? "未知"
? unknownLabel
: `${value.toFixed(1)}%`;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,246 @@
// Comic tooltip: a floating paper panel showing a panel comic above (or below)
// its trigger. Visibility is three-channel, matching platform conventions:
// hover on fine pointers, keyboard focus via :has(:focus-visible), and the
// Material long-press on touch (500ms hold → open, ~1.5s after release →
// auto-hide, context menu and the trailing synthetic click suppressed).
// Alignment: the align prop is a desktop-tuned preference; whenever a show
// channel opens, the trigger's live viewport position is measured and
// start/center/end is re-picked so the panel never clips off-screen (rows
// wrap at narrow widths, so a static choice cannot hold). The caret lives on
// the wrapper, so it stays centered on the trigger whatever the panel picks.
// Styling in styles.css under "comic tooltip" / "glyph draw-in". SSR-safe:
// handlers only run in the browser.
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Comic, type ComicKind } from "./Comic";
import { HintComic, isHintKind, type HintKind } from "./hints";
const LONG_PRESS_MS = 500;
const TOUCH_HIDE_MS = 1500;
// Minimum clearance the re-picked alignment keeps to each viewport edge.
const EDGE_MARGIN = 8;
type Align = "center" | "start" | "end";
export function ComicTooltip({
kind,
place = "above",
align = "center",
children,
}: {
kind: ComicKind | HintKind;
/** below = for controls pinned to the viewport top (header). */
place?: "above" | "below";
/** Preferred alignment; re-picked at open time if it would clip off-screen. */
align?: Align;
children: ReactNode;
}) {
const wrapRef = useRef<HTMLSpanElement | null>(null);
const tipRef = useRef<HTMLSpanElement | null>(null);
const pressTimer = useRef<number | null>(null);
const hideTimer = useRef<number | null>(null);
const longPressed = useRef(false);
const pressPoint = useRef<{ x: number; y: number } | null>(null);
const [touchOpen, setTouchOpen] = useState(false);
const [liveAlign, setLiveAlign] = useState<Align | null>(null);
// Re-pick alignment from the trigger's live geometry. The configured align
// wins whenever it fits; otherwise the first alignment that keeps the whole
// panel inside the viewport, then whichever clips least (panel wider than
// the viewport is impossible — CSS caps it — but stay total).
const pickAlign = () => {
const wrap = wrapRef.current;
if (!wrap) return;
const vw = window.innerWidth;
if (!vw) return; // no layout (SSR/test): keep the prop alignment
const rect = wrap.getBoundingClientRect();
const measured = tipRef.current?.offsetWidth ?? 0;
const width = Math.max(0, Math.min(measured || 320, vw - EDGE_MARGIN * 2));
const center = rect.left + rect.width / 2;
const boxes: Record<Align, { left: number; right: number }> = {
center: { left: center - width / 2, right: center + width / 2 },
start: { left: rect.left, right: rect.left + width },
end: { left: rect.right - width, right: rect.right },
};
const clipped = (box: { left: number; right: number }): number =>
Math.max(0, EDGE_MARGIN - box.left) +
Math.max(0, box.right - (vw - EDGE_MARGIN));
const order: Align[] = [
align,
...(["center", "start", "end"] as Align[]).filter((a) => a !== align),
];
const fits = order.find((a) => clipped(boxes[a]) === 0);
setLiveAlign(
fits ??
order.reduce((a, b) => (clipped(boxes[a]) <= clipped(boxes[b]) ? a : b)),
);
};
// Single owner of panel placement: while the tooltip may be open (hover,
// focus, or long-press), viewport resize/scroll re-picks the alignment so
// the panel stays inside the viewport mid-open — no page-level clamps.
const pickAlignRef = useRef(pickAlign);
pickAlignRef.current = pickAlign;
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
let frame = 0;
let listening = false;
const schedulePick = () => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => pickAlignRef.current());
};
const open = () => {
if (listening) return;
listening = true;
window.addEventListener("resize", schedulePick);
window.addEventListener("scroll", schedulePick, true);
};
const close = () => {
if (!listening) return;
listening = false;
window.removeEventListener("resize", schedulePick);
window.removeEventListener("scroll", schedulePick, true);
};
wrap.addEventListener("pointerenter", open);
wrap.addEventListener("pointerleave", close);
wrap.addEventListener("focusin", open);
wrap.addEventListener("focusout", close);
return () => {
window.cancelAnimationFrame(frame);
close();
wrap.removeEventListener("pointerenter", open);
wrap.removeEventListener("pointerleave", close);
wrap.removeEventListener("focusin", open);
wrap.removeEventListener("focusout", close);
};
}, []);
// The long-press channel outlives the touch contact: listen while open.
useEffect(() => {
if (!touchOpen) return;
let frame = 0;
const schedulePick = () => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => pickAlignRef.current());
};
window.addEventListener("resize", schedulePick);
window.addEventListener("scroll", schedulePick, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", schedulePick);
window.removeEventListener("scroll", schedulePick, true);
};
}, [touchOpen]);
useEffect(
() => () => {
if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
},
[],
);
// While long-press-open, any tap elsewhere dismisses (WCAG dismissible).
useEffect(() => {
if (!touchOpen) return;
const dismiss = (event: PointerEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) {
setTouchOpen(false);
}
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [touchOpen]);
const cancelPress = () => {
if (pressTimer.current !== null) {
window.clearTimeout(pressTimer.current);
pressTimer.current = null;
}
};
const placeClass = place === "below" ? " is-below" : "";
const shownAlign = liveAlign ?? align;
const alignClass =
shownAlign === "start" ? " is-start" : shownAlign === "end" ? " is-end" : "";
return (
<span
ref={wrapRef}
className={`lr-comic-tip-wrap${touchOpen ? " is-tip-open" : ""}`}
onPointerEnter={pickAlign}
onFocus={pickAlign}
onPointerDown={(event) => {
if (event.pointerType !== "touch") return;
cancelPress();
pressPoint.current = { x: event.clientX, y: event.clientY };
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null;
longPressed.current = true;
pickAlign();
setTouchOpen(true);
}, LONG_PRESS_MS);
}}
onPointerUp={() => {
cancelPress();
pressPoint.current = null;
if (longPressed.current) {
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
longPressed.current = false;
setTouchOpen(false);
}, TOUCH_HIDE_MS);
}
}}
onPointerCancel={() => {
cancelPress();
pressPoint.current = null;
longPressed.current = false;
if (hideTimer.current !== null) {
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setTouchOpen(false);
}}
onPointerMove={(event) => {
// Only a real drag (scroll intent) cancels the hold — Chrome emits
// sub-pixel settling moves right after touchstart that must not count.
if (pressTimer.current !== null && event.pointerType === "touch" && pressPoint.current) {
const dx = event.clientX - pressPoint.current.x;
const dy = event.clientY - pressPoint.current.y;
if (dx * dx + dy * dy > 100) cancelPress();
}
}}
onContextMenu={(event) => {
// Long-press on a wrapped control means "show the hint", never the
// native context menu. Chrome fires contextmenu as a PointerEvent;
// fall back to treating unknown types as touch.
if ((event.nativeEvent as PointerEvent).pointerType !== "mouse") {
event.preventDefault();
}
}}
onClickCapture={(event) => {
// The synthetic click after a long-press must not fire the control.
if (longPressed.current) {
event.preventDefault();
event.stopPropagation();
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setTouchOpen(false);
(document.activeElement as HTMLElement | null)?.blur?.();
}
}}
>
{children}
<span ref={tipRef} className={`lr-comic-tip${placeClass}${alignClass}`} role="note">
{isHintKind(kind) ? (
<HintComic kind={kind} size={240} />
) : (
<Comic kind={kind} theme="paper" size={240} />
)}
</span>
</span>
);
}
+149
View File
@@ -0,0 +1,149 @@
// The couch: the roster as pawns watching the TV. Joining pawns hop in,
// self carries the green pointer, the host wears a crown (topology only).
import { Glyph } from "../../ui/icons";
import { useCopy } from "../../ui/copy";
const PAWN_COLORS = [
"var(--pawn-1)", "var(--pawn-2)", "var(--pawn-3)", "var(--pawn-4)",
"var(--pawn-5)", "var(--pawn-6)", "var(--pawn-7)", "var(--pawn-8)",
];
const YOU_COLOR = "var(--you)";
export function pawnColor(key: string, you = false): string {
if (you) return YOU_COLOR;
let hash = 0;
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return PAWN_COLORS[Math.abs(hash) % PAWN_COLORS.length]!;
}
export function PawnSvg({ color, crown }: { color: string; crown?: boolean }) {
return (
<svg viewBox="0 0 40 48" width="40" height="48" aria-hidden="true">
{crown ? (
<path
d="M11 12.5 L8.5 2 L14 6.5 L17 0 L20 6.5 L25.5 2 L23 12.5 Z"
fill="var(--pawn-2)"
/>
) : null}
<circle cx="20" cy="14" r="8.5" fill={color} />
<path d="M5 46c0-13 6.5-17 15-17s15 4 15 17Z" fill={color} />
<circle cx="17" cy="13" r="1.6" fill="var(--stage)" />
<circle cx="23" cy="13" r="1.6" fill="var(--stage)" />
</svg>
);
}
export interface CouchEntry {
key: string;
name: string;
connected: boolean;
statusLabel?: string;
you?: boolean;
child?: boolean;
selectable?: boolean;
}
export function Couch({
entries,
selectedKey,
onSelect,
emptyHint,
}: {
entries: CouchEntry[];
selectedKey?: string | null;
onSelect?: (key: string) => void;
emptyHint?: string;
}) {
const { vis, t } = useCopy();
const crowded = entries.length > 10;
return (
<div className="lr-couch">
<svg viewBox="0 0 640 132" aria-hidden="true">
<rect x="70" y="110" width="18" height="16" rx="5" fill="var(--frame)" />
<rect x="552" y="110" width="18" height="16" rx="5" fill="var(--frame)" />
<rect x="28" y="28" width="58" height="80" rx="24" fill="var(--couch-dark)" />
<rect x="554" y="28" width="58" height="80" rx="24" fill="var(--couch-dark)" />
<rect x="56" y="18" width="528" height="58" rx="27" fill="var(--couch)" />
<rect x="44" y="62" width="552" height="50" rx="23" fill="var(--couch)" />
<path d="M212 64v46M428 64v46" stroke="var(--couch-dark)" strokeWidth="4" strokeLinecap="round" />
</svg>
<div
className={`lr-pawns${crowded ? " is-crowded" : ""}`}
role="group"
aria-label={t("common.viewers")}
>
{entries.map((entry, index) => {
const stateLabel = entry.connected
? t("state.peer.connected")
: (entry.statusLabel ?? t("state.peer.connecting"));
const label = entry.you
? `${entry.name} · ${t("common.you")}`
: `${entry.name} · ${stateLabel}`;
const showName = !vis && !entry.child && !crowded;
const inner = (
<>
<PawnSvg color={pawnColor(entry.key, entry.you)} />
<i
className={`lr-pawn-led${entry.connected ? "" : " is-wait"}`}
// With a name pill the default bottom:4px lands on the pill's
// right end; lift the LED onto the figure instead. Scoped here
// because the living-room stylesheet is owned elsewhere.
style={showName ? { bottom: 20 } : undefined}
aria-hidden="true"
/>
{showName ? (
// 58px ellipsizes the EN default "Visitor"; 72px fits it.
<span className="lr-pawn-name" style={{ maxWidth: 72 }}>
{entry.name}
</span>
) : null}
</>
);
// Hop stagger derives from position only, so render stays pure
// (a seen-keys map mutates during render: StrictMode's discarded
// first pass pre-fills it and the committed render gets 0ms).
const style = { animationDelay: `${Math.min(index, 12) * 70}ms` };
const className = `lr-pawn${entry.you ? " is-you" : ""}${entry.child ? " is-child" : ""}${
entry.selectable === false ? " is-static" : ""
}${selectedKey === entry.key ? " is-selected" : ""}`;
if (entry.selectable === false) {
return (
<span
key={entry.key}
className={className}
style={style}
title={vis ? undefined : label}
aria-label={label}
>
{inner}
</span>
);
}
return (
<button
key={entry.key}
type="button"
className={className}
style={style}
title={vis ? undefined : label}
aria-label={label}
aria-pressed={selectedKey === entry.key}
onClick={() => onSelect?.(entry.key)}
>
{inner}
</button>
);
})}
</div>
{entries.length === 0 ? (
<div
className="lr-couch-empty"
title={vis ? undefined : (emptyHint ?? t("host.viewers.empty"))}
>
<Glyph name="users" size={22} />
</div>
) : null}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
// App header: brand mark, LED connection state, language-mode pill
// (中文 / EN / 纯视觉), and theme toggle.
import { VisGlyph } from "./primitives";
import { ComicTooltip } from "./ComicTooltip";
import { useCopy, type Lang } from "../../ui/copy";
import { useTheme } from "../../ui/theme";
export type LedState = "live" | "busy" | "warn" | "bad" | "off";
export function LedStrip({ state, label }: { state: LedState; label: string }) {
const { vis } = useCopy();
return (
<span className="lr-leds" data-state={state} role="status" title={vis ? undefined : label} aria-label={label}>
<i />
<i />
<i />
{vis ? null : <span className="lr-leds-label">{label}</span>}
</span>
);
}
export function HeaderControls() {
const { lang, vis, t, setLang, setVis } = useCopy();
const { theme, toggle } = useTheme();
const option = (mode: Lang | "vis", label: string, tipKey: "mode.zh" | "mode.en" | "mode.vis") => {
const active = mode === "vis" ? vis : !vis && lang === mode;
return (
<button
type="button"
className={active ? "is-selected" : ""}
title={vis ? undefined : t(tipKey)}
aria-label={t(tipKey)}
aria-pressed={active}
onClick={() => (mode === "vis" ? setVis(true) : setLang(mode))}
>
{label}
</button>
);
};
const themeTitle = t(theme === "dark" ? "theme.light" : "theme.dark");
const themeButton = (
<button
type="button"
className="lr-btn"
style={{ minWidth: 40, height: 40, borderRadius: 999 }}
title={vis ? undefined : themeTitle}
aria-label={themeTitle}
onClick={(event) => {
// Hint-wrapped in vis: pointer activation must not leave the comic
// pinned open by focus over the stage (keyboard clicks keep focus).
if (vis && event.detail !== 0) event.currentTarget.blur();
toggle();
}}
>
<VisGlyph name={theme === "dark" ? "sun" : "moon"} size={16} draw="theme-toggle" />
{vis ? null : (
<span className="lr-cap">
{t(theme === "dark" ? "theme.light.short" : "theme.dark.short")}
</span>
)}
</button>
);
return (
<span className="lr-top-right lr-header-controls">
<span className="lr-lang" role="group" aria-label={t("mode.language")}>
{option("zh", "中", "mode.zh")}
{option("en", "EN", "mode.en")}
{option("vis", vis ? "✦" : "视", "mode.vis")}
</span>
{vis ? (
<ComicTooltip kind="hint-theme" place="below" align="end">
{themeButton}
</ComicTooltip>
) : (
themeButton
)}
</span>
);
}
export function AppHeader({ led }: { led?: React.ReactNode }) {
const { t, vis } = useCopy();
return (
<header className="lr-top">
<a className="lr-brand" href="/" aria-label={t("brand.home")} title={vis ? undefined : t("brand.home")}>
<VisGlyph name="tv" size={24} />
</a>
<span className="lr-top-right">
{led}
<HeaderControls />
</span>
</header>
);
}
+257
View File
@@ -0,0 +1,257 @@
// Connection metrics as living-room meter cells: glyph + value, plus the
// label in text modes. Secondary metrics expand behind a chevron.
import { useId, useState, type ReactNode } from "react";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
import { ComicTooltip } from "./ComicTooltip";
import { Pill } from "./primitives";
import type { ConnectionMetrics } from "../../types";
import { formatPacketLossPercent } from "../connection-details";
interface MetricValue {
icon: GlyphName;
label: CopyKey;
value: string;
title?: string;
}
export function codecContractWarnings(
metrics: ConnectionMetrics,
t: (key: CopyKey, vars?: Record<string, string>) => string,
): string[] {
const warnings: string[] = [];
const codecName = (codec: string) => codec.split("/").at(-1) ?? codec;
if (
metrics.codec &&
!["video/h264", "video/vp8"].includes(metrics.codec.toLowerCase())
) {
warnings.push(t("stats.warn.codec", { codec: codecName(metrics.codec) }));
}
if (metrics.audioCodec && metrics.audioCodec.toLowerCase() !== "audio/opus") {
warnings.push(t("stats.warn.audioCodec", { codec: codecName(metrics.audioCodec) }));
}
return warnings;
}
function secondaryMetrics(
metrics: ConnectionMetrics,
direction: "send" | "receive",
t: (key: CopyKey, vars?: Record<string, string>) => string,
): MetricValue[] {
const values: MetricValue[] = [];
const readable = (value: number | null, digits = 0): string =>
value === null || !Number.isFinite(value) ? t("stats.unknown") : value.toFixed(digits);
const addNumber = (
label: CopyKey,
icon: GlyphName,
value: number | null,
unit: string,
digits = 0,
title?: string,
) => {
if (value !== null && Number.isFinite(value)) {
values.push({ icon, label, value: `${readable(value, digits)} ${unit}`, title });
}
};
const qualityReason = (value: string): string => {
const keys: Record<string, CopyKey> = {
none: "stats.quality.normal",
bandwidth: "stats.quality.bandwidth",
cpu: "stats.quality.cpu",
other: "stats.quality.other",
};
return t(keys[value] ?? "stats.quality.unclassified");
};
addNumber("stats.rtt", "clock", metrics.rttMs, "ms");
if (metrics.codec) {
values.push({
icon: "cpu",
label: "stats.codec",
value: metrics.codec.split("/").at(-1) ?? metrics.codec,
});
}
if (direction === "send") {
addNumber("stats.outgoing", "gauge", metrics.availableOutgoingKbps, "kbps");
if (metrics.qualityLimitationReason) {
values.push({
icon: "gauge",
label: "stats.qualityState",
value: qualityReason(metrics.qualityLimitationReason),
});
}
const captureParts: string[] = [];
let captureDiffersFromDelivery = false;
if (metrics.captureWidth !== null && metrics.captureHeight !== null) {
const captureResolution = `${metrics.captureWidth}x${metrics.captureHeight}`;
captureParts.push(captureResolution);
captureDiffersFromDelivery ||= captureResolution !== metrics.resolution;
}
if (metrics.captureFramesPerSecond !== null) {
const captureFps = readable(metrics.captureFramesPerSecond, 1);
captureParts.push(`${captureFps} fps`);
captureDiffersFromDelivery ||=
metrics.framesPerSecond === null ||
!Number.isFinite(metrics.framesPerSecond) ||
captureFps !== metrics.framesPerSecond.toFixed(1);
}
if (captureParts.length > 0 && captureDiffersFromDelivery) {
values.push({ icon: "expand", label: "stats.capture", value: captureParts.join(" · ") });
}
addNumber("stats.inputFps", "wave", metrics.mediaSourceFramesPerSecond, "fps", 1);
if (metrics.encoderImplementation) {
values.push({
icon: "cpu",
label: "stats.encoder",
value: `${metrics.encoderImplementation}${
metrics.powerEfficientEncoder === true
? " · ✓"
: metrics.powerEfficientEncoder === false
? " · ✗"
: ""
}`,
});
}
addNumber("stats.encodeMs", "clock", metrics.intervalEncodeMs, "ms", 1);
} else {
addNumber("stats.jitter", "wave", metrics.jitterMs, "ms", 1);
addNumber("stats.dropped", "drop", metrics.framesDropped, "", 0);
addNumber("stats.decodeMs", "clock", metrics.intervalDecodeMs, "ms", 1);
addNumber("stats.freezeCount", "alert", metrics.intervalFreezeCount, "", 0);
addNumber("stats.freezeDuration", "clock", metrics.intervalFreezeDurationMs, "ms", 1);
}
addNumber("stats.audio", "speaker", metrics.audioBitrateKbps, "kbps");
if (metrics.audioPacketLossPercent !== null) {
values.push({
icon: "drop",
label: "stats.audioLoss",
value: formatPacketLossPercent(metrics.audioPacketLossPercent, t("stats.unknown")),
});
}
addNumber("stats.audioJitter", "wave", metrics.audioJitterMs, "ms", 1);
if (direction === "receive") {
addNumber("stats.playoutDelta", "clock", metrics.audioVideoPlayoutDeltaMs, "ms", 1);
addNumber("stats.videoBuffer", "clock", metrics.videoJitterBufferDelayMs, "ms", 1);
addNumber("stats.audioBuffer", "clock", metrics.audioJitterBufferDelayMs, "ms", 1);
if (metrics.audioConcealedSamplesPercent !== null) {
values.push({
icon: "drop",
label: "stats.audioConcealedRate",
value: formatPacketLossPercent(metrics.audioConcealedSamplesPercent, t("stats.unknown")),
});
}
addNumber("stats.audioConcealed", "speaker", metrics.intervalAudioConcealmentEvents, "", 0);
}
return values;
}
export function MetricCells({
metrics,
direction,
expanded,
onToggle,
}: {
metrics: ConnectionMetrics;
direction: "send" | "receive";
expanded: boolean;
onToggle: (expanded: boolean) => void;
}) {
const { t, vis } = useCopy();
const cell = ({ icon, label, value, title }: MetricValue): ReactNode => {
if (vis && label === "stats.qualityState") return null;
const display = vis && value === t("stats.unknown") ? "—" : value;
return (
<span className="lr-meter-cell" title={vis ? undefined : title ?? t(label)} key={label + display}>
<Glyph name={icon} size={16} />
{vis ? (
<b>{display}</b>
) : (
<span className="lr-meter-text">
<b>{display}</b>
<small>{t(label)}</small>
</span>
)}
</span>
);
};
const primary: MetricValue[] = [
{ icon: "expand", label: "stats.resolution", value: metrics.resolution ?? t("stats.unknown") },
{
icon: "wave",
label: "stats.fps",
value: metrics.framesPerSecond === null || !Number.isFinite(metrics.framesPerSecond)
? t("stats.unknown")
: `${metrics.framesPerSecond.toFixed(1)} fps`,
},
{
icon: "gauge",
label: "stats.bitrate",
value: metrics.bitrateKbps === null || !Number.isFinite(metrics.bitrateKbps)
? t("stats.unknown")
: `${metrics.bitrateKbps.toFixed(0)} kbps`,
},
{ icon: "drop", label: "stats.loss", value: formatPacketLossPercent(metrics.packetLossPercent, t("stats.unknown")) },
];
const secondary = secondaryMetrics(metrics, direction, t);
const warnings = codecContractWarnings(metrics, t);
const secondaryId = useId();
const moreTitle = t(expanded ? "stats.less" : "stats.more");
const moreButton = (
<button
type="button"
className={`lr-btn lr-metrics-more${expanded ? " is-open" : ""}`}
title={vis ? undefined : moreTitle}
aria-label={moreTitle}
aria-expanded={expanded}
aria-controls={secondaryId}
onClick={(event) => {
// Hint-wrapped in vis: pointer activation must not leave the comic
// pinned open by focus (keyboard clicks keep focus).
if (vis && event.detail !== 0) event.currentTarget.blur();
onToggle(!expanded);
}}
>
<Glyph name="chevron" size={15} />
{vis ? null : <span className="lr-cap">{t("stats.more")}</span>}
</button>
);
return (
<>
<div className="lr-meter" role="group" aria-label={t("stats.title")}>
{primary.map(cell)}
</div>
{secondary.length > 0 ? (
<>
{vis ? (
// This button lands at the card's left edge (the meter takes a
// full flex basis), so prefer start; ComicTooltip re-picks from
// live geometry if that would clip.
<ComicTooltip kind="hint-more-metrics" align="start">{moreButton}</ComicTooltip>
) : (
moreButton
)}
{expanded ? (
<div id={secondaryId} className="lr-meter" role="group" aria-label={t("stats.more")}>
{secondary.map(cell)}
</div>
) : null}
</>
) : null}
{warnings.map((warning) => (
<Pill icon="alert" label={warning} comic="warning" key={warning} />
))}
</>
);
}
// Standalone wrapper for pages that keep the expanded state themselves.
export function useMetricsExpanded(): [boolean, (next: boolean) => void] {
const [expanded, setExpanded] = useState(false);
return [expanded, setExpanded];
}
+139
View File
@@ -0,0 +1,139 @@
// Per-viewer drill-down: name, route glyph, primary metrics, expandable
// detailed metrics. Host sees every viewer; a viewer sees itself and its
// relay children (matching what the wire actually delivers).
import { PawnSvg, pawnColor } from "./Couch";
import { Glyph } from "../../ui/icons";
import { ComicTooltip } from "./ComicTooltip";
import { useCopy } from "../../ui/copy";
import type { ConnectionMetrics } from "../../types";
import { MetricCells } from "./Metrics";
export function RouteGlyph({ route }: { route: "p2p" | "sfu" }) {
const { t, vis } = useCopy();
const cell = (
<span
className="lr-meter-cell"
title={vis ? undefined : t(route === "sfu" ? "state.route.sfu" : "state.route.p2p")}
// Hint-wrapped in vis: focusable so keyboard users reach the comic.
tabIndex={vis ? 0 : undefined}
>
{route === "sfu" ? (
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" stroke="#53676a" strokeWidth="2" aria-hidden="true">
<circle cx="4" cy="8" r="3" fill="#53676a" stroke="none" />
<rect x="14" y="3" width="8" height="10" rx="2" />
<circle cx="32" cy="8" r="3" fill="#53676a" stroke="none" />
<path d="M7 8h7M22 8h7" />
</svg>
) : (
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" stroke="#53676a" strokeWidth="2" aria-hidden="true">
<circle cx="4" cy="8" r="3" fill="#53676a" stroke="none" />
<circle cx="32" cy="8" r="3" fill="#53676a" stroke="none" />
<path d="M7 8h22" />
</svg>
)}
<b>{route === "sfu" ? "SFU" : "P2P"}</b>
</span>
);
return vis ? (
<ComicTooltip kind={route === "sfu" ? "hint-route-sfu" : "hint-route-p2p"}>
{cell}
</ComicTooltip>
) : (
cell
);
}
export function PawnDetail({
pawnKey,
name,
you = false,
route,
metrics,
direction,
tag,
error,
expanded,
onToggleMetrics,
onClose,
}: {
pawnKey: string;
name: string;
you?: boolean;
route: "p2p" | "sfu" | null;
metrics?: ConnectionMetrics | null;
direction: "send" | "receive";
tag?: { icon: "arrowUp" | "loader"; label: string };
error?: string | null;
expanded: boolean;
onToggleMetrics: (expanded: boolean) => void;
onClose: () => void;
}) {
const { t, vis } = useCopy();
return (
<div className="lr-row is-sub lr-pawn-detail" role="group" aria-label={name}>
<span className="lr-pawn-mini">
<PawnSvg color={pawnColor(pawnKey, you)} />
</span>
{vis ? (
<span className="visually-hidden">{name}</span>
) : (
<span className="lr-pawn-detail-name">{name}</span>
)}
{tag ? (
<span className="lr-meter-cell" title={vis ? undefined : tag.label}>
<Glyph name={tag.icon} size={16} />
{vis ? <span className="visually-hidden">{tag.label}</span> : <b>{tag.label}</b>}
</span>
) : null}
{error ? (
<ComicTooltip kind="route-failed">
<span
className="lr-pill is-bad"
role="alert"
title={vis ? undefined : error}
// Comic-wrapped: focusable so keyboard users reach the comic.
tabIndex={vis ? 0 : undefined}
>
<Glyph name="alert" size={16} />
{vis ? <span className="visually-hidden">{error}</span> : <span>{error}</span>}
</span>
</ComicTooltip>
) : null}
{route ? <RouteGlyph route={route} /> : null}
{metrics ? (
<MetricCells
metrics={metrics}
direction={direction}
expanded={expanded}
onToggle={onToggleMetrics}
/>
) : null}
{vis ? (
<ComicTooltip kind="hint-close">
<button
type="button"
className="lr-pawn-detail-close"
aria-label={t("common.close")}
onClick={(event) => {
// Pointer activation must not leave the comic pinned by focus.
if (event.detail !== 0) event.currentTarget.blur();
onClose();
}}
>
<Glyph name="x" size={16} />
</button>
</ComicTooltip>
) : (
<button
type="button"
className="lr-pawn-detail-close"
title={t("common.close")}
aria-label={t("common.close")}
onClick={onClose}
>
<Glyph name="x" size={16} />
</button>
)}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
// LCD room code chip with copy and in-place room replacement confirmation.
import { useEffect, useRef, useState } from "react";
import { Glyph } from "../../ui/icons";
import { useCopy } from "../../ui/copy";
import { copyRoomCode } from "../room-code";
import { Btn } from "./primitives";
import { ComicTooltip } from "./ComicTooltip";
export function Lcd({ code }: { code: string }) {
const { t, vis } = useCopy();
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">(
"idle",
);
const timerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
},
[],
);
async function copy(): Promise<void> {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
try {
await copyRoomCode(code, (value) => navigator.clipboard.writeText(value));
setCopyState("copied");
} catch {
setCopyState("failed");
}
timerRef.current = window.setTimeout(() => setCopyState("idle"), 1500);
}
const copyFeedback =
copyState === "copied"
? t("common.copied")
: copyState === "failed"
? t("common.copyFailed")
: "";
const copyButton = (
<button
type="button"
title={vis ? undefined : t("common.copy")}
aria-label={copyFeedback || t("common.copy")}
onClick={(event) => {
// Hint-wrapped in vis: pointer activation must not leave the comic
// pinned open by focus (keyboard clicks keep focus).
if (vis && event.detail !== 0) event.currentTarget.blur();
void copy();
}}
>
<Glyph
name={
copyState === "copied"
? "check"
: copyState === "failed"
? "alert"
: "copy"
}
size={16}
draw="lcd-copy"
/>
</button>
);
return (
<span
className="lr-lcd"
role="group"
aria-label={`${t("common.roomCode")} ${code}`}
title={vis ? undefined : t("common.roomCode")}
>
<span className="lr-lcd-roll" aria-hidden="true">
{[...code].map((digit, index) => (
<span key={`${code}-${index}`} style={{ animationDelay: `${index * 45}ms` }}>
{digit}
</span>
))}
</span>
{vis ? (
<ComicTooltip kind="hint-copy-code">{copyButton}</ComicTooltip>
) : (
copyButton
)}
<span className="visually-hidden" role="status" aria-live="polite">
{copyFeedback}
</span>
</span>
);
}
export function RoomChip({
roomId,
onReplace,
replaceDisabled = false,
}: {
roomId: string;
onReplace?: () => void | Promise<void>;
replaceDisabled?: boolean;
}) {
const { vis } = useCopy();
const [confirming, setConfirming] = useState(false);
const [pending, setPending] = useState(false);
const pendingRef = useRef(false);
async function replace(): Promise<void> {
if (!onReplace || pendingRef.current || replaceDisabled) return;
pendingRef.current = true;
setPending(true);
try {
await onReplace();
} finally {
pendingRef.current = false;
setPending(false);
setConfirming(false);
}
}
return (
<>
<Lcd code={roomId} />
{onReplace ? (
confirming ? (
<>
<Btn
icon="check"
title="host.roomReplaceConfirm"
hint="hint-shuffle-code"
disabled={pending || replaceDisabled}
onClick={() => void replace()}
/>
<Btn
icon="x"
title="common.cancel"
hint="hint-close"
disabled={pending}
onClick={() => setConfirming(false)}
/>
</>
) : (
<Btn
icon="refresh"
title="host.roomReplace"
cap={vis ? undefined : "host.roomReplace"}
hint="hint-shuffle-code"
disabled={replaceDisabled}
onClick={() => setConfirming(true)}
/>
)
) : null}
</>
);
}
+321
View File
@@ -0,0 +1,321 @@
// The connection topology as a real tree: host (crowned) roots direct P2P
// viewers and the SFU node; relay children hang off their parent viewer.
// Data comes from deriveParticipantTopology.
import { PawnSvg, pawnColor } from "./Couch";
import { useCopy } from "../../ui/copy";
import type { LabeledViewerPresence } from "../../lib/viewer-presence";
import { deriveParticipantTopology, type TopologyBranch } from "../../lib/participant-topology";
interface TreeNode {
key: string;
label: string;
via: string | null;
sfu: boolean;
you: boolean;
}
const HOST_X = 40;
const COLUMN_GAP = 190;
const ROW_BASE = 40;
const PENDING_COLOR = "#d98e04";
function xForDepth(depth: number): number {
return HOST_X + (depth + 1) * COLUMN_GAP;
}
export function RouteTree({
hostPeerId,
hostLabel,
viewers,
selfPeerId,
flowing = false,
}: {
hostPeerId: string | null;
hostLabel: string;
viewers: readonly LabeledViewerPresence[];
selfPeerId?: string | null;
flowing?: boolean;
}) {
const { t, vis } = useCopy();
const topology = deriveParticipantTopology(hostPeerId, viewers);
const nodes: TreeNode[] = [];
const collect = (branch: TopologyBranch, via: string | null, sfu: boolean) => {
nodes.push({
key: branch.viewer.peerId,
label: branch.viewer.label,
via,
sfu,
you: selfPeerId === branch.viewer.peerId,
});
branch.children.forEach((child) => collect(child, branch.viewer.peerId, false));
};
topology.peerRoots.forEach((root) => collect(root, null, false));
topology.sfuRoots.forEach((root) => collect(root, null, true));
const childrenOf = new Map<string | "sfu" | null, TreeNode[]>();
const add = (parent: string | "sfu" | null, node: TreeNode) => {
const list = childrenOf.get(parent) ?? [];
list.push(node);
childrenOf.set(parent, list);
};
for (const node of nodes) {
if (node.via) add(node.via, node);
else if (node.sfu) add("sfu", node);
else add(null, node);
}
const hasSfu = nodes.some((n) => n.sfu);
const spacing = nodes.length > 10 ? 32 : 42;
let row = 0;
const pos = new Map<string, { x: number; y: number }>();
function layout(node: TreeNode, depth: number): void {
const kids = childrenOf.get(node.key) ?? [];
let y: number;
if (kids.length === 0) {
y = ROW_BASE + row * spacing;
row += 1;
} else {
kids.forEach((kid) => layout(kid, depth + 1));
y = kids.reduce((sum, kid) => sum + pos.get(kid.key)!.y, 0) / kids.length;
}
pos.set(node.key, { x: xForDepth(depth), y });
}
(childrenOf.get(null) ?? []).forEach((node) => layout(node, 0));
let sfuPos: { x: number; y: number } | null = null;
if (hasSfu) {
const sfuKids = (childrenOf.get("sfu") ?? []);
sfuKids.forEach((node) => layout(node, 1));
sfuPos = {
x: xForDepth(0),
y: sfuKids.reduce((sum, kid) => sum + pos.get(kid.key)!.y, 0) / sfuKids.length,
};
}
// Pending viewers (upstream not yet in the tree) dangle off the host as
// dashed amber edges — visible in every language mode, not just text.
const pendingPos = topology.pending.map((viewer) => {
const p = { x: xForDepth(0), y: ROW_BASE + row * spacing };
row += 1;
return { viewer, ...p };
});
const height = Math.max(140, row * spacing + 64);
const rootYs = [
...(childrenOf.get(null) ?? []).map((node) => pos.get(node.key)!.y),
...(sfuPos ? [sfuPos.y] : []),
...pendingPos.map((p) => p.y),
];
const hostPos = {
x: HOST_X,
y:
rootYs.length > 0
? rootYs.reduce((a, b) => a + b, 0) / rootYs.length
: height / 2,
};
const width = Math.max(
640,
...[...pos.values(), ...pendingPos, ...(sfuPos ? [sfuPos] : [])].map(
({ x }) => x + 70,
),
);
// Center the whole composition vertically inside the panel.
const allYs = [
...[...pos.values()].map((p) => p.y),
...(sfuPos ? [sfuPos.y] : []),
...pendingPos.map((p) => p.y),
hostPos.y,
];
const minY = Math.min(...allYs) - 26;
const maxY = Math.max(...allYs) + (vis ? 22 : 34);
const shift = Math.max(0, (height - (maxY - minY)) / 2 - minY + 4);
if (shift > 0) {
pos.forEach((p) => {
p.y += shift;
});
pendingPos.forEach((p) => {
p.y += shift;
});
if (sfuPos) sfuPos.y += shift;
hostPos.y += shift;
}
const flowClass = flowing ? "flow" : undefined;
const labelText = (name: string, x: number, y: number) =>
vis ? null : (
<text x={x} y={y} textAnchor="middle">
{name}
</text>
);
const pendingLabel = `${t("host.topology.pending")}: ${topology.pending
.map((viewer) => viewer.label)
.join(", ")}`;
const srSummary = [
`${t("common.host")} ${hostLabel}`,
...nodes.map(
(node) =>
`${node.label} (${t(node.sfu ? "state.route.sfu" : "state.route.p2p")})`,
),
...topology.pending.map(
(viewer) => `${viewer.label} (${t("host.topology.pending")})`,
),
].join(" · ");
return (
<div
className="lr-route"
role="group"
aria-label={`${t("host.topology")}: ${srSummary}`}
id="room-topology"
>
<svg
viewBox={`0 0 ${width} ${height}`}
style={{ width, minWidth: "100%", maxWidth: "none" }}
>
{/* In vis mode <title> doubles as a native hover tooltip, leaking
human-language UI text into the zero-text mode; accessible names
ride on aria-label instead, which never becomes hover text. */}
{vis ? null : <title>{t("host.topology")}</title>}
{/* edges first, nodes on top */}
{nodes.map((node) => {
const p = pos.get(node.key)!;
const parentNode = node.via ? pos.get(node.via) : undefined;
const parent = parentNode ?? (node.sfu && sfuPos ? sfuPos : hostPos);
const color = node.sfu ? "#8ea3b8" : "#2fa66a";
return (
<path
key={`edge-${node.key}`}
d={`M ${parent.x + 20} ${parent.y} Q ${(parent.x + p.x) / 2} ${parent.y + (p.y - parent.y) * 0.55}, ${p.x - 18} ${p.y}`}
fill="none"
stroke={color}
strokeWidth={2.5}
className={flowClass}
/>
);
})}
{pendingPos.map((p) => (
<path
key={`edge-pending-${p.viewer.peerId}`}
d={`M ${hostPos.x + 20} ${hostPos.y} Q ${(hostPos.x + p.x) / 2} ${hostPos.y + (p.y - hostPos.y) * 0.55}, ${p.x - 18} ${p.y}`}
fill="none"
stroke={PENDING_COLOR}
strokeWidth={2.5}
strokeDasharray="6 7"
className={flowClass}
/>
))}
{sfuPos ? (
<path
d={`M ${hostPos.x + 20} ${hostPos.y} L ${sfuPos.x - 20} ${sfuPos.y}`}
fill="none"
stroke="#8ea3b8"
strokeWidth={2.5}
className={flowClass}
/>
) : null}
<g
transform={`translate(${hostPos.x - 4}, ${hostPos.y - 20}) scale(0.85)`}
role={vis ? "img" : undefined}
aria-label={vis ? `${hostLabel} · ${t("common.host")}` : undefined}
>
{vis ? null : <title>{`${hostLabel} · ${t("common.host")}`}</title>}
<PawnSvg color="var(--couch)" crown />
</g>
{labelText(hostLabel, hostPos.x + 14, hostPos.y + 30)}
{sfuPos ? (
<g
transform={`translate(${sfuPos.x - 20}, ${sfuPos.y - 16})`}
role={vis ? "img" : undefined}
aria-label={vis ? t("host.sfu") : undefined}
>
{vis ? null : <title>{t("host.sfu")}</title>}
<rect width="40" height="32" rx="7" fill="none" stroke="#8ea3b8" strokeWidth="2.5" />
<path d="M8 12h24M8 20h24" stroke="#8ea3b8" strokeWidth="2.5" strokeLinecap="round" />
</g>
) : null}
{sfuPos ? labelText(t("host.sfu"), sfuPos.x, sfuPos.y + 32) : null}
{nodes.map((node) => {
const p = pos.get(node.key)!;
const isChild = node.via !== null && !node.sfu;
const nodeLabel = node.you ? `${node.label} · ${t("common.you")}` : node.label;
return (
<g
key={node.key}
transform={`translate(${p.x - 14}, ${p.y - 15}) scale(${isChild ? 0.52 : 0.72})`}
role={vis ? "img" : undefined}
aria-label={vis ? nodeLabel : undefined}
>
{vis ? null : <title>{nodeLabel}</title>}
<PawnSvg color={pawnColor(node.key, node.you)} />
{node.you ? (
<circle cx="20" cy="26" r="22" fill="none" stroke="var(--you)" strokeWidth="3" />
) : null}
</g>
);
})}
{pendingPos.map((p) => (
<g
key={`pending-${p.viewer.peerId}`}
transform={`translate(${p.x - 14}, ${p.y - 15}) scale(0.72)`}
opacity={0.55}
role={vis ? "img" : undefined}
aria-label={vis ? `${p.viewer.label} · ${t("host.topology.pending")}` : undefined}
>
{vis ? null : <title>{`${p.viewer.label} · ${t("host.topology.pending")}`}</title>}
<PawnSvg color={pawnColor(p.viewer.peerId, selfPeerId === p.viewer.peerId)} />
</g>
))}
{vis
? null
: nodes.map((node) => {
const p = pos.get(node.key)!;
const isChild = node.via !== null && !node.sfu;
return (
<text key={`label-${node.key}`} x={p.x + 4} y={p.y + (isChild ? 20 : 26)} textAnchor="middle">
{node.label}
</text>
);
})}
{vis
? null
: pendingPos.map((p) => (
<text
key={`label-pending-${p.viewer.peerId}`}
x={p.x + 4}
y={p.y + 26}
textAnchor="middle"
opacity={0.7}
>
{p.viewer.label}
</text>
))}
</svg>
<ul className="visually-hidden">
{nodes.map((node) => (
<li key={`sr-${node.key}`}>
{node.label} {node.sfu ? "SFU" : "P2P"}
</li>
))}
{topology.pending.map((viewer) => (
<li key={`sr-pending-${viewer.peerId}`}>
{viewer.label} {t("host.topology.pending")}
</li>
))}
</ul>
{topology.pending.length > 0 ? (
vis ? (
<span className="visually-hidden">{pendingLabel}</span>
) : (
<div className="lr-status-text" style={{ padding: "4px 6px" }}>
{pendingLabel}
</div>
)
) : null}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
// The TV stage: frame, screen, chin LED. Pages render their own <video>
// (stream binding stays with the page); overlays and the connecting
// storyboard are provided here.
import type { ReactNode } from "react";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
import { Comic, type ComicKind } from "./Comic";
export type ChinState = "off" | "on" | "warn" | "bad" | "busy";
export function StageTv({
chin,
hasEntry,
live,
children,
label,
}: {
chin: ChinState;
hasEntry?: boolean;
live?: boolean;
children: ReactNode;
label: string;
}) {
return (
<div className="lr-tv">
<div
className={`lr-tv-screen${hasEntry ? " has-entry" : ""}${live ? " is-on" : ""}`}
role="group"
aria-label={label}
>
{children}
</div>
<div className="lr-tv-chin">
<i className={chin === "off" ? "" : `is-${chin}`} />
</div>
</div>
);
}
export function StaticNoise() {
return <div className="lr-tv-static" aria-hidden="true" />;
}
export function StoryBoard({ step }: { step: 0 | 1 | 2 }) {
const { vis, t } = useCopy();
const panels: [GlyphName, CopyKey][] = [
["door", "story.room"],
["plug", "story.link"],
["tv", "story.show"],
];
return (
<div className="lr-story" role="status" aria-label={t("host.starting")}>
{panels.map(([icon, key], index) => (
<span key={key} style={{ display: "contents" }}>
{index > 0 ? (
<span className={`lr-story-link${step > index - 1 ? " is-done" : ""}`} />
) : null}
<span
className={`lr-story-item${step > index ? " is-done" : step === index ? " is-now" : ""}`}
>
<span
className={`lr-story-panel${step > index ? " is-done" : step === index ? " is-now" : ""}`}
>
<Glyph name={step > index ? "check" : icon} size={22} />
</span>
{vis ? null : <span className="lr-story-cap">{t(key)}</span>}
</span>
</span>
))}
</div>
);
}
export function StageOverlay({
icon,
message,
dim,
spin,
comic,
onActivate,
}: {
icon: GlyphName;
message: string;
dim?: boolean;
spin?: boolean;
comic?: ComicKind;
onActivate?: () => void;
}) {
const { vis } = useCopy();
const content = (
<>
{vis && comic ? <Comic kind={comic} theme="stage" /> : null}
<span className={`lr-tv-big${spin ? " lr-spin" : ""}${onActivate ? " is-action is-ripple" : ""}`}>
<Glyph name={icon} size={30} draw="stage-overlay" />
</span>
{vis ? null : <span className="lr-tv-msg">{message}</span>}
</>
);
if (onActivate) {
return (
<button type="button" className={`lr-tv-overlay${dim ? " is-dim" : ""}`} onClick={onActivate} aria-label={message}>
{content}
</button>
);
}
return (
<div
className={`lr-tv-overlay${dim ? " is-dim" : ""}`}
role="status"
aria-label={message}
>
{content}
</div>
);
}
@@ -0,0 +1,128 @@
// Control hint scenes (操作提示漫画): small 2-panel before→after idioms used
// as hover/focus tooltips on controls in pure-visual mode, where no human
// language may appear. Same cast, palette, 320x96 canvas, and motion
// constitution as the state comics. Sets live in set1..set4.tsx;
// each must define exactly its SetNKind keys — the merge below then proves
// completeness at compile time. JSX-free shell: this module is also loaded
// by the tsx preview harness, which uses the classic JSX runtime.
import { createElement, type CSSProperties, type ReactNode } from "react";
import type { ComicTheme } from "../Comic";
import { SET1_SCENES } from "./set1";
import { SET2_SCENES } from "./set2";
import { SET3_SCENES } from "./set3";
import { SET4_SCENES } from "./set4";
export type Set1Kind =
| "hint-share-start"
| "hint-share-stop"
| "hint-pause"
| "hint-resume"
| "hint-switch-source"
| "hint-reconnect";
export type Set2Kind =
| "hint-copy-code"
| "hint-shuffle-code"
| "hint-copy-invite"
| "hint-rotate-invite"
| "hint-revoke-invite"
| "hint-policy-open"
| "hint-policy-private"
| "hint-password";
export type Set3Kind =
| "hint-quality"
| "hint-audio-quality"
| "hint-degrade-pref"
| "hint-codec"
| "hint-advanced"
| "hint-details"
| "hint-more-metrics";
export type Set4Kind =
| "hint-topology"
| "hint-close"
| "hint-admit"
| "hint-deny"
| "hint-rename"
| "hint-theme"
| "hint-join-go"
| "hint-route-p2p"
| "hint-route-sfu";
export type HintKind = Set1Kind | Set2Kind | Set3Kind | Set4Kind;
export const HINT_KINDS: readonly HintKind[] = [
"hint-share-start",
"hint-share-stop",
"hint-pause",
"hint-resume",
"hint-switch-source",
"hint-reconnect",
"hint-copy-code",
"hint-shuffle-code",
"hint-copy-invite",
"hint-rotate-invite",
"hint-revoke-invite",
"hint-policy-open",
"hint-policy-private",
"hint-password",
"hint-quality",
"hint-audio-quality",
"hint-degrade-pref",
"hint-codec",
"hint-advanced",
"hint-details",
"hint-more-metrics",
"hint-topology",
"hint-close",
"hint-admit",
"hint-deny",
"hint-rename",
"hint-theme",
"hint-join-go",
"hint-route-p2p",
"hint-route-sfu",
];
export type HintScene = (props: { theme: ComicTheme }) => ReactNode;
export const HINT_SCENES: Record<HintKind, HintScene> = {
...SET1_SCENES,
...SET2_SCENES,
...SET3_SCENES,
...SET4_SCENES,
};
const HINT_KIND_SET: ReadonlySet<string> = new Set(HINT_KINDS);
export function isHintKind(kind: string): kind is HintKind {
return HINT_KIND_SET.has(kind);
}
/** All hint scenes are paper idioms: they float on a paper tooltip. */
export function HintComic({
kind,
size,
}: {
kind: HintKind;
size?: number;
}) {
const style: CSSProperties = {
width: size != null ? `${size}px` : "min(320px, 86%)",
height: "auto",
display: "block",
};
return createElement(
"svg",
{
viewBox: "0 0 320 96",
style,
"aria-hidden": true,
focusable: false,
xmlns: "http://www.w3.org/2000/svg",
},
HINT_SCENES[kind]({ theme: "paper" }),
);
}
+294
View File
@@ -0,0 +1,294 @@
// Hint set 1 — sharing lifecycle: start/stop/pause/resume/source/fullscreen/reconnect.
// Scenes follow ../Comic.tsx conventions: 320x96 canvas, 2-panel
// before→after idiom (Frame x={4} w={152} + Frame x={164} w={152}), vls-
// prefixed keyframes in an inline <style>, rmBlock for reduced motion.
import {
FAINT,
Floor,
Frame,
INK_STAGE,
LIVE,
MINT,
MiniTv,
Moon,
Pawn,
Plug,
SKY,
Socket,
Spark,
Star,
TV_BODY,
TV_EDGE,
WARN,
YOU,
rmBlock,
} from "../Comic";
import type { HintScene, Set1Kind } from "./index";
/* hint-share-start: [dark TV + pawn reaching toward it] → [TV bright, mint
glow, rays + Star]. The after panel replays one power-on beat per loop
(CRT screen-on → rays → star → pawn hop), then rests lit ~60%. */
const SceneShareStart: HintScene = ({ theme }) => (
<>
<style>{`
.vls-hs-reach{transform-box:fill-box;transform-origin:50% 100%;animation:vlsHsReach 3.2s ease-in-out infinite}
.vls-hs-intent{animation:vlsHsIntent 3.2s ease-in-out infinite}
.vls-hs-crt{transform-box:fill-box;transform-origin:center;animation:vlsHsCrt 3.2s ease-out infinite}
.vls-hs-rays{animation:vlsHsRays 3.2s ease-out infinite}
.vls-hs-star{transform-box:fill-box;transform-origin:center;animation:vlsHsStar 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
.vls-hs-hop{animation:vlsHsHop 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsHsReach{0%{transform:rotate(0)}10%{transform:rotate(7deg)}26%{transform:rotate(7deg)}40%,100%{transform:rotate(0)}}
@keyframes vlsHsIntent{0%{opacity:0}10%{opacity:.9}26%{opacity:.9}40%,100%{opacity:0}}
@keyframes vlsHsCrt{0%{transform:scaleY(.06);opacity:.5}12%,100%{transform:scaleY(1);opacity:1}}
@keyframes vlsHsRays{0%,8%{opacity:0;transform:translateY(2px)}18%,100%{opacity:1;transform:translateY(0)}}
@keyframes vlsHsStar{0%,18%{opacity:0;transform:scale(0)}26%{opacity:1;transform:scale(1.25)}33%,100%{opacity:1;transform:scale(1)}}
@keyframes vlsHsHop{0%,26%{transform:translateY(0)}33%{transform:translateY(-6px)}40%,100%{transform:translateY(0)}}
${rmBlock(
["vls-hs-reach", "vls-hs-intent", "vls-hs-crt", "vls-hs-rays", "vls-hs-star", "vls-hs-hop"],
[
[".vls-hs-crt,.vls-hs-rays,.vls-hs-star", "opacity:1;transform:none"],
[".vls-hs-intent", "opacity:0"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<Floor x1={14} x2={146} y={78} />
<MiniTv x={55} y={24} w={66} h={42} />
<circle cx={88} cy={73} r={3} fill={TV_EDGE} />
<g className="vls-hs-reach">
<path d="M20 78 c0-11 4.5-15.5 10-15.5 s10 4.5 10 15.5 Z" fill={YOU} />
<circle cx={30} cy={58} r={6} fill={YOU} />
<circle cx={31.5} cy={57.5} r={1.2} fill="#101a2c" />
<circle cx={34.9} cy={57.5} r={1.2} fill="#101a2c" />
</g>
<g className="vls-hs-intent" opacity={0} stroke={FAINT} strokeWidth={2.5} strokeLinecap="round">
<path d="M43 45 l7 -5 M46 55 l8 -3" />
</g>
<Floor x1={174} x2={306} y={78} />
<MiniTv x={207} y={24} w={66} h={42} />
<g className="vls-hs-crt">
<rect x={211.62} y={28.62} width={56.76} height={27.72} rx={4} fill={MINT} />
<path d="M233 37 L247 42.5 L233 48 Z" fill="#101a2c" />
</g>
<g className="vls-hs-rays" stroke={MINT} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M222 17 l-6 -7 M240 14 V6 M258 17 l6 -7" />
</g>
<Star x={272} y={15} r={7} className="vls-hs-star" />
<circle cx={240} cy={73} r={3} fill={LIVE} />
<g className="vls-hs-hop">
<Pawn x={184} yb={78} s={9} eyes />
</g>
</>
);
/* hint-share-stop: [bright TV, pawn watching] → [TV dims, chin LED out, small
Moon, pawn content]. Calm power-down: dim + LED-out beat, moon settles. */
const SceneShareStop: HintScene = ({ theme }) => (
<>
<style>{`
.vls-hx-eyes{transform-box:fill-box;transform-origin:center;animation:vlsHxBlink 3.4s ease-in-out infinite}
.vls-hx-dim{animation:vlsHxDim 3.4s ease-out infinite}
.vls-hx-led{animation:vlsHxLed 3.4s ease-out infinite}
.vls-hx-moon{animation:vlsHxMoon 3.4s ease-in-out infinite}
.vls-hx-settle{animation:vlsHxSettle 3.4s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsHxBlink{0%,44%,52%,100%{transform:scaleY(1)}48%{transform:scaleY(.12)}}
@keyframes vlsHxDim{0%{opacity:0}14%,100%{opacity:1}}
@keyframes vlsHxLed{0%{opacity:1}14%,100%{opacity:0}}
@keyframes vlsHxMoon{0%,16%{opacity:0;transform:translateY(3px)}28%{opacity:1;transform:translateY(0)}38%{transform:translateY(-2px)}50%,100%{opacity:1;transform:translateY(0)}}
@keyframes vlsHxSettle{0%,48%{transform:translateY(0)}54%{transform:translateY(-2px)}60%,100%{transform:translateY(0)}}
${rmBlock(
["vls-hx-eyes", "vls-hx-dim", "vls-hx-led", "vls-hx-moon", "vls-hx-settle"],
[
[".vls-hx-dim", "opacity:1"],
[".vls-hx-led", "opacity:0"],
[".vls-hx-moon", "opacity:1;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<Floor x1={14} x2={146} y={78} />
<MiniTv x={47} y={24} w={66} h={42} />
<rect x={51.62} y={28.62} width={56.76} height={27.72} rx={4} fill={MINT} />
<path d="M73 37 L87 42.5 L73 48 Z" fill="#101a2c" />
<g stroke={MINT} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M62 17 l-6 -7 M80 14 V6 M98 17 l6 -7" />
</g>
<circle cx={80} cy={73} r={3} fill={LIVE} />
<path d="M15 78 c0-9.9 4.05-13.95 9-13.95 s9 4.05 9 13.95 Z" fill={YOU} />
<circle cx={24} cy={60} r={5.4} fill={YOU} />
<g className="vls-hx-eyes" fill="#101a2c">
<circle cx={24} cy={59.5} r={1.1} />
<circle cx={27.6} cy={59.5} r={1.1} />
</g>
<Floor x1={174} x2={306} y={78} />
<MiniTv x={207} y={24} w={66} h={42} />
<rect className="vls-hx-dim" x={211.62} y={28.62} width={56.76} height={27.72} rx={4} fill="#0a101c" />
<circle cx={240} cy={73} r={3} fill={TV_EDGE} />
<circle className="vls-hx-led" cx={240} cy={73} r={3} fill={LIVE} opacity={0} />
<g className="vls-hx-moon">
<Moon x={240} y={42} k={0.8} />
</g>
<g className="vls-hx-settle">
<path d="M187 78 c0-9.9 4.05-13.95 9-13.95 s9 4.05 9 13.95 Z" fill={YOU} />
<circle cx={196} cy={60} r={5.4} fill={YOU} />
<path
d="M191.8 59.5 q1.7 -2.2 3.4 0 M198.8 59.5 q1.7 -2.2 3.4 0"
stroke="#101a2c"
strokeWidth={1.8}
strokeLinecap="round"
fill="none"
/>
</g>
</>
);
/* hint-pause: [TV alive, motion lines] → [same picture frozen + pause-bars
stamp + amber LED]. Reuses the host-paused idiom (dim + bars + amber). */
const ScenePause: HintScene = ({ theme }) => (
<>
<style>{`
.vls-pz-flick{animation:vlsPzFlick 3s ease-in-out infinite}
.vls-pz-hold{animation:vlsPzHold 3s ease-out infinite}
.vls-pz-bars{transform-box:fill-box;transform-origin:center;animation:vlsPzBars 3s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsPzFlick{0%{opacity:.5}10%{opacity:.9}20%{opacity:.2}32%{opacity:.85}44%,100%{opacity:.5}}
@keyframes vlsPzHold{0%{opacity:0}12%,100%{opacity:1}}
@keyframes vlsPzBars{0%,5%{opacity:0;transform:scale(1.45)}16%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-pz-flick", "vls-pz-hold", "vls-pz-bars"],
[[".vls-pz-flick", "opacity:.5"], [".vls-pz-hold,.vls-pz-bars", "opacity:1;transform:none"]],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={WARN} />
<MiniTv x={44} y={22} w={72} h={46} />
<path d="M73 34 L89 42.2 L73 50.4 Z" fill={MINT} />
<g className="vls-pz-flick" stroke={SKY} strokeWidth={2} strokeLinecap="round" opacity={0.5}>
<path d="M54 33 v7 M58 45 v6 M106 34 v6 M102 46 v5" />
</g>
<circle cx={80} cy={74} r={3} fill={LIVE} />
<MiniTv x={204} y={22} w={72} h={46} />
<path d="M233 34 L249 42.2 L233 50.4 Z" fill={MINT} />
<g className="vls-pz-hold">
<rect x={209.04} y={27.06} width={61.92} height={30.36} rx={4} fill="#0a101c" opacity={0.45} />
<circle cx={240} cy={74} r={3} fill={WARN} />
</g>
<circle cx={240} cy={74} r={3} fill={LIVE} opacity={0.35} />
<g className="vls-pz-bars" fill={INK_STAGE}>
<rect x={229} y={30.5} width={8.5} height={23.5} rx={3.5} />
<rect x={242.5} y={30.5} width={8.5} height={23.5} rx={3.5} />
</g>
</>
);
/* hint-resume: [frozen picture + pause bars] → [bars gone, play triangle
springs, motion lines return]. Before panel is perfectly still = frozen. */
const SceneResume: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rs-play{transform-box:fill-box;transform-origin:center;animation:vlsRsPlay 3s cubic-bezier(.3,1.5,.5,1) infinite}
.vls-rs-led{animation:vlsRsLed 3s ease-out infinite}
.vls-rs-flick{animation:vlsRsFlick 3s ease-in-out infinite}
@keyframes vlsRsPlay{0%{transform:scale(.55);opacity:.5}14%{transform:scale(1.18);opacity:1}22%,100%{transform:scale(1);opacity:1}}
@keyframes vlsRsLed{0%{opacity:.2}10%,100%{opacity:1}}
@keyframes vlsRsFlick{0%,12%{opacity:0}20%{opacity:.9}30%{opacity:.25}40%{opacity:.85}54%,100%{opacity:.5}}
${rmBlock(
["vls-rs-play", "vls-rs-led", "vls-rs-flick"],
[[".vls-rs-play,.vls-rs-led", "opacity:1;transform:none"], [".vls-rs-flick", "opacity:.5"]],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<MiniTv x={44} y={22} w={72} h={46} />
<path d="M73 34 L89 42.2 L73 50.4 Z" fill={MINT} />
<rect x={49.04} y={27.06} width={61.92} height={30.36} rx={4} fill="#0a101c" opacity={0.45} />
<g fill={INK_STAGE}>
<rect x={69} y={30.5} width={8.5} height={23.5} rx={3.5} />
<rect x={82.5} y={30.5} width={8.5} height={23.5} rx={3.5} />
</g>
<circle cx={80} cy={74} r={3} fill={WARN} />
<MiniTv x={204} y={22} w={72} h={46} />
<path className="vls-rs-play" d="M233 34 L249 42.2 L233 50.4 Z" fill={MINT} />
<g className="vls-rs-flick" stroke={SKY} strokeWidth={2} strokeLinecap="round" opacity={0}>
<path d="M214 33 v7 M218 45 v6 M266 34 v6 M262 46 v5" />
</g>
<circle className="vls-rs-led" cx={240} cy={74} r={3} fill={LIVE} />
</>
);
/* hint-switch-source: [two monitor cards, left lit] → [right lit, swap arcs
with two dots exchanging seats — the Hearth switch do-si-do]. */
const SceneSwitchSource: HintScene = ({ theme }) => (
<>
<style>{`
.vls-sw-d1{animation:vlsSwD1 3.2s ease-in-out infinite}
.vls-sw-d2{animation:vlsSwD2 3.2s ease-in-out infinite}
@keyframes vlsSwD1{0%{transform:translate(0,0);opacity:0}8%{opacity:1}22%{transform:translate(11px,-6px)}36%{transform:translate(22px,0);opacity:1}46%,100%{transform:translate(22px,0);opacity:0}}
@keyframes vlsSwD2{0%{transform:translate(0,0);opacity:0}8%{opacity:1}22%{transform:translate(-11px,6px)}36%{transform:translate(-22px,0);opacity:1}46%,100%{transform:translate(-22px,0);opacity:0}}
${rmBlock(["vls-sw-d1", "vls-sw-d2"], [[".vls-sw-d1,.vls-sw-d2", "opacity:0;transform:none"]])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={SKY} />
<rect x={27} y={28} width={46} height={32} rx={6} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={32} y={32} width={36} height={16} rx={3} fill={MINT} />
<circle cx={50} cy={54} r={2.5} fill={LIVE} />
<rect x={87} y={28} width={46} height={32} rx={6} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={92} y={32} width={36} height={16} rx={3} fill="#16233c" />
<circle cx={110} cy={54} r={2.5} fill={TV_EDGE} />
<rect x={187} y={28} width={46} height={32} rx={6} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={192} y={32} width={36} height={16} rx={3} fill="#16233c" />
<circle cx={210} cy={54} r={2.5} fill={TV_EDGE} />
<rect x={247} y={28} width={46} height={32} rx={6} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={252} y={32} width={36} height={16} rx={3} fill={MINT} />
<circle cx={270} cy={54} r={2.5} fill={LIVE} />
<g stroke={SKY} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<path d="M229 29 C236 16 244 16 251 29 M244.5 26.5 L251 29 L249 22.5" />
<path d="M251 63 C244 76 236 76 229 63 M235.5 65.5 L229 63 L231 69.5" />
</g>
<circle className="vls-sw-d1" cx={229} cy={29} r={2.5} fill={MINT} opacity={0} />
<circle className="vls-sw-d2" cx={251} cy={63} r={2.5} fill={MINT} opacity={0} />
</>
);
/* hint-reconnect: [Plug out of Socket, small Spark at the gap] → [Plug seats
with a wiggle, LIVE dot pops]. Same plug language as recovering. */
const SceneReconnect: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rj-spark{animation:vlsRjSpark 3.2s ease-in-out infinite}
.vls-rj-plug{transform-box:fill-box;transform-origin:50% 100%;animation:vlsRjPlug 3.2s ease-in-out infinite}
.vls-rj-led{transform-box:fill-box;transform-origin:center;animation:vlsRjLed 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsRjSpark{0%,100%{opacity:.55}15%{opacity:1}}
@keyframes vlsRjPlug{0%{transform:translate(-2px,-8px) rotate(-6deg)}10%{transform:translate(-1px,-3px) rotate(4deg)}20%{transform:translate(0,-1px) rotate(-2deg)}28%,100%{transform:none}}
@keyframes vlsRjLed{0%,24%{opacity:0;transform:scale(.4)}32%{opacity:1;transform:scale(1.3)}40%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-rj-spark", "vls-rj-plug", "vls-rj-led"],
[
[".vls-rj-spark", "opacity:1"],
[".vls-rj-plug", "transform:none"],
[".vls-rj-led", "opacity:1;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<Socket x={90} y={52} />
<Plug x={80} y={23} />
<Spark x={93} y={49} className="vls-rj-spark" />
<Socket x={250} y={52} />
<g className="vls-rj-plug">
<Plug x={260} y={36} />
</g>
<circle className="vls-rj-led" cx={284} cy={38} r={3.5} fill={LIVE} />
</>
);
export const SET1_SCENES: Record<Set1Kind, HintScene> = {
"hint-share-start": SceneShareStart,
"hint-share-stop": SceneShareStop,
"hint-pause": ScenePause,
"hint-resume": SceneResume,
"hint-switch-source": SceneSwitchSource,
"hint-reconnect": SceneReconnect,
};
+537
View File
@@ -0,0 +1,537 @@
// Hint set 2 — room & invite: code copy/shuffle, invite copy/rotate/revoke, policy open/private, password.
// Scenes follow ../Comic.tsx conventions: 320x96 canvas, 2-panel
// before→after idiom (Frame x={4} w={152} + Frame x={164} w={152}), vls-
// prefixed keyframes in an inline <style>, rmBlock for reduced motion.
// Paper idioms: var(--ink)/var(--wall-2)/var(--paper) carry the drawing;
// room codes are always 4 abstract digit slots, never real digits.
import {
Door,
FAINT,
Frame,
INK_STAGE,
Pawn,
RedX,
SKY,
Spark,
Star,
STAR_GOLD,
TV_SCREEN,
rmBlock,
} from "../Comic";
import type { HintScene, Set2Kind } from "./index";
/* LCD code card: pale body, dark screen, 4 abstract digit slots at +12/+27/
+42/+57 inside the 78x46 body. */
const SLOT_DX = [12, 27, 42, 57] as const;
function LcdBase({ x, y }: { x: number; y: number }) {
return (
<>
<rect
x={x}
y={y}
width={78}
height={46}
rx={9}
fill="var(--wall-2)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
<rect x={x + 6} y={y + 6} width={66} height={34} rx={5} fill={TV_SCREEN} />
</>
);
}
function LcdSlots({
x,
y,
className,
}: {
x: number;
y: number;
className?: string;
}) {
const slots = (
<>
{SLOT_DX.map((dx) => (
<rect
key={dx}
x={x + dx}
y={y + 17}
width={9}
height={10}
rx={2}
fill="none"
stroke={INK_STAGE}
strokeWidth={2}
/>
))}
</>
);
return className ? <g className={className}>{slots}</g> : slots;
}
/** One chain link: a stadium ring, optionally tilted (static transform). */
function LinkRing({
x,
y,
w,
h,
tilt = 0,
}: {
x: number;
y: number;
w: number;
h: number;
tilt?: number;
}) {
const ring = (
<rect
x={x}
y={y}
width={w}
height={h}
rx={h / 2}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
);
return tilt ? <g transform={`rotate(${tilt} ${x + w / 2} ${y + h / 2})`}>{ring}</g> : ring;
}
/** Open doorway: warm lit frame + leaf swung open on the left hinge. */
function OpenDoorway({ x, y }: { x: number; y: number }) {
return (
<>
<rect
x={x}
y={y}
width={32}
height={56}
rx={5}
fill={STAR_GOLD}
opacity={0.3}
stroke="var(--ink)"
strokeWidth={2.5}
/>
<path
d={`M${x} ${y} L${x - 12} ${y + 6} L${x - 12} ${y + 62} L${x} ${y + 56} Z`}
fill="var(--wall-2)"
stroke="var(--ink)"
strokeWidth={2.5}
strokeLinejoin="round"
/>
<circle cx={x - 8} cy={y + 32} r={1.8} fill="var(--ink)" />
</>
);
}
/* ------------------------------------------------------------------ */
/* hint-copy-code: tap the code card → a second copy + star. */
/* ------------------------------------------------------------------ */
const SceneCopyCode: HintScene = ({ theme }) => (
<>
<style>{`
.vls-copy-pawn{transform-box:fill-box;transform-origin:50% 100%;animation:vlsCopyLean 2.6s ease-in-out infinite}
.vls-copy-ring{transform-box:fill-box;transform-origin:center;animation:vlsCopyRing 2.6s ease-out infinite}
.vls-copy-front{transform-box:fill-box;transform-origin:50% 100%;animation:vlsCopyHop 2.6s ease-in-out infinite}
.vls-copy-star{transform-box:fill-box;transform-origin:center;animation:vlsCopyStar 2.6s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsCopyLean{0%,8%{transform:rotate(0)}18%,30%{transform:rotate(7deg)}42%,100%{transform:rotate(0)}}
@keyframes vlsCopyRing{0%,14%{transform:scale(.6);opacity:0}20%{opacity:.8}38%,100%{transform:scale(1.7);opacity:0}}
@keyframes vlsCopyHop{0%,12%{transform:translateY(0)}20%{transform:translateY(-2.5px)}30%,100%{transform:translateY(0)}}
@keyframes vlsCopyStar{0%,14%{opacity:0;transform:scale(0)}24%{opacity:1;transform:scale(1.25)}32%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-copy-pawn", "vls-copy-ring", "vls-copy-front", "vls-copy-star"],
[
[".vls-copy-ring", "opacity:0"],
[".vls-copy-star", "opacity:1;transform:scale(1)"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<LcdBase x={58} y={22} />
<LcdSlots x={58} y={22} />
<circle
className="vls-copy-ring"
cx={57}
cy={45}
r={6}
fill="none"
stroke={SKY}
strokeWidth={2.5}
opacity={0}
/>
<Pawn x={38} yb={76} s={9} eyes className="vls-copy-pawn" />
<LcdBase x={210} y={18} />
<g className="vls-copy-front">
<LcdBase x={190} y={32} />
<LcdSlots x={190} y={32} />
</g>
<Star x={292} y={22} r={7} className="vls-copy-star" baseOpacity={0} />
</>
);
/* ------------------------------------------------------------------ */
/* hint-shuffle-code: slots at rest → slots mid-roll + spark. */
/* ------------------------------------------------------------------ */
const SceneShuffleCode: HintScene = ({ theme }) => (
<>
<style>{`
.vls-shuf-slots{opacity:.45}
.vls-shuf-roll{animation:vlsShufRoll 2.4s linear infinite}
.vls-shuf-spark{transform-box:fill-box;transform-origin:center;animation:vlsShufSpark 2.4s ease-out infinite}
@keyframes vlsShufRoll{0%{transform:translateY(-2px)}8%{transform:translateY(2px)}16%{transform:translateY(-2px)}24%{transform:translateY(2px)}32%{transform:translateY(-1px)}40%,100%{transform:translateY(0)}}
@keyframes vlsShufSpark{0%,8%{opacity:0;transform:scale(.5)}16%{opacity:1;transform:scale(1.1)}22%,70%{opacity:1;transform:scale(1)}82%,100%{opacity:0}}
${rmBlock(
["vls-shuf-roll", "vls-shuf-spark"],
[
[".vls-shuf-roll", "transform:none"],
[".vls-shuf-spark", "opacity:1;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<LcdBase x={41} y={25} />
<LcdSlots x={41} y={25} />
<LcdBase x={201} y={25} />
<LcdSlots x={201} y={25} className="vls-shuf-slots" />
<g className="vls-shuf-roll" fill={INK_STAGE} opacity={0.9}>
{SLOT_DX.map((dx) => (
<g key={dx}>
<rect x={202 + dx} y={43.5} width={7} height={1.8} rx={0.9} />
<rect x={202 + dx} y={47} width={7} height={1.8} rx={0.9} />
<rect x={202 + dx} y={50.5} width={7} height={1.8} rx={0.9} />
</g>
))}
</g>
<Spark x={288} y={30} className="vls-shuf-spark" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-copy-invite: pawn balancing a link → link arcs to a friend. */
/* ------------------------------------------------------------------ */
const SceneCopyInvite: HintScene = ({ theme }) => (
<>
<style>{`
.vls-cinv-eyes{transform-box:fill-box;transform-origin:center;animation:vlsCinvBlink 2.8s ease-in-out infinite}
.vls-cinv-fly{animation:vlsCinvFly 2.8s ease-in-out infinite}
.vls-cinv-arc{animation:vlsCinvArc 2.8s ease-in-out infinite}
.vls-cinv-star{transform-box:fill-box;transform-origin:center;animation:vlsCinvStar 2.8s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsCinvBlink{0%,60%,68%,100%{transform:scaleY(1)}64%{transform:scaleY(.12)}}
@keyframes vlsCinvFly{0%,6%{transform:translate(-92px,0);opacity:0}10%{opacity:1}32%{transform:translate(-46px,-20px)}50%{transform:translate(0,0)}56%{transform:translate(0,-3px)}62%,100%{transform:translate(0,0)}}
@keyframes vlsCinvArc{0%,10%{opacity:0}20%{opacity:.7}55%{opacity:.7}72%,100%{opacity:0}}
@keyframes vlsCinvStar{0%,50%{opacity:0;transform:scale(0)}58%{opacity:1;transform:scale(1.25)}66%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-cinv-eyes", "vls-cinv-fly", "vls-cinv-arc", "vls-cinv-star"],
[
[".vls-cinv-fly", "transform:none;opacity:1"],
[".vls-cinv-arc", "opacity:.35"],
[".vls-cinv-star", "opacity:1"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<Pawn x={62} yb={76} s={10} />
<g className="vls-cinv-eyes" fill="#101a2c">
<circle cx={59.8} cy={56} r={1.1} />
<circle cx={64.2} cy={56} r={1.1} />
</g>
<LinkRing x={50} y={27.5} w={24} h={13} tilt={-12} />
<path
className="vls-cinv-arc"
d="M196 50 Q242 14 288 50"
stroke={FAINT}
strokeWidth={2}
strokeDasharray="4 4"
fill="none"
opacity={0}
/>
<Pawn x={196} yb={76} s={8.5} eyes />
<Pawn x={288} yb={76} s={8.5} eyes color={SKY} />
<g className="vls-cinv-fly" opacity={0}>
<LinkRing x={278} y={31.5} w={20} h={11} tilt={-12} />
</g>
<Star x={288} y={16} r={6} className="vls-cinv-star" baseOpacity={0} />
</>
);
/* ------------------------------------------------------------------ */
/* hint-rotate-invite: a link → the link spun fresh + spark. */
/* ------------------------------------------------------------------ */
const SceneRotateInvite: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rinv-ring{transform-box:view-box;transform-origin:240px 48px;animation:vlsRinvSpin 2.6s ease-in-out infinite}
.vls-rinv-spark{transform-box:fill-box;transform-origin:center;animation:vlsRinvSpark 2.6s ease-out infinite}
@keyframes vlsRinvSpin{0%,10%{transform:rotate(0)}50%,100%{transform:rotate(360deg)}}
@keyframes vlsRinvSpark{0%,44%{opacity:0;transform:scale(.5)}54%{opacity:1;transform:scale(1.1)}62%,82%{opacity:1;transform:scale(1)}92%,100%{opacity:0}}
${rmBlock(
["vls-rinv-ring", "vls-rinv-spark"],
[[".vls-rinv-spark", "opacity:1;transform:none"]],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<LinkRing x={65} y={40} w={30} h={16} tilt={-15} />
<LinkRing x={225} y={40} w={30} h={16} tilt={-15} />
<g
className="vls-rinv-ring"
stroke={SKY}
strokeWidth={2.5}
strokeLinecap="round"
fill="none"
>
<path d="M240 28 A20 20 0 1 1 220 48" />
<path d="M227 46 L220 48 L222 55" />
</g>
<Spark x={272} y={24} className="vls-rinv-spark" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-revoke-invite: intact chain → snapped, ends droop, red-X. */
/* ------------------------------------------------------------------ */
const SceneRevokeInvite: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rev-l{transform-box:view-box;transform-origin:223px 52.5px;animation:vlsRevL 2.8s ease-in-out infinite}
.vls-rev-r{transform-box:view-box;transform-origin:257px 52.5px;animation:vlsRevR 2.8s ease-in-out infinite}
.vls-rev-x{transform-box:fill-box;transform-origin:center;animation:vlsRevX 2.8s ease-out infinite}
@keyframes vlsRevL{0%,10%{transform:translate(9px,-4.5px) rotate(-18deg)}20%{transform:translate(-2px,1px) rotate(5deg)}30%,100%{transform:translate(0,0) rotate(0)}}
@keyframes vlsRevR{0%,10%{transform:translate(-9px,-4.5px) rotate(18deg)}20%{transform:translate(2px,1px) rotate(-5deg)}30%,100%{transform:translate(0,0) rotate(0)}}
@keyframes vlsRevX{0%,30%{opacity:0;transform:scale(1.6) rotate(8deg)}38%,100%{opacity:1;transform:scale(1) rotate(8deg)}}
${rmBlock(
["vls-rev-l", "vls-rev-r", "vls-rev-x"],
[[".vls-rev-x", "opacity:1;transform:scale(1) rotate(8deg)"]],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<LinkRing x={56} y={41} w={28} h={14} />
<LinkRing x={76} y={41} w={28} h={14} />
<g className="vls-rev-l">
<g transform="rotate(18 223 52.5)">
<rect
x={210}
y={46}
width={26}
height={13}
rx={6.5}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
</g>
</g>
<g className="vls-rev-r">
<g transform="rotate(-18 257 52.5)">
<rect
x={244}
y={46}
width={26}
height={13}
rx={6.5}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
</g>
</g>
<RedX cx={240} cy={38} arm={5} className="vls-rev-x" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-policy-open: shut door → door wide open, two pawns hop in. */
/* ------------------------------------------------------------------ */
const ScenePolicyOpen: HintScene = ({ theme }) => (
<>
<style>{`
.vls-open-p1{transform-box:fill-box;transform-origin:50% 100%;animation:vlsOpenHop 2.8s ease-in-out infinite}
.vls-open-p2{transform-box:fill-box;transform-origin:50% 100%;animation:vlsOpenHop 2.8s ease-in-out .2s infinite}
@keyframes vlsOpenHop{0%,6%{transform:translateY(0)}14%{transform:translateY(-5px)}22%{transform:translateY(0)}28%{transform:translateY(-3px)}34%,100%{transform:translateY(0)}}
${rmBlock(["vls-open-p1", "vls-open-p2"], [])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<Door x={64} y={20} />
<OpenDoorway x={224} y={18} />
<Pawn x={238} yb={74} s={6.5} eyes className="vls-open-p1" />
<Pawn x={206} yb={78} s={5.5} eyes color={SKY} className="vls-open-p2" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-policy-private: open door → shut + locked, one pawn waits with */
/* its little invite card. */
/* ------------------------------------------------------------------ */
const ScenePolicyPrivate: HintScene = ({ theme }) => (
<>
<style>{`
.vls-priv-eyes{transform-box:fill-box;transform-origin:center;animation:vlsPrivBlink 3s ease-in-out infinite}
@keyframes vlsPrivBlink{0%,52%,60%,100%{transform:scaleY(1)}56%{transform:scaleY(.12)}}
${rmBlock(["vls-priv-eyes"], [])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<OpenDoorway x={64} y={18} />
<Pawn x={204} yb={76} s={8} />
<g className="vls-priv-eyes" fill="#101a2c">
<circle cx={201.8} cy={60} r={0.9} />
<circle cx={206.2} cy={60} r={0.9} />
</g>
<g transform="rotate(-10 202 44)">
<rect
x={195}
y={39}
width={14}
height={10}
rx={2}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={1.5}
/>
<path
d="M197.5 42.5 h4 M197.5 45.5 h6"
stroke="var(--ink)"
strokeWidth={1.2}
strokeLinecap="round"
/>
</g>
<Door x={224} y={18} />
<path
d="M235 38 v-5 a5.5 5.5 0 0 1 11 0 v5"
stroke="var(--ink)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
/>
<rect
x={232}
y={38}
width={16}
height={13}
rx={3}
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
/>
<circle cx={240} cy={44} r={1.8} fill="var(--ink)" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-password: key approaches the lock → shackle up, door ajar. */
/* ------------------------------------------------------------------ */
const ScenePassword: HintScene = ({ theme }) => (
<>
<style>{`
.vls-pass-key{animation:vlsPassKey 2.8s ease-in-out infinite}
.vls-pass-lines{animation:vlsPassLines 2.8s ease-in-out infinite}
.vls-pass-shackle{transform-box:view-box;transform-origin:190px 44px;animation:vlsPassShackle 2.8s ease-in-out infinite}
.vls-pass-glow{animation:vlsPassGlow 2.8s ease-in-out infinite}
@keyframes vlsPassKey{0%{transform:translateX(0);opacity:0}6%{opacity:1}30%{transform:translateX(26px)}68%{transform:translateX(26px);opacity:1}80%{transform:translateX(26px);opacity:0}82%,100%{transform:translateX(0);opacity:0}}
@keyframes vlsPassLines{0%,4%{opacity:0}10%{opacity:.9}24%{opacity:.9}32%,100%{opacity:0}}
@keyframes vlsPassShackle{0%,8%{transform:rotate(34deg)}20%{transform:rotate(-6deg)}28%,100%{transform:rotate(0)}}
@keyframes vlsPassGlow{0%,30%{opacity:.3}42%{opacity:.65}55%,100%{opacity:.3}}
${rmBlock(
["vls-pass-key", "vls-pass-lines", "vls-pass-shackle", "vls-pass-glow"],
[
[".vls-pass-key", "transform:translateX(26px);opacity:1"],
[".vls-pass-lines", "opacity:0"],
[".vls-pass-glow", "opacity:.45"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<g className="vls-pass-lines" opacity={0}>
<path d="M42 42 l-7 -2 M42 52 l-7 2" stroke={FAINT} strokeWidth={2} strokeLinecap="round" />
</g>
<g className="vls-pass-key" opacity={0}>
<circle cx={52} cy={47} r={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2.5} />
<path d="M57 47 h14 M66 47 v4 M70 47 v4" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" />
</g>
<path
d="M92 40 v-5 a6 6 0 0 1 12 0 v5"
stroke="var(--ink)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
/>
<rect
x={88}
y={40}
width={18}
height={14}
rx={3}
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
/>
<circle cx={97} cy={47} r={1.8} fill="var(--ink)" />
<rect
className="vls-pass-glow"
x={230}
y={16}
width={36}
height={58}
rx={5}
fill={SKY}
opacity={0.3}
/>
<rect
x={230}
y={16}
width={36}
height={58}
rx={5}
fill="none"
stroke="var(--ink)"
strokeWidth={2.5}
/>
<path
d="M230 16 L260 20 L260 70 L230 74 Z"
fill="var(--wall-2)"
stroke="var(--ink)"
strokeWidth={2.5}
strokeLinejoin="round"
/>
<circle cx={254} cy={46} r={1.8} fill="var(--ink)" />
<rect
x={186}
y={44}
width={18}
height={14}
rx={3}
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
/>
<circle cx={195} cy={51} r={1.8} fill="var(--ink)" />
<path
className="vls-pass-shackle"
d="M190 44 v-5 a6 6 0 0 1 11.8 -2.2"
stroke="var(--ink)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
/>
</>
);
export const SET2_SCENES: Record<Set2Kind, HintScene> = {
"hint-copy-code": SceneCopyCode,
"hint-shuffle-code": SceneShuffleCode,
"hint-copy-invite": SceneCopyInvite,
"hint-rotate-invite": SceneRotateInvite,
"hint-revoke-invite": SceneRevokeInvite,
"hint-policy-open": ScenePolicyOpen,
"hint-policy-private": ScenePolicyPrivate,
"hint-password": ScenePassword,
};
+416
View File
@@ -0,0 +1,416 @@
// Hint set 3 — settings: video/audio quality, degradation preference, codec, advanced door, details, more-metrics.
// Scenes follow ../Comic.tsx conventions: 320x96 canvas, 2-panel
// before→after idiom (Frame x={4} w={152} + Frame x={164} w={152}), vls-
// prefixed keyframes in an inline <style>, rmBlock for reduced motion.
import {
FAINT,
Frame,
LINE,
LIVE,
MINT,
Pawn,
SKY,
STAR_GOLD,
Star,
TV_BODY,
TV_EDGE,
TV_SCREEN,
rmBlock,
} from "../Comic";
import type { HintScene, Set3Kind } from "./index";
/** hint-quality: soft TV (few fat scanlines, squinting pawn) → sharp TV (many
* thin crisp lines, one light sweep, star pop). Loop 3s. */
const hintQuality: HintScene = ({ theme }) => (
<>
<style>{`
.vls-vq-soft{animation:vlsVqSoft 3s ease-in-out infinite}
.vls-vq-sweep{animation:vlsVqSweep 3s ease-in-out infinite}
.vls-vq-star{transform-box:fill-box;transform-origin:center;animation:vlsVqStar 3s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsVqSoft{0%,48%,100%{opacity:.4}12%{opacity:.58}24%{opacity:.34}36%{opacity:.52}}
@keyframes vlsVqSweep{0%,22%{transform:translateX(0);opacity:0}30%{opacity:.3}48%,100%{transform:translateX(49px);opacity:0}}
@keyframes vlsVqStar{0%,55%{opacity:0;transform:scale(0)}66%{opacity:1;transform:scale(1.25)}74%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-vq-soft", "vls-vq-sweep", "vls-vq-star"],
[
[".vls-vq-soft", "opacity:.4"],
[".vls-vq-sweep", "opacity:0"],
[".vls-vq-star", "opacity:1;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: pawn squints at a soft picture (3 fat faint scanlines) */}
<Pawn x={26} yb={80} s={8} />
<path d="M24 64h2.8 M27.6 64h2.8" stroke="#101a2c" strokeWidth={2} strokeLinecap="round" fill="none" />
<rect x={52} y={22} width={76} height={46} rx={8} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={58} y={28} width={64} height={30} rx={3} fill={TV_SCREEN} />
<g className="vls-vq-soft" fill={LINE} opacity={0.4}>
<rect x={63} y={33} width={52} height={6} rx={3} />
<rect x={63} y={42} width={52} height={6} rx={3} />
<rect x={63} y={51} width={52} height={6} rx={3} />
</g>
{/* AFTER: crisp thin scanlines + sweep + star; pawn brightens up */}
<Pawn x={186} yb={80} s={8} />
<circle cx={187} cy={62.8} r={1.2} fill="#101a2c" />
<circle cx={190} cy={62.8} r={1.2} fill="#101a2c" />
<rect x={212} y={22} width={76} height={46} rx={8} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={218} y={28} width={64} height={30} rx={3} fill={TV_SCREEN} />
<g fill={MINT} opacity={0.95}>
<rect x={221} y={30} width={58} height={2.5} rx={1.25} />
<rect x={221} y={35} width={58} height={2.5} rx={1.25} />
<rect x={221} y={40} width={58} height={2.5} rx={1.25} />
<rect x={221} y={45} width={58} height={2.5} rx={1.25} />
<rect x={221} y={50} width={58} height={2.5} rx={1.25} />
<rect x={221} y={55} width={58} height={2.5} rx={1.25} />
</g>
<rect className="vls-vq-sweep" x={218} y={28} width={9} height={30} fill="#ffffff" opacity={0} />
<Star x={294} y={17} r={7} className="vls-vq-star" baseOpacity={0} />
</>
);
/** hint-audio-quality: speaker with one thin faint arc (pawn leans in to
* hear) → three bold rich arcs (happy-eye pawn static, star). Loop 3s. */
const hintAudioQuality: HintScene = ({ theme }) => (
<>
<style>{`
.vls-aq-weak{animation:vlsAqWeak 3s ease-in-out infinite}
.vls-aq-listen{transform-box:fill-box;transform-origin:50% 100%;animation:vlsAqListen 3s ease-in-out infinite}
.vls-aq-a1{animation:vlsAqPing 3s ease-out infinite}
.vls-aq-a2{animation:vlsAqPing 3s ease-out .08s infinite}
.vls-aq-a3{animation:vlsAqPing 3s ease-out .16s infinite}
.vls-aq-star{transform-box:fill-box;transform-origin:center;animation:vlsAqStar 3s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsAqWeak{0%,30%,100%{opacity:.4}12%{opacity:.6}22%{opacity:.32}}
@keyframes vlsAqListen{0%,30%,58%,100%{transform:rotate(0)}40%,50%{transform:rotate(-8deg)}}
@keyframes vlsAqPing{0%,5%{opacity:.25}12%,100%{opacity:1}}
@keyframes vlsAqStar{0%,46%{opacity:0;transform:scale(0)}54%{opacity:1;transform:scale(1.25)}60%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-aq-weak", "vls-aq-listen", "vls-aq-a1", "vls-aq-a2", "vls-aq-a3", "vls-aq-star"],
[
[".vls-aq-weak", "opacity:.4"],
[".vls-aq-listen,.vls-aq-star", "transform:none"],
[".vls-aq-a1,.vls-aq-a2,.vls-aq-a3,.vls-aq-star", "opacity:1"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: thin faint arc, pawn cups its ear and leans in */}
<rect x={40} y={42} width={13} height={18} rx={3} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<path d="M53 47 L67 38 V64 L53 55 Z" fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} strokeLinejoin="round" />
<path className="vls-aq-weak" d="M73 45 a9 9 0 0 1 0 12" stroke={LINE} strokeWidth={2} strokeLinecap="round" fill="none" opacity={0.4} />
<g className="vls-aq-listen">
<Pawn x={122} yb={80} s={9} />
<circle cx={118.5} cy={61.3} r={1.3} fill="#101a2c" />
<circle cx={122} cy={61.3} r={1.3} fill="#101a2c" />
<path d="M116 55 a7 7 0 0 0 0 12" stroke="#23804f" strokeWidth={2.5} strokeLinecap="round" fill="none" />
</g>
{/* AFTER: three bold arcs, happy-eye pawn static, star */}
<rect x={200} y={42} width={13} height={18} rx={3} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<path d="M213 47 L227 38 V64 L213 55 Z" fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} strokeLinejoin="round" />
<g stroke={LIVE} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path className="vls-aq-a1" d="M232 46 a8 8 0 0 1 0 10" />
<path className="vls-aq-a2" d="M237 41 a13 13 0 0 1 0 20" />
<path className="vls-aq-a3" d="M242 36 a18 18 0 0 1 0 30" />
</g>
<Pawn x={282} yb={80} s={9} />
<path d="M277.5 62 q1.7 -2.3 3.4 0 M282.5 62 q1.7 -2.3 3.4 0" stroke="#101a2c" strokeWidth={1.8} strokeLinecap="round" fill="none" />
<Star x={257} y={21} r={6.5} className="vls-aq-star" baseOpacity={0} />
</>
);
/** hint-degrade-pref: balance scale weighing a crisp frame against a
* motion-blur frame (beam wobbles) → scale settles level, star. Loop 3.2s. */
const hintDegradePref: HintScene = ({ theme }) => (
<>
<style>{`
.vls-dp-beam{transform-box:fill-box;transform-origin:50% 0%;animation:vlsDpBeam 3.2s ease-in-out infinite}
.vls-dp-settle{transform-box:fill-box;transform-origin:50% 0%;animation:vlsDpSettle 3.2s ease-in-out infinite}
.vls-dp-star{transform-box:fill-box;transform-origin:center;animation:vlsDpStar 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsDpBeam{0%,40%,100%{transform:rotate(0)}12%{transform:rotate(5deg)}26%{transform:rotate(-4deg)}}
@keyframes vlsDpSettle{0%,16%,100%{transform:rotate(0)}4%{transform:rotate(3deg)}10%{transform:rotate(-2deg)}}
@keyframes vlsDpStar{0%,30%{opacity:0;transform:scale(0)}40%{opacity:1;transform:scale(1.25)}48%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-dp-beam", "vls-dp-settle", "vls-dp-star"],
[
[".vls-dp-beam,.vls-dp-settle,.vls-dp-star", "transform:none"],
[".vls-dp-star", "opacity:1"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: pawn watches the scale wobble — crisp frame vs blur frame */}
<Pawn x={26} yb={80} s={7.5} />
<circle cx={27.3} cy={64} r={1.1} fill="#101a2c" />
<circle cx={29.7} cy={64} r={1.1} fill="#101a2c" />
<path d="M84 78 V34 M70 78 H98" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none" />
<g className="vls-dp-beam" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M52 34 H116" />
<path d="M52 34 V44 M116 34 V44" strokeWidth={2} />
<path d="M44 44 Q52 51 60 44 M44 44 H60" strokeWidth={2} />
<path d="M108 44 Q116 51 124 44 M108 44 H124" strokeWidth={2} />
<rect x={46.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
<rect x={50.2} y={38} width={3.6} height={3.6} rx={0.8} fill="var(--ink)" stroke="none" />
<rect x={104.5} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.25} />
<rect x={107} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.5} />
<rect x={109.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
</g>
<circle cx={84} cy={34} r={2.5} fill="var(--ink)" />
{/* AFTER: settle once, level, star; pawn pleased */}
<Pawn x={298} yb={80} s={7.5} />
<path d="M294 65 q1.6 -2.2 3.2 0 M299 65 q1.6 -2.2 3.2 0" stroke="#101a2c" strokeWidth={1.8} strokeLinecap="round" fill="none" />
<path d="M244 78 V34 M230 78 H258" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none" />
<g className="vls-dp-settle" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M212 34 H276" />
<path d="M212 34 V44 M276 34 V44" strokeWidth={2} />
<path d="M204 44 Q212 51 220 44 M204 44 H220" strokeWidth={2} />
<path d="M268 44 Q276 51 284 44 M268 44 H284" strokeWidth={2} />
<rect x={206.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
<rect x={210.2} y={38} width={3.6} height={3.6} rx={0.8} fill="var(--ink)" stroke="none" />
<rect x={264.5} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.25} />
<rect x={267} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.5} />
<rect x={269.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
</g>
<circle cx={244} cy={34} r={2.5} fill="var(--ink)" />
<Star x={244} y={16} r={7} className="vls-dp-star" baseOpacity={0} />
</>
);
/** hint-codec: puzzle piece with eyes floats beside a TV with a dashed
* socket → piece slots in, LED goes live, star. Loop 3s. */
const hintCodec: HintScene = ({ theme }) => (
<>
<style>{`
.vls-cd-bob{animation:vlsCdBob 3s ease-in-out infinite}
.vls-cd-dock{animation:vlsCdDock 3s cubic-bezier(.3,1.5,.5,1) infinite}
.vls-cd-star{transform-box:fill-box;transform-origin:center;animation:vlsCdStar 3s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsCdBob{0%,40%,100%{transform:translateY(0)}20%{transform:translateY(-2.5px)}}
@keyframes vlsCdDock{0%,6%{transform:translateX(-8px)}20%{transform:translateX(1px)}26%,100%{transform:translateX(0)}}
@keyframes vlsCdStar{0%,32%{opacity:0;transform:scale(0)}42%{opacity:1;transform:scale(1.25)}50%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-cd-bob", "vls-cd-dock", "vls-cd-star"],
[
[".vls-cd-bob,.vls-cd-dock,.vls-cd-star", "transform:none"],
[".vls-cd-star", "opacity:1"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: piece bobs beside the TV, dashed socket waits on the edge */}
<g className="vls-cd-bob">
<path
d="M40 34 a4 4 0 0 1 4-4 h16 a4 4 0 0 1 4 4 v5 a3.5 3.5 0 0 1 0 7 v4 a4 4 0 0 1-4 4 h-16 a4 4 0 0 1-4-4 Z"
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
<circle cx={48.5} cy={39} r={1.5} fill="#101a2c" />
<circle cx={55.5} cy={39} r={1.5} fill="#101a2c" />
</g>
<rect x={92} y={22} width={52} height={40} rx={7} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={97} y={27} width={42} height={26} rx={3} fill={TV_SCREEN} />
<path d="M92 37 a5 5 0 0 1 0 10" stroke={FAINT} strokeWidth={2} strokeLinecap="round" strokeDasharray="3 3" fill="none" />
<circle cx={118} cy={68} r={2.5} fill={TV_EDGE} />
{/* AFTER: piece docked flush, happy eyes, LED live, star */}
<rect x={252} y={22} width={52} height={40} rx={7} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={257} y={27} width={42} height={26} rx={3} fill={TV_SCREEN} />
<circle cx={278} cy={68} r={2.5} fill={LIVE} />
<g className="vls-cd-dock">
<path
d="M224 34 a4 4 0 0 1 4-4 h16 a4 4 0 0 1 4 4 v5 a3.5 3.5 0 0 1 0 7 v4 a4 4 0 0 1-4 4 h-16 a4 4 0 0 1-4-4 Z"
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
<path d="M234 40 q1.8 -2.4 3.6 0 M241 40 q1.8 -2.4 3.6 0" stroke="#101a2c" strokeWidth={1.8} strokeLinecap="round" fill="none" />
</g>
<Star x={284} y={16} r={7} className="vls-cd-star" baseOpacity={0} />
</>
);
/** hint-advanced: closed cabinet door with a sliders glyph → door swings
* open revealing 3 slider tracks with set knobs; pawn hops. Loop 3.2s. */
const hintAdvanced: HintScene = ({ theme }) => (
<>
<style>{`
.vls-adv-blink{transform-box:fill-box;transform-origin:center;animation:vlsAdvBlink 3.2s ease-in-out infinite}
.vls-adv-door{transform-box:fill-box;transform-origin:left center;animation:vlsAdvDoor 3.2s ease-in-out infinite}
.vls-adv-hop{transform-box:fill-box;transform-origin:50% 100%;animation:vlsAdvHop 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsAdvBlink{0%,52%,60%,100%{transform:scaleY(1)}56%{transform:scaleY(.12)}}
@keyframes vlsAdvDoor{0%,8%{transform:scaleX(1)}26%{transform:scaleX(.09)}33%,100%{transform:scaleX(.14)}}
@keyframes vlsAdvHop{0%,40%,62%,100%{transform:translateY(0)}46%{transform:translateY(-4.5px)}52%{transform:translateY(0)}57%{transform:translateY(-2px)}}
${rmBlock(
["vls-adv-blink", "vls-adv-door", "vls-adv-hop"],
[
[".vls-adv-blink,.vls-adv-hop", "transform:none"],
[".vls-adv-door", "transform:scaleX(.14)"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: closed door, sliders glyph embossed, pawn glances over */}
<rect x={44} y={18} width={56} height={60} rx={8} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2.5} />
<rect x={49} y={23} width={44} height={50} rx={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
<path d="M56 36 H74 M56 46 H74" stroke={FAINT} strokeWidth={2} strokeLinecap="round" fill="none" />
<circle cx={64} cy={36} r={2.5} fill="var(--ink)" />
<circle cx={70} cy={46} r={2.5} fill="var(--ink)" />
<circle cx={87} cy={48} r={2.5} fill="var(--ink)" />
<Pawn x={122} yb={80} s={7.5} />
<g className="vls-adv-blink">
<circle cx={118.6} cy={64.2} r={1.2} fill="#101a2c" />
<circle cx={122} cy={64.2} r={1.2} fill="#101a2c" />
</g>
{/* AFTER: tracks + set knobs revealed as the door swings open */}
<rect x={204} y={18} width={56} height={60} rx={8} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2.5} />
<g stroke={FAINT} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M215 34 H251" />
<path d="M215 48 H251" />
<path d="M215 62 H251" />
</g>
<circle cx={222} cy={34} r={4} fill={LIVE} stroke="var(--ink)" strokeWidth={1.5} />
<circle cx={242} cy={48} r={4} fill={STAR_GOLD} stroke="var(--ink)" strokeWidth={1.5} />
<circle cx={230} cy={62} r={4} fill={SKY} stroke="var(--ink)" strokeWidth={1.5} />
<g className="vls-adv-door">
<rect x={209} y={23} width={44} height={50} rx={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
</g>
<g className="vls-adv-hop">
<Pawn x={284} yb={80} s={7.5} />
<circle cx={281} cy={64.2} r={1.2} fill="#101a2c" />
<circle cx={284.4} cy={64.2} r={1.2} fill="#101a2c" />
</g>
</>
);
/** hint-details: a plain row of meter bars → magnifier pops over the bars,
* enlarged inside the lens; pawn peeks. Loop 3s. */
const hintDetails: HintScene = ({ theme }) => (
<>
<style>{`
.vls-dt-lens{transform-box:fill-box;transform-origin:center;animation:vlsDtLens 3s cubic-bezier(.3,1.5,.5,1) infinite}
.vls-dt-blink{transform-box:fill-box;transform-origin:center;animation:vlsDtBlink 3s ease-in-out infinite}
@keyframes vlsDtLens{0%,6%{transform:scale(0);opacity:0}14%{transform:scale(1.12);opacity:1}20%,100%{transform:scale(1);opacity:1}}
@keyframes vlsDtBlink{0%,56%,64%,100%{transform:scaleY(1)}60%{transform:scaleY(.12)}}
${rmBlock(
["vls-dt-lens", "vls-dt-blink"],
[
[".vls-dt-lens", "transform:none;opacity:1"],
[".vls-dt-blink", "transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: plain bar row, pawn looks on */}
<path d="M28 74 H116" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none" />
<g fill="var(--ink)">
<rect x={34} y={60} width={8} height={14} rx={2.5} />
<rect x={50} y={50} width={8} height={24} rx={2.5} />
<rect x={66} y={56} width={8} height={18} rx={2.5} />
<rect x={82} y={44} width={8} height={30} rx={2.5} />
<rect x={98} y={52} width={8} height={22} rx={2.5} />
</g>
<Pawn x={132} yb={80} s={7} />
<circle cx={129.8} cy={65.2} r={1.2} fill="#101a2c" />
<circle cx={132.6} cy={65.2} r={1.2} fill="#101a2c" />
{/* AFTER: magnifier over the same bars, enlarged inside the lens */}
<path d="M188 74 H276" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none" />
<g fill="var(--ink)">
<rect x={194} y={60} width={8} height={14} rx={2.5} />
<rect x={210} y={50} width={8} height={24} rx={2.5} />
<rect x={226} y={56} width={8} height={18} rx={2.5} />
<rect x={242} y={44} width={8} height={30} rx={2.5} />
<rect x={258} y={52} width={8} height={22} rx={2.5} />
</g>
<g className="vls-dt-lens">
<circle cx={232} cy={52} r={21} fill="var(--paper)" stroke="var(--ink)" strokeWidth={3} />
<path d="M247 67 L258 78" stroke="var(--ink)" strokeWidth={4} strokeLinecap="round" fill="none" />
<g fill="var(--ink)">
<rect x={214} y={42} width={10} height={30} rx={3} />
<rect x={227} y={50} width={10} height={22} rx={3} />
<rect x={240} y={40} width={10} height={32} rx={3} />
</g>
</g>
<Pawn x={296} yb={80} s={7} />
<g className="vls-dt-blink">
<circle cx={293.4} cy={65.6} r={1.2} fill="#101a2c" />
<circle cx={296.2} cy={65.6} r={1.2} fill="#101a2c" />
</g>
</>
);
/** hint-more-metrics: one meter row + chevron-down (beckons) → three rows
* unfold with overshoot + chevron-up. Loop 3s. */
const hintMoreMetrics: HintScene = ({ theme }) => (
<>
<style>{`
.vls-mm-chev{animation:vlsMmChev 3s ease-in-out infinite}
.vls-mm-r2{animation:vlsMmIn 3s cubic-bezier(.3,1.5,.5,1) infinite}
.vls-mm-r3{animation:vlsMmIn 3s cubic-bezier(.3,1.5,.5,1) .12s infinite}
@keyframes vlsMmChev{0%,30%,100%{transform:translateY(0)}15%{transform:translateY(2.5px)}}
@keyframes vlsMmIn{0%{opacity:0;transform:translateY(-7px)}8%{opacity:1;transform:translateY(1px)}14%,100%{opacity:1;transform:translateY(0)}}
${rmBlock(
["vls-mm-chev", "vls-mm-r2", "vls-mm-r3"],
[
[".vls-mm-chev", "transform:none"],
[".vls-mm-r2,.vls-mm-r3", "opacity:1;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
{/* BEFORE: one folded row, chevron-down bobs, pawn watches */}
<rect x={34} y={32} width={92} height={15} rx={7.5} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
<g fill="var(--ink)">
<rect x={46} y={38} width={5} height={5} rx={1.5} />
<rect x={56} y={35} width={5} height={8} rx={1.5} />
<rect x={66} y={37} width={5} height={6} rx={1.5} />
<circle cx={80} cy={39.5} r={2} />
</g>
<path className="vls-mm-chev" d="M70 62 l10 8 l10 -8" stroke="var(--ink)" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Pawn x={140} yb={80} s={6} />
<circle cx={138.2} cy={67.4} r={1} fill="#101a2c" />
<circle cx={140.6} cy={67.4} r={1} fill="#101a2c" />
{/* AFTER: three rows unfolded, chevron-up on top */}
<path d="M230 22 l10 -7 l10 7" stroke="var(--ink)" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<rect x={194} y={28} width={92} height={15} rx={7.5} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
<g fill="var(--ink)">
<rect x={206} y={34} width={5} height={5} rx={1.5} />
<rect x={216} y={31} width={5} height={8} rx={1.5} />
<rect x={226} y={33} width={5} height={6} rx={1.5} />
<circle cx={240} cy={35.5} r={2} />
</g>
<g className="vls-mm-r2">
<rect x={194} y={46} width={92} height={15} rx={7.5} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
<rect x={206} y={52} width={5} height={5} rx={1.5} fill={LIVE} />
<rect x={216} y={49} width={5} height={8} rx={1.5} fill={LIVE} />
<rect x={226} y={51} width={5} height={6} rx={1.5} fill={LIVE} />
<circle cx={240} cy={53.5} r={2} fill={LIVE} />
</g>
<g className="vls-mm-r3">
<rect x={194} y={64} width={92} height={15} rx={7.5} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
<rect x={206} y={70} width={5} height={5} rx={1.5} fill={STAR_GOLD} />
<rect x={216} y={67} width={5} height={8} rx={1.5} fill={STAR_GOLD} />
<rect x={226} y={69} width={5} height={6} rx={1.5} fill={STAR_GOLD} />
<circle cx={240} cy={71.5} r={2} fill={STAR_GOLD} />
</g>
<Pawn x={300} yb={80} s={6} />
<circle cx={298.2} cy={67.4} r={1} fill="#101a2c" />
<circle cx={300.6} cy={67.4} r={1} fill="#101a2c" />
</>
);
export const SET3_SCENES: Record<Set3Kind, HintScene> = {
"hint-quality": hintQuality,
"hint-audio-quality": hintAudioQuality,
"hint-degrade-pref": hintDegradePref,
"hint-codec": hintCodec,
"hint-advanced": hintAdvanced,
"hint-details": hintDetails,
"hint-more-metrics": hintMoreMetrics,
};
+441
View File
@@ -0,0 +1,441 @@
// Hint set 4 — people & misc: topology, close, admit/deny, rename, theme, join-go, route p2p/sfu.
// Scenes follow ../Comic.tsx conventions: 320x96 canvas, 2-panel
// before→after idiom (Frame x={4} w={152} + Frame x={164} w={152}), vls-
// prefixed keyframes in an inline <style>, rmBlock for reduced motion.
// Absolute coordinates only: any element whose class animates `transform`
// carries no transform attribute — positions are baked into path data.
import {
Crown,
Door,
FAINT,
Frame,
LIVE,
MINT,
Pawn,
RedX,
SKY,
ServerBox,
Spark,
Star,
STAR_GOLD,
TV_SCREEN,
rmBlock,
} from "../Comic";
import type { HintScene, Set4Kind } from "./index";
const INK = "var(--ink)";
const WALL2 = "var(--wall-2)";
const EYE = "#101a2c";
/* hint-topology: [one crowned pawn alone] → [crowned pawn with two leaf
pawns on live-green edges]. Loop 3.2s: leaves hop in turn (8-20%, 26-38%),
everyone blinks at 62%; rest >=55%. */
const HintTopology: HintScene = ({ theme }) => (
<>
<style>{`
.vls-top-eyes{transform-box:fill-box;transform-origin:center;animation:vlsTopBlink 3.2s ease-in-out infinite}
.vls-top-leaf1{transform-box:fill-box;transform-origin:50% 100%;animation:vlsTopHop1 3.2s ease-in-out infinite}
.vls-top-leaf2{transform-box:fill-box;transform-origin:50% 100%;animation:vlsTopHop2 3.2s ease-in-out infinite}
@keyframes vlsTopBlink{0%,58%,66%,100%{transform:scaleY(1)}62%{transform:scaleY(.12)}}
@keyframes vlsTopHop1{0%,8%{transform:translateY(0)}14%{transform:translateY(-4px)}20%,100%{transform:translateY(0)}}
@keyframes vlsTopHop2{0%,26%{transform:translateY(0)}32%{transform:translateY(-4px)}38%,100%{transform:translateY(0)}}
${rmBlock(
["vls-top-eyes", "vls-top-leaf1", "vls-top-leaf2"],
[[".vls-top-eyes,.vls-top-leaf1,.vls-top-leaf2", "transform:none"]],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<Crown x={80} y={47} />
<Pawn x={80} yb={78} s={12} />
<g className="vls-top-eyes" fill={EYE}>
<circle cx={77.4} cy={54} r={1.3} />
<circle cx={82.6} cy={54} r={1.3} />
</g>
<g stroke={LIVE} strokeWidth={3} strokeLinecap="round" fill="none">
<path d="M240 47 C231 55 222 58 213 65" />
<path d="M240 47 C249 55 258 58 267 65" />
</g>
<Pawn x={240} yb={46} s={7} />
<g className="vls-top-eyes" fill={EYE}>
<circle cx={237.8} cy={32} r={0.9} />
<circle cx={242.2} cy={32} r={0.9} />
</g>
<Crown x={240} y={27} k={0.75} />
<Pawn x={210} yb={80} s={7} color={SKY} eyes className="vls-top-leaf1" />
<Pawn x={270} yb={80} s={7} color={MINT} eyes className="vls-top-leaf2" />
</>
);
/* hint-close: [open card] → [card folds away + small RedX stamp]. Loop 3.4s:
card wobbles (10-28%), folds out (40-52%), X stamps once at 56-62% and
holds; rest 62-100%. */
const HintClose: HintScene = ({ theme }) => (
<>
<style>{`
.vls-cls-card{transform-box:fill-box;transform-origin:center;animation:vlsClsCard 3.4s ease-in-out infinite}
.vls-cls-x{transform-box:fill-box;transform-origin:center;animation:vlsClsX 3.4s ease-out infinite}
@keyframes vlsClsCard{0%,10%{transform:rotate(0);opacity:1}16%{transform:rotate(2.5deg)}22%{transform:rotate(-2.5deg)}28%,40%{transform:rotate(0);opacity:1}52%,100%{transform:rotate(-14deg) translate(-12px,20px) scale(.55);opacity:0}}
@keyframes vlsClsX{0%,56%{opacity:0;transform:scale(1.6) rotate(8deg)}62%,100%{opacity:1;transform:scale(1) rotate(8deg)}}
${rmBlock(
["vls-cls-card", "vls-cls-x"],
[
[".vls-cls-card", "opacity:0;transform:none"],
[".vls-cls-x", "opacity:1;transform:scale(1) rotate(8deg)"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<rect x={52} y={22} width={56} height={50} rx={8} fill={WALL2} stroke={INK} strokeWidth={2.5} />
<g stroke={INK} strokeWidth={2} strokeLinecap="round" fill="none" opacity={0.5}>
<path d="M62 36 q4 -5 8 0 t8 0 t8 0" />
<path d="M62 48 q4 -5 8 0 t8 0" />
</g>
<g className="vls-cls-card">
<rect x={212} y={22} width={56} height={50} rx={8} fill={WALL2} stroke={INK} strokeWidth={2.5} />
<g stroke={INK} strokeWidth={2} strokeLinecap="round" fill="none" opacity={0.5}>
<path d="M222 36 q4 -5 8 0 t8 0 t8 0" />
<path d="M222 48 q4 -5 8 0 t8 0" />
</g>
</g>
<RedX cx={266} cy={24} arm={6.5} className="vls-cls-x" />
</>
);
/* hint-admit: [pawn knocking at a shut door] → [door open, pawn hops in,
star]. Loop 3.2s: two knocks 0-24%, hop-in 10-26%, star pop 30-42%;
rest ~58%. */
const HintAdmit: HintScene = ({ theme }) => (
<>
<style>{`
.vls-adm-knock{animation:vlsAdmKnock 3.2s ease-in-out infinite}
.vls-adm-kn{animation:vlsAdmKn 3.2s ease-in-out infinite}
.vls-adm-hop{transform-box:fill-box;transform-origin:50% 100%;animation:vlsAdmHop 3.2s cubic-bezier(.3,1.4,.5,1) infinite}
.vls-adm-star{transform-box:fill-box;transform-origin:center;animation:vlsAdmStar 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsAdmKnock{0%{transform:translateX(0)}6%{transform:translateX(4px)}12%{transform:translateX(0)}18%{transform:translateX(4px)}24%,100%{transform:translateX(0)}}
@keyframes vlsAdmKn{0%,3%{opacity:0}7%{opacity:.9}11%{opacity:0}15%{opacity:.9}22%,100%{opacity:0}}
@keyframes vlsAdmHop{0%,10%{transform:translate(-40px,0)}18%{transform:translate(-18px,-9px)}26%,100%{transform:translate(0,0)}}
@keyframes vlsAdmStar{0%,30%{opacity:0;transform:scale(0)}36%{opacity:1;transform:scale(1.25)}42%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-adm-knock", "vls-adm-kn", "vls-adm-hop", "vls-adm-star"],
[
[".vls-adm-kn", "opacity:0"],
[".vls-adm-hop,.vls-adm-star", "transform:none"],
[".vls-adm-star", "opacity:1"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<g className="vls-adm-knock">
<Pawn x={63} yb={76} s={9} eyes />
</g>
<g className="vls-adm-kn" stroke={INK} strokeWidth={2} strokeLinecap="round" opacity={0}>
<path d="M84 36l-7-3M82 46h-8M84 56l-7 3" />
</g>
<Door x={96} y={18} />
<rect x={242} y={18} width={38} height={56} rx={5} fill={TV_SCREEN} stroke={INK} strokeWidth={2.5} />
<path d="M242 18 L262 25 L262 67 L242 74 Z" fill={WALL2} stroke={INK} strokeWidth={2.5} strokeLinejoin="round" />
<circle cx={257} cy={46} r={1.8} fill={INK} />
<g className="vls-adm-hop">
<Pawn x={271} yb={74} s={8} eyes />
</g>
<Star x={288} y={24} className="vls-adm-star" baseOpacity={0} />
</>
);
/* hint-deny: [pawn at a shut door] → [door stays shut, pawn turned away
waving bye with a head-tilt; footprints trail back]. Gentle: no RedX.
Loop 3.2s: two knocks 0-24%, wave 32-52%; rest ~48%. */
const HintDeny: HintScene = ({ theme }) => (
<>
<style>{`
.vls-dny-kn{animation:vlsDnyKn 3.2s ease-in-out infinite}
.vls-dny-wave{transform-box:fill-box;transform-origin:50% 100%;animation:vlsDnyWave 3.2s ease-in-out infinite}
@keyframes vlsDnyKn{0%,3%{opacity:0}7%{opacity:.9}11%{opacity:0}15%{opacity:.9}22%,100%{opacity:0}}
@keyframes vlsDnyWave{0%,32%{transform:rotate(0)}36%{transform:rotate(-8deg)}40%{transform:rotate(8deg)}44%{transform:rotate(-8deg)}48%{transform:rotate(8deg)}52%,100%{transform:rotate(0)}}
${rmBlock(
["vls-dny-kn", "vls-dny-wave"],
[[".vls-dny-kn", "opacity:0"], [".vls-dny-wave", "transform:none"]],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} />
<Pawn x={60} yb={76} s={9} eyes />
<g className="vls-dny-kn" stroke={INK} strokeWidth={2} strokeLinecap="round" opacity={0}>
<path d="M88 36l-7-3M86 46h-8M88 56l-7 3" />
</g>
<Door x={96} y={18} />
<Door x={252} y={18} />
<g fill={INK} opacity={0.35}>
<circle cx={222} cy={72} r={1.5} />
<circle cx={232} cy={68} r={1.5} />
</g>
<g className="vls-dny-wave">
<Pawn x={206} yb={76} s={9} />
<circle cx={202.5} cy={58} r={1} fill={EYE} />
<circle cx={206} cy={58} r={1} fill={EYE} />
</g>
</>
);
/* hint-rename: [blank name tag] → [pencil writes a squiggle on the tag +
spark]. Loop 3.2s: pencil rides the pen tip left→right wiggling (6-34%)
as the line draws, spark pops 38-46%, holds; rest 54%. */
const HintRename: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rnm-line{stroke-dasharray:1;animation:vlsRnmLine 3.2s ease-in-out infinite}
.vls-rnm-pencil{transform-box:fill-box;transform-origin:0% 100%;animation:vlsRnmPencil 3.2s ease-in-out infinite}
.vls-rnm-spark{transform-box:fill-box;transform-origin:center;animation:vlsRnmSpark 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsRnmLine{0%,6%{stroke-dashoffset:1}34%,100%{stroke-dashoffset:0}}
@keyframes vlsRnmPencil{0%,6%{transform:translate(-30px,0) rotate(0)}12%{transform:translate(-21px,0) rotate(-6deg)}18%{transform:translate(-13px,0) rotate(5deg)}24%{transform:translate(-6px,0) rotate(-5deg)}30%{transform:translate(-2px,0) rotate(4deg)}34%,100%{transform:translate(0,0) rotate(0)}}
@keyframes vlsRnmSpark{0%,38%{opacity:0;transform:scale(.4)}44%{opacity:1;transform:scale(1.2)}50%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-rnm-line", "vls-rnm-pencil", "vls-rnm-spark"],
[
[".vls-rnm-line", "stroke-dashoffset:0"],
[".vls-rnm-pencil", "transform:none"],
[".vls-rnm-spark", "opacity:1;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<rect x={44} y={30} width={72} height={30} rx={8} fill={WALL2} stroke={INK} strokeWidth={2.5} />
<circle cx={55} cy={45} r={2.5} fill="var(--paper)" stroke={INK} strokeWidth={1.5} />
<path d="M66 45 h40" stroke={INK} strokeWidth={2} strokeLinecap="round" strokeDasharray="3 4" opacity={0.3} />
<rect x={204} y={30} width={72} height={30} rx={8} fill={WALL2} stroke={INK} strokeWidth={2.5} />
<circle cx={215} cy={45} r={2.5} fill="var(--paper)" stroke={INK} strokeWidth={1.5} />
<path
className="vls-rnm-line"
d="M224 45 q5 -7 10 0 t10 0 t10 0"
pathLength={1}
stroke={INK}
strokeWidth={2.5}
strokeLinecap="round"
fill="none"
/>
<g className="vls-rnm-pencil">
<polygon points="254.3,39.7 265.6,28.4 270.6,33.4 259.3,44.7" fill={STAR_GOLD} stroke={INK} strokeWidth={2} strokeLinejoin="round" />
<polygon points="254.3,39.7 259.3,44.7 254,45" fill={INK} />
</g>
<Spark x={266} y={24} className="vls-rnm-spark" />
</>
);
/* hint-theme: single wide panel — sun and moon on a two-arrow do-si-do.
Loop 3s: sun owns 0-38%, crossfade 38-50%, moon owns 50-88%, fade back;
arrows static. */
const HintTheme: HintScene = ({ theme }) => (
<>
<style>{`
.vls-thm-sun{transform-box:fill-box;transform-origin:center;animation:vlsThmSun 3s ease-in-out infinite}
.vls-thm-moon{transform-box:fill-box;transform-origin:center;animation:vlsThmMoon 3s ease-in-out infinite}
@keyframes vlsThmSun{0%,38%{opacity:1;transform:scale(1)}50%,88%{opacity:.45;transform:scale(.92)}100%{opacity:1;transform:scale(1)}}
@keyframes vlsThmMoon{0%,38%{opacity:.45;transform:scale(.92)}50%,88%{opacity:1;transform:scale(1)}100%{opacity:.45;transform:scale(.92)}}
${rmBlock(
["vls-thm-sun", "vls-thm-moon"],
[[".vls-thm-sun,.vls-thm-moon", "opacity:1;transform:none"]],
)}
`}</style>
<Frame x={4} w={312} theme={theme} />
<g stroke={INK} strokeWidth={2.5} strokeLinecap="round" fill="none" opacity={0.55}>
<path d="M100 34 A90 90 0 0 1 220 34" />
<path d="M212 27 L220 34 L212 41" />
<path d="M220 62 A90 90 0 0 0 100 62" />
<path d="M108 55 L100 62 L108 69" />
</g>
<g className="vls-thm-sun">
<g stroke={INK} strokeWidth={2.5} strokeLinecap="round">
<path d="M125 48h5 M90 48h5 M117.5 61l2.5 4.3 M102.5 35l-2.5-4.3 M102.5 61l-2.5 4.3 M117.5 35l2.5-4.3" />
</g>
<circle cx={110} cy={48} r={11} fill={STAR_GOLD} stroke={INK} strokeWidth={2} />
</g>
<g className="vls-thm-moon">
<circle cx={210} cy={48} r={11} fill={INK} />
<circle cx={214} cy={44} r={9} fill="var(--paper)" />
<circle cx={228} cy={32} r={1.8} fill={STAR_GOLD} />
<circle cx={232} cy={48} r={1.4} fill={STAR_GOLD} />
</g>
</>
);
/* hint-join-go: [pawn beside 4 filled code slots] → [pawn hops through an
open door + star]. Loop 3.2s: blink at 62%, hop-in 8-24%, star 28-42%;
rest ~58%. */
const HintJoinGo: HintScene = ({ theme }) => (
<>
<style>{`
.vls-jgo-eyes{transform-box:fill-box;transform-origin:center;animation:vlsJgoBlink 3.2s ease-in-out infinite}
.vls-jgo-hop{transform-box:fill-box;transform-origin:50% 100%;animation:vlsJgoHop 3.2s cubic-bezier(.3,1.4,.5,1) infinite}
.vls-jgo-star{transform-box:fill-box;transform-origin:center;animation:vlsJgoStar 3.2s cubic-bezier(.3,1.5,.5,1) infinite}
@keyframes vlsJgoBlink{0%,58%,66%,100%{transform:scaleY(1)}62%{transform:scaleY(.12)}}
@keyframes vlsJgoHop{0%,8%{transform:translate(-38px,0)}16%{transform:translate(-18px,-10px)}24%,100%{transform:translate(0,0)}}
@keyframes vlsJgoStar{0%,28%{opacity:0;transform:scale(0)}34%{opacity:1;transform:scale(1.25)}42%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-jgo-eyes", "vls-jgo-hop", "vls-jgo-star"],
[
[".vls-jgo-eyes,.vls-jgo-hop,.vls-jgo-star", "transform:none"],
[".vls-jgo-star", "opacity:1"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<Pawn x={32} yb={76} s={9} />
<g className="vls-jgo-eyes" fill={EYE}>
<circle cx={34.6} cy={58} r={1} />
<circle cx={38.2} cy={58} r={1} />
</g>
<g fill={WALL2} stroke={INK} strokeWidth={2}>
<rect x={58} y={32} width={15} height={24} rx={4} />
<rect x={79} y={32} width={15} height={24} rx={4} />
<rect x={100} y={32} width={15} height={24} rx={4} />
<rect x={121} y={32} width={15} height={24} rx={4} />
</g>
<g fill={INK}>
<circle cx={65.5} cy={44} r={2.5} />
<circle cx={86.5} cy={44} r={2.5} />
<circle cx={107.5} cy={44} r={2.5} />
<circle cx={128.5} cy={44} r={2.5} />
</g>
<rect x={224} y={16} width={40} height={58} rx={5} fill={TV_SCREEN} stroke={INK} strokeWidth={2.5} />
<path d="M224 16 L246 24 L246 68 L224 74 Z" fill={WALL2} stroke={INK} strokeWidth={2.5} strokeLinejoin="round" />
<circle cx={241} cy={45} r={1.8} fill={INK} />
<g className="vls-jgo-hop">
<Pawn x={255} yb={76} s={9} eyes />
</g>
<Star x={280} y={22} className="vls-jgo-star" baseOpacity={0} />
</>
);
/* hint-route-p2p: [two pawns apart] → [direct live-green arc pawn→pawn,
live dot on the apex]. Loop 3.2s: arc draws 6-28%, both pawns hop 30-42%,
panel-1 pair blinks at 64%; rest ~58%. */
const HintRouteP2p: HintScene = ({ theme }) => (
<>
<style>{`
.vls-p2p-eyes{transform-box:fill-box;transform-origin:center;animation:vlsP2pBlink 3.2s ease-in-out infinite}
.vls-p2p-arc{stroke-dasharray:1;animation:vlsP2pArc 3.2s ease-in-out infinite}
.vls-p2p-hop{animation:vlsP2pHop 3.2s ease-in-out infinite}
@keyframes vlsP2pBlink{0%,60%,68%,100%{transform:scaleY(1)}64%{transform:scaleY(.12)}}
@keyframes vlsP2pArc{0%,6%{stroke-dashoffset:1}28%,100%{stroke-dashoffset:0}}
@keyframes vlsP2pHop{0%,30%{transform:translateY(0)}36%{transform:translateY(-4px)}42%,100%{transform:translateY(0)}}
${rmBlock(
["vls-p2p-eyes", "vls-p2p-arc", "vls-p2p-hop"],
[
[".vls-p2p-arc", "stroke-dashoffset:0"],
[".vls-p2p-eyes,.vls-p2p-hop", "transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<Pawn x={42} yb={74} s={9} />
<g className="vls-p2p-eyes" fill={EYE}>
<circle cx={45} cy={56} r={1} />
<circle cx={48.5} cy={56} r={1} />
</g>
<Pawn x={118} yb={74} s={9} color={SKY} />
<g className="vls-p2p-eyes" fill={EYE}>
<circle cx={111.5} cy={56} r={1} />
<circle cx={115} cy={56} r={1} />
</g>
<g fill={FAINT}>
<circle cx={74} cy={60} r={1.5} />
<circle cx={88} cy={60} r={1.5} />
</g>
<g className="vls-p2p-hop">
<Pawn x={202} yb={74} s={9} eyes />
<Pawn x={278} yb={74} s={9} color={SKY} eyes />
</g>
<path
className="vls-p2p-arc"
d="M213 58 Q240 30 267 58"
pathLength={1}
stroke={LIVE}
strokeWidth={3}
strokeLinecap="round"
fill="none"
/>
<circle cx={240} cy={44} r={3.5} fill={LIVE} />
</>
);
/* hint-route-sfu: [two pawns apart] → [two live-green arcs detour up
through a lit server box]. Loop 3.2s: left arc 6-24%, right arc 14-32%,
both pawns hop 34-46%, panel-1 pair blinks at 64%; rest ~54%. */
const HintRouteSfu: HintScene = ({ theme }) => (
<>
<style>{`
.vls-sfu-eyes{transform-box:fill-box;transform-origin:center;animation:vlsSfuBlink 3.2s ease-in-out infinite}
.vls-sfu-a1{stroke-dasharray:1;animation:vlsSfuA1 3.2s ease-in-out infinite}
.vls-sfu-a2{stroke-dasharray:1;animation:vlsSfuA2 3.2s ease-in-out infinite}
.vls-sfu-hop{animation:vlsSfuHop 3.2s ease-in-out infinite}
@keyframes vlsSfuBlink{0%,60%,68%,100%{transform:scaleY(1)}64%{transform:scaleY(.12)}}
@keyframes vlsSfuA1{0%,6%{stroke-dashoffset:1}24%,100%{stroke-dashoffset:0}}
@keyframes vlsSfuA2{0%,14%{stroke-dashoffset:1}32%,100%{stroke-dashoffset:0}}
@keyframes vlsSfuHop{0%,34%{transform:translateY(0)}40%{transform:translateY(-4px)}46%,100%{transform:translateY(0)}}
${rmBlock(
["vls-sfu-eyes", "vls-sfu-a1", "vls-sfu-a2", "vls-sfu-hop"],
[
[".vls-sfu-a1,.vls-sfu-a2", "stroke-dashoffset:0"],
[".vls-sfu-eyes,.vls-sfu-hop", "transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={LIVE} />
<Pawn x={42} yb={74} s={9} />
<g className="vls-sfu-eyes" fill={EYE}>
<circle cx={45} cy={56} r={1} />
<circle cx={48.5} cy={56} r={1} />
</g>
<Pawn x={118} yb={74} s={9} color={SKY} />
<g className="vls-sfu-eyes" fill={EYE}>
<circle cx={111.5} cy={56} r={1} />
<circle cx={115} cy={56} r={1} />
</g>
<g fill={FAINT}>
<circle cx={74} cy={60} r={1.5} />
<circle cx={88} cy={60} r={1.5} />
</g>
<ServerBox x={228} y={12} lit />
<g className="vls-sfu-hop">
<Pawn x={200} yb={76} s={9} eyes />
<Pawn x={280} yb={76} s={9} color={SKY} eyes />
</g>
<path
className="vls-sfu-a1"
d="M209 60 Q219 34 232 29"
pathLength={1}
stroke={LIVE}
strokeWidth={3}
strokeLinecap="round"
fill="none"
/>
<path
className="vls-sfu-a2"
d="M271 60 Q261 34 248 29"
pathLength={1}
stroke={LIVE}
strokeWidth={3}
strokeLinecap="round"
fill="none"
/>
</>
);
export const SET4_SCENES: Record<Set4Kind, HintScene> = {
"hint-topology": HintTopology,
"hint-close": HintClose,
"hint-admit": HintAdmit,
"hint-deny": HintDeny,
"hint-rename": HintRename,
"hint-theme": HintTheme,
"hint-join-go": HintJoinGo,
"hint-route-p2p": HintRouteP2p,
"hint-route-sfu": HintRouteSfu,
};
+306
View File
@@ -0,0 +1,306 @@
// Living-room control primitives. Every glyph control keeps its copy in
// title/aria; text modes add a visible caption from the same catalog.
import type { CSSProperties, MouseEvent, ReactNode } from "react";
import { ComicTooltip } from "./ComicTooltip";
import type { ComicKind } from "./Comic";
import type { HintKind } from "./hints";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
// Matches the global `button:disabled` look (styles.css) without the disabled
// attribute, which would swallow the pointer events and focus the comic
// tooltip needs. pointer-events: none routes hover/touch to the wrap so all
// three show channels keep working; keyboard focus is unaffected.
const SOFT_DISABLED_STYLE: CSSProperties = { opacity: 0.4, pointerEvents: "none" };
// Activation guard for soft-disabled (aria-disabled) hint triggers: the
// control stays focusable so its comic is reachable, but it never fires.
function blockSoftDisabledClick(event: MouseEvent<HTMLButtonElement>): void {
event.preventDefault();
event.stopPropagation();
}
// Pointer activations blur the trigger so the comic is not pinned open by
// focus after the action completes; keyboard clicks (detail 0) keep focus,
// so the panel persists for genuine keyboard use.
function blurAfterPointerClick(event: MouseEvent<HTMLButtonElement>): void {
if (event.detail !== 0) event.currentTarget.blur();
}
// Renders the functional icon in every mode; vis mode never swaps icons for
// script — controls stay identical to the text version, hints come from the
// comic tooltip.
export function VisGlyph({
name,
size = 18,
draw,
}: {
name: GlyphName;
size?: number;
/** Draw-in spot id (state-beat icons only — see Glyph). */
draw?: string;
}) {
return <Glyph name={name} size={size} draw={draw} />;
}
export function Cap({ k }: { k: CopyKey }) {
const { vis, t } = useCopy();
return vis ? null : <span className="lr-cap">{t(k)}</span>;
}
export function Btn({
icon,
cap,
tone,
title,
disabled,
pressed,
expanded,
controls,
onClick,
type = "button",
hint,
draw,
}: {
icon: GlyphName;
cap?: CopyKey;
tone?: "primary" | "danger" | "on";
title: CopyKey;
disabled?: boolean;
pressed?: boolean;
expanded?: boolean;
controls?: string;
onClick?: () => void;
type?: "button" | "submit";
/** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */
hint?: HintKind;
/** Draw-in spot id for state-beat toggle icons (see Glyph). */
draw?: string;
}) {
const { t, vis } = useCopy();
const label = t(title);
const wrapped = vis && hint;
const softDisabled = Boolean(wrapped && disabled);
const button = (
<button
type={type}
className={`lr-btn${tone ? ` is-${tone}` : ""}`}
title={vis ? undefined : label}
aria-label={label}
disabled={disabled && !softDisabled}
aria-disabled={softDisabled || undefined}
aria-pressed={pressed}
aria-expanded={expanded}
aria-controls={controls}
style={softDisabled ? SOFT_DISABLED_STYLE : undefined}
onClick={
softDisabled
? blockSoftDisabledClick
: wrapped
? (event) => {
blurAfterPointerClick(event);
onClick?.();
}
: onClick
}
>
<Glyph name={icon} size={19} draw={draw} />
{cap ? <Cap k={cap} /> : null}
</button>
);
return wrapped ? (
<ComicTooltip kind={wrapped}>{button}</ComicTooltip>
) : (
button
);
}
export function Chip({
selected,
disabled,
onClick,
title,
hint,
children,
}: {
selected: boolean;
disabled?: boolean;
onClick?: () => void;
title: string;
/** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */
hint?: HintKind;
children: ReactNode;
}) {
const { vis } = useCopy();
const wrapped = vis && hint;
const softDisabled = Boolean(wrapped && disabled);
const chip = (
<button
type="button"
className={`lr-chip${selected ? " is-selected" : ""}`}
aria-pressed={selected}
disabled={disabled && !softDisabled}
aria-disabled={softDisabled || undefined}
style={softDisabled ? SOFT_DISABLED_STYLE : undefined}
title={vis ? undefined : title}
aria-label={title}
onClick={
softDisabled
? blockSoftDisabledClick
: wrapped
? (event) => {
blurAfterPointerClick(event);
onClick?.();
}
: onClick
}
>
{children}
</button>
);
return wrapped ? (
<ComicTooltip kind={wrapped}>{chip}</ComicTooltip>
) : (
chip
);
}
export function Row({
sub,
label,
children,
}: {
sub?: boolean;
label?: string;
children: ReactNode;
}) {
return (
<div className={`lr-row${sub ? " is-sub" : ""}`} role={label ? "group" : undefined} aria-label={label}>
{children}
</div>
);
}
export function RowGroup({
actions,
children,
}: {
actions?: boolean;
children: ReactNode;
}) {
return (
<div className={actions ? "lr-row-group lr-group-actions" : "lr-row-group"}>
{children}
</div>
);
}
export function Pill({
icon,
tone,
label,
alert,
comic,
}: {
icon: GlyphName;
tone?: "bad" | "good";
label: string;
alert?: boolean;
comic?: ComicKind;
}) {
const { vis } = useCopy();
const wrapped = vis && comic;
const body = (
<span
className={`lr-pill${tone ? ` is-${tone}` : ""}`}
role={alert ? "alert" : "status"}
title={vis ? undefined : label}
// Hint-wrapped bodies must take keyboard focus, or the comic is
// unreachable for keyboard users (spans never match :focus-visible).
tabIndex={wrapped ? 0 : undefined}
>
<Glyph name={icon} size={16} />
{vis ? <span className="visually-hidden">{label}</span> : <span>{label}</span>}
</span>
);
return wrapped ? (
<ComicTooltip kind={wrapped}>{body}</ComicTooltip>
) : (
body
);
}
export function SwitchItem({
checked,
disabled,
onChange,
label,
note,
hint,
}: {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
label: string;
/** Text modes: extra sentence appended to the title tooltip. */
note?: string;
/** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */
hint?: HintKind;
}) {
const { vis } = useCopy();
const wrapped = vis && hint;
const softDisabled = Boolean(wrapped && disabled);
const item = (
<span className="lr-switch-item">
<button
type="button"
className="lr-switch"
role="switch"
aria-checked={checked}
aria-label={label}
title={vis ? undefined : note ? `${label} · ${note}` : label}
disabled={disabled && !softDisabled}
aria-disabled={softDisabled || undefined}
style={softDisabled ? SOFT_DISABLED_STYLE : undefined}
onClick={
softDisabled
? blockSoftDisabledClick
: (event) => {
if (wrapped) blurAfterPointerClick(event);
onChange(!checked);
}
}
/>
{vis ? null : <span className="lr-cap">{label}</span>}
</span>
);
return wrapped ? (
<ComicTooltip kind={wrapped}>{item}</ComicTooltip>
) : (
item
);
}
export function NameTag({ name }: { name: string }) {
const { t, vis } = useCopy();
return (
<span className="lr-name-tag" title={vis ? undefined : t("host.name")}>
<i aria-hidden="true" />
<span>{name}</span>
</span>
);
}
export function FieldCap({ k }: { k: CopyKey }) {
const { vis, t } = useCopy();
return vis ? null : <span className="lr-field-cap">{t(k)}</span>;
}
export function StatusText({ children }: { children: ReactNode }) {
const { vis } = useCopy();
return vis ? null : (
<span className="lr-status-text" role="status" aria-live="polite">
{children}
</span>
);
}
+1 -7
View File
@@ -1,13 +1,7 @@
import type { ParticipantRouteAssignment } from "../../shared/protocol";
import type { ConnectionMetrics, PeerSnapshot } from "../types";
export const MEDIA_ROUTE_PRESENTATION = {
p2p: { tone: "good", label: "P2P", icon: "network" },
sfu: { tone: "warning", label: "SFU fallback", icon: "server" },
} as const;
export const ROUTING_STATUS_PRESENTATION = { tone: "neutral", label: "线路分配中" } as const;
export type PresentedMediaRoute = keyof typeof MEDIA_ROUTE_PRESENTATION;
export type PresentedMediaRoute = "p2p" | "sfu";
export function hasPeerRouteEvidence(
peer: PeerSnapshot | null | undefined,
+28 -17
View File
@@ -1,3 +1,4 @@
import { say } from "../ui/copy";
import {
createRoomResponseSchema,
roomAccessUpdateResponseSchema,
@@ -26,20 +27,20 @@ async function responseBody(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
throw new ApiError("服务返回了无法识别的响应", response.status);
throw new ApiError(say("host.err.serverError"), response.status);
}
}
function parseSiteAccessStatus(value: unknown): SiteAccessStatus {
if (!value || typeof value !== "object") {
throw new ApiError("验证服务返回的数据格式不正确", 502);
throw new ApiError(say("host.err.serverError"), 502);
}
const record = value as Record<string, unknown>;
if (
typeof record.required !== "boolean" ||
typeof record.authenticated !== "boolean"
) {
throw new ApiError("验证服务返回的数据格式不正确", 502);
throw new ApiError(say("host.err.serverError"), 502);
}
return {
required: record.required,
@@ -55,8 +56,10 @@ export async function getSiteAccess(): Promise<SiteAccessStatus> {
if (!response.ok) {
throw new ApiError(
response.status === 401
? "站点访问已失效,请重新验证"
: `站点验证服务暂时不可用 (${response.status})`,
? say("gate.expired")
: say("gate.serviceUnavailable", {
status: String(response.status),
}),
response.status,
);
}
@@ -77,8 +80,10 @@ export async function authenticateSiteAccess(
if (!response.ok) {
throw new ApiError(
response.status === 401
? "站点口令不正确,请重试"
: `站点验证服务暂时不可用 (${response.status})`,
? say("gate.wrong")
: say("gate.serviceUnavailable", {
status: String(response.status),
}),
response.status,
);
}
@@ -108,15 +113,17 @@ export async function createRoom(
if (!response.ok) {
throw new ApiError(
response.status === 401
? "站点访问已失效,请重新验证"
: `当前无法创建房间 (${response.status})`,
? say("gate.expired")
: say("host.err.createRoomStatus", {
status: String(response.status),
}),
response.status,
);
}
const parsed = createRoomResponseSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError("建房服务返回的数据格式不正确", 502);
throw new ApiError(say("host.err.serverError"), 502);
}
return parsed.data;
}
@@ -143,14 +150,16 @@ export async function replaceOwnedRoom(
if (!response.ok) {
throw new ApiError(
response.status === 404
? "房间不存在或已过期"
: `当前无法更换房间 (${response.status})`,
? say("viewer.msg.notFound")
: say("host.err.replaceRoomStatus", {
status: String(response.status),
}),
response.status,
);
}
const parsed = createRoomResponseSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError("换房服务返回的数据格式不正确", 502);
throw new ApiError(say("host.err.serverError"), 502);
}
return parsed.data;
}
@@ -173,17 +182,19 @@ export async function updateRoomAccess(
if (!response.ok) {
throw new ApiError(
response.status === 401
? "站点访问已失效,请重新验证"
? say("gate.expired")
: response.status === 404
? "房间不存在或已过期"
: `当前无法更新房间设置 (${response.status})`,
? say("viewer.msg.notFound")
: say("host.err.updateRoomStatus", {
status: String(response.status),
}),
response.status,
);
}
const parsed = roomAccessUpdateResponseSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError("房间设置服务返回的数据格式不正确", 502);
throw new ApiError(say("host.err.serverError"), 502);
}
return parsed.data;
}
+6 -6
View File
@@ -1,9 +1,8 @@
import {
DEFAULT_HOST_DISPLAY_NAME_PREFIX,
DEFAULT_VIEWER_DISPLAY_NAME,
normalizeDisplayName,
type DisplayName,
} from "../../shared/protocol";
import { say } from "../ui/copy";
const DISPLAY_NAME_STORAGE_KEY = "screener:display-name:v1";
@@ -25,22 +24,23 @@ export function readStoredDisplayName(): DisplayName | null {
}
export function readDisplayName(
fallback: DisplayName = DEFAULT_VIEWER_DISPLAY_NAME,
fallback: DisplayName = say("common.name.viewerDefault") as DisplayName,
): DisplayName {
return readStoredDisplayName() ?? fallback;
}
export function defaultHostDisplayName(clientId: string): DisplayName {
const suffix = clientId.slice(-6);
const prefix = say("common.name.hostDefault");
return (
normalizeDisplayName(`${DEFAULT_HOST_DISPLAY_NAME_PREFIX}-${suffix}`) ??
DEFAULT_HOST_DISPLAY_NAME_PREFIX
normalizeDisplayName(suffix ? `${prefix}-${suffix}` : prefix) ??
(prefix as DisplayName)
);
}
export function saveDisplayName(
value: string,
fallback: DisplayName = DEFAULT_VIEWER_DISPLAY_NAME,
fallback: DisplayName = say("common.name.viewerDefault") as DisplayName,
): DisplayName | null {
const normalized = normalizeDisplayName(value);
const useFallback = value.length === 0 || /^\p{Zs}+$/u.test(value);
+3
View File
@@ -1,8 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { initTheme } from "./ui/theme";
import "./styles.css";
initTheme();
const root = document.getElementById("root");
if (!root) {
throw new Error("Application root was not found");
+9 -8
View File
@@ -1,3 +1,4 @@
import { say, type CopyKey } from "../ui/copy";
import type {
ClientMessage,
MediaRoutePhase,
@@ -663,13 +664,13 @@ async function disconnectPublisher(
}
function sfuFailureWarning(stage: SfuPublisherFailureStage): string {
const label: Record<SfuPublisherFailureStage, string> = {
connect: "连接",
source: "分享源",
"video-publish": "视频发布",
"sender-config": "视频参数配置",
"audio-publish": "音频发布",
transport: "传输",
const stageKey: Record<SfuPublisherFailureStage, CopyKey> = {
connect: "host.warn.sfuStage.connect",
source: "host.warn.sfuStage.source",
"video-publish": "host.warn.sfuStage.videoPublish",
"sender-config": "host.warn.sfuStage.senderConfig",
"audio-publish": "host.warn.sfuStage.audioPublish",
transport: "host.warn.sfuStage.transport",
};
return `SFU ${label[stage]}失败,已启动自动恢复`;
return say("host.warn.sfuRecover", { stage: say(stageKey[stage]) });
}
+30 -39
View File
@@ -6,6 +6,7 @@ import {
type QualitySettings,
type ScreenAudioQuality,
} from "../../shared/protocol";
import { joinItems, say, type CopyKey } from "../ui/copy";
import { displayMediaOptions } from "./audio-capture";
export type {
@@ -37,11 +38,11 @@ export const QUALITY_PROFILES = {
},
} as const satisfies Record<QualityProfileId, QualitySettings>;
export const QUALITY_PROFILE_LABELS = {
"720p30": "720p · 30 帧",
"1080p30": "1080p · 30 帧",
"1080p60": "1080p · 60 帧",
} as const satisfies Record<QualityProfileId, string>;
const QUALITY_PROFILE_KEYS = {
"720p30": "host.quality.720p30",
"1080p30": "host.quality.1080p30",
"1080p60": "host.quality.1080p60",
} as const satisfies Record<QualityProfileId, CopyKey>;
export const QUALITY_RESOLUTIONS = {
"480p": { width: 854, height: 480, label: "480p" },
@@ -53,23 +54,11 @@ export const QUALITY_RESOLUTIONS = {
{ width: number; height: number; label: string }
>;
export const DEGRADATION_PREFERENCE_LABELS = {
"maintain-resolution": "清晰",
balanced: "均衡",
"maintain-framerate": "流畅",
} as const satisfies Record<DegradationPreference, string>;
export const DEGRADATION_PREFERENCE_HINTS = {
"maintain-resolution": "保留细节",
balanced: "自动权衡",
"maintain-framerate": "优先帧率",
} as const satisfies Record<DegradationPreference, string>;
export const SCREEN_AUDIO_QUALITY_LABELS = {
saver: "普通",
music: "音乐",
"very-high": "保真",
} as const satisfies Record<ScreenAudioQuality, string>;
const DEGRADATION_PREFERENCE_KEYS = {
"maintain-resolution": "host.advanced.preference.resolution",
balanced: "host.advanced.preference.balanced",
"maintain-framerate": "host.advanced.preference.framerate",
} as const satisfies Record<DegradationPreference, CopyKey>;
export const DEFAULT_SCREEN_AUDIO_QUALITY: ScreenAudioQuality = "music";
export const SCREEN_AUDIO_BITRATES = {
@@ -176,9 +165,9 @@ export function matchingQualityProfileId(
export function qualitySettingsLabel(settings: QualitySettings): string {
const profileId = matchingQualityProfileId(settings);
if (profileId) {
return QUALITY_PROFILE_LABELS[profileId];
return say(QUALITY_PROFILE_KEYS[profileId]);
}
return `${QUALITY_RESOLUTIONS[settings.resolution].label} ${settings.maxFramerate} · ${(settings.maxBitrate / 1_000_000).toFixed(1)} Mbps · ${DEGRADATION_PREFERENCE_LABELS[settings.degradationPreference]}`;
return `${QUALITY_RESOLUTIONS[settings.resolution].label} ${settings.maxFramerate} · ${(settings.maxBitrate / 1_000_000).toFixed(1)} Mbps · ${say(DEGRADATION_PREFERENCE_KEYS[settings.degradationPreference])}`;
}
function captureConstraints(profile: QualityProfile): MediaTrackConstraints {
@@ -197,7 +186,7 @@ export async function captureDisplay(
profile: QualityProfile,
): Promise<MediaStream> {
if (!navigator.mediaDevices?.getDisplayMedia) {
throw new Error("当前浏览器不支持屏幕共享");
throw new Error(say("host.capture.unavailable"));
}
const stream = await navigator.mediaDevices.getDisplayMedia(
@@ -207,7 +196,7 @@ export async function captureDisplay(
const videoTrack = stream.getVideoTracks()[0];
if (!videoTrack) {
stream.getTracks().forEach((track) => track.stop());
throw new Error("浏览器没有返回可分享的视频轨道");
throw new Error(say("host.capture.noSource"));
}
videoTrack.contentHint = "motion";
for (const audioTrack of stream.getAudioTracks()) {
@@ -222,7 +211,7 @@ export async function applyCaptureProfile(
): Promise<void> {
const videoTrack = stream.getVideoTracks()[0];
if (!videoTrack) {
throw new Error("共享流缺少视频轨道");
throw new Error(say("host.capture.noSource"));
}
await videoTrack.applyConstraints(captureConstraints(profile));
}
@@ -368,24 +357,26 @@ export function audioSenderParameterWarning(
return null;
}
return readback.appliedMaxBitrate === null
? "浏览器未读回音频码率上限"
: `浏览器将音频码率上限改写为 ${Math.round(readback.appliedMaxBitrate / 1_000)} kbps`;
? say("host.warn.audioUnread")
: say("host.warn.audioRewritten", { kbps: String(Math.round(readback.appliedMaxBitrate / 1_000)) });
}
const PARAMETER_LABELS = {
maxBitrate: "码率上限",
maxFramerate: "帧率上限",
scaleResolutionDownBy: "分辨率缩放",
degradationPreference: "质量优先级",
scalabilityMode: "伸缩模式",
} as const satisfies Record<keyof VideoSenderParameterValues, string>;
const PARAMETER_KEYS = {
maxBitrate: "host.warn.param.maxBitrate",
maxFramerate: "host.warn.param.maxFramerate",
scaleResolutionDownBy: "host.warn.param.scaleResolutionDownBy",
degradationPreference: "host.warn.param.degradationPreference",
scalabilityMode: "host.warn.param.scalabilityMode",
} as const satisfies Record<keyof VideoSenderParameterValues, CopyKey>;
export function senderParameterWarning(
readback: VideoSenderParameterReadback,
): string | null {
return readback.mismatches.length > 0
? `浏览器未完整接受${readback.mismatches
.map((key) => PARAMETER_LABELS[key])
.join("、")}`
? say("host.warn.senderPartial", {
params: joinItems(
readback.mismatches.map((key) => say(PARAMETER_KEYS[key])),
),
})
: null;
}
+45 -41
View File
@@ -1,4 +1,5 @@
import type { ServerMessage } from "../../shared/protocol";
import type { CopyKey } from "../ui/copy";
import type { SignalConnectionState } from "../types";
export type ViewerHostState =
@@ -27,6 +28,9 @@ export type ViewerFailureCode =
| "AUTOPLAY_BLOCKED"
| "PLAYBACK_FAILED";
export type ViewerMessageKey = Extract<CopyKey, `viewer.msg.${string}`>;
export type ViewerNoticeKey = Extract<CopyKey, `viewer.notice.${string}`>;
export type ViewerStage =
| "joining"
| "room-not-found"
@@ -130,8 +134,8 @@ export type ViewerPresentationAction =
export interface ViewerPresentation {
stage: ViewerStage;
message: string;
notice: string | null;
messageKey: ViewerMessageKey;
noticeKey: ViewerNoticeKey | null;
overlay: "none" | "status" | "blocking";
hasCurrentFrame: boolean;
hasRetainedFrame: boolean;
@@ -439,62 +443,62 @@ export function deriveViewerPresentation(
const frameOverlay = retainedFrame ? "status" : "blocking";
if (state.access === "checking") {
return presentation("joining", "正在加入房间", "blocking", state);
return presentation("joining", "viewer.msg.joining", "blocking", state);
}
if (state.access === "denied") {
switch (state.failure) {
case "ROOM_NOT_FOUND":
return presentation(
"room-not-found",
"房间不存在或已过期",
"viewer.msg.notFound",
"blocking",
state,
);
case "ROOM_ACCESS_DENIED":
return presentation(
"access-denied",
"当前无法通过房间号加入",
"viewer.msg.denied",
"blocking",
state,
);
case "INVALID_TOKEN":
return presentation(
"invalid-invite",
"邀请链接无效或已失效",
"viewer.msg.invalidInvite",
"blocking",
state,
);
case "ROOM_EXPIRED":
return presentation("room-expired", "房间已过期", "blocking", state);
return presentation("room-expired", "viewer.msg.expired", "blocking", state);
case "ROOM_CLOSED":
return presentation("room-closed", "房间已关闭", "blocking", state);
return presentation("room-closed", "viewer.msg.closed", "blocking", state);
case "ROOM_FULL":
return presentation("room-full", "当前无法加入房间", "blocking", state);
return presentation("room-full", "viewer.msg.full", "blocking", state);
case "STALE_CLIENT":
return presentation(
"stale-client",
"页面版本已更新,请刷新后重试",
"viewer.msg.stale",
"blocking",
state,
);
case "SESSION_REPLACED":
return presentation(
"session-replaced",
"此页面的会话已被另一个标签页接管",
"viewer.msg.sessionReplaced",
"blocking",
state,
);
case "SIGNAL_TERMINATED":
return presentation(
"signal-terminated",
"连接已终止,请刷新后重试",
"viewer.msg.signalTerminated",
"blocking",
state,
);
default:
return presentation(
"server-error",
"暂时无法加入房间",
"viewer.msg.joinUnavailable",
"blocking",
state,
);
@@ -504,7 +508,7 @@ export function deriveViewerPresentation(
if (state.host === "paused") {
return presentation(
"host-paused",
"分享者已暂停",
"viewer.msg.hostPaused",
currentFrame || retainedFrame ? "status" : "blocking",
state,
);
@@ -515,7 +519,7 @@ export function deriveViewerPresentation(
state.autoplayBlockedGeneration === state.media.generation &&
state.connection === "connected"
) {
return presentation("needs-play", "点击播放", "status", state);
return presentation("needs-play", "viewer.msg.needsPlay", "status", state);
}
if (currentFrame) {
@@ -527,16 +531,16 @@ export function deriveViewerPresentation(
return {
...presentation(
signalRecovering || mediaRecovering ? "recovering" : "playing",
signalRecovering || mediaRecovering ? "正在恢复连接" : "正在播放",
signalRecovering || mediaRecovering ? "viewer.msg.recovering" : "viewer.msg.playing",
"none",
state,
),
notice: state.host === "offline"
? "分享者连接已中断,画面可能冻结"
noticeKey: state.host === "offline"
? "viewer.notice.hostOffline"
: signalRecovering
? "服务器连接正在恢复,画面仍在播放"
? "viewer.notice.signalRecovering"
: mediaRecovering
? "媒体连接正在恢复"
? "viewer.notice.mediaRecovering"
: null,
};
}
@@ -544,7 +548,7 @@ export function deriveViewerPresentation(
if (state.routeStatus?.state === "failed") {
return presentation(
"route-failed",
"没有可用的媒体线路",
"viewer.msg.routeFailed",
frameOverlay,
state,
);
@@ -554,56 +558,56 @@ export function deriveViewerPresentation(
case "ROUTE_EXHAUSTED":
return presentation(
"route-failed",
"没有可用的媒体线路",
"viewer.msg.routeFailed",
frameOverlay,
state,
);
case "PLAYBACK_FAILED":
return presentation(
"playback-failed",
"浏览器无法播放当前画面",
"viewer.msg.playbackFailed",
frameOverlay,
state,
);
case "SERVER_ERROR":
return presentation(
"server-error",
"连接服务暂时不可用",
"viewer.msg.serverError",
frameOverlay,
state,
);
case "STALE_CLIENT":
return presentation(
"stale-client",
"页面版本已更新,请刷新后重试",
"viewer.msg.stale",
frameOverlay,
state,
);
case "SESSION_REPLACED":
return presentation(
"session-replaced",
"此页面的会话已被另一个标签页接管",
"viewer.msg.sessionReplaced",
frameOverlay,
state,
);
case "SIGNAL_TERMINATED":
return presentation(
"signal-terminated",
"连接已终止,请刷新后重试",
"viewer.msg.signalTerminated",
frameOverlay,
state,
);
case "HOST_OFFLINE":
return presentation(
"host-offline",
"分享者连接已中断",
"viewer.msg.hostOffline",
frameOverlay,
state,
);
case "HOST_STOPPED":
return presentation(
"waiting-host",
"等待开始分享",
"viewer.msg.waitingHost",
frameOverlay,
state,
);
@@ -612,7 +616,7 @@ export function deriveViewerPresentation(
if (state.routeStatus?.state === "waiting") {
return presentation(
"waiting-sfu",
"正在连接备用线路",
"viewer.msg.preparingSfu",
frameOverlay,
state,
);
@@ -624,7 +628,7 @@ export function deriveViewerPresentation(
) {
return presentation(
"recovering",
"正在恢复连接",
"viewer.msg.recovering",
frameOverlay,
state,
);
@@ -632,7 +636,7 @@ export function deriveViewerPresentation(
if (state.media) {
return presentation(
"receiving",
"正在接收画面",
"viewer.msg.receiving",
frameOverlay,
state,
);
@@ -640,7 +644,7 @@ export function deriveViewerPresentation(
if (state.route?.phase === "prepare") {
return presentation(
state.route.kind === "sfu" ? "preparing-sfu" : "preparing-p2p",
state.route.kind === "sfu" ? "正在连接备用线路" : "正在建立 P2P",
state.route.kind === "sfu" ? "viewer.msg.preparingSfu" : "viewer.msg.preparingP2p",
frameOverlay,
state,
);
@@ -648,7 +652,7 @@ export function deriveViewerPresentation(
if (state.host === "stopped" || state.host === "unknown") {
return presentation(
"waiting-host",
"等待开始分享",
"viewer.msg.waitingHost",
frameOverlay,
state,
);
@@ -656,7 +660,7 @@ export function deriveViewerPresentation(
if (state.host === "offline") {
return presentation(
"host-offline",
"分享者连接已中断",
"viewer.msg.hostOffline",
frameOverlay,
state,
);
@@ -664,7 +668,7 @@ export function deriveViewerPresentation(
if (state.route?.kind === "p2p") {
return presentation(
"preparing-p2p",
"正在建立 P2P",
"viewer.msg.preparingP2p",
frameOverlay,
state,
);
@@ -672,14 +676,14 @@ export function deriveViewerPresentation(
if (state.route?.kind === "sfu") {
return presentation(
"preparing-sfu",
"正在连接备用线路",
"viewer.msg.preparingSfu",
frameOverlay,
state,
);
}
return presentation(
"allocating",
"正在分配线路",
"viewer.msg.allocating",
frameOverlay,
state,
);
@@ -713,14 +717,14 @@ function hasCurrentFrame(state: ViewerPresentationState): boolean {
function presentation(
stage: ViewerStage,
message: string,
messageKey: ViewerMessageKey,
overlay: ViewerPresentation["overlay"],
state: ViewerPresentationState,
): ViewerPresentation {
return {
stage,
message,
notice: null,
messageKey,
noticeKey: null,
overlay,
hasCurrentFrame: hasCurrentFrame(state),
hasRetainedFrame: state.retainedFrame,
File diff suppressed because it is too large. Load diff
+96 -6
View File
@@ -1,12 +1,102 @@
import { AppHeader } from "../components/AppHeader";
import { RoomCodeEntry } from "../components/RoomCodeEntry";
import { useState, type FormEvent } from "react";
import { AppHeader } from "../components/living/Header";
import { ComicTooltip } from "../components/living/ComicTooltip";
import { Glyph } from "../ui/icons";
import { useCopy } from "../ui/copy";
import { roomRouteForExplicitEntry } from "../lib/session";
export function JoinPage() {
const { t, vis } = useCopy();
const [roomId, setRoomId] = useState("");
const [error, setError] = useState(false);
function join(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
const route = roomRouteForExplicitEntry(roomId);
if (!route) {
setError(true);
return;
}
window.location.assign(route);
}
const goButton = (
<button
className="lr-join-go"
type="submit"
title={vis ? undefined : t("join.submit")}
aria-label={t("join.submit")}
disabled={roomId.length !== 4}
>
<Glyph name="arrowRight" size={26} />
</button>
);
return (
<div className="app-shell">
<AppHeader status={null} />
<main className="access-workspace">
<RoomCodeEntry id="join-room-code" autoFocus />
<div className="lr-app">
<AppHeader />
<main className="lr-join">
<form className="lr-join-panel" onSubmit={join} noValidate>
<span
className={`lr-join-door${error ? " is-shake" : ""}`}
title={vis ? undefined : t("join.title")}
role="img"
aria-label={t("join.title")}
>
<Glyph name="door" size={96} draw="join-door" />
</span>
{vis ? null : (
<div className="lr-access-text">
<h1>{t("join.title")}</h1>
<p>{t("join.hint")}</p>
</div>
)}
<div className="lr-dials-wrap">
<div className="lr-dials" aria-hidden="true">
{[0, 1, 2, 3].map((index) => (
<span
key={index}
className={`lr-dial${roomId[index] ? " is-filled" : index === roomId.length ? " is-active" : ""}`}
>
{roomId[index] ?? ""}
</span>
))}
</div>
<input
value={roomId}
inputMode="numeric"
autoComplete="off"
maxLength={4}
autoFocus
aria-label={t("join.field")}
aria-invalid={error}
onChange={(event) => {
setRoomId(event.target.value.replace(/\D/g, "").slice(0, 4));
setError(false);
}}
/>
</div>
{error ? (
<>
<span className="lr-join-error" role="alert" aria-label={t("join.invalid")}>
<Glyph name="x" size={24} />
</span>
{vis ? (
<span className="visually-hidden">{t("join.invalid")}</span>
) : (
<span className="lr-cap" style={{ color: "var(--danger)" }}>
{t("join.invalid")}
</span>
)}
</>
) : null}
{vis ? (
<ComicTooltip kind="hint-join-go">{goButton}</ComicTooltip>
) : (
goButton
)}
{vis ? null : <span className="lr-cap">{t("join.submit")}</span>}
</form>
</main>
</div>
);
File diff suppressed because it is too large. Load diff
+36 -35
View File
@@ -1,4 +1,5 @@
import type { ServerMessage } from "../../shared/protocol";
import { joinSentences, say, type CopyKey } from "../ui/copy";
export type HostAction =
| "capture"
@@ -7,12 +8,12 @@ export type HostAction =
| "connection"
| "room";
const HOST_ACTION_FALLBACK: Record<HostAction, string> = {
capture: "启动分享失败",
source: "切换分享来源失败",
quality: "应用画质设置失败",
connection: "观看连接处理失败",
room: "房间操作失败",
const HOST_ACTION_FALLBACK: Record<HostAction, CopyKey> = {
capture: "host.fail.start",
source: "host.fail.source",
quality: "host.fail.quality",
connection: "host.fail.connection",
room: "host.fail.room",
};
type ServerErrorCode = Extract<
@@ -20,18 +21,18 @@ type ServerErrorCode = Extract<
{ type: "error" }
>["code"];
const HOST_SERVER_ERROR_NOTICE: Record<ServerErrorCode, string> = {
AUTH_REQUIRED: "站点访问已失效,请重新验证",
INVALID_MESSAGE: "页面版本已更新,请刷新后重试",
INVALID_TOKEN: "分享凭证已失效,请重新创建房间",
ROOM_NOT_FOUND: "房间不存在或已过期",
ROOM_ACCESS_DENIED: "当前操作没有权限",
ROOM_EXPIRED: "房间已过期,请重新创建",
ROOM_FULL: "房间已满",
HOST_ALREADY_CONNECTED: "此房间已在另一个页面中分享",
PEER_NOT_FOUND: "对应的观看连接已经离开",
FORBIDDEN: "当前操作不可用",
SERVER_ERROR: "服务暂时不可用,请稍后重试",
const HOST_SERVER_ERROR_NOTICE: Record<ServerErrorCode, CopyKey> = {
AUTH_REQUIRED: "gate.expired",
INVALID_MESSAGE: "host.terminated.stale",
INVALID_TOKEN: "host.err.invalidToken",
ROOM_NOT_FOUND: "viewer.msg.notFound",
ROOM_ACCESS_DENIED: "host.err.accessDenied",
ROOM_EXPIRED: "host.err.roomExpired",
ROOM_FULL: "join.full",
HOST_ALREADY_CONNECTED: "host.err.alreadyConnected",
PEER_NOT_FOUND: "host.err.peerGone",
FORBIDDEN: "host.err.forbidden",
SERVER_ERROR: "host.err.serverError",
};
export function hostActionErrorNotice(
@@ -44,20 +45,20 @@ export function hostActionErrorNotice(
) {
switch (error.name) {
case "NotAllowedError":
return "屏幕选择已取消或没有共享权限";
return say("host.capture.cancelled");
case "NotFoundError":
return "没有可用的屏幕分享来源";
return say("host.capture.noSource");
case "NotReadableError":
return "浏览器暂时无法读取所选分享来源";
return say("host.capture.readFailed");
case "SecurityError":
return "当前页面无法启动屏幕分享";
return say("host.capture.unavailable");
}
}
return HOST_ACTION_FALLBACK[action];
return say(HOST_ACTION_FALLBACK[action]);
}
export function hostServerErrorNotice(code: ServerErrorCode): string {
return HOST_SERVER_ERROR_NOTICE[code];
return say(HOST_SERVER_ERROR_NOTICE[code]);
}
export function sourceSwitchNotice({
@@ -70,23 +71,23 @@ export function sourceSwitchNotice({
sfuWarning: string | null;
}): string {
const peerWarning =
failedPeerCount > 0 ? "部分观看者正在重新连接" : null;
failedPeerCount > 0 ? say("host.notice.reconnecting") : null;
if (!sfuReplaced) {
return [
sfuWarning ?? "SFU 分享来源未切换成功,正在恢复观看连接",
const parts = [
sfuWarning ?? say("host.notice.sfuRecovering"),
peerWarning,
]
.filter((message): message is string => message !== null)
.join(";");
].filter((message): message is string => message !== null);
return joinSentences(parts);
}
if (sfuWarning) {
return [sfuWarning, peerWarning]
.filter((message): message is string => message !== null)
.join(";");
const parts = [sfuWarning, peerWarning].filter(
(message): message is string => message !== null,
);
return joinSentences(parts);
}
return peerWarning
? `分享来源已切换,但${peerWarning}`
: "分享来源已切换";
? say("host.notice.sourceSwitch.partial", { warning: peerWarning })
: say("host.notice.sourceSwitch.ok");
}
export function shouldPauseLocalPreview(
+5 -4
View File
@@ -1,3 +1,4 @@
import { say } from "../ui/copy";
import type {
LocalTrackPublication,
Room,
@@ -440,7 +441,7 @@ export class SfuPublisher {
if (!this.owns(room, generation)) {
return false;
}
const failureWarning = "切换 SFU 分享来源失败";
const failureWarning = say("host.fail.sfuSwitch");
// A rejected publish/unpublish may have changed server state without
// returning enough ownership information to undo it safely.
@@ -591,7 +592,7 @@ export class SfuPublisher {
return false;
}
videoSucceeded = false;
const failureWarning = "应用 SFU 发送参数失败";
const failureWarning = say("host.fail.sfuParams");
try {
const rolledBack = await configurePublishedVideo(
video,
@@ -1268,7 +1269,7 @@ function retainPublishedAudioOptions(
}
function audioSenderFailureWarning(_error: unknown): string {
return "应用 SFU 音频发送参数失败";
return say("host.fail.sfuAudioParams");
}
function mergeQualityWarnings(...warnings: Array<string | null>): string | null {
@@ -1332,7 +1333,7 @@ async function safeDisconnect(room: Room): Promise<void> {
function requiredVideoTrack(stream: MediaStream): MediaStreamTrack {
const track = stream.getVideoTracks()[0];
if (!track) {
throw new Error("屏幕共享没有视频轨道");
throw new Error(say("host.capture.noSource"));
}
return track;
}
+1057 -1492
View File
File diff suppressed because it is too large. Load diff
+842
View File
@@ -0,0 +1,842 @@
// The single copy catalog. zh and en carry identical keys (type-enforced);
// "visual" is a presentation mode over the same keys, not another catalog.
//
// Store notes: lang/vis sync across tabs via the window "storage" event (a
// commit in one tab re-applies the persisted values here; theme.ts keeps its
// own per-tab store). Known trade-off: flipping lang/vis while a hint tooltip
// is open remounts the trigger, because the living primitives mount
// ComicTooltip conditionally, so keyboard focus drops to <body> on a mode
// flip. The wrapper lives outside this module; the behavior is documented
// here where the flip originates.
import { useSyncExternalStore } from "react";
export type Lang = "zh" | "en";
export type CopyMode = "text" | "vis";
const zh = {
"brand.home": "Screener 首页",
"common.roomCode": "房间号",
"common.copy": "复制",
"common.copied": "已复制",
"common.copyFailed": "复制失败",
"common.cancel": "取消",
"common.retry": "重试",
"common.refresh": "刷新页面",
"common.edit": "编辑",
"common.close": "收起",
"common.viewers": "观看者",
"common.you": "你",
"common.host": "分享者",
"common.name.viewerDefault": "访客",
"common.name.hostDefault": "分享者",
"gate.title": "站点访问",
"gate.hint": "请输入站点口令",
"gate.password": "站点口令",
"gate.submit": "进入站点",
"gate.checking": "正在验证站点访问",
"gate.wrong": "站点口令不正确,请重试",
"gate.unavailable": "暂时无法验证站点访问",
"gate.malformed": "房间号格式不正确",
"gate.malformedHint": "请输入 1000 至 9999 的四位房间号",
"gate.unavailableRoute": "无法访问",
"join.title": "加入房间",
"join.hint": "输入分享者提供的房间号",
"join.field": "房间号",
"join.submit": "加入",
"join.invalid": "请输入 1000 至 9999 的四位房间号",
"join.joining": "正在加入房间",
"join.notFound": "房间不存在或已过期",
"join.notFoundHint": "请确认房间号,或向分享者获取新的邀请链接。",
"join.denied": "当前无法通过房间号加入",
"join.deniedHint": "请使用分享者提供的邀请链接,或尝试房间密码。",
"join.password": "房间密码",
"join.passwordError": "无法加入房间,请重试",
"join.passwordRule": "请输入 1-{max} 个可见字符",
"join.passwordAction": "输入房间密码",
"join.full": "房间已满",
"host.title": "{name} 的屏幕",
"host.idle.heading": "想播点什么?",
"host.idle.hint": "分享你的屏幕,朋友凭房间号或邀请链接就能进来看。",
"host.start": "开始分享",
"host.join": "加入房间",
"host.starting": "正在连接",
"host.cancelStart": "取消",
"host.live": "正在分享",
"host.paused": "已暂停",
"host.ended": "已停止分享",
"host.roomReady": "房间已就绪",
"host.pause": "暂停分享",
"host.resume": "恢复分享",
"host.switchSource": "切换来源",
"host.switching": "正在选择",
"host.stop": "停止分享",
"host.noAudio": "当前来源没有可共享音频",
"host.localPreviewPaused": "本地预览已暂停,分享仍在继续",
"host.pauseNotice": "音视频分享已暂停",
"host.resumeNotice": "音视频分享已恢复",
"host.stopNotice": "已停止分享",
"host.roomReplaced": "房间号已更换",
"host.roomInvalid": "房间已失效,再次点击将创建新房",
"host.roomReplace": "更换房间号",
"host.roomReplaceConfirm": "确认更换房间号",
"host.captureError": "无法开始屏幕采集,请检查浏览器权限后重试",
"host.terminated.stale": "页面版本已更新,请刷新后重试",
"host.terminated.session": "此页面的会话已被另一个标签页接管",
"host.terminated.signal": "服务器连接已终止,请刷新后重试",
"host.notice.qualityApplied": "分享设置已应用",
"host.notice.qualitySet": "画质已切换为 {label}",
"host.notice.audioSet": "音频质量已切换为 {label}",
"host.notice.partialApply": "分享设置已更新,但部分观看连接未能应用新参数",
"host.notice.sourceSwitch.ok": "分享来源已切换",
"host.notice.sourceSwitch.partial": "分享来源已切换,但{warning}",
"host.name": "昵称",
"host.nameSave": "保存昵称",
"host.nameCancel": "取消编辑昵称",
"host.nameEdit": "编辑昵称",
"host.nameError": "名称格式无效或超过 24 个字符",
"host.nameOffline": "开始分享并连接后才能修改昵称",
"host.invite": "邀请链接",
"host.invite.copy": "复制邀请链接",
"host.invite.copyFailed": "无法复制邀请链接,请稍后重试",
"host.invite.rotate": "更新邀请链接",
"host.invite.revoke": "撤销邀请链接",
"host.invite.rotateShort": "更新链接",
"host.invite.revokeShort": "撤销链接",
"host.invite.updated": "邀请链接已更新",
"host.invite.revoked": "邀请链接已撤销",
"host.invite.emptyOpen": "暂无邀请链接,仍可凭房间号加入",
"host.invite.emptyPassword": "暂无邀请链接,仍可凭房间号和密码加入",
"host.invite.emptyPrivate": "暂无邀请链接,请先更新链接再邀请他人",
"host.policy": "准入方式",
"host.policy.open": "公开",
"host.policy.private": "私密",
"host.policy.openHint": "知道房间号即可加入",
"host.policy.privateHint": "凭邀请链接加入,或设置房间密码",
"host.policy.setOpen": "房间已设为公开",
"host.policy.setPrivateInvite": "房间已设为私密,仅限邀请加入",
"host.policy.setPrivatePassword": "房间已设为私密,可凭邀请或密码加入",
"host.password.set": "房间密码已设置",
"host.password.unset": "房间密码未设置",
"host.password.placeholder": "设置后可凭房间号加入",
"host.password.setAction": "设置房间密码",
"host.password.changeAction": "更改房间密码",
"host.password.remove": "移除房间密码",
"host.password.show": "显示房间密码",
"host.password.hide": "隐藏房间密码",
"host.password.saved": "房间密码已设置",
"host.password.updated": "房间密码已更新",
"host.password.removed": "房间密码已移除,仅限邀请加入",
"host.password.rule": "房间密码只需 1-{max} 个可见字符",
"host.accessFailed": "当前无法更新房间设置,请稍后重试",
"host.viewers.empty": "暂无观看者",
"host.viewers.waiting": "等待朋友加入",
"host.quality": "视频预设",
"host.quality.720p30": "720p · 30 帧",
"host.quality.1080p30": "1080p · 30 帧",
"host.quality.1080p60": "1080p · 60 帧",
"host.advanced": "高级设置",
"host.advanced.resolution": "分辨率上限",
"host.advanced.framerate": "帧率上限",
"host.advanced.bitrate": "视频码率上限",
"host.advanced.preference": "画面偏好",
"host.advanced.preference.resolution": "清晰",
"host.advanced.preference.balanced": "均衡",
"host.advanced.preference.framerate": "流畅",
"host.advanced.preference.resolutionHint": "保留细节",
"host.advanced.preference.balancedHint": "自动权衡",
"host.advanced.preference.framerateHint": "优先帧率",
"host.advanced.audio": "音频质量",
"host.advanced.audio.saver": "普通",
"host.advanced.audio.music": "音乐",
"host.advanced.audio.veryHigh": "保真",
"host.advanced.route": "路由策略",
"host.advanced.route.topo": "自动优化拓扑",
"host.advanced.route.topoHint": "观看中逐步选择更健康的连接",
"host.advanced.route.peerOnly": "纯 P2P",
"host.advanced.route.peerOnlyHint": "不使用媒体服务器,无法直连时停止尝试",
"host.advanced.codec": "视频编码",
"host.advanced.codec.auto": "自动",
"host.advanced.codec.autoHint": "自动选择",
"host.advanced.codec.vp8Hint": "兼容优先",
"host.advanced.codec.h264Hint": "硬件优先",
"host.details": "显示连接详情",
"host.details.hide": "隐藏连接详情",
"host.topology": "连接拓扑",
"host.topology.show": "显示连接拓扑",
"host.topology.hide": "隐藏连接拓扑",
"host.topology.pending": "连接中",
"host.sfu": "媒体服务器",
"state.signal.connected": "服务器已连接",
"state.signal.connecting": "正在连接",
"state.signal.reconnecting": "正在恢复",
"state.signal.offline": "服务器未连接",
"state.peer.connected": "已连接",
"state.peer.connecting": "正在连接",
"state.peer.routing": "线路分配中",
"state.peer.waiting": "等待开始分享",
"state.peer.reconnecting": "正在恢复",
"state.peer.failed": "连接失败",
"state.peer.disconnected": "连接中断",
"state.peer.closed": "已关闭",
"state.peer.new": "准备中",
"state.route.p2p": "P2P 直连",
"state.route.sfu": "服务器中转",
"stats.title": "连接数据",
"stats.relay": "转发数据",
"stats.downstream": "下游接收数据",
"stats.more": "详细指标",
"stats.less": "收起详细指标",
"stats.resolution": "分辨率",
"stats.fps": "帧率",
"stats.bitrate": "码率",
"stats.loss": "丢包率",
"stats.rtt": "RTT",
"stats.codec": "视频编码",
"stats.jitter": "网络抖动",
"stats.dropped": "丢帧",
"stats.encoder": "编码器",
"stats.audio": "音频码率",
"stats.unknown": "未知",
"stats.outgoing": "可用上行",
"stats.qualityState": "质量状态",
"stats.quality.normal": "正常",
"stats.quality.bandwidth": "带宽受限",
"stats.quality.cpu": "编码受限",
"stats.quality.other": "其他限制",
"stats.quality.unclassified": "未分类限制",
"stats.capture": "捕获设置",
"stats.inputFps": "编码输入帧率",
"stats.encodeMs": "编码耗时/帧",
"stats.decodeMs": "解码耗时/帧",
"stats.freezeCount": "画面冻结",
"stats.freezeDuration": "冻结时长",
"stats.audioLoss": "音频丢包率",
"stats.audioJitter": "音频抖动",
"stats.playoutDelta": "音视频播放差",
"stats.videoBuffer": "视频缓冲",
"stats.audioBuffer": "音频缓冲",
"stats.audioConcealedRate": "音频补偿率",
"stats.audioConcealed": "音频补偿",
"viewer.title": "{name} 的屏幕",
"viewer.titleFallback": "好友屏幕",
"viewer.reconnect": "重新连接媒体",
"viewer.msg.joining": "正在加入房间",
"viewer.msg.waitingHost": "等待开始分享",
"viewer.msg.preparingP2p": "正在建立 P2P",
"viewer.msg.preparingSfu": "正在连接备用线路",
"viewer.msg.allocating": "正在分配线路",
"viewer.msg.receiving": "正在接收画面",
"viewer.msg.playing": "正在播放",
"viewer.msg.needsPlay": "点击播放",
"viewer.msg.hostPaused": "分享者已暂停",
"viewer.msg.recovering": "正在恢复连接",
"viewer.msg.routeFailed": "没有可用的媒体线路",
"viewer.msg.playbackFailed": "浏览器无法播放当前画面",
"viewer.msg.hostOffline": "分享者连接已中断",
"viewer.msg.serverError": "连接服务暂时不可用",
"viewer.msg.notFound": "房间不存在或已过期",
"viewer.msg.denied": "当前无法通过房间号加入",
"viewer.msg.invalidInvite": "邀请链接无效或已失效",
"viewer.msg.expired": "房间已过期",
"viewer.msg.closed": "房间已关闭",
"viewer.msg.full": "当前无法加入房间",
"viewer.msg.stale": "页面版本已更新,请刷新后重试",
"viewer.msg.sessionReplaced": "此页面的会话已被另一个标签页接管",
"viewer.msg.signalTerminated": "连接已终止,请刷新后重试",
"viewer.notice.signalRecovering": "服务器连接正在恢复,画面仍在播放",
"viewer.notice.mediaRecovering": "媒体连接正在恢复",
"viewer.notice.hostOffline": "分享者连接已中断,画面可能冻结",
"viewer.hint.denied": "请使用分享者提供的邀请链接,或尝试房间密码。",
"viewer.hint.notFound": "请确认房间号,或向分享者获取新的邀请链接。",
"viewer.hint.invite": "请向分享者获取新的邀请链接。",
"viewer.hint.generic": "请检查入口后重试。",
"viewer.error.p2p": "P2P 媒体连接异常",
"viewer.error.relay": "下游媒体连接异常",
"mode.language": "界面语言",
"mode.zh": "中文",
"mode.en": "English",
"mode.vis": "纯视觉",
"theme.dark": "切换到暗色模式",
"theme.light": "切换到浅色模式",
"theme.dark.short": "暗色",
"theme.light.short": "浅色",
"story.room": "找到房间",
"story.link": "建立连接",
"story.show": "接收画面",
"gate.connectFailed": "无法连接站点访问服务,请重试",
"gate.expired": "站点访问已失效,请重新验证",
"gate.serviceUnavailable": "站点验证服务暂时不可用 ({status})",
"host.onlineCount": "{n} / {max} 人在线",
"host.notStarted": "尚未开始",
"host.startCancelled": "启动已取消",
"host.switchingSource": "正在切换来源",
"host.capture.fpsUnknown": "帧率未知",
"host.capture.codecPending": "编码待定",
"host.capture.hasAudio": "含音频",
"host.capture.noAudio": "无音频",
"host.stageAria": "分享或加入房间",
"host.captureAria": "实际捕获参数",
"host.quality.title": "{label} · {mbps} Mbps",
"host.audio.title": "{label} · {kbps} kbps 上限",
"host.roomExpired": "房间已过期",
"host.roomClosed": "房间已关闭",
"host.pause.noTracksResume": "当前分享没有可恢复的媒体轨道",
"host.pause.signalRecovering": "服务器连接正在恢复,分享仍保持暂停",
"host.pause.noTracksPause": "当前分享没有可暂停的媒体轨道",
"host.pause.stillPaused": "分享仍保持暂停",
"host.shareEnded": "当前分享已经结束",
"host.fail.start": "启动分享失败",
"host.err.createConnection": "创建连接失败",
"host.err.codecUnsupported": "当前浏览器无法使用支持的视频编码",
"host.err.applySender": "应用发送参数失败",
"host.err.applyAudioSender": "应用音频发送参数失败",
"host.fail.source": "切换分享来源失败",
"host.fail.quality": "应用画质设置失败",
"host.fail.connection": "观看连接处理失败",
"host.fail.room": "房间操作失败",
"host.err.invalidToken": "分享凭证已失效,请重新创建房间",
"host.err.accessDenied": "当前操作没有权限",
"host.err.roomExpired": "房间已过期,请重新创建",
"host.err.alreadyConnected": "此房间已在另一个页面中分享",
"host.err.peerGone": "对应的观看连接已经离开",
"host.err.forbidden": "当前操作不可用",
"host.err.serverError": "服务暂时不可用,请稍后重试",
"host.err.createRoomStatus": "当前无法创建房间 ({status})",
"host.err.replaceRoomStatus": "当前无法更换房间 ({status})",
"host.err.updateRoomStatus": "当前无法更新房间设置 ({status})",
"host.capture.cancelled": "屏幕选择已取消或没有共享权限",
"host.capture.noSource": "没有可用的屏幕分享来源",
"host.capture.readFailed": "浏览器暂时无法读取所选分享来源",
"host.capture.unavailable": "当前页面无法启动屏幕分享",
"host.notice.reconnecting": "部分观看者正在重新连接",
"host.notice.sfuRecovering": "SFU 分享来源未切换成功,正在恢复观看连接",
"host.password.inputPlaceholder": "输入密码",
"stats.warn.codec": "视频编码为 {codec},预期 H264 或 VP8",
"stats.warn.audioCodec": "音频编码为 {codec},预期 Opus",
"viewer.msg.joinUnavailable": "暂时无法加入房间",
"viewer.onlineCount": "在线 {n}",
"viewer.stageAria": "共享画面",
"host.warn.audioUnread": "浏览器未读回音频码率上限",
"host.warn.bandwidth": "当前连接带宽受限,画质已自动降低",
"host.warn.encoding": "编码性能受限,画质已自动降低",
"host.warn.other": "浏览器持续报告其他画质限制",
"host.warn.unclassified": "浏览器持续报告未分类的画质限制",
"host.warn.audioRewritten": "浏览器将音频码率上限改写为 {kbps} kbps",
"host.warn.senderPartial": "浏览器未完整接受{params}",
"host.warn.param.maxBitrate": "码率上限",
"host.warn.param.maxFramerate": "帧率上限",
"host.warn.param.scaleResolutionDownBy": "分辨率缩放",
"host.warn.param.degradationPreference": "质量优先级",
"host.warn.param.scalabilityMode": "伸缩模式",
"host.warn.sfuRecover": "SFU {stage}失败,已启动自动恢复",
"host.warn.sfuStage.connect": "连接",
"host.warn.sfuStage.source": "分享源",
"host.warn.sfuStage.videoPublish": "视频发布",
"host.warn.sfuStage.senderConfig": "视频参数配置",
"host.warn.sfuStage.audioPublish": "音频发布",
"host.warn.sfuStage.transport": "传输",
"host.fail.sfuSwitch": "切换 SFU 分享来源失败",
"host.fail.sfuAudioParams": "应用 SFU 音频发送参数失败",
"host.fail.sfuParams": "应用 SFU 发送参数失败",
} as const;
const en: Record<keyof typeof zh, string> = {
"brand.home": "Screener home",
"common.roomCode": "Room code",
"common.copy": "Copy",
"common.copied": "Copied",
"common.copyFailed": "Copy failed",
"common.cancel": "Cancel",
"common.retry": "Retry",
"common.refresh": "Refresh page",
"common.edit": "Edit",
"common.close": "Close",
"common.viewers": "Viewers",
"common.you": "you",
"common.host": "Host",
"common.name.viewerDefault": "Visitor",
"common.name.hostDefault": "Host",
"gate.title": "Site access",
"gate.hint": "Enter the site passphrase",
"gate.password": "Site passphrase",
"gate.submit": "Enter site",
"gate.checking": "Checking site access",
"gate.wrong": "Incorrect passphrase, try again",
"gate.unavailable": "Site access cannot be verified right now",
"gate.malformed": "Invalid room code format",
"gate.malformedHint": "Enter a four-digit room code between 1000 and 9999",
"gate.unavailableRoute": "Unavailable",
"join.title": "Join a room",
"join.hint": "Enter the four-digit room code from your friend",
"join.field": "Room code",
"join.submit": "Join",
"join.invalid": "Enter a four-digit room code between 1000 and 9999",
"join.joining": "Joining the room",
"join.notFound": "Room not found or expired",
"join.notFoundHint": "Check the room code, or ask the host for a new invite link.",
"join.denied": "This room cannot be joined by code right now",
"join.deniedHint": "Use the host's invite link, or try the room password.",
"join.password": "Room password",
"join.passwordError": "Could not join; try again",
"join.passwordRule": "Enter 1-{max} visible characters",
"join.passwordAction": "Enter room password",
"join.full": "Room is full",
"host.title": "{name}'s screen",
"host.idle.heading": "Something worth watching?",
"host.idle.hint": "Share your screen and friends can drop in with the room code or invite link.",
"host.start": "Start sharing",
"host.join": "Join a room",
"host.starting": "Connecting",
"host.cancelStart": "Cancel",
"host.live": "Live",
"host.paused": "Paused",
"host.ended": "Sharing stopped",
"host.roomReady": "Room ready",
"host.pause": "Pause sharing",
"host.resume": "Resume sharing",
"host.switchSource": "Switch source",
"host.switching": "Choosing",
"host.stop": "Stop sharing",
"host.noAudio": "This source has no shareable audio",
"host.localPreviewPaused": "Local preview paused; sharing continues",
"host.pauseNotice": "Audio and video sharing paused",
"host.resumeNotice": "Audio and video sharing resumed",
"host.stopNotice": "Sharing stopped",
"host.roomReplaced": "Room code replaced",
"host.roomInvalid": "Room expired; press again to create a new one",
"host.roomReplace": "Replace room code",
"host.roomReplaceConfirm": "Confirm replacing the room code",
"host.captureError": "Screen capture unavailable; check browser permission and retry",
"host.terminated.stale": "Page updated; refresh to continue",
"host.terminated.session": "This page's session was taken over by another tab",
"host.terminated.signal": "Server connection terminated; refresh to continue",
"host.notice.qualityApplied": "Share settings applied",
"host.notice.qualitySet": "Quality switched to {label}",
"host.notice.audioSet": "Audio quality switched to {label}",
"host.notice.partialApply": "Settings updated, but some viewer connections could not apply them",
"host.notice.sourceSwitch.ok": "Share source switched",
"host.notice.sourceSwitch.partial": "Source switched, but {warning}",
"host.name": "Display name",
"host.nameSave": "Save display name",
"host.nameCancel": "Cancel editing",
"host.nameEdit": "Edit display name",
"host.nameError": "Invalid name, or longer than 24 characters",
"host.nameOffline": "Display name can only change while sharing and connected",
"host.invite": "Invite link",
"host.invite.copy": "Copy invite link",
"host.invite.copyFailed": "Could not copy the invite link; try again",
"host.invite.rotate": "Rotate invite link",
"host.invite.revoke": "Revoke invite link",
"host.invite.rotateShort": "Rotate link",
"host.invite.revokeShort": "Revoke link",
"host.invite.updated": "Invite link rotated",
"host.invite.revoked": "Invite link revoked",
"host.invite.emptyOpen": "No invite link; the room code still works",
"host.invite.emptyPassword": "No invite link; room code plus password still works",
"host.invite.emptyPrivate": "No invite link; rotate one before inviting",
"host.policy": "Admission",
"host.policy.open": "Open",
"host.policy.private": "Private",
"host.policy.openHint": "Anyone with the room code can join",
"host.policy.privateHint": "Invite link only, or set a room password",
"host.policy.setOpen": "Room is now open",
"host.policy.setPrivateInvite": "Room is now private, invite link only",
"host.policy.setPrivatePassword": "Room is now private, invite link or password",
"host.password.set": "Room password set",
"host.password.unset": "No room password",
"host.password.placeholder": "Set one to allow code entry",
"host.password.setAction": "Set room password",
"host.password.changeAction": "Change room password",
"host.password.remove": "Remove room password",
"host.password.show": "Show room password",
"host.password.hide": "Hide room password",
"host.password.saved": "Room password set",
"host.password.updated": "Room password updated",
"host.password.removed": "Password removed; invite link only",
"host.password.rule": "1-{max} visible characters",
"host.accessFailed": "Cannot update room settings right now; try again later",
"host.viewers.empty": "No viewers yet",
"host.viewers.waiting": "Waiting for friends to join",
"host.quality": "Video preset",
"host.quality.720p30": "720p · 30 fps",
"host.quality.1080p30": "1080p · 30 fps",
"host.quality.1080p60": "1080p · 60 fps",
"host.advanced": "Advanced settings",
"host.advanced.resolution": "Resolution ceiling",
"host.advanced.framerate": "Frame rate ceiling",
"host.advanced.bitrate": "Video bitrate ceiling",
"host.advanced.preference": "Picture preference",
"host.advanced.preference.resolution": "Clarity",
"host.advanced.preference.balanced": "Balanced",
"host.advanced.preference.framerate": "Fluency",
"host.advanced.preference.resolutionHint": "Keep detail",
"host.advanced.preference.balancedHint": "Weigh automatically",
"host.advanced.preference.framerateHint": "Prefer frame rate",
"host.advanced.audio": "Audio quality",
"host.advanced.audio.saver": "Standard",
"host.advanced.audio.music": "Music",
"host.advanced.audio.veryHigh": "Studio",
"host.advanced.route": "Route policy",
"host.advanced.route.topo": "Auto topology tuning",
"host.advanced.route.topoHint": "Gradually pick healthier paths while live",
"host.advanced.route.peerOnly": "Pure P2P",
"host.advanced.route.peerOnlyHint": "No media server; stop when direct paths fail",
"host.advanced.codec": "Video codec",
"host.advanced.codec.auto": "Auto",
"host.advanced.codec.autoHint": "Pick automatically",
"host.advanced.codec.vp8Hint": "Compatibility first",
"host.advanced.codec.h264Hint": "Hardware first",
"host.details": "Show connection details",
"host.details.hide": "Hide connection details",
"host.topology": "Connection topology",
"host.topology.show": "Show connection topology",
"host.topology.hide": "Hide connection topology",
"host.topology.pending": "Connecting",
"host.sfu": "Media server",
"state.signal.connected": "Server connected",
"state.signal.connecting": "Connecting",
"state.signal.reconnecting": "Recovering",
"state.signal.offline": "Server offline",
"state.peer.connected": "Connected",
"state.peer.connecting": "Connecting",
"state.peer.routing": "Routing",
"state.peer.waiting": "Waiting for share",
"state.peer.reconnecting": "Recovering",
"state.peer.failed": "Connection failed",
"state.peer.disconnected": "Connection lost",
"state.peer.closed": "Closed",
"state.peer.new": "Preparing",
"state.route.p2p": "P2P direct",
"state.route.sfu": "Via server",
"stats.title": "Connection data",
"stats.relay": "Relay data",
"stats.downstream": "Downstream receive data",
"stats.more": "Detailed metrics",
"stats.less": "Hide detailed metrics",
"stats.resolution": "Resolution",
"stats.fps": "Frame rate",
"stats.bitrate": "Bitrate",
"stats.loss": "Packet loss",
"stats.rtt": "RTT",
"stats.codec": "Video codec",
"stats.jitter": "Jitter",
"stats.dropped": "Dropped frames",
"stats.encoder": "Encoder",
"stats.audio": "Audio bitrate",
"stats.unknown": "Unknown",
"stats.outgoing": "Available uplink",
"stats.qualityState": "Quality state",
"stats.quality.normal": "Normal",
"stats.quality.bandwidth": "Bandwidth-limited",
"stats.quality.cpu": "Encoder-limited",
"stats.quality.other": "Other limit",
"stats.quality.unclassified": "Unclassified limit",
"stats.capture": "Capture settings",
"stats.inputFps": "Encoder input rate",
"stats.encodeMs": "Encode time/frame",
"stats.decodeMs": "Decode time/frame",
"stats.freezeCount": "Freezes",
"stats.freezeDuration": "Freeze duration",
"stats.audioLoss": "Audio packet loss",
"stats.audioJitter": "Audio jitter",
"stats.playoutDelta": "A/V playout delta",
"stats.videoBuffer": "Video buffer",
"stats.audioBuffer": "Audio buffer",
"stats.audioConcealedRate": "Audio concealment rate",
"stats.audioConcealed": "Audio concealments",
"viewer.title": "{name}'s screen",
"viewer.titleFallback": "A friend's screen",
"viewer.reconnect": "Reconnect media",
"viewer.msg.joining": "Joining the room",
"viewer.msg.waitingHost": "Waiting for the host to share",
"viewer.msg.preparingP2p": "Establishing P2P",
"viewer.msg.preparingSfu": "Connecting backup route",
"viewer.msg.allocating": "Allocating a route",
"viewer.msg.receiving": "Receiving video",
"viewer.msg.playing": "Playing",
"viewer.msg.needsPlay": "Tap to play",
"viewer.msg.hostPaused": "Host paused",
"viewer.msg.recovering": "Recovering connection",
"viewer.msg.routeFailed": "No media route available",
"viewer.msg.playbackFailed": "The browser cannot play this stream",
"viewer.msg.hostOffline": "Host connection lost",
"viewer.msg.serverError": "Connection service unavailable",
"viewer.msg.notFound": "Room not found or expired",
"viewer.msg.denied": "This room cannot be joined by code right now",
"viewer.msg.invalidInvite": "Invite link invalid or expired",
"viewer.msg.expired": "Room expired",
"viewer.msg.closed": "Room closed",
"viewer.msg.full": "Cannot join the room right now",
"viewer.msg.stale": "Page updated; refresh to continue",
"viewer.msg.sessionReplaced": "This page's session was taken over by another tab",
"viewer.msg.signalTerminated": "Connection terminated; refresh to continue",
"viewer.notice.signalRecovering": "Server reconnecting; video keeps playing",
"viewer.notice.mediaRecovering": "Media connection recovering",
"viewer.notice.hostOffline": "Host connection lost; picture may freeze",
"viewer.hint.denied": "Use the host's invite link, or try the room password.",
"viewer.hint.notFound": "Check the room code, or ask the host for a new invite link.",
"viewer.hint.invite": "Ask the host for a new invite link.",
"viewer.hint.generic": "Check your entry point and try again.",
"viewer.error.p2p": "P2P media connection error",
"viewer.error.relay": "Downstream media connection error",
"mode.language": "Interface language",
"mode.zh": "中文",
"mode.en": "English",
"mode.vis": "Visual only",
"theme.dark": "Switch to dark mode",
"theme.light": "Switch to light mode",
"theme.dark.short": "Dark",
"theme.light.short": "Light",
"story.room": "Room found",
"story.link": "Linking up",
"story.show": "Receiving picture",
"gate.connectFailed": "Cannot reach the site access service; try again",
"gate.expired": "Site access expired; verify again",
"gate.serviceUnavailable": "Site access service unavailable ({status})",
"host.onlineCount": "{n} / {max} online",
"host.notStarted": "Not started",
"host.startCancelled": "Start cancelled",
"host.switchingSource": "Switching source",
"host.capture.fpsUnknown": "Unknown frame rate",
"host.capture.codecPending": "Codec pending",
"host.capture.hasAudio": "With audio",
"host.capture.noAudio": "No audio",
"host.stageAria": "Share or join a room",
"host.captureAria": "Actual capture parameters",
"host.quality.title": "{label} · {mbps} Mbps",
"host.audio.title": "{label} · {kbps} kbps ceiling",
"host.roomExpired": "Room expired",
"host.roomClosed": "Room closed",
"host.pause.noTracksResume": "No resumable media tracks in the current share",
"host.pause.signalRecovering": "Server reconnecting; sharing stays paused",
"host.pause.noTracksPause": "No pausable media tracks in the current share",
"host.pause.stillPaused": "Sharing stays paused",
"host.shareEnded": "The current share has ended",
"host.fail.start": "Failed to start sharing",
"host.err.createConnection": "Failed to create the connection",
"host.err.codecUnsupported": "This browser cannot use a supported video codec",
"host.err.applySender": "Failed to apply sender parameters",
"host.err.applyAudioSender": "Failed to apply audio sender parameters",
"host.fail.source": "Failed to switch share source",
"host.fail.quality": "Failed to apply quality settings",
"host.fail.connection": "Viewer connection handling failed",
"host.fail.room": "Room operation failed",
"host.err.invalidToken": "Share credentials expired; create the room again",
"host.err.accessDenied": "Not permitted",
"host.err.roomExpired": "Room expired; create it again",
"host.err.alreadyConnected": "This room is already shared in another page",
"host.err.peerGone": "The viewer connection has left",
"host.err.forbidden": "Action unavailable",
"host.err.serverError": "Service temporarily unavailable; try again later",
"host.err.createRoomStatus": "Cannot create a room right now ({status})",
"host.err.replaceRoomStatus": "Cannot change rooms right now ({status})",
"host.err.updateRoomStatus": "Cannot update room settings right now ({status})",
"host.capture.cancelled": "Screen selection cancelled or not permitted",
"host.capture.noSource": "No shareable screen source available",
"host.capture.readFailed": "The browser cannot read the selected source right now",
"host.capture.unavailable": "This page cannot start screen sharing",
"host.notice.reconnecting": "Some viewers are reconnecting",
"host.notice.sfuRecovering": "SFU source switch failed; restoring viewer connections",
"host.password.inputPlaceholder": "Enter password",
"stats.warn.codec": "Video codec is {codec}; expected H264 or VP8",
"stats.warn.audioCodec": "Audio codec is {codec}; expected Opus",
"viewer.msg.joinUnavailable": "Cannot join the room right now",
"viewer.onlineCount": "{n} online",
"viewer.stageAria": "Shared picture",
"host.warn.audioUnread": "The browser did not report the audio bitrate ceiling",
"host.warn.bandwidth": "This connection is bandwidth-limited; quality was reduced automatically",
"host.warn.encoding": "Encoding performance is limited; quality was reduced automatically",
"host.warn.other": "The browser keeps reporting other quality limits",
"host.warn.unclassified": "The browser keeps reporting an unclassified quality limit",
"host.warn.audioRewritten": "The browser rewrote the audio bitrate ceiling to {kbps} kbps",
"host.warn.senderPartial": "The browser did not fully accept {params}",
"host.warn.param.maxBitrate": "bitrate ceiling",
"host.warn.param.maxFramerate": "frame rate ceiling",
"host.warn.param.scaleResolutionDownBy": "resolution scaling",
"host.warn.param.degradationPreference": "quality preference",
"host.warn.param.scalabilityMode": "scalability mode",
"host.warn.sfuRecover": "SFU {stage} failed; automatic recovery started",
"host.warn.sfuStage.connect": "connect",
"host.warn.sfuStage.source": "source",
"host.warn.sfuStage.videoPublish": "video publish",
"host.warn.sfuStage.senderConfig": "sender config",
"host.warn.sfuStage.audioPublish": "audio publish",
"host.warn.sfuStage.transport": "transport",
"host.fail.sfuSwitch": "Failed to switch the SFU share source",
"host.fail.sfuAudioParams": "Failed to apply SFU audio sender parameters",
"host.fail.sfuParams": "Failed to apply SFU sender parameters",
};
export type CopyKey = keyof typeof zh;
const CATALOGS: Record<Lang, Record<CopyKey, string>> = { zh, en };
const LANG_STORAGE_KEY = "screener:ui-lang";
const MODE_STORAGE_KEY = "screener:ui-mode";
interface CopyPrefs {
lang: Lang;
vis: boolean;
}
function readStored(): Partial<CopyPrefs> {
try {
const lang = window.localStorage.getItem(LANG_STORAGE_KEY);
const mode = window.localStorage.getItem(MODE_STORAGE_KEY);
return {
...(lang === "zh" || lang === "en" ? { lang } : {}),
...(mode === "text" || mode === "vis" ? { vis: mode === "vis" } : {}),
};
} catch {
return {};
}
}
function detectLang(): Lang {
return typeof navigator !== "undefined" &&
navigator.language?.toLowerCase().startsWith("zh")
? "zh"
: "en";
}
const stored = readStored();
const state: CopyPrefs = {
lang: stored.lang ?? detectLang(),
vis: stored.vis ?? true,
};
function syncDocumentLanguage(): void {
if (typeof document !== "undefined") {
document.documentElement.lang = state.lang === "zh" ? "zh-CN" : "en";
}
}
syncDocumentLanguage();
const listeners = new Set<() => void>();
// Applies values without persisting (storage-event sync path); commit() is
// the persisting variant for local user actions.
function apply(next: Partial<CopyPrefs>): void {
if (next.lang) state.lang = next.lang;
if (next.vis !== undefined) state.vis = next.vis;
syncDocumentLanguage();
listeners.forEach((listener) => listener());
}
function commit(next: Partial<CopyPrefs>): void {
apply(next);
try {
window.localStorage.setItem(LANG_STORAGE_KEY, state.lang);
window.localStorage.setItem(MODE_STORAGE_KEY, state.vis ? "vis" : "text");
} catch {
// Restricted storage only disables persistence.
}
}
// Cross-tab sync: a commit in another tab re-applies the persisted values
// here. Identical-value writes fire no storage event, so tabs converge
// without re-notify loops.
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === LANG_STORAGE_KEY || event.key === MODE_STORAGE_KEY) {
apply(readStored());
}
});
}
// Non-React setter (used by the mode pill and by tests).
export const setCopy = commit;
export function t(lang: Lang, key: CopyKey, vars?: Record<string, string>): string {
let value = CATALOGS[lang][key];
if (vars) {
for (const [name, replacement] of Object.entries(vars)) {
value = value.replace(`{${name}}`, replacement);
}
}
return value;
}
// Non-hook accessor for transient notices composed outside React render
// (pages keep resolved strings in state; a language switch leaves an already
// shown notice in its original language until replaced).
export function say(key: CopyKey, vars?: Record<string, string>): string {
return t(state.lang, key, vars);
}
// Locale-aware joins for composed notices (say() composites).
export function joinSentences(parts: string[]): string {
return parts.join(state.lang === "zh" ? ";" : "; ");
}
export function joinItems(parts: string[]): string {
return parts.join(state.lang === "zh" ? "、" : ", ");
}
export interface Copy {
lang: Lang;
vis: boolean;
t: (key: CopyKey, vars?: Record<string, string>) => string;
setLang: (lang: Lang) => void;
setVis: (vis: boolean) => void;
}
export function useCopy(): Copy {
const snapshot = () => `${state.lang}:${state.vis}`;
const current = useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
snapshot,
snapshot,
);
const [lang, vis] = current.split(":") as [Lang, string];
return {
lang,
vis: vis === "true",
t: (key, vars) => t(lang, key, vars),
setLang: (next) => commit({ lang: next, vis: false }),
setVis: (next) => commit({ vis: next }),
};
}
+109
View File
@@ -0,0 +1,109 @@
// Hand-drawn flat icon set for the living-room UI. Stroke icons use
// currentColor; solid icons (play/pause/stop) are filled. Bodies are real JSX
// children (never dangerouslySetInnerHTML — React re-sets innerHTML on every
// render pass, which would restart the draw-in animation on unrelated state
// changes; reconciled children stay put, so the draw only replays on a true
// icon swap). pathLength={1} on every shape paces the draw-in evenly.
import type { ReactNode } from "react";
const PATHS: Record<string, { body: ReactNode; solid?: boolean }> = {
copy: { body: (<><rect pathLength={1} x="9" y="9" width="12" height="12" rx="2"/><path pathLength={1} d="M5 15V5a2 2 0 0 1 2-2h10"/></>) },
check: { body: (<><path pathLength={1} d="M4 12.5 9.5 18 20 6.5"/></>) },
refresh: { body: (<><path pathLength={1} d="M3 12a9 9 0 0 1 9-9 9.7 9.7 0 0 1 6.7 2.7L21 8"/><path pathLength={1} d="M21 3v5h-5"/><path pathLength={1} d="M21 12a9 9 0 0 1-9 9 9.7 9.7 0 0 1-6.7-2.7L3 16"/><path pathLength={1} d="M8 16H3v5"/></>) },
x: { body: (<><path pathLength={1} d="M18 6 6 18M6 6l12 12"/></>) },
eye: { body: (<><path pathLength={1} d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle pathLength={1} cx="12" cy="12" r="3"/></>) },
eyeOff: { body: (<><path pathLength={1} d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 8 10 8a18.5 18.5 0 0 1-2.16 3.19M6.61 6.61A13.5 13.5 0 0 0 2 12s3 8 10 8a9.74 9.74 0 0 0 5.39-1.61"/><path pathLength={1} d="m2 2 20 20"/><path pathLength={1} d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/></>) },
play: { solid: true, body: (<><path pathLength={1} d="M7 4.8v14.4a1 1 0 0 0 1.52.86l11.4-7.2a1 1 0 0 0 0-1.72L8.52 3.94A1 1 0 0 0 7 4.8Z"/></>) },
pause: { solid: true, body: (<><rect pathLength={1} x="6" y="4" width="4.2" height="16" rx="1.2"/><rect pathLength={1} x="13.8" y="4" width="4.2" height="16" rx="1.2"/></>) },
stop: { solid: true, body: (<><rect pathLength={1} x="6" y="6" width="12" height="12" rx="2"/></>) },
share: { body: (<><rect pathLength={1} x="2" y="4" width="20" height="13" rx="2"/><path pathLength={1} d="M12 17v-7m0 0-3 3m3-3 3 3"/><path pathLength={1} d="M8 21h8"/></>) },
hash: { body: (<><path pathLength={1} d="M9 3 7 21M17 3l-2 18M4 8h17M3 16h17"/></>) },
users: { body: (<><circle pathLength={1} cx="9" cy="8" r="3.5"/><path pathLength={1} d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><circle pathLength={1} cx="17" cy="9" r="2.5"/><path pathLength={1} d="M16.2 15.2c2.5.4 4.6 1.8 5.3 4.8"/></>) },
network: { body: (<><rect pathLength={1} x="9" y="2" width="6" height="6" rx="1.5"/><rect pathLength={1} x="2" y="16" width="6" height="6" rx="1.5"/><rect pathLength={1} x="16" y="16" width="6" height="6" rx="1.5"/><path pathLength={1} d="M12 8v3.5M12 11.5 5.5 16M12 11.5l6.5 4.5"/></>) },
chevron: { body: (<><path pathLength={1} d="m6 9 6 6 6-6"/></>) },
loader: { body: (<><path pathLength={1} d="M21 12a9 9 0 1 1-6.22-8.56"/></>) },
alert: { body: (<><path pathLength={1} d="M12 3.5 2.8 19.5a1 1 0 0 0 .87 1.5h16.66a1 1 0 0 0 .87-1.5L12 3.5Z"/><path pathLength={1} d="M12 10v4.5"/><path pathLength={1} d="M12 17.8h.01"/></>) },
wifiOff: { body: (<><path pathLength={1} d="m2 2 20 20"/><path pathLength={1} d="M8.5 16.5a5 5 0 0 1 7 0"/><path pathLength={1} d="M5 12.9a11 11 0 0 1 3.1-2.2M15.9 11.4a11 11 0 0 1 3.1 1.5"/><path pathLength={1} d="M1.5 8.8A16 16 0 0 1 8 5.4m8 .2a16 16 0 0 1 6.5 3.2"/><path pathLength={1} d="M12 20h.01"/></>) },
moon: { body: (<><path pathLength={1} d="M20.2 14.2A8.5 8.5 0 1 1 9.8 3.8a7 7 0 1 0 10.4 10.4Z"/></>) },
pencil: { body: (<><path pathLength={1} d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z"/></>) },
link: { body: (<><path pathLength={1} d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path pathLength={1} d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></>) },
linkOff: { body: (<><path pathLength={1} d="M10 13a5 5 0 0 0 7.54.54l1.1-1.1M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/><path pathLength={1} d="m3 3 18 18"/></>) },
arrowRight: { body: (<><path pathLength={1} d="M4 12h16m-6.5-6.5L20 12l-6.5 6.5"/></>) },
arrowUp: { body: (<><path pathLength={1} d="M12 20V4m-6.5 6.5L12 4l6.5 6.5"/></>) },
arrowDown: { body: (<><path pathLength={1} d="M12 4v16m-6.5-6.5L12 20l6.5-6.5"/></>) },
key: { body: (<><circle pathLength={1} cx="7.5" cy="15.5" r="4.5"/><path pathLength={1} d="m11 12.5 9.5-9.5M14.5 6l3 3M17.5 3l3 3"/></>) },
globe: { body: (<><circle pathLength={1} cx="12" cy="12" r="9"/><path pathLength={1} d="M3 12h18"/><path pathLength={1} d="M12 3c2.5 2.6 3.9 5.6 3.9 9s-1.4 6.4-3.9 9c-2.5-2.6-3.9-5.6-3.9-9S9.5 5.6 12 3Z"/></>) },
lock: { body: (<><rect pathLength={1} x="4" y="11" width="16" height="10" rx="2"/><path pathLength={1} d="M8 11V7a4 4 0 0 1 8 0v4"/></>) },
signal: { body: (<><path pathLength={1} d="M2 9a15 15 0 0 1 20 0"/><path pathLength={1} d="M5 12.5a10 10 0 0 1 14 0"/><path pathLength={1} d="M8.5 16a5 5 0 0 1 7 0"/><path pathLength={1} d="M12 19.5h.01"/></>) },
server: { body: (<><rect pathLength={1} x="3" y="4" width="18" height="7" rx="2"/><rect pathLength={1} x="3" y="13" width="18" height="7" rx="2"/><path pathLength={1} d="M7 7.5h.01M7 16.5h.01"/></>) },
tv: { body: (<><rect pathLength={1} x="2" y="5" width="20" height="14" rx="2"/><path pathLength={1} d="M8 2l4 3 4-3"/></>) },
door: { body: (<><path pathLength={1} d="M4 21V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v16"/><path pathLength={1} d="M2 21h20"/><path pathLength={1} d="M12.5 12h.01"/></>) },
plug: { body: (<><path pathLength={1} d="M9 7V3m6 4V3"/><path pathLength={1} d="M6 7h12v4a6 6 0 0 1-12 0V7Z"/><path pathLength={1} d="M12 17v4"/></>) },
gauge: { body: (<><path pathLength={1} d="M4 14.5a8 8 0 1 1 16 0"/><path pathLength={1} d="m12 14 3.5-4"/><path pathLength={1} d="M3.5 17.5h17"/></>) },
drop: { body: (<><path pathLength={1} d="M12 3s6 6.4 6 11a6 6 0 0 1-12 0c0-4.6 6-11 6-11Z"/></>) },
expand: { body: (<><path pathLength={1} d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></>) },
wave: { body: (<><path pathLength={1} d="M2 12c1.7 0 1.7-2.5 3.4-2.5S7 12 8.7 12s1.6-2.5 3.3-2.5S13.6 12 15.3 12s1.7-2.5 3.4-2.5S20.3 12 22 12"/></>) },
speaker: { body: (<><path pathLength={1} d="M4 9v6h4l5 4V5L8 9H4Z"/><path pathLength={1} d="M16 9a4.2 4.2 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"/></>) },
sliders: { body: (<><path pathLength={1} d="M3 6h9m4 0h5M3 12h3m4 0h11M3 18h12m4 0h2"/><circle pathLength={1} cx="14" cy="6" r="2"/><circle pathLength={1} cx="8" cy="12" r="2"/><circle pathLength={1} cx="17" cy="18" r="2"/></>) },
mountain: { body: (<><path pathLength={1} d="m3 18 5.5-8.5L13 15l3.5-5L21 18H3Z"/></>) },
balance: { body: (<><path pathLength={1} d="M12 4v16m-5 0h10"/><path pathLength={1} d="m12 6-6 1.5L3.5 13a3.1 3.1 0 0 0 5 0L6 7.5M12 6l6 1.5 2.5 5.5a3.1 3.1 0 0 1-5 0L18 7.5"/></>) },
branch: { body: (<><circle pathLength={1} cx="6" cy="6" r="2.5"/><circle pathLength={1} cx="6" cy="18" r="2.5"/><circle pathLength={1} cx="18" cy="8" r="2.5"/><path pathLength={1} d="M6 8.5v7M6 13c5 0 5.5-2 9.3-3.2"/></>) },
clock: { body: (<><circle pathLength={1} cx="12" cy="12" r="9"/><path pathLength={1} d="M12 7v5.2l3.4 2"/></>) },
cpu: { body: (<><rect pathLength={1} x="7" y="7" width="10" height="10" rx="2"/><path pathLength={1} d="M10 2v3m4-3v3M10 19v3m4-3v3M2 10h3M2 14h3m14-4h3m-3 4h3"/></>) },
star: { body: (<><path pathLength={1} d="m12 3 2.7 5.7 6.3.8-4.6 4.3 1.2 6.2-5.6-3-5.6 3 1.2-6.2L3 9.5l6.3-.8L12 3Z"/></>) },
zap: { body: (<><path pathLength={1} d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z"/></>) },
sparkles: { body: (<><path pathLength={1} d="m12 4 1.6 4.4L18 10l-4.4 1.6L12 16l-1.6-4.4L6 10l4.4-1.6L12 4Z"/><path pathLength={1} d="m18.5 15.5.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z"/></>) },
cast: { body: (<><path pathLength={1} d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9.95 9.95 0 0 1 9.95 20M2 8a14 14 0 0 1 14 14"/><circle pathLength={1} cx="3" cy="20" r="1.2" fill="currentColor" stroke="none"/><rect pathLength={1} x="2" y="4" width="20" height="15" rx="2"/></>) },
sun: { body: (<><circle pathLength={1} cx="12" cy="12" r="4"/><path pathLength={1} d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></>) },
};
export type GlyphName = keyof typeof PATHS;
export function Glyph({
name,
size = 18,
className,
draw,
}: {
name: GlyphName | string;
size?: number;
className?: string;
/**
* Declares this icon a draw-in SPOT (the value is the spot's stable id,
* used for documentation only). Spots are state-beat positions the author
* chose deliberately: theatrical mounts (entry cast/door, stage overlay,
* join/gate doors) and toggle icons whose form changes (pause↔play,
* copy↔check, moon↔sun, eye↔eyeOff). The draw replays exactly when the
* icon's innerHTML changes — a swap at the spot, or a genuine re-entry
* remount. No runtime inference: icons without a spot never draw, so list
* reconciliation and StrictMode can never cause a false trigger.
*/
draw?: string;
}) {
const icon = PATHS[name] ?? PATHS.alert;
const isSolid = icon?.solid === true;
const drawClass = [
draw ? "lr-glyph-draw" : null,
draw && isSolid ? "is-solid" : null,
className ?? null,
]
.filter(Boolean)
.join(" ");
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={isSolid ? "currentColor" : "none"}
stroke={isSolid ? "none" : "currentColor"}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
className={drawClass || undefined}
>
{icon?.body}
</svg>
);
}
+56
View File
@@ -0,0 +1,56 @@
// Light/dark theme with system default, persistence, and no-flash startup.
// index.html applies the stored/system theme before first paint; this module
// owns runtime toggles.
import { useSyncExternalStore } from "react";
export type Theme = "light" | "dark";
const THEME_STORAGE_KEY = "screener:ui-theme";
function detectTheme(): Theme {
return typeof window !== "undefined" &&
window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function readInitial(): Theme {
const attr = document.documentElement.dataset.theme;
if (attr === "dark" || attr === "light") {
return attr;
}
return detectTheme();
}
const state: { theme: Theme } = { theme: "light" };
const listeners = new Set<() => void>();
export function applyTheme(theme: Theme): void {
state.theme = theme;
document.documentElement.dataset.theme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Restricted storage only disables persistence.
}
listeners.forEach((listener) => listener());
}
export function initTheme(): void {
state.theme = readInitial();
document.documentElement.dataset.theme = state.theme;
}
export function useTheme(): { theme: Theme; toggle: () => void } {
const theme = useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state.theme,
);
return {
theme,
toggle: () => applyTheme(theme === "dark" ? "light" : "dark"),
};
}
+14 -13
View File
@@ -1,3 +1,4 @@
import { say } from "../ui/copy";
import {
PERSISTENT_NATIVE_EDGE_DEGRADED_WINDOWS,
type IceConfig,
@@ -107,7 +108,7 @@ export class HostPeer {
async start(): Promise<boolean> {
const videoTrack = this.stream.getVideoTracks()[0];
if (!videoTrack) {
this.setError(new Error("共享流缺少视频轨道"), "创建连接失败");
this.setError(new Error(say("host.capture.noSource")), say("host.err.createConnection"));
return false;
}
const audioTrack = this.stream.getAudioTracks()[0] ?? null;
@@ -116,7 +117,7 @@ export class HostPeer {
streams: [this.stream],
});
if (!applyVideoCodecPreference(videoTransceiver, this.videoCodec)) {
this.setError(null, "当前浏览器无法使用支持的视频编码");
this.setError(null, say("host.err.codecUnsupported"));
return false;
}
this.videoSender = videoTransceiver.sender;
@@ -173,7 +174,7 @@ export class HostPeer {
videoSender.replaceTrack(previousVideoTrack),
audioSender.replaceTrack(previousAudioTrack),
]);
this.setError(error, "切换共享源失败");
this.setError(error, say("host.fail.source"));
return false;
}
@@ -292,7 +293,7 @@ export class HostPeer {
this.pendingCandidates.push(payload.candidate);
}
} catch (error) {
this.setError(error, "建立观看连接失败");
this.setError(error, say("host.err.createConnection"));
}
}
@@ -325,7 +326,7 @@ export class HostPeer {
iceServers: iceConfig.iceServers,
});
} catch (error) {
this.setError(error, "更新网络配置失败");
this.setError(error, say("host.err.createConnection"));
}
}
@@ -424,7 +425,7 @@ export class HostPeer {
},
})
) {
throw new Error("服务器暂时离线,等待重新连接");
throw new Error(say("host.err.serverError"));
}
this.snapshot = { ...this.snapshot, error: null };
this.emit();
@@ -436,7 +437,7 @@ export class HostPeer {
if (this.ordinaryAnswerEpoch === epoch) {
this.ordinaryAnswerEpoch = null;
}
this.setError(error, restart ? "恢复连接失败" : "创建连接失败");
this.setError(error, restart ? say("host.err.createConnection") : say("host.err.createConnection"));
return false;
}
}
@@ -592,7 +593,7 @@ export class HostPeer {
videoWarning = senderParameterWarning(senderParameters);
} catch {
videoSucceeded = false;
videoWarning = "应用发送参数失败";
videoWarning = say("host.err.applySender");
}
}
@@ -614,7 +615,7 @@ export class HostPeer {
audioWarning = audioSenderParameterWarning(audioSenderParameters);
} catch {
audioSucceeded = false;
audioWarning = "应用音频发送参数失败";
audioWarning = say("host.err.applyAudioSender");
}
} else {
audioSenderParameters = null;
@@ -682,14 +683,14 @@ export class HostPeer {
}
switch (this.limitationReason) {
case "bandwidth":
return "当前连接带宽受限,画质已自动降低";
return say("host.warn.bandwidth");
case "cpu":
return "编码性能受限,画质已自动降低";
return say("host.warn.encoding");
case "other":
return "浏览器持续报告其他画质限制";
return say("host.warn.other");
default:
return this.limitationReason
? "浏览器持续报告未分类的画质限制"
? say("host.warn.unclassified")
: null;
}
}
+5 -4
View File
@@ -1,3 +1,4 @@
import { say } from "../ui/copy";
import type { IceConfig, SignalPayload } from "../../shared/protocol";
import {
EMPTY_METRICS,
@@ -108,7 +109,7 @@ export class ViewerPeer {
iceServers: iceConfig.iceServers,
});
} catch (error) {
this.setError(error, "更新网络配置失败");
this.setError(error, say("host.fail.connection"));
}
}
@@ -306,7 +307,7 @@ export class ViewerPeer {
sdp: connection.localDescription.sdp,
},
})) {
throw new Error("服务器连接暂时不可用");
throw new Error(say("viewer.msg.serverError"));
}
this.offerRecoveryAttempts = 0;
this.scheduleInitialConnectionDeadline(connection, connectionId);
@@ -314,7 +315,7 @@ export class ViewerPeer {
if (!this.isCurrentConnection(connection, connectionId)) {
return;
}
this.setError(error, "建立分享连接失败");
this.setError(error, say("host.fail.connection"));
if (
this.offerRecoveryAttempts < 1 &&
this.events.sendRestartRequest(parentPeerId, connectionId, true)
@@ -347,7 +348,7 @@ export class ViewerPeer {
await connection.addIceCandidate(payload.candidate);
} catch (error) {
if (this.isCurrentConnection(connection, connectionId)) {
this.setError(error, "建立分享连接失败");
this.setError(error, say("host.fail.connection"));
}
}
}
-29
View File
@@ -1,29 +0,0 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { ConnectionDetailsToggle } from "../src/client/components/ConnectionDetailsToggle.tsx";
describe("ConnectionDetailsToggle", () => {
it("renders only the controlled connection-details checkbox", () => {
const expanded = renderToStaticMarkup(
createElement(ConnectionDetailsToggle, {
checked: true,
onChange: vi.fn(),
}),
);
const collapsed = renderToStaticMarkup(
createElement(ConnectionDetailsToggle, {
checked: false,
onChange: vi.fn(),
}),
);
expect(expanded).toContain("显示连接详情");
expect(expanded).toContain('type="checkbox"');
expect(expanded).toContain("checked=\"\"");
expect(expanded).not.toContain("button");
expect(collapsed).not.toContain("checked=\"\"");
expect(collapsed).not.toContain("下载");
});
});
+4 -4
View File
@@ -31,9 +31,9 @@ describe("progressive connection details", () => {
});
it("formats interval packet loss without inventing unknown values", () => {
expect(formatPacketLossPercent(1.25)).toBe("1.3%");
expect(formatPacketLossPercent(0)).toBe("0.0%");
expect(formatPacketLossPercent(null)).toBe("未知");
expect(formatPacketLossPercent(Number.NaN)).toBe("未知");
expect(formatPacketLossPercent(1.25, "未知")).toBe("1.3%");
expect(formatPacketLossPercent(0, "未知")).toBe("0.0%");
expect(formatPacketLossPercent(null, "未知")).toBe("未知");
expect(formatPacketLossPercent(Number.NaN, "未知")).toBe("未知");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { say, setCopy, t, type CopyKey } from "../src/client/ui/copy.ts";
describe("copy catalog", () => {
afterEach(() => {
vi.unstubAllGlobals();
setCopy({ lang: "zh", vis: false });
});
it("renders zh and en for every key", () => {
const zh = t("zh", "join.title");
expect(zh).toBe("加入房间");
expect(t("en", "join.title")).toBe("Join a room");
});
it("interpolates variables", () => {
expect(t("zh", "host.title", { name: "阿舟" })).toBe("阿舟 的屏幕");
expect(t("en", "host.onlineCount", { n: "4", max: "20" })).toBe(
"4 / 20 online",
);
});
it("keeps zh and en key sets identical", () => {
// Type-level parity is enforced by Record<keyof typeof zh, string>;
// this guards runtime drift if the catalog shape changes.
for (const key of ["host.start", "viewer.msg.playing", "stats.more"] as CopyKey[]) {
expect(t("zh", key).length).toBeGreaterThan(0);
expect(t("en", key).length).toBeGreaterThan(0);
}
});
it("say() follows the current language", () => {
setCopy({ lang: "zh" });
expect(say("common.copied")).toBe("已复制");
setCopy({ lang: "en" });
expect(say("common.copied")).toBe("Copied");
});
it("keeps the document language aligned with the selected catalog", () => {
const documentElement = { lang: "zh-CN" };
vi.stubGlobal("document", { documentElement });
setCopy({ lang: "en" });
expect(documentElement.lang).toBe("en");
setCopy({ lang: "zh" });
expect(documentElement.lang).toBe("zh-CN");
});
});
+3
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { setCopy } from "../src/client/ui/copy.ts";
import {
hostActionErrorNotice,
hostServerErrorNotice,
@@ -7,6 +8,8 @@ import {
sourceSwitchNotice,
} from "../src/client/pages/host-page-notices.ts";
setCopy({ lang: "zh" });
describe("host error notices", () => {
it("maps every server error code without exposing server text", () => {
const notices = {
+55
View File
@@ -0,0 +1,55 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it } from "vitest";
import { Couch } from "../src/client/components/living/Couch.tsx";
import { StageOverlay } from "../src/client/components/living/Stage.tsx";
import { NameTag } from "../src/client/components/living/primitives.tsx";
import { setCopy } from "../src/client/ui/copy.ts";
describe("living-room presentation", () => {
afterEach(() => setCopy({ lang: "zh", vis: false }));
it("keeps visual-only stage status available to assistive technology", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(StageOverlay, {
icon: "refresh",
message: "Media connection recovering",
}),
);
expect(html).toContain('role="status"');
expect(html).toContain('aria-label="Media connection recovering"');
expect(html).not.toContain("lr-tv-msg");
});
it("announces the supplied state for a Viewer without committed media", () => {
setCopy({ lang: "en", vis: false });
const html = renderToStaticMarkup(
createElement(Couch, {
entries: [
{
key: "viewer-1",
name: "TNT",
connected: false,
statusLabel: "Recovering",
},
],
}),
);
expect(html).toContain("TNT · Recovering");
expect(html).toContain("lr-pawn-led is-wait");
});
it("keeps the current display name visible in visual mode", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(NameTag, { name: "TNT" }),
);
expect(html).toContain(">TNT<");
expect(html).not.toContain("visually-hidden");
});
});
@@ -2,12 +2,15 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StatsGrid } from "../src/client/components/StatsGrid.tsx";
import { MetricCells } from "../src/client/components/living/Metrics.tsx";
import { setCopy } from "../src/client/ui/copy.ts";
import {
EMPTY_METRICS,
type ConnectionMetrics,
} from "../src/client/types.ts";
setCopy({ lang: "zh", vis: false });
const metrics = {
...EMPTY_METRICS,
bitrateKbps: 4_200,
@@ -40,28 +43,33 @@ const metrics = {
qualityLimitationReason: "bandwidth",
} satisfies ConnectionMetrics;
describe("StatsGrid progressive disclosure", () => {
it("keeps one-glance picture results above an accessible detail panel", () => {
const html = renderToStaticMarkup(
createElement(StatsGrid, {
describe("MetricCells progressive disclosure", () => {
it("keeps one-glance picture results above an accessible detail expansion", () => {
const collapsed = renderToStaticMarkup(
createElement(MetricCells, {
metrics,
direction: "send",
progressive: true,
expanded: false,
onToggle: () => undefined,
}),
);
for (const label of ["分辨率", "帧率", "码率", "丢包率"]) {
expect(html).toContain(label);
expect(collapsed).toContain(label);
}
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('title="展开详细指标"');
const controlId = html.match(/aria-controls="([^"]+)"/)?.[1];
expect(controlId).toBeTruthy();
const panelStart = html.indexOf(`<dl id="${controlId}"`);
expect(panelStart).toBeGreaterThan(0);
expect(html.slice(panelStart, html.indexOf(">", panelStart))).toContain(
'hidden=""',
expect(collapsed).toContain('aria-expanded="false"');
expect(collapsed).toContain('title="详细指标"');
expect(collapsed).not.toContain("可用上行");
const expanded = renderToStaticMarkup(
createElement(MetricCells, {
metrics,
direction: "send",
expanded: true,
onToggle: () => undefined,
}),
);
expect(expanded).toContain('aria-expanded="true"');
for (const label of [
"RTT",
"视频编码",
@@ -73,18 +81,23 @@ describe("StatsGrid progressive disclosure", () => {
"编码耗时/帧",
"音频码率",
]) {
expect(html.indexOf(label)).toBeGreaterThan(panelStart);
expect(expanded).toContain(label);
}
expect(html).toContain("58.5 fps");
expect(html).toContain("VP8");
expect(expanded).toContain("58.5 fps");
expect(expanded).toContain("VP8");
});
it("shows the negotiated video codec but omits browser-internal transport details", () => {
const html = renderToStaticMarkup(
createElement(StatsGrid, { metrics, direction: "send" }),
createElement(MetricCells, {
metrics,
direction: "send",
expanded: true,
onToggle: () => undefined,
}),
);
expect(html).toContain("<dt>视频编码</dt><dd>VP8</dd>");
expect(html).toContain("VP8");
for (const omitted of [
"音频 Codec",
"候选路径",
@@ -94,42 +107,43 @@ describe("StatsGrid progressive disclosure", () => {
"STUN 响应",
"candidate-pair-7",
"192.0.2.10",
"2001:db8::10",
"当前 RID",
"max-fs=8160",
"请求 / 应用",
]) {
expect(html).not.toContain(omitted);
}
expect(html).not.toContain("详细指标");
expect(html).not.toContain("aria-expanded");
});
it("shows codecs only when the negotiated contract is unexpected", () => {
const html = renderToStaticMarkup(
createElement(StatsGrid, {
createElement(MetricCells, {
metrics: {
...metrics,
codec: "video/VP9",
audioCodec: "audio/PCMU",
},
direction: "receive",
expanded: false,
onToggle: () => undefined,
}),
);
expect(html).toContain("视频编码为 VP9,预期 H264 或 VP8");
expect(html).toContain("音频编码为 PCMU,预期 Opus");
expect(html).not.toContain("视频 Codec");
expect(html).not.toContain("音频 Codec");
});
it("does not expose an unknown browser quality-limitation value", () => {
const html = renderToStaticMarkup(
createElement(StatsGrid, {
createElement(MetricCells, {
metrics: {
...metrics,
qualityLimitationReason: "browser-internal-sentinel",
},
direction: "send",
expanded: true,
onToggle: () => undefined,
}),
);
@@ -152,15 +166,19 @@ describe("StatsGrid progressive disclosure", () => {
intervalAudioConcealmentEvents: 3,
} satisfies ConnectionMetrics;
const receiveHtml = renderToStaticMarkup(
createElement(StatsGrid, {
createElement(MetricCells, {
metrics: receiverMetrics,
direction: "receive",
expanded: true,
onToggle: () => undefined,
}),
);
const sendHtml = renderToStaticMarkup(
createElement(StatsGrid, {
createElement(MetricCells, {
metrics: receiverMetrics,
direction: "send",
expanded: true,
onToggle: () => undefined,
}),
);
@@ -185,14 +203,35 @@ describe("StatsGrid progressive disclosure", () => {
it("does not render an empty second level", () => {
const html = renderToStaticMarkup(
createElement(StatsGrid, {
createElement(MetricCells, {
metrics: EMPTY_METRICS,
direction: "receive",
progressive: true,
expanded: false,
onToggle: () => undefined,
}),
);
expect(html).not.toContain("详细指标");
expect(html).not.toContain("aria-expanded");
});
it("omits capture input when it matches the delivered picture", () => {
const html = renderToStaticMarkup(
createElement(MetricCells, {
metrics: {
...metrics,
resolution: "1920x1080",
framesPerSecond: 60,
captureWidth: 1920,
captureHeight: 1080,
captureFramesPerSecond: 60,
},
direction: "send",
expanded: true,
onToggle: () => undefined,
}),
);
expect(html).not.toContain("捕获设置");
});
});
+124
View File
@@ -0,0 +1,124 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { RouteTree } from "../src/client/components/living/RouteTree.tsx";
import { labelParticipantSnapshot } from "../src/client/lib/viewer-presence.ts";
import { setCopy } from "../src/client/ui/copy.ts";
setCopy({ lang: "zh", vis: false });
describe("RouteTree", () => {
it("disambiguates Host and Viewer names without exposing unique peer IDs", () => {
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_private_123456",
displayName: "同名",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_private_root_654321",
displayName: "同名",
upstream: { kind: "peer", peerId: "host_private_123456" },
},
{
role: "viewer",
peerId: "viewer_private_child_456789",
displayName: "朋友乙",
upstream: { kind: "peer", peerId: "viewer_private_root_654321" },
},
{
role: "viewer",
peerId: "viewer_private_sfu_789012",
displayName: "朋友丙",
upstream: { kind: "sfu" },
},
]);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "分享者",
viewers,
selfPeerId: null,
}),
);
expect(html).toContain("同名 (123456)");
expect(html).toContain("同名 (654321)");
expect(html).toContain("朋友乙");
expect(html).toContain("朋友丙");
expect(html).toContain("媒体服务器");
expect(html).toContain("分享者");
expect(html).not.toContain("host_private_123456");
expect(html).not.toContain("viewer_private_root_654321");
expect(html).not.toContain("viewer_private_child_456789");
expect(html).not.toContain("viewer_private_sfu_789012");
});
it("keeps the tree text-free in visual mode", () => {
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_abc12345",
displayName: "阿舟",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_def67890",
displayName: "阿茶",
upstream: { kind: "peer", peerId: "host_abc12345" },
},
]);
setCopy({ vis: true });
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "分享者",
viewers,
selfPeerId: null,
}),
);
expect(html).not.toContain("<text");
// Accessible names ride on aria-label: <title> doubles as a native hover
// tooltip, which leaks localized UI text into the zero-text mode.
expect(html).not.toContain("<title");
expect(html).toContain('aria-label="阿茶"');
setCopy({ vis: false });
});
it("gives every reachable depth its own horizontal column", () => {
const participants = [
{
role: "host" as const,
peerId: "host",
displayName: "Host",
upstream: { kind: "none" as const },
},
...Array.from({ length: 5 }, (_, index) => ({
role: "viewer" as const,
peerId: `viewer-${index}`,
displayName: `Viewer ${index}`,
upstream: {
kind: "peer" as const,
peerId: index === 0 ? "host" : `viewer-${index - 1}`,
},
})),
];
const { host, viewers } = labelParticipantSnapshot(participants);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "Host",
viewers,
}),
);
const width = Number(html.match(/viewBox="0 0 (\d+) /)?.[1]);
expect(width).toBeGreaterThan(640);
expect(new Set([...html.matchAll(/translate\((\d+),/g)].map((match) => match[1])).size).toBeGreaterThanOrEqual(6);
});
});
-73
View File
@@ -1,73 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StageEntryActions } from "../src/client/components/StageEntryActions.tsx";
describe("Host stage entry layout", () => {
it("keeps disclosure as the immediate sibling after the fixed action row", () => {
const closed = renderToStaticMarkup(
createElement(StageEntryActions, {
joiningRoom: false,
onJoinToggle: () => undefined,
onStartSharing: () => undefined,
}),
);
const open = renderToStaticMarkup(
createElement(StageEntryActions, {
joiningRoom: true,
onJoinToggle: () => undefined,
onStartSharing: () => undefined,
}),
);
expect(closed.match(/class="entry-action"/g)).toHaveLength(2);
expect(open.match(/class="entry-action"/g)).toHaveLength(2);
expect(open).toMatch(
/class="entry-actions"[\s\S]*?<\/div><form[^>]*class="room-code-entry is-inline"/,
);
const row = open.match(/<div class="entry-actions">([\s\S]*?)<\/div>/)?.[1];
expect(row).toContain("开始分享");
expect(row).toContain("加入房间");
expect(open).toContain('title="输入房间号加入观看"');
expect(row).not.toContain('class="room-code-entry');
expect(row).not.toContain("<form");
expect(open).toContain('aria-expanded="true"');
expect(open).toContain('id="host-room-code-entry-input"');
});
it("uses bounded tracks for both the row and its independent disclosure", () => {
const css = readFileSync(
join(import.meta.dirname, "../src/client/styles.css"),
"utf8",
);
expect(css).toMatch(
/\.entry-actions\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) 24px minmax\(0, 1fr\);[\s\S]*?width:\s*min\(520px, calc\(100% - 20px\)\);/,
);
expect(css).toMatch(
/\.stage-entry\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\);[\s\S]*?grid-template-rows:\s*minmax\(0, 1fr\) 44px minmax\(0, 1fr\);/,
);
expect(css).toMatch(
/\.entry-action\s*\{[\s\S]*?height:\s*44px;[\s\S]*?min-height:\s*44px;/,
);
expect(css).toMatch(
/\.room-code-entry\.is-inline\s*\{[\s\S]*?width:\s*min\(520px, calc\(100% - 20px\)\);[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) 44px;/,
);
});
it("disables share start while room authority is mutating", () => {
const markup = renderToStaticMarkup(
createElement(StageEntryActions, {
joiningRoom: false,
startSharingDisabled: true,
onJoinToggle: () => undefined,
onStartSharing: () => undefined,
}),
);
expect(markup).toMatch(/<button[^>]*disabled=""[^>]*>/);
});
});
+1 -15
View File
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
import {
hasPeerRouteEvidence,
MEDIA_ROUTE_PRESENTATION,
ROUTING_STATUS_PRESENTATION,
viewerReconnectRoute,
viewerRouteEvidence,
} from "../src/client/components/status-badge-model.ts";
@@ -18,20 +16,8 @@ const peerSnapshot: PeerSnapshot = {
error: null,
};
describe("route status badges", () => {
it("uses distinct icon and color language for active P2P and SFU routes", () => {
expect(MEDIA_ROUTE_PRESENTATION.p2p).toEqual({
tone: "good", label: "P2P", icon: "network",
});
expect(MEDIA_ROUTE_PRESENTATION.sfu).toEqual({
tone: "warning", label: "SFU fallback", icon: "server",
});
});
describe("route evidence models", () => {
it("keeps an assigned route neutral before current media evidence", () => {
expect(ROUTING_STATUS_PRESENTATION).toEqual({
tone: "neutral", label: "线路分配中",
});
expect(viewerRouteEvidence({ kind: "sfu" }, null, null)).toEqual({
route: null,
evidence: null,
-56
View File
@@ -1,56 +0,0 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { TopologyView } from "../src/client/components/TopologyView.tsx";
import { labelParticipantSnapshot } from "../src/client/lib/viewer-presence.ts";
describe("TopologyView", () => {
it("disambiguates Host and Viewer names without exposing unique peer IDs", () => {
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_private_123456",
displayName: "同名",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_private_root_654321",
displayName: "同名",
upstream: { kind: "peer", peerId: "host_private_123456" },
},
{
role: "viewer",
peerId: "viewer_private_child_456789",
displayName: "朋友乙",
upstream: { kind: "peer", peerId: "viewer_private_root_654321" },
},
{
role: "viewer",
peerId: "viewer_private_sfu_789012",
displayName: "朋友丙",
upstream: { kind: "sfu" },
},
]);
const html = renderToStaticMarkup(
createElement(TopologyView, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "分享者",
viewers,
}),
);
expect(html).toContain('aria-label="当前连接拓扑"');
expect(html).toContain("同名 (123456)");
expect(html).toContain("同名 (654321)");
expect(html).toContain("朋友乙");
expect(html).toContain("朋友丙");
expect(html).toContain("媒体服务器");
expect(html).not.toContain("host_private_123456");
expect(html).not.toContain("viewer_private_root_654321");
expect(html).not.toContain("viewer_private_child_456789");
expect(html).not.toContain("viewer_private_sfu_789012");
});
});
+12 -12
View File
@@ -32,7 +32,7 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(preparing)).toMatchObject({
stage: "preparing-p2p",
message: "正在建立 P2P",
messageKey: "viewer.msg.preparingP2p",
overlay: "blocking",
});
@@ -67,7 +67,7 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(blocked)).toMatchObject({
stage: "needs-play",
message: "点击播放",
messageKey: "viewer.msg.needsPlay",
overlay: "status",
failureCode: "AUTOPLAY_BLOCKED",
});
@@ -293,7 +293,7 @@ describe("Viewer presentation reducer", () => {
expect(deriveViewerPresentation(state)).toMatchObject({
stage: "recovering",
overlay: "none",
notice: "服务器连接正在恢复,画面仍在播放",
noticeKey: "viewer.notice.signalRecovering",
});
});
@@ -315,8 +315,8 @@ describe("Viewer presentation reducer", () => {
expect(resumed.connection).toBe("connected");
expect(deriveViewerPresentation(resumed)).toMatchObject({
stage: "playing",
message: "正在播放",
notice: null,
messageKey: "viewer.msg.playing",
noticeKey: null,
});
});
@@ -388,7 +388,7 @@ describe("Viewer presentation reducer", () => {
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
notice: "分享者连接已中断,画面可能冻结",
noticeKey: "viewer.notice.hostOffline",
});
expect(
reduceViewerPresentation(offlineWithMedia, {
@@ -423,9 +423,9 @@ describe("Viewer presentation reducer", () => {
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
stage: "recovering",
message: "正在恢复连接",
messageKey: "viewer.msg.recovering",
overlay: "none",
notice: "服务器连接正在恢复,画面仍在播放",
noticeKey: "viewer.notice.signalRecovering",
});
});
@@ -490,7 +490,7 @@ describe("Viewer presentation reducer", () => {
expect(deriveViewerPresentation(failed)).toMatchObject({
stage: "route-failed",
message: "没有可用的媒体线路",
messageKey: "viewer.msg.routeFailed",
overlay: "status",
hasCurrentFrame: false,
hasRetainedFrame: true,
@@ -577,7 +577,7 @@ describe("Viewer presentation reducer", () => {
});
expect(deriveViewerPresentation(denied)).toMatchObject({
stage: "access-denied",
message: "当前无法通过房间号加入",
messageKey: "viewer.msg.denied",
});
const notFound = reduceViewerPresentation(failed, {
@@ -587,7 +587,7 @@ describe("Viewer presentation reducer", () => {
});
expect(deriveViewerPresentation(notFound)).toMatchObject({
stage: "room-not-found",
message: "房间不存在或已过期",
messageKey: "viewer.msg.notFound",
});
});
@@ -630,7 +630,7 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(restarted)).toMatchObject({
stage: "playing",
message: "正在播放",
messageKey: "viewer.msg.playing",
overlay: "none",
});
});