feat(ui): refine topology presentation (#326)
This commit is contained in:
1 parent
3d92ddcf66
commit
050a20c4c7
9 files changed
+770
-323
No files matched your search
@@ -68,74 +68,77 @@ export function 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 }}>
|
||||
<div className="lr-pawns-window">
|
||||
<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 inner = (
|
||||
<>
|
||||
<PawnSvg color={pawnColor(entry.key, entry.you)} />
|
||||
<i
|
||||
className={`lr-pawn-led${entry.connected ? "" : " is-wait"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={`lr-pawn-name${entry.child ? " is-mini" : ""}`}
|
||||
>
|
||||
{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) {
|
||||
</>
|
||||
);
|
||||
// Hop stagger derives from position only, so render stays pure.
|
||||
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 (
|
||||
<span
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
<span
|
||||
className="lr-couch-count"
|
||||
aria-label={`${t("common.viewers")} ${entries.length}`}
|
||||
>
|
||||
<Glyph name="users" size={14} />
|
||||
<b>{entries.length}</b>
|
||||
</span>
|
||||
{entries.length === 0 ? (
|
||||
<div
|
||||
className="lr-couch-empty"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
// 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 { memo } from "react";
|
||||
import { PawnSvg, pawnColor } from "./Couch";
|
||||
import { useCopy } from "../../ui/copy";
|
||||
import { memo, useSyncExternalStore } from "react";
|
||||
|
||||
import type { LabeledViewerPresence } from "../../lib/viewer-presence";
|
||||
import { deriveParticipantTopology, type TopologyBranch } from "../../lib/participant-topology";
|
||||
import {
|
||||
deriveParticipantTopology,
|
||||
type TopologyBranch,
|
||||
} from "../../lib/participant-topology";
|
||||
import { useCopy } from "../../ui/copy";
|
||||
import { PawnSvg, pawnColor } from "./Couch";
|
||||
import {
|
||||
topologyLayoutForViewport,
|
||||
type TopologyLayout,
|
||||
} from "./route-tree-layout";
|
||||
|
||||
interface TreeNode {
|
||||
key: string;
|
||||
@@ -13,15 +21,58 @@ interface TreeNode {
|
||||
via: string | null;
|
||||
sfu: boolean;
|
||||
you: boolean;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const HOST_X = 40;
|
||||
const COLUMN_GAP = 190;
|
||||
const ROW_BASE = 40;
|
||||
const PENDING_COLOR = "#d98e04";
|
||||
const NARROW_TOPOLOGY_QUERY = "(max-width: 640px)";
|
||||
|
||||
function xForDepth(depth: number): number {
|
||||
return HOST_X + (depth + 1) * COLUMN_GAP;
|
||||
function subscribeToNarrowViewport(listener: () => void): () => void {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const query = window.matchMedia(NARROW_TOPOLOGY_QUERY);
|
||||
query.addEventListener("change", listener);
|
||||
return () => query.removeEventListener("change", listener);
|
||||
}
|
||||
|
||||
function narrowViewportSnapshot(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
Boolean(window.matchMedia?.(NARROW_TOPOLOGY_QUERY).matches)
|
||||
);
|
||||
}
|
||||
|
||||
function useNarrowViewport(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeToNarrowViewport,
|
||||
narrowViewportSnapshot,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
function compactVisibleLabel(label: string, maximumCodePoints: number): string {
|
||||
const codePoints = Array.from(label);
|
||||
if (codePoints.length <= maximumCodePoints) return label;
|
||||
|
||||
const suffix = label.match(/ \([^()]+\)$/u)?.[0] ?? null;
|
||||
if (suffix) {
|
||||
const suffixCodePoints = Array.from(suffix);
|
||||
const nameCodePoints = Array.from(label.slice(0, -suffix.length));
|
||||
const visibleNameLength = Math.max(
|
||||
0,
|
||||
maximumCodePoints - suffixCodePoints.length - 1,
|
||||
);
|
||||
return `${nameCodePoints.slice(0, visibleNameLength).join("")}…${suffix}`;
|
||||
}
|
||||
|
||||
return `${codePoints.slice(0, maximumCodePoints - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function xForDepth(depth: number, layout: TopologyLayout): number {
|
||||
return layout.hostX + (depth + 1) * layout.columnGap;
|
||||
}
|
||||
|
||||
function edgeClass(kind: "p2p" | "sfu", ready = true): string {
|
||||
return `lr-route-edge is-${kind}${ready ? "" : " is-recovering"}`;
|
||||
}
|
||||
|
||||
export const RouteTree = memo(function RouteTree({
|
||||
@@ -29,15 +80,17 @@ export const RouteTree = memo(function RouteTree({
|
||||
hostLabel,
|
||||
viewers,
|
||||
selfPeerId,
|
||||
flowing = false,
|
||||
selectedPeerId,
|
||||
}: {
|
||||
hostPeerId: string | null;
|
||||
hostLabel: string;
|
||||
viewers: readonly LabeledViewerPresence[];
|
||||
selfPeerId?: string | null;
|
||||
flowing?: boolean;
|
||||
selectedPeerId?: string | null;
|
||||
}) {
|
||||
const { t, vis } = useCopy();
|
||||
const { t } = useCopy();
|
||||
const narrowViewport = useNarrowViewport();
|
||||
const layoutConfig = topologyLayoutForViewport(narrowViewport);
|
||||
const topology = deriveParticipantTopology(hostPeerId, viewers);
|
||||
|
||||
const nodes: TreeNode[] = [];
|
||||
@@ -48,8 +101,11 @@ export const RouteTree = memo(function RouteTree({
|
||||
via,
|
||||
sfu,
|
||||
you: selfPeerId === branch.viewer.peerId,
|
||||
ready: branch.viewer.mediaReady === true,
|
||||
});
|
||||
branch.children.forEach((child) => collect(child, branch.viewer.peerId, false));
|
||||
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));
|
||||
@@ -65,258 +121,324 @@ export const RouteTree = memo(function RouteTree({
|
||||
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;
|
||||
const spacing = nodes.length > 10 ? 42 : 44;
|
||||
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) ?? [];
|
||||
const children = childrenOf.get(node.key) ?? [];
|
||||
let y: number;
|
||||
if (kids.length === 0) {
|
||||
if (children.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;
|
||||
children.forEach((child) => layout(child, depth + 1));
|
||||
y =
|
||||
children.reduce((sum, child) => sum + pos.get(child.key)!.y, 0) /
|
||||
children.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,
|
||||
};
|
||||
pos.set(node.key, { x: xForDepth(depth, layoutConfig), y });
|
||||
}
|
||||
|
||||
// Pending viewers (upstream not yet in the tree) dangle off the host as
|
||||
// dashed amber edges — visible in every language mode, not just text.
|
||||
(childrenOf.get(null) ?? []).forEach((node) => layout(node, 0));
|
||||
const sfuChildren = childrenOf.get("sfu") ?? [];
|
||||
sfuChildren.forEach((node) => layout(node, 1));
|
||||
let sfuPos: { x: number; y: number } | null =
|
||||
sfuChildren.length > 0
|
||||
? {
|
||||
x: xForDepth(0, layoutConfig),
|
||||
y:
|
||||
sfuChildren.reduce(
|
||||
(sum, child) => sum + pos.get(child.key)!.y,
|
||||
0,
|
||||
) / sfuChildren.length,
|
||||
}
|
||||
: null;
|
||||
|
||||
const pendingPos = topology.pending.map((viewer) => {
|
||||
const p = { x: xForDepth(0), y: ROW_BASE + row * spacing };
|
||||
const point = {
|
||||
x: xForDepth(0, layoutConfig),
|
||||
y: ROW_BASE + row * spacing,
|
||||
};
|
||||
row += 1;
|
||||
return { viewer, ...p };
|
||||
return { viewer, ...point };
|
||||
});
|
||||
|
||||
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),
|
||||
...pendingPos.map((point) => point.y),
|
||||
];
|
||||
const hostPos = {
|
||||
x: HOST_X,
|
||||
x: layoutConfig.hostX,
|
||||
y:
|
||||
rootYs.length > 0
|
||||
? rootYs.reduce((a, b) => a + b, 0) / rootYs.length
|
||||
? rootYs.reduce((sum, y) => sum + y, 0) / rootYs.length
|
||||
: height / 2,
|
||||
};
|
||||
const width = Math.max(
|
||||
640,
|
||||
...[...pos.values(), ...pendingPos, ...(sfuPos ? [sfuPos] : [])].map(
|
||||
({ x }) => x + 70,
|
||||
),
|
||||
layoutConfig.baseWidth,
|
||||
...[
|
||||
...pos.values(),
|
||||
...pendingPos,
|
||||
...(sfuPos ? [sfuPos] : []),
|
||||
].map(({ x }) => x + layoutConfig.rightLabelReserve),
|
||||
);
|
||||
|
||||
// Center the whole composition vertically inside the panel.
|
||||
const allYs = [
|
||||
...[...pos.values()].map((p) => p.y),
|
||||
...[...pos.values()].map((point) => point.y),
|
||||
...(sfuPos ? [sfuPos.y] : []),
|
||||
...pendingPos.map((p) => p.y),
|
||||
...pendingPos.map((point) => point.y),
|
||||
hostPos.y,
|
||||
];
|
||||
const minY = Math.min(...allYs) - 26;
|
||||
const maxY = Math.max(...allYs) + (vis ? 22 : 34);
|
||||
const maxY = Math.max(...allYs) + 34;
|
||||
const shift = Math.max(0, (height - (maxY - minY)) / 2 - minY + 4);
|
||||
if (shift > 0) {
|
||||
pos.forEach((p) => {
|
||||
p.y += shift;
|
||||
pos.forEach((point) => {
|
||||
point.y += shift;
|
||||
});
|
||||
pendingPos.forEach((p) => {
|
||||
p.y += shift;
|
||||
pendingPos.forEach((point) => {
|
||||
point.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(" · ");
|
||||
const labelByPeer = new Map(nodes.map((node) => [node.key, node.label]));
|
||||
const topologyTitleId = "room-topology-title";
|
||||
const scrollableCanvas = width > layoutConfig.baseWidth;
|
||||
const sfuChildPoints = sfuChildren.map((child) => ({
|
||||
child,
|
||||
point: pos.get(child.key)!,
|
||||
}));
|
||||
const sfuRail =
|
||||
sfuPos && sfuChildPoints.length > 1
|
||||
? {
|
||||
x: sfuPos.x + layoutConfig.columnGap * 0.45,
|
||||
minY: Math.min(...sfuChildPoints.map(({ point }) => point.y)),
|
||||
maxY: Math.max(...sfuChildPoints.map(({ point }) => point.y)),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lr-route"
|
||||
role="group"
|
||||
aria-label={`${t("host.topology")}: ${srSummary}`}
|
||||
aria-labelledby={topologyTitleId}
|
||||
id="room-topology"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span id={topologyTitleId} className="visually-hidden">
|
||||
{t("host.topology")}
|
||||
</span>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
style={{ width, minWidth: "100%", maxWidth: "none" }}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={
|
||||
narrowViewport || scrollableCanvas
|
||||
? { width, maxWidth: "none" }
|
||||
: {
|
||||
width: "100%",
|
||||
maxWidth: nodes.length > 10 ? layoutConfig.baseWidth : 880,
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* 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 (
|
||||
{nodes
|
||||
.filter((node) => !node.sfu)
|
||||
.map((node) => {
|
||||
const point = pos.get(node.key)!;
|
||||
const parent = node.via ? pos.get(node.via)! : hostPos;
|
||||
return (
|
||||
<path
|
||||
key={`edge-${node.key}`}
|
||||
d={`M ${parent.x + 20} ${parent.y} Q ${(parent.x + point.x) / 2} ${parent.y + (point.y - parent.y) * 0.55}, ${point.x - 18} ${point.y}`}
|
||||
className={edgeClass("p2p", node.ready)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{sfuPos ? (
|
||||
<>
|
||||
<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}
|
||||
d={`M ${hostPos.x + 20} ${hostPos.y} L ${sfuPos.x - 20} ${sfuPos.y}`}
|
||||
className={edgeClass("sfu")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pendingPos.map((p) => (
|
||||
{sfuChildren.length === 1 ? (
|
||||
<path
|
||||
d={`M ${sfuPos.x + 20} ${sfuPos.y} L ${pos.get(sfuChildren[0]!.key)!.x - 18} ${pos.get(sfuChildren[0]!.key)!.y}`}
|
||||
className={edgeClass("sfu", sfuChildren[0]!.ready)}
|
||||
/>
|
||||
) : sfuRail ? (
|
||||
<>
|
||||
<path
|
||||
d={`M ${sfuPos.x + 20} ${sfuPos.y} H ${sfuRail.x}`}
|
||||
className={edgeClass("sfu")}
|
||||
/>
|
||||
<path
|
||||
d={`M ${sfuRail.x} ${sfuRail.minY} V ${sfuRail.maxY}`}
|
||||
className={edgeClass("sfu")}
|
||||
/>
|
||||
{sfuChildPoints.map(({ child, point }) => (
|
||||
<path
|
||||
key={`edge-${child.key}`}
|
||||
d={`M ${sfuRail.x} ${point.y} H ${point.x - 18}`}
|
||||
className={edgeClass("sfu", child.ready)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{pendingPos.map((point) => (
|
||||
<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}
|
||||
key={`edge-pending-${point.viewer.peerId}`}
|
||||
d={`M ${hostPos.x + 20} ${hostPos.y} Q ${(hostPos.x + point.x) / 2} ${hostPos.y + (point.y - hostPos.y) * 0.55}, ${point.x - 18} ${point.y}`}
|
||||
className="lr-route-edge is-pending"
|
||||
/>
|
||||
))}
|
||||
{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
|
||||
className="lr-route-node is-host"
|
||||
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)}
|
||||
<text
|
||||
className="lr-route-label is-host"
|
||||
x={hostPos.x + 14}
|
||||
y={hostPos.y + 30}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{compactVisibleLabel(
|
||||
hostLabel,
|
||||
layoutConfig.maxVisibleLabelCodePoints,
|
||||
)}
|
||||
</text>
|
||||
|
||||
{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>
|
||||
<>
|
||||
<g
|
||||
className="lr-route-sfu"
|
||||
transform={`translate(${sfuPos.x - 20}, ${sfuPos.y - 16})`}
|
||||
>
|
||||
<rect width="40" height="32" rx="7" />
|
||||
<path d="M8 12h24M8 20h24" />
|
||||
</g>
|
||||
<text
|
||||
className="lr-route-label is-sfu"
|
||||
x={sfuPos.x}
|
||||
y={sfuPos.y + 32}
|
||||
textAnchor="middle"
|
||||
>
|
||||
SFU
|
||||
</text>
|
||||
</>
|
||||
) : 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;
|
||||
const point = pos.get(node.key)!;
|
||||
const child = node.via !== null && !node.sfu;
|
||||
const selected = selectedPeerId === node.key;
|
||||
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}
|
||||
className={`lr-route-node${node.ready ? "" : " is-recovering"}${selected ? " is-selected" : ""}`}
|
||||
transform={`translate(${point.x - 14}, ${point.y - 15}) scale(${child ? 0.52 : 0.72})`}
|
||||
>
|
||||
{vis ? null : <title>{nodeLabel}</title>}
|
||||
<PawnSvg color={pawnColor(node.key, node.you)} />
|
||||
{selected ? (
|
||||
<circle className="lr-route-selection" cx="20" cy="26" r="25" />
|
||||
) : null}
|
||||
{node.you ? (
|
||||
<circle cx="20" cy="26" r="22" fill="none" stroke="var(--you)" strokeWidth="3" />
|
||||
<circle className="lr-route-you" cx="20" cy="26" r="21" />
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{pendingPos.map((p) => (
|
||||
|
||||
{pendingPos.map((point) => (
|
||||
<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}
|
||||
key={`pending-${point.viewer.peerId}`}
|
||||
className={`lr-route-node is-recovering${selectedPeerId === point.viewer.peerId ? " is-selected" : ""}`}
|
||||
transform={`translate(${point.x - 14}, ${point.y - 15}) scale(0.72)`}
|
||||
>
|
||||
{vis ? null : <title>{`${p.viewer.label} · ${t("host.topology.pending")}`}</title>}
|
||||
<PawnSvg color={pawnColor(p.viewer.peerId, selfPeerId === p.viewer.peerId)} />
|
||||
<PawnSvg
|
||||
color={pawnColor(
|
||||
point.viewer.peerId,
|
||||
selfPeerId === point.viewer.peerId,
|
||||
)}
|
||||
/>
|
||||
{selectedPeerId === point.viewer.peerId ? (
|
||||
<circle className="lr-route-selection" cx="20" cy="26" r="25" />
|
||||
) : null}
|
||||
{selfPeerId === point.viewer.peerId ? (
|
||||
<circle className="lr-route-you" cx="20" cy="26" r="21" />
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
{nodes.map((node) => {
|
||||
const point = pos.get(node.key)!;
|
||||
const child = node.via !== null && !node.sfu;
|
||||
return (
|
||||
<text
|
||||
key={`label-${node.key}`}
|
||||
className={`lr-route-label${selectedPeerId === node.key ? " is-selected" : ""}`}
|
||||
x={point.x + 4}
|
||||
y={point.y + (child ? 20 : 26)}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{compactVisibleLabel(
|
||||
node.label,
|
||||
layoutConfig.maxVisibleLabelCodePoints,
|
||||
)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{pendingPos.map((point) => (
|
||||
<text
|
||||
key={`label-pending-${point.viewer.peerId}`}
|
||||
className={`lr-route-label is-recovering${selectedPeerId === point.viewer.peerId ? " is-selected" : ""}`}
|
||||
x={point.x + 4}
|
||||
y={point.y + 26}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{compactVisibleLabel(
|
||||
point.viewer.label,
|
||||
layoutConfig.maxVisibleLabelCodePoints,
|
||||
)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<ul className="visually-hidden">
|
||||
<li>
|
||||
{hostLabel} · {t("common.host")}
|
||||
</li>
|
||||
{nodes.map((node) => {
|
||||
const parentLabel = node.via
|
||||
? (labelByPeer.get(node.via) ?? hostLabel)
|
||||
: node.sfu
|
||||
? "SFU"
|
||||
: hostLabel;
|
||||
return (
|
||||
<li key={`sr-${node.key}`}>
|
||||
{node.label} ← {parentLabel} ·{" "}
|
||||
{t(node.sfu ? "state.route.sfu" : "state.route.p2p")}
|
||||
{node.ready ? "" : ` · ${t("host.topology.pending")}`}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{topology.pending.map((viewer) => (
|
||||
<li key={`sr-pending-${viewer.peerId}`}>
|
||||
{viewer.label} {t("host.topology.pending")}
|
||||
{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,27 @@
|
||||
export interface TopologyLayout {
|
||||
baseWidth: number;
|
||||
hostX: number;
|
||||
columnGap: number;
|
||||
rightLabelReserve: number;
|
||||
maxVisibleLabelCodePoints: number;
|
||||
}
|
||||
|
||||
const DESKTOP_LAYOUT: TopologyLayout = {
|
||||
baseWidth: 640,
|
||||
hostX: 170,
|
||||
columnGap: 220,
|
||||
rightLabelReserve: 170,
|
||||
maxVisibleLabelCodePoints: 16,
|
||||
};
|
||||
|
||||
const NARROW_LAYOUT: TopologyLayout = {
|
||||
baseWidth: 260,
|
||||
hostX: 54,
|
||||
columnGap: 130,
|
||||
rightLabelReserve: 65,
|
||||
maxVisibleLabelCodePoints: 10,
|
||||
};
|
||||
|
||||
export function topologyLayoutForViewport(narrow: boolean): TopologyLayout {
|
||||
return narrow ? NARROW_LAYOUT : DESKTOP_LAYOUT;
|
||||
}
|
||||
@@ -3601,7 +3601,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
hostPeerId={hostPeerId}
|
||||
hostLabel={labeledHostPresence?.label ?? displayName}
|
||||
viewers={viewers}
|
||||
flowing={phase === "starting"}
|
||||
selectedPeerId={selectedPawn}
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
NameTag,
|
||||
Pill,
|
||||
Row,
|
||||
RowGroup,
|
||||
StatusText,
|
||||
} from "../components/living/primitives";
|
||||
import { Glyph, type GlyphName } from "../ui/icons";
|
||||
@@ -137,16 +136,6 @@ interface RemoteMediaBinding {
|
||||
videoTrackKey: string;
|
||||
}
|
||||
|
||||
const CONNECTING_STAGES: readonly ViewerStage[] = [
|
||||
"joining",
|
||||
"preparing-p2p",
|
||||
"preparing-sfu",
|
||||
"waiting-sfu",
|
||||
"receiving",
|
||||
"allocating",
|
||||
"recovering",
|
||||
];
|
||||
|
||||
function stageOverlayGlyph(stage: ViewerStage): { icon: GlyphName; spin: boolean } {
|
||||
switch (stage) {
|
||||
case "needs-play":
|
||||
@@ -299,17 +288,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
),
|
||||
[peerSnapshot, relaySnapshot],
|
||||
);
|
||||
const hostDisplayName = useMemo(
|
||||
() =>
|
||||
participantPresence?.find(
|
||||
(participant) => participant.role === "host",
|
||||
)?.displayName ?? null,
|
||||
[participantPresence],
|
||||
);
|
||||
const { host: labeledHostPresence, viewers } = useMemo(
|
||||
() => labelParticipantSnapshot(participantPresence ?? []),
|
||||
[participantPresence],
|
||||
);
|
||||
const hostDisplayName = labeledHostPresence?.label ?? null;
|
||||
|
||||
function clearParticipantPresence(): void {
|
||||
setParticipantPresence(null);
|
||||
@@ -2367,25 +2350,26 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
onClose={() => setSelectedPawn(null)}
|
||||
/>
|
||||
) : null}
|
||||
<Row>
|
||||
<RowGroup>
|
||||
<div className="lr-row lr-viewer-summary-row">
|
||||
<div className="lr-row-group lr-viewer-room-slot">
|
||||
<FieldCap k="common.roomCode" />
|
||||
<Lcd code={roomId} />
|
||||
</RowGroup>
|
||||
<RowGroup>
|
||||
<StatusText>
|
||||
{hostDisplayName
|
||||
? `${t("viewer.title", { name: hostDisplayName })} · `
|
||||
: ""}
|
||||
{t(presentation.messageKey)}
|
||||
{participantPresence
|
||||
? ` · ${t("viewer.onlineCount", { n: String(viewers.length) })}`
|
||||
: ""}
|
||||
</StatusText>
|
||||
</RowGroup>
|
||||
<span className="lr-spacer" />
|
||||
</div>
|
||||
<div
|
||||
className="lr-row-group lr-viewer-host-slot"
|
||||
aria-label={t(
|
||||
hostDisplayName ? "viewer.title" : "viewer.titleFallback",
|
||||
hostDisplayName ? { name: hostDisplayName } : undefined,
|
||||
)}
|
||||
>
|
||||
<Glyph name="tv" size={17} />
|
||||
<b>{hostDisplayName ?? t("common.host")}</b>
|
||||
</div>
|
||||
<div className="lr-row-group lr-viewer-state-slot">
|
||||
<StatusText>{t(presentation.messageKey)}</StatusText>
|
||||
</div>
|
||||
<form
|
||||
className="lr-row-group lr-group-name"
|
||||
className="lr-row-group lr-group-name lr-viewer-self-slot"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commitDisplayName();
|
||||
@@ -2448,7 +2432,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
<Pill icon="alert" tone="bad" label={t("host.nameError")} alert />
|
||||
)}
|
||||
</form>
|
||||
<RowGroup actions>
|
||||
<div className="lr-row-group lr-group-actions lr-viewer-actions-slot">
|
||||
{labeledHostPresence
|
||||
? hintWrap(
|
||||
"hint-topology",
|
||||
@@ -2492,8 +2476,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
onClick={() => setShowConnectionDetails((current) => !current)}
|
||||
/>
|
||||
</RowGroup>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
{presentation.noticeKey ? (
|
||||
<Pill icon="alert" label={t(presentation.noticeKey)} />
|
||||
) : null}
|
||||
@@ -2552,7 +2536,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
hostLabel={labeledHostPresence.label}
|
||||
viewers={viewers}
|
||||
selfPeerId={selfPeerId}
|
||||
flowing={CONNECTING_STAGES.includes(presentation.stage)}
|
||||
selectedPeerId={selectedPawn}
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
+133
-18
@@ -27,6 +27,9 @@
|
||||
--live: #2fa66a;
|
||||
--warn: #d98e04;
|
||||
--danger: #d64541;
|
||||
--route-p2p: #1f7a4a;
|
||||
--route-sfu: #53676a;
|
||||
--route-pending: #875400;
|
||||
--lcd: #1d2b22;
|
||||
--lcd-digits: #9de8bf;
|
||||
--stage: #101a2c;
|
||||
@@ -50,6 +53,9 @@ html[data-theme="dark"] {
|
||||
--live: #3fb97a;
|
||||
--warn: #e8a33d;
|
||||
--danger: #e25a52;
|
||||
--route-p2p: #3fb97a;
|
||||
--route-sfu: #9bb0c5;
|
||||
--route-pending: #e8a33d;
|
||||
--lcd: #0d1611;
|
||||
}
|
||||
|
||||
@@ -345,20 +351,30 @@ ul { margin: 0; padding: 0; list-style: none; }
|
||||
margin: 22px auto 0;
|
||||
}
|
||||
.lr-couch > svg { display: block; width: 100%; height: auto; }
|
||||
.lr-pawns {
|
||||
.lr-pawns-window {
|
||||
position: absolute;
|
||||
inset: 0 8% auto;
|
||||
top: -16px;
|
||||
top: -35px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 20px 12px 10px;
|
||||
scrollbar-color: var(--route-sfu) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.lr-pawns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
gap: 2px 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.lr-pawn {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 44px;
|
||||
width: max-content;
|
||||
min-width: 44px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -370,7 +386,8 @@ ul { margin: 0; padding: 0; list-style: none; }
|
||||
.lr-pawn svg { width: 40px; height: 48px; display: block; }
|
||||
.lr-pawn-led {
|
||||
position: absolute;
|
||||
top: 2px; right: 0;
|
||||
top: 2px;
|
||||
left: calc(50% + 10px);
|
||||
width: 10px; height: 10px;
|
||||
border: 2px solid var(--wall);
|
||||
border-radius: 50%;
|
||||
@@ -397,14 +414,13 @@ ul { margin: 0; padding: 0; list-style: none; }
|
||||
border: 2.5px solid var(--action);
|
||||
border-radius: 13px;
|
||||
}
|
||||
.lr-pawn.is-child { width: 34px; margin-left: -8px; align-self: end; }
|
||||
.lr-pawn.is-child { min-width: 34px; align-self: end; }
|
||||
.lr-pawn.is-child svg { width: 30px; height: 36px; }
|
||||
.lr-pawns.is-crowded { gap: 6px 3px; top: -14px; }
|
||||
.lr-pawns.is-crowded .lr-pawn { width: 33px; }
|
||||
.lr-pawns.is-crowded { gap: 6px; }
|
||||
.lr-pawns.is-crowded .lr-pawn { min-width: 33px; }
|
||||
.lr-pawns.is-crowded .lr-pawn svg { width: 29px; height: 35px; }
|
||||
.lr-pawns.is-crowded .lr-pawn-led { width: 8px; height: 8px; }
|
||||
.lr-pawn-name {
|
||||
max-width: 58px;
|
||||
margin-top: -4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 7px;
|
||||
@@ -412,12 +428,27 @@ ul { margin: 0; padding: 0; list-style: none; }
|
||||
color: var(--ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .12);
|
||||
}
|
||||
.lr-pawn-name.is-mini { font-size: 9px; max-width: 40px; padding: 0 5px; }
|
||||
.lr-pawn-name.is-mini { font-size: 9px; padding: 0 5px; }
|
||||
.lr-couch-count {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
.lr-couch-count b { font-size: inherit; }
|
||||
.lr-couch-empty {
|
||||
position: absolute;
|
||||
inset: -8px 0 auto;
|
||||
@@ -451,6 +482,37 @@ ul { margin: 0; padding: 0; list-style: none; }
|
||||
.lr-row.is-sub { background: var(--wall-2); border-color: transparent; }
|
||||
.lr-row-group { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-width: 0; }
|
||||
.lr-group-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
|
||||
.lr-viewer-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(140px, 220px) minmax(220px, 1fr);
|
||||
}
|
||||
.lr-viewer-room-slot { grid-column: 1; grid-row: 1; }
|
||||
.lr-viewer-host-slot {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
max-width: 220px;
|
||||
flex-wrap: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.lr-viewer-host-slot svg { flex: 0 0 auto; color: #53676a; }
|
||||
html[data-theme="dark"] .lr-viewer-host-slot svg { color: #9bb0c5; }
|
||||
.lr-viewer-host-slot b {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.lr-viewer-state-slot { grid-column: 3; grid-row: 1; }
|
||||
.lr-viewer-self-slot {
|
||||
display: grid;
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
.lr-viewer-actions-slot {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
.lr-spacer { flex: 1; }
|
||||
.lr-divider { width: 2.5px; align-self: stretch; border-radius: 2px; background: var(--wall-2); }
|
||||
.lr-row.is-sub .lr-divider { background: #c3d4cb; }
|
||||
@@ -771,10 +833,49 @@ html[data-theme="dark"] .lr-meter-cell svg { color: #7d92a8; }
|
||||
overflow-x: auto;
|
||||
padding: 6px 2px;
|
||||
box-sizing: border-box;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-color: var(--route-sfu) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.lr-route svg { display: block; width: 100%; max-width: 880px; height: auto; margin: 0 auto; }
|
||||
.lr-route text { fill: var(--ink); font-size: 11px; font-weight: 600; opacity: .72; paint-order: stroke; stroke: var(--paper); stroke-width: 3px; }
|
||||
.lr-route .flow { stroke-dasharray: 5 7; animation: lr-flow 1.1s linear infinite; }
|
||||
.lr-route:focus-visible { border-radius: 10px; }
|
||||
.lr-route-edge {
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.lr-route-edge.is-p2p { stroke: var(--route-p2p); }
|
||||
.lr-route-edge.is-sfu { stroke: var(--route-sfu); }
|
||||
.lr-route-edge.is-pending,
|
||||
.lr-route-edge.is-recovering {
|
||||
stroke: var(--route-pending);
|
||||
stroke-dasharray: 6 7;
|
||||
animation: lr-flow 1.1s linear infinite;
|
||||
}
|
||||
.lr-route-node { transition: opacity 180ms ease; }
|
||||
.lr-route-node.is-recovering { opacity: .58; }
|
||||
.lr-route-selection,
|
||||
.lr-route-you { fill: none; vector-effect: non-scaling-stroke; }
|
||||
.lr-route-selection { stroke: var(--action); stroke-width: 3; }
|
||||
.lr-route-you { stroke: var(--route-p2p); stroke-width: 2.5; stroke-dasharray: 4 3; }
|
||||
.lr-route-sfu { fill: none; stroke: var(--route-sfu); stroke-width: 2.5; }
|
||||
.lr-route-sfu path { stroke-linecap: round; }
|
||||
.lr-route-label {
|
||||
fill: var(--ink);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
opacity: .78;
|
||||
paint-order: stroke;
|
||||
stroke: var(--wall-2);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.lr-route-label.is-host { font-weight: 800; opacity: 1; }
|
||||
.lr-route-label.is-sfu { fill: var(--route-sfu); font-weight: 800; opacity: 1; }
|
||||
.lr-route-label.is-recovering { fill: var(--route-pending); opacity: 1; }
|
||||
.lr-route-label.is-selected { fill: var(--action-deep); font-weight: 800; opacity: 1; }
|
||||
@keyframes lr-flow { to { stroke-dashoffset: -24; } }
|
||||
|
||||
/* ---------- advanced door ---------- */
|
||||
@@ -899,13 +1000,26 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lr-pawns { top: -14px; gap: 2px 6px; }
|
||||
.lr-pawn { width: 36px; }
|
||||
.lr-pawns-window { inset-inline: 4%; }
|
||||
.lr-pawns { gap: 2px 6px; }
|
||||
.lr-pawn { min-width: 36px; }
|
||||
.lr-pawn svg { width: 32px; height: 39px; }
|
||||
.lr-route { padding-inline: 0; }
|
||||
.lr-row { flex-direction: column; align-items: stretch; gap: 12px; padding: 14px; }
|
||||
.lr-row > .lr-spacer,
|
||||
.lr-row > .lr-divider { display: none; }
|
||||
.lr-row-group { width: 100%; gap: 9px; }
|
||||
.lr-viewer-summary-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.lr-viewer-room-slot,
|
||||
.lr-viewer-host-slot,
|
||||
.lr-viewer-state-slot,
|
||||
.lr-viewer-self-slot,
|
||||
.lr-viewer-actions-slot {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.lr-viewer-actions-slot { justify-self: stretch; }
|
||||
.lr-group-actions { display: flex; width: 100%; gap: 8px; min-width: 0; }
|
||||
.lr-group-actions .lr-btn { flex: 1 1 0; min-width: 0; min-height: 52px; padding: 4px 5px; gap: 6px; }
|
||||
.lr-group-actions .lr-btn .lr-cap { white-space: normal; text-align: center; line-height: 1.25; font-size: 11px; }
|
||||
@@ -1077,7 +1191,8 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
.lr-tv-static { animation: none; opacity: .35; }
|
||||
.lr-route .flow { animation: none; stroke-dasharray: none; }
|
||||
.lr-route-edge.is-pending,
|
||||
.lr-route-edge.is-recovering { animation: none; }
|
||||
}
|
||||
|
||||
/* ---------- interaction and legibility constraints ---------- */
|
||||
|
||||
@@ -333,7 +333,6 @@ const zh = {
|
||||
"stats.warn.audioCodec": "音频编码为 {codec},预期 Opus",
|
||||
|
||||
"viewer.msg.joinUnavailable": "暂时无法加入房间",
|
||||
"viewer.onlineCount": "在线 {n}",
|
||||
"viewer.stageAria": "共享画面",
|
||||
|
||||
"host.warn.audioUnread": "浏览器未读回音频码率上限",
|
||||
@@ -680,7 +679,6 @@ const en: Record<keyof typeof zh, string> = {
|
||||
"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",
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("living-room presentation", () => {
|
||||
|
||||
expect(html).toContain("TNT · Recovering");
|
||||
expect(html).toContain("lr-pawn-led is-wait");
|
||||
expect(html).toContain('aria-label="Viewers 1"');
|
||||
});
|
||||
|
||||
it("keeps the current display name visible in visual mode", () => {
|
||||
@@ -69,4 +70,27 @@ describe("living-room presentation", () => {
|
||||
expect(html).toContain(">Share</button>");
|
||||
expect(html).not.toContain('viewBox="0 0 320 96"');
|
||||
});
|
||||
|
||||
it("keeps every crowded and relay Viewer name visible in visual mode", () => {
|
||||
setCopy({ lang: "en", vis: true });
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(Couch, {
|
||||
entries: Array.from({ length: 12 }, (_, index) => ({
|
||||
key: `viewer-${index}`,
|
||||
name:
|
||||
index === 11
|
||||
? "Relay Viewer With A Full Name"
|
||||
: `Viewer ${index}`,
|
||||
connected: true,
|
||||
child: index === 11,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("lr-pawns is-crowded");
|
||||
expect(html).toContain(
|
||||
'<span class="lr-pawn-name is-mini">Relay Viewer With A Full Name</span>',
|
||||
);
|
||||
expect(html).not.toContain("max-width:72px");
|
||||
});
|
||||
});
|
||||
+201
-27
@@ -1,93 +1,261 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { RouteTree } from "../src/client/components/living/RouteTree.tsx";
|
||||
import { topologyLayoutForViewport } from "../src/client/components/living/route-tree-layout.ts";
|
||||
import { labelParticipantSnapshot } from "../src/client/lib/viewer-presence.ts";
|
||||
import { setCopy } from "../src/client/ui/copy.ts";
|
||||
|
||||
setCopy({ lang: "zh", vis: false });
|
||||
afterEach(() => setCopy({ lang: "zh", vis: false }));
|
||||
|
||||
describe("RouteTree", () => {
|
||||
it("disambiguates Host and Viewer names without exposing unique peer IDs", () => {
|
||||
it("uses real-size narrow coordinates without compressing deep trees", () => {
|
||||
const narrow = topologyLayoutForViewport(true);
|
||||
const desktop = topologyLayoutForViewport(false);
|
||||
|
||||
expect(narrow).toEqual({
|
||||
baseWidth: 260,
|
||||
hostX: 54,
|
||||
columnGap: 130,
|
||||
rightLabelReserve: 65,
|
||||
maxVisibleLabelCodePoints: 10,
|
||||
});
|
||||
expect(
|
||||
narrow.hostX + narrow.columnGap + narrow.rightLabelReserve,
|
||||
).toBeLessThanOrEqual(narrow.baseWidth);
|
||||
expect(
|
||||
narrow.hostX + narrow.columnGap * 3 + narrow.rightLabelReserve,
|
||||
).toBeGreaterThan(narrow.baseWidth);
|
||||
expect(desktop.baseWidth).toBe(640);
|
||||
expect(desktop.columnGap).toBe(220);
|
||||
});
|
||||
|
||||
it("disambiguates names without exposing complete peer IDs", () => {
|
||||
const { host, viewers } = labelParticipantSnapshot([
|
||||
{
|
||||
role: "host",
|
||||
peerId: "host_private_123456",
|
||||
displayName: "同名",
|
||||
displayName: "Same",
|
||||
upstream: { kind: "none" },
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_private_root_654321",
|
||||
displayName: "同名",
|
||||
displayName: "Same",
|
||||
upstream: { kind: "peer", peerId: "host_private_123456" },
|
||||
mediaReady: true,
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_private_child_456789",
|
||||
displayName: "朋友乙",
|
||||
displayName: "Friend A",
|
||||
upstream: { kind: "peer", peerId: "viewer_private_root_654321" },
|
||||
mediaReady: true,
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_private_sfu_789012",
|
||||
displayName: "朋友丙",
|
||||
displayName: "Friend B",
|
||||
upstream: { kind: "sfu" },
|
||||
mediaReady: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RouteTree, {
|
||||
hostPeerId: host?.peerId ?? null,
|
||||
hostLabel: host?.label ?? "分享者",
|
||||
hostLabel: host?.label ?? "Host",
|
||||
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).toContain("Same (123456)");
|
||||
expect(html).toContain("Same (654321)");
|
||||
expect(html).toContain("Friend A");
|
||||
expect(html).toContain("Friend B");
|
||||
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", () => {
|
||||
it("compacts long visible labels while preserving duplicate suffixes", () => {
|
||||
const longChinese = "一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯";
|
||||
const longEnglish = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const { host, viewers } = labelParticipantSnapshot([
|
||||
{
|
||||
role: "host",
|
||||
peerId: "host_private_111111",
|
||||
displayName: longChinese,
|
||||
upstream: { kind: "none" },
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_private_123456",
|
||||
displayName: longEnglish,
|
||||
upstream: { kind: "peer", peerId: "host_private_111111" },
|
||||
mediaReady: true,
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_private_654321",
|
||||
displayName: longEnglish,
|
||||
upstream: { kind: "peer", peerId: "host_private_111111" },
|
||||
mediaReady: true,
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_private_999999",
|
||||
displayName: `${longChinese}尾`,
|
||||
upstream: { kind: "peer", peerId: "host_private_111111" },
|
||||
mediaReady: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RouteTree, {
|
||||
hostPeerId: host?.peerId ?? null,
|
||||
hostLabel: host?.label ?? "Host",
|
||||
viewers,
|
||||
}),
|
||||
);
|
||||
const visibleLabels = [...html.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map(
|
||||
(match) => match[1]!,
|
||||
);
|
||||
const firstDuplicate = visibleLabels.find((label) =>
|
||||
label.endsWith(" (123456)"),
|
||||
);
|
||||
const secondDuplicate = visibleLabels.find((label) =>
|
||||
label.endsWith(" (654321)"),
|
||||
);
|
||||
|
||||
expect(firstDuplicate).toBeDefined();
|
||||
expect(secondDuplicate).toBeDefined();
|
||||
expect(Array.from(firstDuplicate!).length).toBeLessThanOrEqual(16);
|
||||
expect(Array.from(secondDuplicate!).length).toBeLessThanOrEqual(16);
|
||||
expect(visibleLabels.some((label) => label.endsWith("…"))).toBe(true);
|
||||
expect(html).toContain(`${longEnglish} (123456)`);
|
||||
expect(html).toContain(`${longEnglish} (654321)`);
|
||||
expect(html).toContain(`${longChinese}尾`);
|
||||
expect(html).not.toContain("viewer_private_123456");
|
||||
expect(html).not.toContain("viewer_private_654321");
|
||||
});
|
||||
|
||||
it("keeps participant data labels visible in visual mode", () => {
|
||||
const { host, viewers } = labelParticipantSnapshot([
|
||||
{
|
||||
role: "host",
|
||||
peerId: "host_abc12345",
|
||||
displayName: "阿舟",
|
||||
displayName: "Alice",
|
||||
upstream: { kind: "none" },
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_def67890",
|
||||
displayName: "阿茶",
|
||||
displayName: "Bob",
|
||||
upstream: { kind: "peer", peerId: "host_abc12345" },
|
||||
mediaReady: true,
|
||||
},
|
||||
]);
|
||||
setCopy({ vis: true });
|
||||
setCopy({ lang: "en", vis: true });
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RouteTree, {
|
||||
hostPeerId: host?.peerId ?? null,
|
||||
hostLabel: host?.label ?? "分享者",
|
||||
hostLabel: host?.label ?? "Host",
|
||||
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).toContain(">Alice</text>");
|
||||
expect(html).toContain(">Bob</text>");
|
||||
expect(html).not.toContain("<title");
|
||||
expect(html).toContain('aria-label="阿茶"');
|
||||
setCopy({ vis: false });
|
||||
});
|
||||
|
||||
it("fits a one-Viewer topology inside a narrow container", () => {
|
||||
const hostName = "这是一个合法的二十四字中文分享者显示名称甲乙丙丁";
|
||||
const { host, viewers } = labelParticipantSnapshot([
|
||||
{
|
||||
role: "host",
|
||||
peerId: "host_mobile",
|
||||
displayName: hostName,
|
||||
upstream: { kind: "none" },
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "viewer_mobile",
|
||||
displayName: "Mobile Viewer",
|
||||
upstream: { kind: "peer", peerId: "host_mobile" },
|
||||
mediaReady: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RouteTree, {
|
||||
hostPeerId: host?.peerId ?? null,
|
||||
hostLabel: host?.label ?? "Host",
|
||||
viewers,
|
||||
}),
|
||||
);
|
||||
const visibleLabels = [...html.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map(
|
||||
(match) => match[1]!,
|
||||
);
|
||||
|
||||
expect(html).toContain('viewBox="0 0 640 ');
|
||||
expect(html).toContain('style="width:100%;max-width:880px"');
|
||||
expect(html).not.toContain("min-width:640px");
|
||||
expect(html).toContain('tabindex="0"');
|
||||
expect(visibleLabels).toContain("这是一个合法的二十四字中文分享…");
|
||||
expect(html).toContain(hostName);
|
||||
});
|
||||
|
||||
it("distinguishes ready, recovering, pending, and selected viewers", () => {
|
||||
const { host, viewers } = labelParticipantSnapshot([
|
||||
{
|
||||
role: "host",
|
||||
peerId: "host",
|
||||
displayName: "Host",
|
||||
upstream: { kind: "none" },
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "ready",
|
||||
displayName: "Ready",
|
||||
upstream: { kind: "peer", peerId: "host" },
|
||||
mediaReady: true,
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "recovering",
|
||||
displayName: "Recovering",
|
||||
upstream: { kind: "peer", peerId: "host" },
|
||||
},
|
||||
{
|
||||
role: "viewer",
|
||||
peerId: "pending",
|
||||
displayName: "Pending",
|
||||
upstream: { kind: "none" },
|
||||
},
|
||||
]);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RouteTree, {
|
||||
hostPeerId: host?.peerId ?? null,
|
||||
hostLabel: host?.label ?? "Host",
|
||||
viewers,
|
||||
selectedPeerId: "recovering",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('class="lr-route-edge is-p2p"');
|
||||
expect(html).toContain('class="lr-route-edge is-p2p is-recovering"');
|
||||
expect(html).toContain('class="lr-route-edge is-pending"');
|
||||
expect(html).toContain(
|
||||
'class="lr-route-node is-recovering is-selected"',
|
||||
);
|
||||
expect(html).toContain("Recovering");
|
||||
expect(html).toContain("← Host");
|
||||
});
|
||||
|
||||
it("gives every reachable depth its own horizontal column", () => {
|
||||
@@ -106,6 +274,7 @@ describe("RouteTree", () => {
|
||||
kind: "peer" as const,
|
||||
peerId: index === 0 ? "host" : `viewer-${index - 1}`,
|
||||
},
|
||||
mediaReady: true as const,
|
||||
})),
|
||||
];
|
||||
const { host, viewers } = labelParticipantSnapshot(participants);
|
||||
@@ -119,6 +288,11 @@ describe("RouteTree", () => {
|
||||
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);
|
||||
expect(html).toContain(`style="width:${width}px;max-width:none"`);
|
||||
expect(
|
||||
new Set(
|
||||
[...html.matchAll(/translate\((\d+),/g)].map((match) => match[1]),
|
||||
).size,
|
||||
).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user