feat(ui): integrate living-room UI (#321)
This commit is contained in:
1 parent
0bfbad63e6
commit
045b1d758e
58 files changed
+9758
-3978
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+1413
-839
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
|
||||
+640
-410
File diff suppressed because it is too large.
Load diff
@@ -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(
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("下载");
|
||||
});
|
||||
});
|
||||
@@ -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("未知");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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("捕获设置");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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=""[^>]*>/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user