fix(ui): clarify visual status semantics (#348)
This commit is contained in:
1 parent
e47d06fcdd
commit
c01303a491
14 files changed
+359
-38
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<ComicKind, ComicTheme> = {
|
||||
"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 (
|
||||
<>
|
||||
<style>{`
|
||||
.vls-bw-flow-a{animation:vlsBwFlowA 2.8s ease-in-out infinite}
|
||||
.vls-bw-flow-b{animation:vlsBwFlowB 2.8s ease-in-out infinite}
|
||||
.vls-bw-throat{transform-box:fill-box;transform-origin:center;animation:vlsBwThroat 2.8s ease-in-out infinite}
|
||||
.vls-bw-small{transform-box:fill-box;transform-origin:center;animation:vlsBwSmall 2.8s ease-in-out infinite}
|
||||
@keyframes vlsBwFlowA{0%,12%{transform:translateX(-8px);opacity:0}26%,58%{transform:none;opacity:1}72%,100%{transform:translateX(8px);opacity:0}}
|
||||
@keyframes vlsBwFlowB{0%,32%{transform:translateX(-7px);opacity:0}48%,72%{transform:none;opacity:1}86%,100%{transform:translateX(5px);opacity:0}}
|
||||
@keyframes vlsBwThroat{0%,30%,100%{transform:scaleY(1)}48%,76%{transform:scaleY(.62)}}
|
||||
@keyframes vlsBwSmall{0%,42%{transform:scale(1)}58%,100%{transform:scale(.82)}}
|
||||
${rmBlock(
|
||||
["vls-bw-flow-a", "vls-bw-flow-b", "vls-bw-throat", "vls-bw-small"],
|
||||
[
|
||||
[".vls-bw-flow-a,.vls-bw-flow-b", "opacity:1;transform:none"],
|
||||
[".vls-bw-throat", "transform:scaleY(.62)"],
|
||||
[".vls-bw-small", "transform:scale(.82)"],
|
||||
],
|
||||
)}
|
||||
`}</style>
|
||||
<Frame x={4} w={152} theme={theme} />
|
||||
<Frame x={164} w={152} theme={theme} accent={WARN} />
|
||||
<Floor x1={18} x2={142} />
|
||||
<Floor x1={178} x2={302} />
|
||||
<Pawn x={28} yb={76} s={7} eyes />
|
||||
<path d="M45 48H90" stroke={SKY} strokeWidth={8} strokeLinecap="round" />
|
||||
<g className="vls-bw-flow-a" fill={MINT}>
|
||||
<circle cx={55} cy={48} r={3.5} />
|
||||
<circle cx={68} cy={48} r={3.5} />
|
||||
<circle cx={81} cy={48} r={3.5} />
|
||||
</g>
|
||||
<MiniTv x={94} y={28} w={44} h={36} />
|
||||
<path
|
||||
className="vls-bw-throat"
|
||||
d="M181 38H208L226 45V53L208 60H181"
|
||||
fill="none"
|
||||
stroke={WARN}
|
||||
strokeWidth={3}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M226 49H250" stroke={WARN} strokeWidth={4} strokeLinecap="round" />
|
||||
<g className="vls-bw-flow-b" fill={STAR_GOLD}>
|
||||
<circle cx={192} cy={49} r={3.2} />
|
||||
<circle cx={207} cy={49} r={3.2} />
|
||||
<circle cx={239} cy={49} r={2.6} />
|
||||
</g>
|
||||
<g className="vls-bw-small">
|
||||
<MiniTv x={254} y={37} w={34} h={28} />
|
||||
</g>
|
||||
<Pawn x={298} yb={76} s={6.5} eyes color={WARN} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Native CPU adaptation: frames queue at the encoder, one falls away, and
|
||||
* the delivered picture shrinks. */
|
||||
function SceneEncoderLimited({ theme }: { theme: ComicTheme }) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.vls-en-frame-a{animation:vlsEnFlow 2.9s ease-in-out infinite}
|
||||
.vls-en-frame-b{animation:vlsEnFlow 2.9s ease-in-out .3s infinite}
|
||||
.vls-en-drop{transform-box:fill-box;transform-origin:center;animation:vlsEnDrop 2.9s ease-in infinite}
|
||||
.vls-en-heat{animation:vlsEnHeat 2.9s ease-out infinite}
|
||||
.vls-en-small{transform-box:fill-box;transform-origin:center;animation:vlsEnSmall 2.9s ease-in-out infinite}
|
||||
@keyframes vlsEnFlow{0%,12%{transform:translateX(-7px);opacity:0}28%,60%{transform:none;opacity:1}76%,100%{transform:translateX(8px);opacity:0}}
|
||||
@keyframes vlsEnDrop{0%,42%{transform:none;opacity:1}64%,100%{transform:translateY(16px) rotate(12deg);opacity:0}}
|
||||
@keyframes vlsEnHeat{0%,34%{transform:translateY(3px);opacity:0}48%,72%{transform:none;opacity:1}86%,100%{opacity:0}}
|
||||
@keyframes vlsEnSmall{0%,46%{transform:scale(1)}62%,100%{transform:scale(.82)}}
|
||||
${rmBlock(
|
||||
["vls-en-frame-a", "vls-en-frame-b", "vls-en-drop", "vls-en-heat", "vls-en-small"],
|
||||
[
|
||||
[".vls-en-frame-a,.vls-en-frame-b,.vls-en-heat", "opacity:1;transform:none"],
|
||||
[".vls-en-drop", "opacity:0;transform:translateY(16px)"],
|
||||
[".vls-en-small", "transform:scale(.82)"],
|
||||
],
|
||||
)}
|
||||
`}</style>
|
||||
<Frame x={4} w={152} theme={theme} />
|
||||
<Frame x={164} w={152} theme={theme} accent={WARN} />
|
||||
<Floor x1={18} x2={142} />
|
||||
<Floor x1={178} x2={302} />
|
||||
<Pawn x={27} yb={76} s={6.5} eyes />
|
||||
<g fill="none" stroke={SKY} strokeWidth={2.5}>
|
||||
<rect x={52} y={34} width={36} height={28} rx={5} />
|
||||
<path d="M58 30v4m8-4v4m8-4v4m8-4v4M58 62v4m8-4v4m8-4v4m8-4v4" />
|
||||
</g>
|
||||
<g className="vls-en-frame-a" fill={MINT}>
|
||||
<rect x={96} y={38} width={8} height={8} rx={1.5} />
|
||||
<rect x={108} y={38} width={8} height={8} rx={1.5} />
|
||||
</g>
|
||||
<MiniTv x={119} y={30} w={28} h={30} />
|
||||
<g fill="none" stroke={WARN} strokeWidth={2.5}>
|
||||
<rect x={184} y={34} width={38} height={28} rx={5} />
|
||||
<path d="M190 30v4m8-4v4m8-4v4m8-4v4M190 62v4m8-4v4m8-4v4m8-4v4" />
|
||||
</g>
|
||||
<g className="vls-en-heat" stroke={WARN} strokeWidth={2} strokeLinecap="round">
|
||||
<path d="M191 26l-3-6m13 6v-7m10 7l4-6" />
|
||||
</g>
|
||||
<g className="vls-en-frame-b" fill={STAR_GOLD}>
|
||||
<rect x={229} y={35} width={8} height={8} rx={1.5} />
|
||||
<rect x={241} y={35} width={8} height={8} rx={1.5} />
|
||||
</g>
|
||||
<g className="vls-en-drop">
|
||||
<rect x={241} y={49} width={8} height={8} rx={1.5} fill={DANGER} />
|
||||
<RedX cx={245} cy={53} arm={3} />
|
||||
</g>
|
||||
<g className="vls-en-small">
|
||||
<MiniTv x={258} y={37} w={34} h={28} />
|
||||
</g>
|
||||
<Pawn x={301} yb={76} s={6} eyes color={WARN} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<ComicKind, (props: { theme: ComicTheme }) => ReactNode> = {
|
||||
"access-denied": SceneAccessDenied,
|
||||
"invalid-invite": SceneInvalidInvite,
|
||||
"room-full": SceneRoomFull,
|
||||
"bandwidth-limited": SceneBandwidthLimited,
|
||||
"encoder-limited": SceneEncoderLimited,
|
||||
warning: SceneWarning,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<span className="lr-leds" data-state={state} role="status" title={vis ? undefined : label} aria-label={label}>
|
||||
const wrapped = vis && comic;
|
||||
const strip = (
|
||||
<span
|
||||
className="lr-leds"
|
||||
data-state={state}
|
||||
role="status"
|
||||
title={vis ? undefined : label}
|
||||
aria-label={label}
|
||||
tabIndex={wrapped ? 0 : undefined}
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
{vis ? null : <span className="lr-leds-label">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
return wrapped ? (
|
||||
<ComicTooltip kind={wrapped} place="below" align="start">
|
||||
{strip}
|
||||
</ComicTooltip>
|
||||
) : (
|
||||
strip
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderControls() {
|
||||
|
||||
@@ -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 (
|
||||
<Pill
|
||||
icon={presentation.icon}
|
||||
tone={presentation.tone}
|
||||
label={t(presentation.label)}
|
||||
comic={presentation.comic}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -106,6 +106,9 @@ export const en: Record<CopyKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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": "房间已设为公开",
|
||||
|
||||
@@ -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 = {}) {
|
||||
<LedStrip
|
||||
state={SIGNAL_LED_STATE[signalStatus]}
|
||||
label={t(SIGNAL_LED_LABEL[signalStatus])}
|
||||
comic={
|
||||
signalStatus === "reconnecting" || signalStatus === "offline"
|
||||
? "recovering"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -2863,6 +2871,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
onReplace={replaceCurrentRoom}
|
||||
replaceDisabled={phase === "starting" || roomMutating}
|
||||
/>
|
||||
{activeCodeEntryPolicy ? (
|
||||
<RoomAdmissionBadge
|
||||
policy={activeCodeEntryPolicy}
|
||||
passwordEnabled={viewerPasswordEnabled}
|
||||
/>
|
||||
) : null}
|
||||
</RowGroup>
|
||||
) : null}
|
||||
<RowGroup>
|
||||
@@ -2871,7 +2885,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<Pill icon="speaker" label={t("host.noAudio")} comic="no-audio" />
|
||||
) : null}
|
||||
{qualityLimitation ? (
|
||||
<Pill icon="alert" label={qualityLimitation} comic="warning" />
|
||||
<Pill
|
||||
icon="alert"
|
||||
label={qualityLimitation.message}
|
||||
comic={
|
||||
qualityLimitation.kind === "bandwidth"
|
||||
? "bandwidth-limited"
|
||||
: qualityLimitation.kind === "cpu"
|
||||
? "encoder-limited"
|
||||
: "warning"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{noticeText && (vis || noticeText !== phaseLine) ? (
|
||||
<Pill
|
||||
@@ -3353,6 +3377,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
? "host.invite.emptyPassword"
|
||||
: "host.invite.emptyPrivate",
|
||||
)}
|
||||
comic={
|
||||
activeCodeEntryPolicy === "private" &&
|
||||
!viewerPasswordEnabled
|
||||
? "invalid-invite"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</RowGroup>
|
||||
|
||||
@@ -2499,8 +2499,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
{qualityLimitation ? (
|
||||
<Pill
|
||||
icon="alert"
|
||||
label={qualityLimitation}
|
||||
comic="warning"
|
||||
label={qualityLimitation.message}
|
||||
comic={
|
||||
qualityLimitation.kind === "bandwidth"
|
||||
? "bandwidth-limited"
|
||||
: qualityLimitation.kind === "cpu"
|
||||
? "encoder-limited"
|
||||
: "warning"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -2533,22 +2539,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="lr-deck">
|
||||
{selectedPawn !== null && selectedChildEvidence ? (
|
||||
<PawnDetail
|
||||
pawnKey={selectedPawn}
|
||||
name={
|
||||
viewers.find((viewer) => 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}
|
||||
<div className="lr-row lr-viewer-summary-row">
|
||||
<div className="lr-row-group lr-viewer-room-slot">
|
||||
<FieldCap k="common.roomCode" />
|
||||
@@ -2754,6 +2744,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
{selectedPawn !== null && selectedChildEvidence ? (
|
||||
<PawnDetail
|
||||
pawnKey={selectedPawn}
|
||||
name={
|
||||
viewers.find((viewer) => 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 ? (
|
||||
<Row sub>
|
||||
<RouteTree
|
||||
|
||||
@@ -6,6 +6,11 @@ export type SignalConnectionState =
|
||||
|
||||
export type MediaPath = "direct" | "unknown";
|
||||
export type NativeEdgeQualityState = "unknown" | "healthy" | "degraded";
|
||||
export type QualityWarningKind =
|
||||
| "bandwidth"
|
||||
| "cpu"
|
||||
| "other"
|
||||
| "configuration";
|
||||
|
||||
export interface ConnectionMetrics {
|
||||
sampleTimestampMs: number | null;
|
||||
@@ -88,6 +93,7 @@ export interface PeerSnapshot {
|
||||
| import("./media/quality").AudioSenderParameterReadback
|
||||
| null;
|
||||
qualityWarning?: string | null;
|
||||
qualityWarningKind?: QualityWarningKind | null;
|
||||
}
|
||||
|
||||
export const EMPTY_METRICS: ConnectionMetrics = {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
EMPTY_METRICS,
|
||||
type PeerSnapshot,
|
||||
type QualityWarningKind,
|
||||
} from "../types";
|
||||
import {
|
||||
captureMetrics,
|
||||
@@ -114,6 +115,7 @@ export class HostPeer {
|
||||
senderParameters: null,
|
||||
audioSenderParameters: null,
|
||||
qualityWarning: null,
|
||||
qualityWarningKind: null,
|
||||
};
|
||||
this.bindConnectionEvents();
|
||||
}
|
||||
@@ -638,8 +640,7 @@ export class HostPeer {
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
metrics,
|
||||
qualityWarning:
|
||||
this.combinedSenderWarning() ?? this.persistentLimitationWarning(),
|
||||
...this.qualityWarningSnapshot(),
|
||||
};
|
||||
this.emit();
|
||||
} catch {
|
||||
@@ -736,8 +737,7 @@ export class HostPeer {
|
||||
...this.snapshot,
|
||||
senderParameters,
|
||||
audioSenderParameters: this.appliedAudioSenderParameters,
|
||||
qualityWarning:
|
||||
this.combinedSenderWarning() ?? this.persistentLimitationWarning(),
|
||||
...this.qualityWarningSnapshot(),
|
||||
};
|
||||
this.emit();
|
||||
return videoSucceeded && audioSucceeded;
|
||||
@@ -785,6 +785,30 @@ export class HostPeer {
|
||||
}
|
||||
}
|
||||
|
||||
private qualityWarningSnapshot(): {
|
||||
qualityWarning: string | null;
|
||||
qualityWarningKind: QualityWarningKind | null;
|
||||
} {
|
||||
const configurationWarning = this.combinedSenderWarning();
|
||||
if (configurationWarning) {
|
||||
return {
|
||||
qualityWarning: configurationWarning,
|
||||
qualityWarningKind: "configuration",
|
||||
};
|
||||
}
|
||||
const qualityWarning = this.persistentLimitationWarning();
|
||||
if (!qualityWarning) {
|
||||
return { qualityWarning: null, qualityWarningKind: null };
|
||||
}
|
||||
return {
|
||||
qualityWarning,
|
||||
qualityWarningKind:
|
||||
this.limitationReason === "bandwidth" || this.limitationReason === "cpu"
|
||||
? this.limitationReason
|
||||
: "other",
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
|
||||
@@ -4,9 +4,16 @@ import {
|
||||
formatPacketLossPercent,
|
||||
qualityLimitationSummary,
|
||||
} from "../src/client/components/connection-details.ts";
|
||||
import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
|
||||
import {
|
||||
EMPTY_METRICS,
|
||||
type PeerSnapshot,
|
||||
type QualityWarningKind,
|
||||
} from "../src/client/types.ts";
|
||||
|
||||
function snapshot(qualityWarning: string | null): PeerSnapshot {
|
||||
function snapshot(
|
||||
qualityWarning: string | null,
|
||||
qualityWarningKind: QualityWarningKind | null = null,
|
||||
): PeerSnapshot {
|
||||
return {
|
||||
peerId: "viewer-1",
|
||||
connectionId: "connection-1",
|
||||
@@ -15,6 +22,7 @@ function snapshot(qualityWarning: string | null): PeerSnapshot {
|
||||
metrics: { ...EMPTY_METRICS, qualityLimitationReason: "bandwidth" },
|
||||
error: null,
|
||||
qualityWarning,
|
||||
qualityWarningKind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,10 +31,13 @@ describe("progressive connection details", () => {
|
||||
expect(
|
||||
qualityLimitationSummary([
|
||||
snapshot(null),
|
||||
snapshot("当前连接带宽受限,画质已自动降低"),
|
||||
snapshot("编码性能受限,画质已自动降低"),
|
||||
snapshot("当前连接带宽受限,画质已自动降低", "bandwidth"),
|
||||
snapshot("编码性能受限,画质已自动降低", "cpu"),
|
||||
]),
|
||||
).toBe("当前连接带宽受限,画质已自动降低");
|
||||
).toEqual({
|
||||
message: "当前连接带宽受限,画质已自动降低",
|
||||
kind: "bandwidth",
|
||||
});
|
||||
expect(qualityLimitationSummary([snapshot(null)])).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
`<span class="visually-hidden">${t("en", labelKey)}</span>`,
|
||||
);
|
||||
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" }),
|
||||
|
||||
Reference in new issue
Block a user