diff --git a/src/client/components/connection-details.ts b/src/client/components/connection-details.ts index 584a766..ed6274c 100644 --- a/src/client/components/connection-details.ts +++ b/src/client/components/connection-details.ts @@ -1,4 +1,9 @@ -import type { PeerSnapshot } from "../types"; +import type { PeerSnapshot, QualityWarningKind } from "../types"; + +export interface QualityLimitationSummary { + message: string; + kind: QualityWarningKind; +} export function formatPacketLossPercent( value: number | null, @@ -11,10 +16,13 @@ export function formatPacketLossPercent( export function qualityLimitationSummary( snapshots: readonly PeerSnapshot[], -): string | null { +): QualityLimitationSummary | null { for (const snapshot of snapshots) { if (snapshot.qualityWarning) { - return snapshot.qualityWarning; + return { + message: snapshot.qualityWarning, + kind: snapshot.qualityWarningKind ?? "other", + }; } } return null; diff --git a/src/client/components/living/Comic.tsx b/src/client/components/living/Comic.tsx index 29e5a4f..7f7a566 100644 --- a/src/client/components/living/Comic.tsx +++ b/src/client/components/living/Comic.tsx @@ -22,6 +22,8 @@ export type ComicKind = | "access-denied" | "invalid-invite" | "room-full" + | "bandwidth-limited" + | "encoder-limited" | "warning"; export const COMIC_KINDS: readonly ComicKind[] = [ @@ -39,6 +41,8 @@ export const COMIC_KINDS: readonly ComicKind[] = [ "access-denied", "invalid-invite", "room-full", + "bandwidth-limited", + "encoder-limited", "warning", ]; @@ -60,6 +64,8 @@ const DEFAULT_THEME: Record = { "access-denied": "paper", "invalid-invite": "paper", "room-full": "paper", + "bandwidth-limited": "paper", + "encoder-limited": "paper", warning: "paper", }; @@ -1192,7 +1198,126 @@ ${rmBlock( ); } -/** 15. warning: 1 wide panel, loop 2.6s. Persistent condition: the you-pawn +/** Native bandwidth adaptation: a roomy path narrows, then the delivered + * picture shrinks. The route owner is deliberately absent; this describes the + * Browser report without guessing which physical link is responsible. */ +function SceneBandwidthLimited({ theme }: { theme: ComicTheme }) { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** Native CPU adaptation: frames queue at the encoder, one falls away, and + * the delivered picture shrinks. */ +function SceneEncoderLimited({ theme }: { theme: ComicTheme }) { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** Generic warning: 1 wide panel, loop 2.6s. Persistent condition: the you-pawn * stands beside the big Hearth coal-bowl warning glyph; heat-rays rise in a * staggered 0-55% window, the pawn blinks at 70%, rest >=50%. */ function SceneWarning({ theme }: { theme: ComicTheme }) { @@ -1260,6 +1385,8 @@ const SCENES: Record ReactNode> = { "access-denied": SceneAccessDenied, "invalid-invite": SceneInvalidInvite, "room-full": SceneRoomFull, + "bandwidth-limited": SceneBandwidthLimited, + "encoder-limited": SceneEncoderLimited, warning: SceneWarning, }; diff --git a/src/client/components/living/Header.tsx b/src/client/components/living/Header.tsx index 94b92fe..08d28af 100644 --- a/src/client/components/living/Header.tsx +++ b/src/client/components/living/Header.tsx @@ -3,21 +3,45 @@ import { VisGlyph } from "./primitives"; import { BrandMark } from "./BrandMark"; import { ComicTooltip } from "./ComicTooltip"; +import type { ComicKind } from "./Comic"; 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 }) { +export function LedStrip({ + state, + label, + comic, +}: { + state: LedState; + label: string; + comic?: ComicKind; +}) { const { vis } = useCopy(); - return ( - + const wrapped = vis && comic; + const strip = ( + {vis ? null : {label}} ); + return wrapped ? ( + + {strip} + + ) : ( + strip + ); } export function HeaderControls() { diff --git a/src/client/components/living/RoomChip.tsx b/src/client/components/living/RoomChip.tsx index 2a2fa77..7c6107c 100644 --- a/src/client/components/living/RoomChip.tsx +++ b/src/client/components/living/RoomChip.tsx @@ -1,9 +1,10 @@ // LCD room code chip with copy and in-place room replacement confirmation. import { useEffect, useRef, useState } from "react"; +import type { CodeEntryPolicy } from "../../../shared/protocol"; import { Glyph } from "../../ui/icons"; import { useCopy } from "../../ui/copy"; import { copyRoomCode } from "../room-code"; -import { Btn } from "./primitives"; +import { Btn, Pill } from "./primitives"; import { ComicTooltip } from "./ComicTooltip"; export function Lcd({ code }: { code: string }) { @@ -151,3 +152,42 @@ export function RoomChip({ ); } + +export function RoomAdmissionBadge({ + policy, + passwordEnabled, +}: { + policy: CodeEntryPolicy; + passwordEnabled: boolean; +}) { + const { t } = useCopy(); + const presentation = + policy === "open" + ? { + icon: "globe" as const, + label: "host.policy.currentOpen" as const, + tone: "good" as const, + comic: "hint-policy-open" as const, + } + : passwordEnabled + ? { + icon: "key" as const, + label: "host.policy.currentPassword" as const, + tone: "good" as const, + comic: "hint-password" as const, + } + : { + icon: "lock" as const, + label: "host.policy.currentInvite" as const, + tone: undefined, + comic: "hint-policy-private" as const, + }; + return ( + + ); +} diff --git a/src/client/components/living/primitives.tsx b/src/client/components/living/primitives.tsx index 2983b22..b32ab52 100644 --- a/src/client/components/living/primitives.tsx +++ b/src/client/components/living/primitives.tsx @@ -207,7 +207,7 @@ export function Pill({ tone?: "bad" | "good"; label: string; alert?: boolean; - comic?: ComicKind; + comic?: ComicKind | HintKind; }) { const { vis } = useCopy(); const wrapped = vis && comic; diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index 3232cdf..7dd69be 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -106,6 +106,9 @@ export const en: Record = { "host.policy": "Admission", "host.policy.open": "Open", "host.policy.private": "Private", + "host.policy.currentOpen": "Room code entry", + "host.policy.currentPassword": "Password entry", + "host.policy.currentInvite": "Invite only", "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", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index 15b318b..795fdb6 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -104,6 +104,9 @@ export const zh = { "host.policy": "准入方式", "host.policy.open": "公开", "host.policy.private": "私密", + "host.policy.currentOpen": "房间号可加入", + "host.policy.currentPassword": "密码可加入", + "host.policy.currentInvite": "仅限邀请", "host.policy.openHint": "知道房间号即可加入", "host.policy.privateHint": "凭邀请链接加入,或设置房间密码", "host.policy.setOpen": "房间已设为公开", diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 239e485..5a9087f 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -26,7 +26,10 @@ import { AppHeader, LedStrip, type LedState } from "../components/living/Header" import { Couch, type CouchEntry } from "../components/living/Couch"; import { useMetricsExpanded } from "../components/living/Metrics"; import { PawnDetail } from "../components/living/PawnDetail"; -import { RoomChip } from "../components/living/RoomChip"; +import { + RoomAdmissionBadge, + RoomChip, +} from "../components/living/RoomChip"; import { RouteTree } from "../components/living/RouteTree"; import { ViewerOverview, @@ -2634,6 +2637,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } /> @@ -2863,6 +2871,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { onReplace={replaceCurrentRoom} replaceDisabled={phase === "starting" || roomMutating} /> + {activeCodeEntryPolicy ? ( + + ) : null} ) : null} @@ -2871,7 +2885,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ) : null} {qualityLimitation ? ( - + ) : null} {noticeText && (vis || noticeText !== phaseLine) ? ( ) : null} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index ca2ef19..fde4ca5 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -2499,8 +2499,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { {qualityLimitation ? ( ) : null} @@ -2533,22 +2539,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { />
- {selectedPawn !== null && selectedChildEvidence ? ( - viewer.peerId === selectedPawn) - ?.label ?? selectedPawn - } - route={null} - metrics={metricsFromQualityEvidence(selectedChildEvidence.evidence)} - direction="receive" - tag={{ icon: "arrowUp", label: t("stats.downstream") }} - expanded={pawnMetricsExpanded} - onToggleMetrics={setPawnMetricsExpanded} - onClose={() => setSelectedPawn(null)} - /> - ) : null}
@@ -2754,6 +2744,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { /> ) : null} + {selectedPawn !== null && selectedChildEvidence ? ( + viewer.peerId === selectedPawn) + ?.label ?? selectedPawn + } + route={null} + metrics={metricsFromQualityEvidence(selectedChildEvidence.evidence)} + direction="receive" + tag={{ icon: "arrowUp", label: t("stats.downstream") }} + expanded={pawnMetricsExpanded} + onToggleMetrics={setPawnMetricsExpanded} + onClose={() => setSelectedPawn(null)} + /> + ) : null} {showTopology && labeledHostPresence ? ( { expect( qualityLimitationSummary([ snapshot(null), - snapshot("当前连接带宽受限,画质已自动降低"), - snapshot("编码性能受限,画质已自动降低"), + snapshot("当前连接带宽受限,画质已自动降低", "bandwidth"), + snapshot("编码性能受限,画质已自动降低", "cpu"), ]), - ).toBe("当前连接带宽受限,画质已自动降低"); + ).toEqual({ + message: "当前连接带宽受限,画质已自动降低", + kind: "bandwidth", + }); expect(qualityLimitationSummary([snapshot(null)])).toBeNull(); }); diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 110223f..67dabd0 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -1511,10 +1511,12 @@ describe("HostPeer source replacement", () => { expect(updates.at(-1)?.qualityWarning).toBe( "当前连接带宽受限,画质已自动降低", ); + expect(updates.at(-1)?.qualityWarningKind).toBe("bandwidth"); expect(sender.setParameters).toHaveBeenCalledTimes(2); await sample(); expect(updates.at(-1)?.qualityWarning).toBeNull(); + expect(updates.at(-1)?.qualityWarningKind).toBeNull(); expect(sender.setParameters).toHaveBeenCalledTimes(2); }); @@ -1546,6 +1548,7 @@ describe("HostPeer source replacement", () => { expect(updates.at(-1)?.qualityWarning).toBe( "浏览器持续报告未分类的画质限制", ); + expect(updates.at(-1)?.qualityWarningKind).toBe("other"); }); it("rolls the first sender back when the second replacement fails", async () => { diff --git a/tests/living-components.test.ts b/tests/living-components.test.ts index 840d139..b51ddb5 100644 --- a/tests/living-components.test.ts +++ b/tests/living-components.test.ts @@ -11,6 +11,7 @@ import { import { Comic } from "../src/client/components/living/Comic.tsx"; import { ComicTooltip } from "../src/client/components/living/ComicTooltip.tsx"; import { HintComic } from "../src/client/components/living/hints/index.tsx"; +import { RoomAdmissionBadge } from "../src/client/components/living/RoomChip.tsx"; import { StageOverlay } from "../src/client/components/living/Stage.tsx"; import { ViewerOverview } from "../src/client/components/living/ViewerOverview.tsx"; import { Btn, NameTag } from "../src/client/components/living/primitives.tsx"; @@ -195,6 +196,41 @@ describe("living-room presentation", () => { expect(html).toContain("vlsPrivLock"); }); + it.each([ + ["open", false, "host.policy.currentOpen", true], + ["private", true, "host.policy.currentPassword", true], + ["private", false, "host.policy.currentInvite", false], + ] as const)( + "renders %s/%s admission from one policy fact", + (policy, passwordEnabled, labelKey, good) => { + setCopy({ lang: "en", vis: true }); + const html = renderToStaticMarkup( + createElement(RoomAdmissionBadge, { policy, passwordEnabled }), + ); + + expect(html).toContain( + `${t("en", labelKey)}`, + ); + expect(html).toContain("lr-comic-tip-wrap"); + expect(html).toContain("visually-hidden"); + expect(html.includes("lr-pill is-good")).toBe(good); + }, + ); + + it("uses distinct native quality-limit comics", () => { + const bandwidth = renderToStaticMarkup( + createElement(Comic, { kind: "bandwidth-limited", size: 320 }), + ); + const encoder = renderToStaticMarkup( + createElement(Comic, { kind: "encoder-limited", size: 320 }), + ); + + expect(bandwidth).toContain("vls-bw-throat"); + expect(bandwidth).toContain("vlsBwSmall"); + expect(encoder).toContain("vls-en-drop"); + expect(encoder).toContain("vlsEnHeat"); + }); + it("keeps the Host-offline comic free of the waiting-room moon", () => { const html = renderToStaticMarkup( createElement(Comic, { kind: "host-offline", theme: "stage" }),