+
+ {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 = (
+ <>
+
+
+
{entry.name}
- ) : 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 (
+
+ {inner}
+
+ );
+ }
return (
-
onSelect?.(entry.key)}
>
{inner}
-
+
);
- }
- return (
-
- );
- })}
+ })}
+
+ 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
();
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 : (
-
- {name}
-
- );
-
- 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 (
+
+ {t("host.topology")}
+
-
- {nodes.map((node) => (
- -
- {node.label} {node.sfu ? "SFU" : "P2P"}
-
+
+ {nodes.map((node) => {
+ const point = pos.get(node.key)!;
+ const child = node.via !== null && !node.sfu;
+ return (
+
+ {compactVisibleLabel(
+ node.label,
+ layoutConfig.maxVisibleLabelCodePoints,
+ )}
+
+ );
+ })}
+ {pendingPos.map((point) => (
+
+ {compactVisibleLabel(
+ point.viewer.label,
+ layoutConfig.maxVisibleLabelCodePoints,
+ )}
+
))}
+
+
+
+ -
+ {hostLabel} · {t("common.host")}
+
+ {nodes.map((node) => {
+ const parentLabel = node.via
+ ? (labelByPeer.get(node.via) ?? hostLabel)
+ : node.sfu
+ ? "SFU"
+ : hostLabel;
+ return (
+ -
+ {node.label} ← {parentLabel} ·{" "}
+ {t(node.sfu ? "state.route.sfu" : "state.route.p2p")}
+ {node.ready ? "" : ` · ${t("host.topology.pending")}`}
+
+ );
+ })}
{topology.pending.map((viewer) => (
-
- {viewer.label} {t("host.topology.pending")}
+ {viewer.label} · {t("host.topology.pending")}
))}
- {topology.pending.length > 0 ? (
- vis ? (
- {pendingLabel}
- ) : (
-
- {pendingLabel}
-
- )
- ) : null}
);
});
diff --git a/src/client/components/living/route-tree-layout.ts b/src/client/components/living/route-tree-layout.ts
new file mode 100644
index 0000000..a7fbcc9
--- /dev/null
+++ b/src/client/components/living/route-tree-layout.ts
@@ -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;
+}
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index e52a17f..2874b5e 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -3601,7 +3601,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
hostPeerId={hostPeerId}
hostLabel={labeledHostPresence?.label ?? displayName}
viewers={viewers}
- flowing={phase === "starting"}
+ selectedPeerId={selectedPawn}
/>
) : null}
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx
index 110746d..699a72a 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -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}
-
-
+
+
-
-
-
- {hostDisplayName
- ? `${t("viewer.title", { name: hostDisplayName })} · `
- : ""}
- {t(presentation.messageKey)}
- {participantPresence
- ? ` · ${t("viewer.onlineCount", { n: String(viewers.length) })}`
- : ""}
-
-
-
+
+
+
+ {hostDisplayName ?? t("common.host")}
+
+
+ {t(presentation.messageKey)}
+
-
+
{labeledHostPresence
? hintWrap(
"hint-topology",
@@ -2492,8 +2476,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
onClick={() => setShowConnectionDetails((current) => !current)}
/>
-
-
+
+
{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}
/>
) : null}
diff --git a/src/client/styles.css b/src/client/styles.css
index 4de639e..84fab79 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -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 ---------- */
diff --git a/src/client/ui/copy.ts b/src/client/ui/copy.ts
index 94472c5..e88a5d4 100644
--- a/src/client/ui/copy.ts
+++ b/src/client/ui/copy.ts
@@ -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 = {
"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",
diff --git a/tests/living-components.test.ts b/tests/living-components.test.ts
index 745a801..4478c9c 100644
--- a/tests/living-components.test.ts
+++ b/tests/living-components.test.ts
@@ -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");
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(
+ 'Relay Viewer With A Full Name',
+ );
+ expect(html).not.toContain("max-width:72px");
+ });
});
diff --git a/tests/route-tree.test.ts b/tests/route-tree.test.ts
index 975f1aa..3407f49 100644
--- a/tests/route-tree.test.ts
+++ b/tests/route-tree.test.ts
@@ -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>/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(" doubles as a native hover
- // tooltip, which leaks localized UI text into the zero-text mode.
+
+ expect(html).toContain(">Alice");
+ expect(html).toContain(">Bob");
expect(html).not.toContain(" {
+ 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>/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);
});
});