fix: fix presentation and audited ownership invariants (#338)

* Fix presentation and frontend ownership invariants

* Fix audited backend ownership invariants
This commit is contained in:
AAA authored and GitHub committed 2026-08-30 18:38:36 +08:00
1 parent c4b59412f2
commit 87d9aad487
43 files changed
+1791 -677

No files matched your search

+29
View File
@@ -16,6 +16,7 @@ lock='/opt/screener/deploy.lock'
public_origin="${SCREENER_PUBLIC_ORIGIN:-}"
descriptor="$(realpath -e -- "$1")"
stage=''
release_owned=0
cutover_started=0
old_release=''
@@ -72,6 +73,29 @@ cleanup_stage() {
rm -rf --one-file-system -- "$resolved"
}
cleanup_release() {
if [ "$release_owned" -ne 1 ] || { [ ! -e "$release" ] && [ ! -L "$release" ]; }; then
return
fi
if [ -n "$stage" ] && [ -e "$stage" ]; then
return
fi
local resolved parent base active
resolved="$(realpath -m -- "$release")"
parent="$(dirname -- "$resolved")"
base="$(basename -- "$resolved")"
active="$(readlink -f -- "$current" 2>/dev/null || true)"
if [ "$parent" != "$release_root" ] || [ "$base" != "$release_id" ] || \
[ "$active" = "$resolved" ] || [ -L "$release" ] || [ ! -d "$release" ]; then
printf 'refusing to remove unexpected release: %s\n' "$resolved" >&2
return 1
fi
if ! rm -rf --one-file-system -- "$resolved"; then
return 1
fi
release_owned=0
}
wait_for_health() {
local body=''
for _ in $(seq 1 120); do
@@ -102,6 +126,9 @@ recover() {
systemctl start screener.service || ok=0
wait_for_health || ok=0
fi
if [ "$ok" -eq 1 ]; then
cleanup_release || ok=0
fi
cleanup_stage
printf 'deployment_failed=%s recovery_ok=%s current=%s active=%s\n' \
"$code" "$ok" \
@@ -267,6 +294,7 @@ old_asset="$(OLD_RELEASE="$old_release" "$node" --input-type=module --eval '
process.stdout.write(match[1]);
')"
release_owned=1
mv -- "$stage" "$release"
stage=''
test -d "$release"
@@ -310,6 +338,7 @@ fi
test -z "$(journalctl -u screener.service --since "$cutover_since" -p warning --no-pager --output=cat)"
asset_sha="$(sha256sum "$release/dist/client/$main_asset" | awk '{print $1}')"
release_owned=0
cutover_started=0
trap - ERR HUP INT TERM EXIT
printf 'deployment=ok revision=%s release=%s previous=%s artifact_sha=%s manifest_sha=%s asset=%s asset_sha=%s health_ms=%s firewall_sha=%s\n' \
-1
View File
@@ -899,7 +899,6 @@ ${rmBlock(
<Frame x={164} w={152} theme={theme} />
<rect x={30} y={22} width={70} height={44} rx={8} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<rect x={36} y={28} width={58} height={30} rx={4} fill="#0a101c" />
<Moon x={72} y={43} k={0.55} />
<path d="M65 66 c0 6 -4 8 -8 10" stroke={TV_EDGE} strokeWidth={2.5} fill="none" strokeLinecap="round" />
<Plug x={57} y={76} className="vls-ho-plug" />
<Floor x1={176} x2={304} y={78} />
+29 -3
View File
@@ -11,9 +11,16 @@
// Styling in styles.css under "comic tooltip" / "glyph draw-in". SSR-safe:
// handlers only run in the browser.
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
isValidElement,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Comic, type ComicKind } from "./Comic";
import { HintComic, isHintKind, type HintKind } from "./hints";
import { isCopyKey, say } from "../../ui/copy";
const LONG_PRESS_MS = 500;
const TOUCH_HIDE_MS = 1500;
@@ -49,6 +56,23 @@ export function ComicTooltip({
const [comicMounted, setComicMounted] = useState(false);
const [liveAlign, setLiveAlign] = useState<Align | null>(null);
const interactionOpen = hoverOpen || focusOpen || touchOpen;
const disabledTrigger =
isValidElement<{
disabled?: boolean;
"aria-label"?: string;
label?: string;
title?: string;
}>(children) &&
children.props.disabled === true;
const rawDisabledTriggerLabel = disabledTrigger
? children.props["aria-label"] ??
children.props.label ??
children.props.title
: undefined;
const disabledTriggerLabel =
rawDisabledTriggerLabel && isCopyKey(rawDisabledTriggerLabel)
? say(rawDisabledTriggerLabel)
: rawDisabledTriggerLabel;
const mountComic = () => {
if (comicUnmountTimer.current !== null) {
@@ -163,7 +187,9 @@ export function ComicTooltip({
return (
<span
ref={wrapRef}
className={`lr-comic-tip-wrap${touchOpen ? " is-tip-open" : ""}`}
className={`lr-comic-tip-wrap${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${touchOpen ? " is-tip-open" : ""}`}
tabIndex={disabledTrigger ? 0 : undefined}
aria-label={disabledTriggerLabel}
onPointerEnter={(event) => {
pickAlign();
if (event.pointerType !== "touch") {
@@ -250,9 +276,9 @@ export function ComicTooltip({
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setHoverOpen(false);
setTouchOpen(false);
setFocusOpen(false);
(document.activeElement as HTMLElement | null)?.blur?.();
} else if ((event.target as HTMLElement).matches(":focus-visible")) {
mountComic();
setFocusOpen(true);
+4 -14
View File
@@ -2,17 +2,7 @@
// self carries the green pointer, the host wears a crown (topology only).
import { Glyph } from "../../ui/icons";
import { useCopy } from "../../ui/copy";
const PAWN_COLORS = [
"var(--pawn-1)", "var(--pawn-2)", "var(--pawn-3)", "var(--pawn-4)",
"var(--pawn-5)", "var(--pawn-6)", "var(--pawn-7)", "var(--pawn-8)",
];
export function pawnColor(key: string): string {
let hash = 0;
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return PAWN_COLORS[Math.abs(hash) % PAWN_COLORS.length]!;
}
import { participantColor } from "./participant-color";
export function PawnSvg({ color, crown }: { color: string; crown?: boolean }) {
return (
@@ -80,7 +70,7 @@ export function Couch({
: `${entry.name} · ${stateLabel}`;
const inner = (
<>
<PawnSvg color={pawnColor(entry.key)} />
<PawnSvg color={participantColor(entry.key)} />
<i
className={`lr-pawn-led${entry.connected ? "" : " is-wait"}`}
aria-hidden="true"
@@ -103,7 +93,7 @@ export function Couch({
key={entry.key}
className={className}
style={style}
title={vis ? undefined : label}
title={entry.name}
aria-label={label}
>
{inner}
@@ -116,7 +106,7 @@ export function Couch({
type="button"
className={className}
style={style}
title={vis ? undefined : label}
title={entry.name}
aria-label={label}
aria-pressed={selectedKey === entry.key}
onClick={() => onSelect?.(entry.key)}
+4 -1
View File
@@ -170,7 +170,10 @@ export function MetricCells({
<span className="lr-meter-cell" title={vis ? undefined : title ?? t(label)} key={label + display}>
<Glyph name={icon} size={16} />
{vis ? (
<b>{display}</b>
<>
<b>{display}</b>
<span className="visually-hidden">{t(label)}</span>
</>
) : (
<span className="lr-meter-text">
<b>{display}</b>
+10 -3
View File
@@ -1,7 +1,8 @@
// Per-viewer drill-down: name, route glyph, primary metrics, expandable
// detailed metrics. Host sees every Viewer; a Viewer uses this only for relay
// children because its own route already has the canonical details panel.
import { PawnSvg, pawnColor } from "./Couch";
import { PawnSvg } from "./Couch";
import { participantColor } from "./participant-color";
import { Glyph } from "../../ui/icons";
import { ComicTooltip } from "./ComicTooltip";
import { useCopy } from "../../ui/copy";
@@ -31,7 +32,13 @@ export function RouteGlyph({ route }: { route: "p2p" | "sfu" }) {
<path d="M7 8h22" />
</svg>
)}
<b>{route === "sfu" ? "SFU" : "P2P"}</b>
{vis ? (
<span className="visually-hidden">
{t(route === "sfu" ? "state.route.sfu" : "state.route.p2p")}
</span>
) : (
<b>{route === "sfu" ? "SFU" : "P2P"}</b>
)}
</span>
);
return vis ? (
@@ -70,7 +77,7 @@ export function PawnDetail({
return (
<div className="lr-row is-sub lr-pawn-detail" role="group" aria-label={name}>
<span className="lr-pawn-mini">
<PawnSvg color={pawnColor(pawnKey)} />
<PawnSvg color={participantColor(pawnKey)} />
</span>
{vis ? (
<span className="visually-hidden">{name}</span>
+5 -4
View File
@@ -9,7 +9,8 @@ import {
type TopologyBranch,
} from "../../lib/participant-topology";
import { useCopy } from "../../ui/copy";
import { PawnSvg, pawnColor } from "./Couch";
import { PawnSvg } from "./Couch";
import { participantColor } from "./participant-color";
import {
topologyLayoutForViewport,
type TopologyLayout,
@@ -326,7 +327,7 @@ export const RouteTree = memo(function RouteTree({
className="lr-route-node is-host"
transform={`translate(${centeredPawnX(hostPos.x, HOST_SCALE)}, ${hostPos.y - 20}) scale(${HOST_SCALE})`}
>
<PawnSvg color="var(--couch)" crown />
<PawnSvg color={participantColor(hostPeerId ?? "host-pending")} crown />
</g>
<text
className="lr-route-label is-host"
@@ -371,7 +372,7 @@ export const RouteTree = memo(function RouteTree({
className={`lr-route-node${node.ready ? "" : " is-recovering"}${selected ? " is-selected" : ""}`}
transform={`translate(${centeredPawnX(point.x, scale)}, ${point.y - 15}) scale(${scale})`}
>
<PawnSvg color={pawnColor(node.key)} />
<PawnSvg color={participantColor(node.key)} />
{selected ? (
<circle className="lr-route-selection" cx="20" cy="26" r="25" />
) : null}
@@ -389,7 +390,7 @@ export const RouteTree = memo(function RouteTree({
transform={`translate(${centeredPawnX(point.x, ROOT_SCALE)}, ${point.y - 15}) scale(${ROOT_SCALE})`}
>
<PawnSvg
color={pawnColor(point.viewer.peerId)}
color={participantColor(point.viewer.peerId)}
/>
{selectedPeerId === point.viewer.peerId ? (
<circle className="lr-route-selection" cx="20" cy="26" r="25" />
@@ -2,7 +2,8 @@ import type { ConnectionMetrics } from "../../types";
import { formatPacketLossPercent } from "../connection-details";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
import { PawnSvg, pawnColor } from "./Couch";
import { PawnSvg } from "./Couch";
import { participantColor } from "./participant-color";
export interface ViewerOverviewEntry {
key: string;
@@ -83,7 +84,7 @@ export function ViewerOverview({
>
<span className="lr-viewer-overview-person" title={entry.statusLabel}>
<span className="lr-viewer-overview-pawn">
<PawnSvg color={pawnColor(entry.key)} />
<PawnSvg color={participantColor(entry.key)} />
<i className={entry.connected ? "" : "is-wait"} aria-hidden="true" />
</span>
<span className="lr-viewer-overview-person-copy">
@@ -102,7 +103,19 @@ export function ViewerOverview({
}
size={14}
/>
<b>{route}</b>
{vis ? (
<span className="visually-hidden">
{t(
entry.route === "sfu"
? "state.route.sfu"
: entry.route === "p2p"
? "state.route.p2p"
: "state.peer.connecting",
)}
</span>
) : (
<b>{route}</b>
)}
</span>
<span>{resolution}</span>
<span>{fps}</span>
@@ -0,0 +1,10 @@
const PARTICIPANT_COLORS = [
"var(--pawn-1)", "var(--pawn-2)", "var(--pawn-3)", "var(--pawn-4)",
"var(--pawn-5)", "var(--pawn-6)", "var(--pawn-7)", "var(--pawn-8)",
];
export function participantColor(identity: string): string {
let hash = 0;
for (const char of identity) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return PARTICIPANT_COLORS[Math.abs(hash) % PARTICIPANT_COLORS.length]!;
}
+6 -2
View File
@@ -2,6 +2,7 @@
// title/aria; text modes add a visible caption from the same catalog.
import type { CSSProperties, MouseEvent, ReactNode } from "react";
import { ComicTooltip } from "./ComicTooltip";
import { participantColor } from "./participant-color";
import type { ComicKind } from "./Comic";
import type { HintKind } from "./hints";
import { Glyph, type GlyphName } from "../../ui/icons";
@@ -281,11 +282,14 @@ export function SwitchItem({
);
}
export function NameTag({ name }: { name: string }) {
export function NameTag({ name, identity }: { name: string; identity: string }) {
const { t, vis } = useCopy();
return (
<span className="lr-name-tag" title={vis ? undefined : t("host.name")}>
<i aria-hidden="true" />
<i
aria-hidden="true"
style={{ backgroundColor: participantColor(identity) }}
/>
<span>{name}</span>
</span>
);
+2 -2
View File
@@ -197,8 +197,7 @@ export class HostSfuRoute {
): Promise<void> {
if (
this.closed ||
!this.route.acceptsConfig(message.revision) ||
this.paused
!this.route.acceptsConfig(message.revision)
) {
return;
}
@@ -516,6 +515,7 @@ export class HostSfuRoute {
await disconnectPublisher(slot.publisher);
return false;
}
slot.publisher.setPaused(this.paused);
if (this.pending !== slot || !this.ownsPublisherSlot(slot)) {
await disconnectPublisher(slot.publisher);
return false;
+35 -11
View File
@@ -1,8 +1,11 @@
type VideoWithFrameCallback = HTMLVideoElement & {
interface OptionalVideoFrameCallbacks {
requestVideoFrameCallback?: (
callback: (now: DOMHighResTimeStamp) => void,
) => number;
cancelVideoFrameCallback?: (handle: number) => void;
}
type VideoWithFrameCallback = HTMLVideoElement & {
webkitDecodedFrameCount?: number;
};
@@ -21,6 +24,9 @@ export function observeCompositedVideoFrame(
onFrame: () => void,
): () => void {
const target = video as VideoWithFrameCallback;
const frameCallbacks = video as unknown as OptionalVideoFrameCallbacks;
const requestVideoFrameCallback =
frameCallbacks.requestVideoFrameCallback?.bind(video);
let active = true;
let frameCallbackHandle: number | null = null;
const fallbackEvents = ["loadeddata", "timeupdate", "playing"] as const;
@@ -51,25 +57,43 @@ export function observeCompositedVideoFrame(
}
}
function finish(): void {
if (
!active ||
video.srcObject !== expectedStream ||
video.readyState < 2
) {
return;
function finish(): boolean {
if (!active) {
return true;
}
if (video.srcObject !== expectedStream) {
active = false;
removeFallbackListeners();
return true;
}
if (video.readyState < 2) {
return false;
}
active = false;
removeFallbackListeners();
onFrame();
return true;
}
if (target.requestVideoFrameCallback) {
frameCallbackHandle = target.requestVideoFrameCallback(() => finish());
function requestNextFrame(): void {
frameCallbackHandle =
requestVideoFrameCallback?.(() => {
frameCallbackHandle = null;
if (!finish()) {
requestNextFrame();
}
}) ?? null;
}
if (requestVideoFrameCallback) {
requestNextFrame();
return () => {
active = false;
if (frameCallbackHandle !== null) {
target.cancelVideoFrameCallback?.(frameCallbackHandle);
frameCallbacks.cancelVideoFrameCallback?.call(
video,
frameCallbackHandle,
);
}
};
}
+16
View File
@@ -0,0 +1,16 @@
type ViewerPlaybackElement = Pick<HTMLVideoElement, "srcObject" | "pause">;
export function prepareViewerPlayback(
video: ViewerPlaybackElement,
stream: MediaStream,
paused: boolean,
): boolean {
if (video.srcObject !== stream) {
video.srcObject = stream;
}
if (paused) {
video.pause();
return false;
}
return true;
}
+114 -189
View File
@@ -71,11 +71,13 @@ interface ViewerRouteStatusFact {
interface ViewerMediaFact {
generation: number;
boundAtRevision: number;
proofEpoch: number;
framePresented: boolean;
}
export interface ViewerPresentationState {
access: "checking" | "ready" | "denied";
accessFailure: ViewerFailureCode | null;
signal: SignalConnectionState;
host: ViewerHostState;
revision: number | null;
@@ -90,7 +92,8 @@ export interface ViewerPresentationState {
media: ViewerMediaFact | null;
retainedFrame: boolean;
autoplayBlockedGeneration: number | null;
failure: ViewerFailureCode | null;
playbackFailedGeneration: number | null;
runtimeFailure: "SERVER_ERROR" | null;
}
export type ViewerPresentationAction =
@@ -118,19 +121,20 @@ export type ViewerPresentationAction =
connection: ViewerPresentationState["connection"];
}
| { type: "media-bound"; generation: number; revision: number }
| { type: "frame-presented"; generation: number; revision: number }
| {
type: "frame-presented";
generation: number;
proofEpoch: number;
revision: number;
}
| { type: "frame-proof-reset"; generation: number; revision: number }
| { type: "frame-proof-rearm"; generation: number }
| { type: "autoplay-blocked"; generation: number; revision: number }
| { type: "autoplay-cleared"; generation: number }
| { type: "playback-failed"; generation: number; revision: number }
| { type: "media-invalidated"; revision: number }
| { type: "media-cleared" }
| { type: "sharing-stopped" }
| {
type: "failure";
failure: ViewerFailureCode | null;
revision?: number;
};
| { type: "server-error" };
export interface ViewerPresentation {
stage: ViewerStage;
@@ -148,6 +152,7 @@ export interface ViewerPresentation {
export const INITIAL_VIEWER_PRESENTATION_STATE: ViewerPresentationState = {
access: "checking",
accessFailure: null,
signal: "offline",
host: "unknown",
revision: null,
@@ -157,7 +162,8 @@ export const INITIAL_VIEWER_PRESENTATION_STATE: ViewerPresentationState = {
media: null,
retainedFrame: false,
autoplayBlockedGeneration: null,
failure: null,
playbackFailedGeneration: null,
runtimeFailure: null,
};
export function reduceViewerPresentation(
@@ -169,35 +175,22 @@ export function reduceViewerPresentation(
return {
...state,
access: action.access,
failure:
accessFailure:
action.failure !== undefined
? action.failure
: action.access === "ready"
? null
: state.failure,
: state.accessFailure,
runtimeFailure:
action.access === "ready" ? null : state.runtimeFailure,
};
case "signal":
return {
...state,
signal: action.signal,
failure:
action.signal === "connected" &&
(state.failure === "SIGNAL_TERMINATED" ||
state.failure === "SESSION_REPLACED")
? null
: state.failure,
};
return { ...state, signal: action.signal };
case "host": {
const failure =
action.host === "offline"
? "HOST_OFFLINE"
: action.host === "stopped"
? "HOST_STOPPED"
: state.failure === "HOST_OFFLINE" ||
state.failure === "HOST_STOPPED"
? null
: state.failure;
return { ...state, host: action.host, failure };
if (action.host === "offline" && state.host === "stopped") {
return state;
}
return { ...state, host: action.host };
}
case "route": {
if (state.revision !== null && action.revision < state.revision) {
@@ -206,9 +199,7 @@ export function reduceViewerPresentation(
const revisionChanged = state.revision !== action.revision;
const committedRoute = action.phase === "active" && action.kind !== "none";
const reactivatingTerminalRoute =
committedRoute &&
(state.routeStatus?.state === "failed" ||
state.failure === "ROUTE_EXHAUSTED");
committedRoute && state.routeStatus?.state === "failed";
return {
...state,
revision: action.revision,
@@ -230,10 +221,6 @@ export function reduceViewerPresentation(
? "idle"
: "connecting"
: state.connection,
failure:
committedRoute && state.failure === "ROUTE_EXHAUSTED"
? null
: state.failure,
};
}
case "route-status": {
@@ -245,7 +232,11 @@ export function reduceViewerPresentation(
const currentFrame = hasCurrentFrame(state);
const currentMedia =
terminal && state.media
? { ...state.media, framePresented: false }
? {
...state.media,
proofEpoch: state.media.proofEpoch + 1,
framePresented: false,
}
: state.media;
return {
...state,
@@ -264,17 +255,7 @@ export function reduceViewerPresentation(
? null
: state.autoplayBlockedGeneration,
retainedFrame:
state.retainedFrame ||
((revisionChanged || terminal) && currentFrame),
failure:
action.state === "failed"
? "ROUTE_EXHAUSTED"
: revisionChanged &&
(state.failure === "ROUTE_EXHAUSTED" ||
state.failure === "AUTOPLAY_BLOCKED" ||
state.failure === "PLAYBACK_FAILED")
? null
: state.failure,
state.retainedFrame || (terminal && currentFrame),
};
}
case "connection":
@@ -290,9 +271,7 @@ export function reduceViewerPresentation(
if (
(state.media && action.generation <= state.media.generation) ||
(state.routeStatus?.revision === action.revision &&
state.routeStatus.state === "failed") ||
(state.revision === action.revision &&
state.failure === "ROUTE_EXHAUSTED")
state.routeStatus.state === "failed")
) {
return state;
}
@@ -301,24 +280,20 @@ export function reduceViewerPresentation(
media: {
generation: action.generation,
boundAtRevision: action.revision,
proofEpoch: 0,
framePresented: false,
},
retainedFrame: state.retainedFrame || hasCurrentFrame(state),
autoplayBlockedGeneration: null,
failure:
state.failure === "AUTOPLAY_BLOCKED" ||
state.failure === "PLAYBACK_FAILED" ||
state.failure === "ROUTE_EXHAUSTED"
? null
: state.failure,
playbackFailedGeneration: null,
};
}
case "frame-presented":
if (
!state.media ||
state.media.generation !== action.generation ||
state.routeStatus?.state === "failed" ||
state.failure === "ROUTE_EXHAUSTED"
state.media.proofEpoch !== action.proofEpoch ||
state.media.framePresented
) {
return state;
}
@@ -328,21 +303,31 @@ export function reduceViewerPresentation(
media: { ...state.media, framePresented: true },
retainedFrame: false,
routeStatus: null,
failure:
state.failure === "PLAYBACK_FAILED" ? null : state.failure,
playbackFailedGeneration: null,
};
case "frame-proof-reset":
if (
!state.media ||
state.media.generation !== action.generation ||
!state.media.framePresented
) {
if (!state.media || state.media.generation !== action.generation) {
return state;
}
return {
...state,
media: { ...state.media, framePresented: false },
retainedFrame: true,
media: {
...state.media,
proofEpoch: state.media.proofEpoch + 1,
framePresented: false,
},
retainedFrame: state.retainedFrame || state.media.framePresented,
};
case "frame-proof-rearm":
if (!state.media || state.media.generation !== action.generation) {
return state;
}
return {
...state,
media: {
...state.media,
proofEpoch: state.media.proofEpoch + 1,
},
};
case "autoplay-blocked":
if (
@@ -354,7 +339,6 @@ export function reduceViewerPresentation(
return {
...state,
autoplayBlockedGeneration: action.generation,
failure: "AUTOPLAY_BLOCKED",
};
case "autoplay-cleared":
if (state.autoplayBlockedGeneration !== action.generation) {
@@ -363,8 +347,6 @@ export function reduceViewerPresentation(
return {
...state,
autoplayBlockedGeneration: null,
failure:
state.failure === "AUTOPLAY_BLOCKED" ? null : state.failure,
};
case "playback-failed":
if (
@@ -373,23 +355,14 @@ export function reduceViewerPresentation(
) {
return state;
}
return { ...state, failure: "PLAYBACK_FAILED" };
case "media-invalidated":
if (state.revision !== action.revision) {
return state;
}
return {
...state,
media: null,
retainedFrame: state.retainedFrame || hasCurrentFrame(state),
autoplayBlockedGeneration: null,
};
return { ...state, playbackFailedGeneration: action.generation };
case "media-cleared":
return {
...state,
media: null,
retainedFrame: false,
autoplayBlockedGeneration: null,
playbackFailedGeneration: null,
};
case "sharing-stopped":
return {
@@ -402,36 +375,11 @@ export function reduceViewerPresentation(
media: null,
retainedFrame: false,
autoplayBlockedGeneration: null,
failure: "HOST_STOPPED",
};
case "failure":
if (
action.revision !== undefined &&
state.revision !== null &&
action.revision < state.revision
) {
return state;
}
const terminalRoute = action.failure === "ROUTE_EXHAUSTED";
const terminalMedia =
terminalRoute && state.media
? { ...state.media, framePresented: false }
: state.media;
return {
...state,
revision:
action.revision === undefined
? state.revision
: Math.max(state.revision ?? 0, action.revision),
media: terminalMedia,
connection: terminalRoute ? "failed" : state.connection,
retainedFrame:
state.retainedFrame || (terminalRoute && hasCurrentFrame(state)),
autoplayBlockedGeneration: terminalRoute
? null
: state.autoplayBlockedGeneration,
failure: action.failure,
playbackFailedGeneration: null,
runtimeFailure: null,
};
case "server-error":
return { ...state, runtimeFailure: "SERVER_ERROR" };
}
}
@@ -446,7 +394,7 @@ export function deriveViewerPresentation(
return presentation("joining", "viewer.msg.joining", "blocking", state);
}
if (state.access === "denied") {
switch (state.failure) {
switch (state.accessFailure) {
case "ROOM_NOT_FOUND":
return presentation(
"room-not-found",
@@ -519,7 +467,13 @@ export function deriveViewerPresentation(
state.autoplayBlockedGeneration === state.media.generation &&
state.connection === "connected"
) {
return presentation("needs-play", "viewer.msg.needsPlay", "status", state);
return presentation(
"needs-play",
"viewer.msg.needsPlay",
"status",
state,
"AUTOPLAY_BLOCKED",
);
}
if (currentFrame) {
@@ -542,6 +496,7 @@ export function deriveViewerPresentation(
: mediaRecovering
? "viewer.notice.mediaRecovering"
: null,
failureCode: state.host === "offline" ? "HOST_OFFLINE" : null,
};
}
@@ -551,66 +506,50 @@ export function deriveViewerPresentation(
"viewer.msg.routeFailed",
frameOverlay,
state,
"ROUTE_EXHAUSTED",
);
}
switch (state.failure) {
case "ROUTE_EXHAUSTED":
return presentation(
"route-failed",
"viewer.msg.routeFailed",
frameOverlay,
state,
);
case "PLAYBACK_FAILED":
return presentation(
"playback-failed",
"viewer.msg.playbackFailed",
frameOverlay,
state,
);
case "SERVER_ERROR":
return presentation(
"server-error",
"viewer.msg.serverError",
frameOverlay,
state,
);
case "STALE_CLIENT":
return presentation(
"stale-client",
"viewer.msg.stale",
frameOverlay,
state,
);
case "SESSION_REPLACED":
return presentation(
"session-replaced",
"viewer.msg.sessionReplaced",
frameOverlay,
state,
);
case "SIGNAL_TERMINATED":
return presentation(
"signal-terminated",
"viewer.msg.signalTerminated",
frameOverlay,
state,
);
case "HOST_OFFLINE":
return presentation(
"host-offline",
"viewer.msg.hostOffline",
frameOverlay,
state,
);
case "HOST_STOPPED":
return presentation(
"waiting-host",
"viewer.msg.waitingHost",
frameOverlay,
state,
);
if (
state.media &&
state.playbackFailedGeneration === state.media.generation
) {
return presentation(
"playback-failed",
"viewer.msg.playbackFailed",
frameOverlay,
state,
"PLAYBACK_FAILED",
);
}
if (state.runtimeFailure === "SERVER_ERROR") {
return presentation(
"server-error",
"viewer.msg.serverError",
frameOverlay,
state,
"SERVER_ERROR",
);
}
if (state.host === "stopped" || state.host === "unknown") {
return presentation(
"waiting-host",
"viewer.msg.waitingHost",
frameOverlay,
state,
state.host === "stopped" ? "HOST_STOPPED" : null,
);
}
if (state.host === "offline") {
return presentation(
"host-offline",
"viewer.msg.hostOffline",
frameOverlay,
state,
"HOST_OFFLINE",
);
}
if (state.routeStatus?.state === "waiting") {
@@ -649,22 +588,6 @@ export function deriveViewerPresentation(
state,
);
}
if (state.host === "stopped" || state.host === "unknown") {
return presentation(
"waiting-host",
"viewer.msg.waitingHost",
frameOverlay,
state,
);
}
if (state.host === "offline") {
return presentation(
"host-offline",
"viewer.msg.hostOffline",
frameOverlay,
state,
);
}
if (state.route?.kind === "p2p") {
return presentation(
"preparing-p2p",
@@ -720,6 +643,8 @@ function presentation(
messageKey: ViewerMessageKey,
overlay: ViewerPresentation["overlay"],
state: ViewerPresentationState,
failureCode: ViewerFailureCode | null =
state.access === "denied" ? state.accessFailure : null,
): ViewerPresentation {
return {
stage,
@@ -728,7 +653,7 @@ function presentation(
overlay,
hasCurrentFrame: hasCurrentFrame(state),
hasRetainedFrame: state.retainedFrame,
failureCode: state.failure,
failureCode,
connectionState:
state.connection === "idle"
? state.host === "online" || state.host === "paused"
+26 -15
View File
@@ -246,25 +246,21 @@ export class ViewerQualityEvidenceReporter {
return false;
}
this.lastSampleTimestampMs = sampleTimestampMs;
if (!presentationEligible) {
if (this.presentationEligible) {
this.invalidatePresentation();
}
return false;
}
if (!this.presentationEligible) {
this.presentationEligible = true;
this.baselinePending = true;
}
const decodedProgress =
metrics.intervalFramesDecoded !== null &&
metrics.intervalFramesDecoded > 0;
if (!decodedProgress) {
return false;
let routingEligible = presentationEligible && decodedProgress;
if (!routingEligible) {
if (this.presentationEligible) {
this.invalidatePresentation();
}
} else if (!this.presentationEligible) {
this.presentationEligible = true;
this.baselinePending = true;
}
if (this.baselinePending) {
if (routingEligible && this.baselinePending) {
this.baselinePending = false;
return false;
routingEligible = false;
}
if (this.sequence > Number.MAX_SAFE_INTEGER) {
return false;
@@ -286,7 +282,7 @@ export class ViewerQualityEvidenceReporter {
presentationEpoch: this.presentationEpoch,
},
sequence: this.sequence,
...window,
...(routingEligible ? window : diagnosticOnlyWindow(window)),
};
const parsed = viewerQualityEvidenceMessageSchema.safeParse(message);
if (
@@ -326,6 +322,21 @@ export class ViewerQualityEvidenceReporter {
}
}
function diagnosticOnlyWindow(
window: ViewerQualityEvidenceWindow,
): ViewerQualityEvidenceWindow {
return {
...window,
metrics: {
...window.metrics,
freezeCountDelta: null,
freezeDurationMsDelta: null,
pauseCountDelta: null,
pauseDurationMsDelta: null,
},
};
}
function sameViewerQualityEvidenceIdentity(
previous: ViewerQualityEvidence,
next: ViewerQualityEvidence,
+7 -2
View File
@@ -271,6 +271,12 @@ export class ViewerSfuRoute {
}
if (paused) {
this.discardPending();
return;
}
for (const slot of new Set([this.active, this.pending])) {
if (slot?.activated && !slot.failed) {
slot.subscriber.armDecodedFrameProof();
}
}
}
@@ -406,8 +412,7 @@ export class ViewerSfuRoute {
): Promise<void> {
if (
this.closed ||
!this.route.acceptsConfig(message.revision) ||
this.paused
!this.route.acceptsConfig(message.revision)
) {
return;
}
+157 -145
View File
@@ -397,9 +397,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
"access" | "replacement" | "sharing" | null
>(null);
const roomMutating = roomMutation !== null;
const [viewerPasswordEnabled, setViewerPasswordEnabled] = useState(
creationProfile.roomPassword !== null,
);
const [viewerPasswordEnabled, setViewerPasswordEnabled] = useState(false);
useEffect(() => {
creationProfileRef.current = creationProfile;
}, [creationProfile]);
@@ -2265,14 +2263,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
}
async function changeCodeEntryPolicy(policy: CodeEntryPolicy): Promise<void> {
if (policy === activeCodeEntryPolicy) {
setNotice(null);
return;
}
const activeRoom = roomRef.current;
if (!activeRoom) {
return;
}
if (activeRoom.codeEntryPolicy === policy) {
setNotice(null);
return;
}
const mutation = beginRoomMutation("access");
if (!mutation) {
return;
@@ -2448,8 +2446,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
window.location.assign(route);
}
const activeCodeEntryPolicy =
room?.codeEntryPolicy ?? creationProfile.codeEntryPolicy;
const activeCodeEntryPolicy = room?.codeEntryPolicy ?? null;
const hostPeerId = hostPresence?.peerId ?? hostPeerIdRef.current;
const couchEntries: CouchEntry[] = viewers.map((viewer) => {
@@ -2825,32 +2822,111 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
</div>
<div className="lr-deck">
{showConnectionDetails && viewerOverviewEntries.length > 0 ? (
<ViewerOverview
entries={viewerOverviewEntries}
selectedKey={selectedPawn}
onSelect={(peerId) =>
setSelectedPawn((current) =>
current === peerId ? null : peerId,
)
}
/>
{showConnectionDetails && details && stream ? (
<Row sub>
<div id="host-details-panel" style={{ display: "contents" }}>
<span
className="lr-meter-tag"
title={vis ? undefined : t("host.captureAria")}
>
<Glyph name="arrowUp" size={17} />
{vis ? null : (
<span className="lr-cap">{t("stats.capture")}</span>
)}
</span>
<div
className="lr-meter"
role="group"
aria-label={t("host.captureAria")}
>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.resolution")}
>
<Glyph name="expand" size={16} />
<b>
{details.resolution ?? (vis ? "—" : t("stats.unknown"))}
</b>
{vis ? (
<span className="visually-hidden">
{t("stats.resolution")}
</span>
) : null}
</span>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.fps")}
>
<Glyph name="wave" size={16} />
<b>
{details.frameRate
? `${details.frameRate.toFixed(0)} fps`
: vis
? "—"
: t("host.capture.fpsUnknown")}
</b>
{vis ? (
<span className="visually-hidden">{t("stats.fps")}</span>
) : null}
</span>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.codec")}
>
<Glyph name="cpu" size={16} />
<b>
{resolvedVideoCodec?.toUpperCase() ??
(vis ? "—" : t("host.capture.codecPending"))}
</b>
{vis ? (
<span className="visually-hidden">{t("stats.codec")}</span>
) : null}
</span>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.audio")}
>
{vis ? (
<span
style={{ position: "relative", display: "inline-flex" }}
>
<Glyph name="speaker" size={16} />
{details.hasAudio ? null : (
<span
aria-hidden="true"
style={{
position: "absolute",
top: -3,
bottom: -3,
left: "50%",
width: 2.5,
borderRadius: 2,
background: "currentColor",
transform: "translateX(-50%) rotate(45deg)",
}}
/>
)}
<span className="visually-hidden">
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</span>
</span>
) : (
<>
<Glyph name="speaker" size={16} />
<b>
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</b>
</>
)}
</span>
</div>
</div>
</Row>
) : null}
{selectedViewer && selectedDetail ? (
<PawnDetail
pawnKey={selectedViewer.peerId}
name={selectedViewer.label}
route={selectedDetail.route}
metrics={selectedDetail.metrics}
direction={selectedDetail.direction}
tag={selectedDetail.tag}
error={selectedDetail.error}
expanded={metricsExpanded}
onToggleMetrics={setMetricsExpanded}
onClose={() => setSelectedPawn(null)}
/>
) : null}
<Row>
{room ? (
<RowGroup>
@@ -2937,7 +3013,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
</form>
) : (
<>
<NameTag name={hostPresence?.displayName ?? displayName} />
<NameTag
name={hostPresence?.displayName ?? displayName}
identity={
hostPeerId ?? hostClientIdRef.current ?? "host-pending"
}
/>
{hintWrap(
"hint-rename",
<Btn
@@ -2973,7 +3054,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{hintWrap(
"hint-switch-source",
<Btn
icon="refresh"
icon="switchSource"
cap={switchingSource ? "host.switching" : "host.switchSource"}
title="host.switchSource"
disabled={switchingSource || changingQuality}
@@ -3578,6 +3659,46 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
</div>
</div>
{showConnectionDetails && viewerOverviewEntries.length > 0 ? (
<ViewerOverview
entries={viewerOverviewEntries}
selectedKey={selectedPawn}
onSelect={(peerId) =>
setSelectedPawn((current) =>
current === peerId ? null : peerId,
)
}
/>
) : null}
{selectedViewer && selectedDetail ? (
<PawnDetail
pawnKey={selectedViewer.peerId}
name={selectedViewer.label}
route={selectedDetail.route}
metrics={selectedDetail.metrics}
direction={selectedDetail.direction}
tag={selectedDetail.tag}
error={selectedDetail.error}
expanded={metricsExpanded}
onToggleMetrics={setMetricsExpanded}
onClose={() => setSelectedPawn(null)}
/>
) : null}
{showTopology ? (
<Row sub>
<RouteTree
hostPeerId={hostPeerId}
hostLabel={labeledHostPresence?.label ?? displayName}
viewers={viewers}
selectedPeerId={selectedPawn}
onSelectPeer={(peerId) =>
setSelectedPawn((current) =>
current === peerId ? null : peerId,
)
}
/>
</Row>
) : null}
<Row label={t("host.details")}>
<RowGroup actions>
{hintWrap(
@@ -3609,115 +3730,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
)}
</RowGroup>
</Row>
{showConnectionDetails && details && stream ? (
<Row sub>
<div id="host-details-panel" style={{ display: "contents" }}>
<span
className="lr-meter-tag"
title={vis ? undefined : t("host.captureAria")}
>
<Glyph name="arrowUp" size={17} />
{vis ? null : (
<span className="lr-cap">{t("stats.capture")}</span>
)}
</span>
<div
className="lr-meter"
role="group"
aria-label={t("host.captureAria")}
>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.resolution")}
>
<Glyph name="expand" size={16} />
<b>
{details.resolution ?? (vis ? "—" : t("stats.unknown"))}
</b>
</span>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.fps")}
>
<Glyph name="wave" size={16} />
<b>
{details.frameRate
? `${details.frameRate.toFixed(0)} fps`
: vis
? "—"
: t("host.capture.fpsUnknown")}
</b>
</span>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.codec")}
>
<Glyph name="cpu" size={16} />
<b>
{resolvedVideoCodec?.toUpperCase() ??
(vis ? "—" : t("host.capture.codecPending"))}
</b>
</span>
<span
className="lr-meter-cell"
title={vis ? undefined : t("stats.audio")}
>
{vis ? (
<span
style={{ position: "relative", display: "inline-flex" }}
>
<Glyph name="speaker" size={16} />
{details.hasAudio ? null : (
<span
aria-hidden="true"
style={{
position: "absolute",
top: -3,
bottom: -3,
left: "50%",
width: 2.5,
borderRadius: 2,
background: "currentColor",
transform: "translateX(-50%) rotate(45deg)",
}}
/>
)}
<span className="visually-hidden">
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</span>
</span>
) : (
<>
<Glyph name="speaker" size={16} />
<b>
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</b>
</>
)}
</span>
</div>
</div>
</Row>
) : null}
{showTopology ? (
<Row sub>
<RouteTree
hostPeerId={hostPeerId}
hostLabel={labeledHostPresence?.label ?? displayName}
viewers={viewers}
selectedPeerId={selectedPawn}
onSelectPeer={(peerId) =>
setSelectedPawn((current) =>
current === peerId ? null : peerId,
)
}
/>
</Row>
) : null}
</div>
</main>
</div>
+290 -173
View File
@@ -85,10 +85,12 @@ import {
deriveViewerPresentation,
reduceViewerPresentation,
viewerFailureFromServerCode,
type ViewerNoticeKey,
type ViewerPresentationAction,
type ViewerRouteKind,
type ViewerStage,
} from "../media/viewer-presentation";
import { prepareViewerPlayback } from "../media/viewer-playback";
import {
isAutoplayPolicyRejection,
observeCompositedVideoFrame,
@@ -154,6 +156,9 @@ function stageOverlayGlyph(stage: ViewerStage): { icon: GlyphName; spin: boolean
case "route-failed":
case "playback-failed":
case "server-error":
case "stale-client":
case "session-replaced":
case "signal-terminated":
return { icon: "alert", spin: false };
case "recovering":
case "waiting-sfu":
@@ -191,6 +196,9 @@ function stageOverlayComic(
case "route-failed":
return "route-failed";
case "server-error":
case "stale-client":
case "session-replaced":
case "signal-terminated":
return "warning";
case "playback-failed":
return "playback-failed";
@@ -204,6 +212,19 @@ function stageOverlayComic(
}
}
function viewerNoticeVisual(
noticeKey: ViewerNoticeKey,
): { icon: GlyphName; comic: ComicKind } {
switch (noticeKey) {
case "viewer.notice.hostOffline":
return { icon: "wifiOff", comic: "host-offline" };
case "viewer.notice.signalRecovering":
return { icon: "wifiOff", comic: "recovering" };
case "viewer.notice.mediaRecovering":
return { icon: "refresh", comic: "recovering" };
}
}
function stageChin(stage: ViewerStage): ChinState {
switch (stage) {
case "playing":
@@ -277,6 +298,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
const [participantPresence, setParticipantPresence] = useState<
ParticipantPresenceEntry[] | null
>(null);
const [lastHostDisplayName, setLastHostDisplayName] = useState<string | null>(
null,
);
const [viewerPasswordDraft, setViewerPasswordDraft] = useState("");
const [viewerPasswordError, setViewerPasswordError] = useState<CopyKey | null>(
null,
@@ -286,7 +310,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
sequence: number;
} | null>(null);
const [viewerPasswordExpanded, setViewerPasswordExpanded] = useState(false);
const [frameProofEpoch, setFrameProofEpoch] = useState(0);
// Presentation-only: which couch pawn is drilled into, and this viewer's
// own peer id (mirrors the authenticated message for couch/route-tree).
const [selectedPawn, setSelectedPawn] = useState<string | null>(null);
@@ -297,6 +320,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
useMetricsExpanded();
const [pawnMetricsExpanded, setPawnMetricsExpanded] = useMetricsExpanded();
const mediaProofGeneration = presentationState.media?.generation ?? null;
const mediaProofEpoch = presentationState.media?.proofEpoch ?? null;
useEffect(() => {
if (!theaterMode) return;
const exitOnEscape = (event: KeyboardEvent) => {
@@ -323,7 +348,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
() => labelParticipantSnapshot(participantPresence ?? []),
[participantPresence],
);
const hostDisplayName = labeledHostPresence?.label ?? null;
const currentHostDisplayName = labeledHostPresence?.label ?? null;
const hostDisplayName = currentHostDisplayName ?? lastHostDisplayName;
const titleFrameKey =
presentationState.host === "paused"
? "paused"
@@ -341,12 +367,16 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
function clearParticipantPresence(): void {
setParticipantPresence(null);
setLastHostDisplayName(null);
}
function clearHostPresence(): void {
function clearHostPresence(forgetDisplayName = true): void {
setParticipantPresence((current) =>
current?.filter((participant) => participant.role !== "host") ?? null,
);
if (forgetDisplayName) {
setLastHostDisplayName(null);
}
}
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -444,6 +474,17 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
});
}
function rearmCurrentFrameProof(): void {
const binding = remoteMediaRef.current;
if (!binding) {
return;
}
dispatchPresentation({
type: "frame-proof-rearm",
generation: binding.generation,
});
}
function invalidateQualityPresentation(): void {
qualityEvidenceReporterRef.current?.invalidatePresentation();
qualityFrameProofGenerationRef.current = null;
@@ -580,7 +621,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
syncDecodedFrameStallPause();
if (pageSuspended) return;
decodedFrameStall.rebaseline();
setFrameProofEpoch((current) => current + 1);
};
const handleVisibilityChange = (): void => {
if (document.visibilityState === "visible") {
@@ -685,7 +725,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
binding &&
qualityFrameProofGenerationRef.current === binding.generation &&
state.routeStatus?.state !== "failed" &&
state.failure !== "ROUTE_EXHAUSTED" &&
video &&
!video.paused &&
!video.ended,
@@ -701,7 +740,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
qualityPresentationEligible(connectionHealthy);
if (!connectionHealthy) {
invalidatePresentedMedia();
} else if (!qualityEligible) {
return;
}
if (!qualityEligible) {
invalidateQualityPresentation();
}
qualityEvidenceReporter.offer(
@@ -1114,7 +1155,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
if (
currentRouteAssignment?.upstream.kind === "sfu" &&
currentRouteConnectionId &&
revision === currentRouteRevision
revision === currentRouteRevision &&
sfuTransportConnected
) {
qualityEvidenceReporter.offerMetrics(
currentRouteConnectionId,
@@ -1460,10 +1502,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
snapshot.connectionState === "closed")
) {
clearPeerState();
dispatchPresentation({
type: "media-invalidated",
revision: currentRouteRevision,
});
invalidatePresentedMedia();
return;
}
setPeerSnapshot(snapshot);
@@ -1489,9 +1528,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
invalidateQualityPresentation();
dispatchPresentation({
type: "failure",
failure: "ROUTE_EXHAUSTED",
type: "route-status",
revision: currentRouteRevision,
state: "failed",
});
return true;
},
@@ -1828,6 +1867,10 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
return;
}
if (message.type === "viewer-presence") {
const hostLabel = labelParticipantSnapshot(message.viewers).host?.label;
if (hostLabel) {
setLastHostDisplayName(hostLabel);
}
setParticipantPresence(message.viewers);
return;
}
@@ -1890,7 +1933,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
if (message.type === "error") {
if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) {
clearPeerState();
clearHostPresence();
clearHostPresence(false);
dispatchPresentation({ type: "host", host: "offline" });
return;
}
@@ -1931,10 +1974,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
return;
}
const failure = viewerFailureFromServerCode(message.code);
if (failure) {
dispatchPresentation({ type: "failure", failure });
} else if (message.code !== "PEER_NOT_FOUND") {
dispatchPresentation({ type: "failure", failure: "SERVER_ERROR" });
if (failure === "SERVER_ERROR") {
dispatchPresentation({ type: "server-error" });
}
}
}
@@ -1981,15 +2022,13 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
return;
}
const streamChanged = video.srcObject !== remoteMedia.stream;
if (streamChanged) {
video.srcObject = remoteMedia.stream;
}
if (presentationState.host === "paused") {
video.pause();
return;
}
if (streamChanged) {
if (
prepareViewerPlayback(
video,
remoteMedia.stream,
presentationState.host === "paused",
)
) {
attemptPlayback(video, remoteMedia);
}
}, [remoteMedia]);
@@ -2019,22 +2058,42 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
useEffect(() => {
const video = videoRef.current;
if (!video || !remoteMedia) {
const mediaFact = presentationState.media;
if (
!video ||
!remoteMedia ||
!mediaFact ||
mediaFact.generation !== remoteMedia.generation ||
presentationState.host === "paused" ||
document.visibilityState !== "visible"
) {
return;
}
const binding = remoteMedia;
const proofEpoch = mediaFact.proofEpoch;
return observeCompositedVideoFrame(video, binding.stream, () => {
if (!mediaBindingIsCurrent(binding)) {
const currentMedia = presentationStateRef.current.media;
if (
!mediaBindingIsCurrent(binding) ||
currentMedia?.generation !== binding.generation ||
currentMedia.proofEpoch !== proofEpoch
) {
return;
}
qualityFrameProofGenerationRef.current = binding.generation;
dispatchPresentation({
type: "frame-presented",
generation: binding.generation,
proofEpoch,
revision: binding.boundAtRevision,
});
});
}, [frameProofEpoch, presentationState.connection, remoteMedia]);
}, [
mediaProofEpoch,
mediaProofGeneration,
presentationState.host,
remoteMedia,
]);
function retryConnection(): void {
if (reconnectRoute === "sfu") {
@@ -2085,43 +2144,44 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
if (accessState !== "ready") {
const failureCode = presentation.failureCode;
const codeOnlyDenied =
!viewerGrant && presentationState.failure === "ROOM_ACCESS_DENIED";
!viewerGrant && failureCode === "ROOM_ACCESS_DENIED";
const canRefresh = [
"STALE_CLIENT",
"SERVER_ERROR",
"SESSION_REPLACED",
"SIGNAL_TERMINATED",
].includes(presentationState.failure ?? "");
].includes(failureCode ?? "");
const deniedComic: ComicKind =
presentationState.failure === "ROOM_FULL"
failureCode === "ROOM_FULL"
? "room-full"
: codeOnlyDenied
? "access-denied"
: presentationState.failure === "INVALID_TOKEN"
: failureCode === "INVALID_TOKEN"
? "invalid-invite"
: presentationState.failure === "ROOM_NOT_FOUND" ||
presentationState.failure === "ROOM_EXPIRED" ||
presentationState.failure === "ROOM_CLOSED"
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_EXPIRED" ||
failureCode === "ROOM_CLOSED"
? "room-not-found"
: "warning";
const deniedIcon: GlyphName =
presentationState.failure === "ROOM_NOT_FOUND" ||
presentationState.failure === "ROOM_EXPIRED" ||
presentationState.failure === "ROOM_CLOSED"
failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_EXPIRED" ||
failureCode === "ROOM_CLOSED"
? "door"
: presentationState.failure === "ROOM_ACCESS_DENIED"
: failureCode === "ROOM_ACCESS_DENIED"
? "lock"
: presentationState.failure === "ROOM_FULL"
: failureCode === "ROOM_FULL"
? "users"
: "alert";
const deniedHintKey: CopyKey = codeOnlyDenied
? "viewer.hint.denied"
: presentationState.failure === "ROOM_NOT_FOUND" ||
presentationState.failure === "ROOM_EXPIRED" ||
presentationState.failure === "ROOM_CLOSED"
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_EXPIRED" ||
failureCode === "ROOM_CLOSED"
? "viewer.hint.notFound"
: presentationState.failure === "INVALID_TOKEN"
: failureCode === "INVALID_TOKEN"
? "viewer.hint.invite"
: "viewer.hint.generic";
return (
@@ -2153,7 +2213,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<>
<Comic kind={deniedComic} theme="paper" />
<span className="visually-hidden" role="alert">
{t(presentation.messageKey)}
{t(presentation.messageKey)} · {t(deniedHintKey)}
</span>
</>
) : (
@@ -2171,9 +2231,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<Glyph name={deniedIcon} size={30} />
</span>
)}
{vis ? (
<Pill icon="alert" tone="bad" label={t(deniedHintKey)} />
) : (
{vis ? null : (
<div className="lr-access-text">
<h1>{t(presentation.messageKey)}</h1>
<p>{t(deniedHintKey)}</p>
@@ -2254,6 +2312,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
presentation.stage,
assignedRouteKind,
);
const noticeVisual = presentation.noticeKey
? viewerNoticeVisual(presentation.noticeKey)
: null;
const selectedChildEvidence =
selectedPawn !== null &&
selectedPawn !== selfPeerId &&
@@ -2341,10 +2402,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
(presentation.stage === "receiving" &&
presentation.hasRetainedFrame)
}
controlsList={theaterMode ? "nofullscreen" : undefined}
playsInline
onPlay={() => {
invalidateQualityPresentation();
setFrameProofEpoch((current) => current + 1);
rearmCurrentFrameProof();
const binding = remoteMediaRef.current;
if (binding) {
dispatchPresentation({
@@ -2390,6 +2452,45 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
/>
)}
</StageTv>
{(presentation.noticeKey ||
(routePresentation.evidence === peerSnapshot &&
peerSnapshot?.error) ||
relaySnapshot?.error ||
qualityLimitation) && (
<div className="lr-stage-notices">
{presentation.noticeKey && noticeVisual ? (
<Pill
icon={noticeVisual.icon}
label={t(presentation.noticeKey)}
comic={noticeVisual.comic}
/>
) : null}
{routePresentation.evidence === peerSnapshot &&
peerSnapshot?.error ? (
<Pill
icon="alert"
tone="bad"
label={t("viewer.error.p2p")}
comic="warning"
/>
) : null}
{relaySnapshot?.error ? (
<Pill
icon="alert"
tone="bad"
label={t("viewer.error.relay")}
comic="warning"
/>
) : null}
{qualityLimitation ? (
<Pill
icon="alert"
label={qualityLimitation}
comic="warning"
/>
) : null}
</div>
)}
{theaterMode ? (
<div className="lr-theater-exit">
<Btn
@@ -2444,138 +2545,154 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<div className="lr-row-group lr-viewer-state-slot">
<StatusText>{t(presentation.messageKey)}</StatusText>
</div>
<form
className="lr-row-group lr-group-name lr-viewer-self-slot"
onSubmit={(event) => {
event.preventDefault();
commitDisplayName();
}}
>
{editingDisplayName ? (
<>
<span className="lr-input lr-name-editor">
<input
id="viewer-display-name"
type="text"
value={displayNameDraft}
maxLength={96}
autoComplete="nickname"
autoFocus
aria-label={t("host.name")}
aria-invalid={displayNameError ? "true" : undefined}
onChange={(event) => {
setDisplayNameDraft(event.target.value);
setDisplayNameError(false);
}}
/>
</span>
<Btn
icon="check"
title="host.nameSave"
type="submit"
disabled={displayNameDraft === displayName}
/>
<Btn
icon="x"
title="host.nameCancel"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(false);
setEditingDisplayName(false);
}}
/>
</>
) : (
<>
<NameTag name={displayName} />
{hintWrap(
"hint-rename",
<div className="lr-viewer-personal-controls">
<form
className="lr-row-group lr-group-name lr-viewer-self-slot"
onSubmit={(event) => {
event.preventDefault();
commitDisplayName();
}}
>
{editingDisplayName ? (
<>
<span className="lr-input lr-name-editor">
<input
id="viewer-display-name"
type="text"
value={displayNameDraft}
maxLength={96}
autoComplete="nickname"
autoFocus
aria-label={t("host.name")}
aria-invalid={displayNameError ? "true" : undefined}
onChange={(event) => {
setDisplayNameDraft(event.target.value);
setDisplayNameError(false);
}}
/>
</span>
<Btn
icon="pencil"
cap="common.edit"
title="host.nameEdit"
icon="check"
title="host.nameSave"
type="submit"
disabled={displayNameDraft === displayName}
/>
<Btn
icon="x"
title="host.nameCancel"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(false);
setEditingDisplayName(true);
setEditingDisplayName(false);
}}
/>
</>
) : (
<>
<NameTag
name={displayName}
identity={selfPeerId ?? viewerClientId}
/>
{hintWrap(
"hint-rename",
<Btn
icon="pencil"
cap="common.edit"
title="host.nameEdit"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(false);
setEditingDisplayName(true);
}}
/>,
"end",
)}
</>
)}
{displayNameError && (
<Pill
icon="alert"
tone="bad"
label={t("host.nameError")}
alert
comic="warning"
/>
)}
</form>
<div className="lr-row-group lr-group-actions lr-viewer-actions-slot">
<span className="lr-viewer-action-cluster">
<Btn
icon={theaterMode ? "contract" : "expand"}
cap={theaterMode ? "viewer.theater.exit" : "viewer.theater"}
title={
theaterMode ? "viewer.theater.exit" : "viewer.theater"
}
tone={theaterMode ? "on" : undefined}
pressed={theaterMode}
controls="viewer-stage"
onClick={() => setTheaterMode((current) => !current)}
/>
{hintWrap(
"hint-reconnect",
<Btn
icon="refresh"
cap="viewer.reconnect"
title="viewer.reconnect"
disabled={!reconnectAvailable}
onClick={retryConnection}
/>,
"end",
)}
</>
)}
{displayNameError && (
<Pill icon="alert" tone="bad" label={t("host.nameError")} alert />
)}
</form>
<div className="lr-row-group lr-group-actions lr-viewer-actions-slot">
<Btn
icon={theaterMode ? "contract" : "expand"}
cap={theaterMode ? "viewer.theater.exit" : "viewer.theater"}
title={theaterMode ? "viewer.theater.exit" : "viewer.theater"}
tone={theaterMode ? "on" : undefined}
pressed={theaterMode}
controls="viewer-stage"
onClick={() => setTheaterMode((current) => !current)}
/>
{labeledHostPresence
? hintWrap(
"hint-topology",
<Btn
icon="network"
cap="host.topology"
title={
showTopology ? "host.topology.hide" : "host.topology.show"
}
tone={showTopology ? "on" : undefined}
expanded={showTopology}
controls="room-topology"
onClick={() => setShowTopology((current) => !current)}
/>,
"start",
)
: null}
{hintWrap(
"hint-reconnect",
<Btn
icon="refresh"
cap="viewer.reconnect"
title="viewer.reconnect"
disabled={!reconnectAvailable}
onClick={retryConnection}
/>,
"end",
)}
<Btn
icon="gauge"
cap="host.details"
title={showConnectionDetails ? "host.details.hide" : "host.details"}
hint="hint-details"
tone={showConnectionDetails ? "on" : undefined}
expanded={showConnectionDetails}
controls="viewer-details-panel"
disabled={
!routePresentation.route &&
!relaySnapshot &&
!relayChildEvidence
}
onClick={() => setShowConnectionDetails((current) => !current)}
/>
</span>
<span
className="lr-viewer-action-separator"
aria-hidden="true"
/>
<span className="lr-viewer-action-cluster">
<Btn
icon="gauge"
cap="host.details"
title={
showConnectionDetails ? "host.details.hide" : "host.details"
}
hint="hint-details"
tone={showConnectionDetails ? "on" : undefined}
expanded={showConnectionDetails}
controls="viewer-details-panel"
disabled={
!routePresentation.route &&
!relaySnapshot &&
!relayChildEvidence
}
onClick={() =>
setShowConnectionDetails((current) => !current)
}
/>
{labeledHostPresence
? hintWrap(
"hint-topology",
<Btn
icon="network"
cap="host.topology"
title={
showTopology
? "host.topology.hide"
: "host.topology.show"
}
tone={showTopology ? "on" : undefined}
expanded={showTopology}
controls="room-topology"
onClick={() =>
setShowTopology((current) => !current)
}
/>,
"start",
)
: null}
</span>
</div>
</div>
</div>
{presentation.noticeKey ? (
<Pill icon="alert" label={t(presentation.noticeKey)} />
) : null}
{routePresentation.evidence === peerSnapshot &&
peerSnapshot?.error ? (
<Pill icon="alert" tone="bad" label={t("viewer.error.p2p")} comic="warning" />
) : null}
{relaySnapshot?.error ? (
<Pill icon="alert" tone="bad" label={t("viewer.error.relay")} comic="warning" />
) : null}
{qualityLimitation ? (
<Pill icon="alert" label={qualityLimitation} comic="warning" />
) : null}
{showConnectionDetails && routePresentation.route ? (
<Row sub>
<div id="viewer-details-panel" style={{ display: "contents" }}>
+5
View File
@@ -121,6 +121,7 @@ export class SfuPublisher {
private statsInFlight: PublisherStatsIdentity | null = null;
private statsTimer: ReturnType<typeof setInterval> | null = null;
private startupVideoProfilePending = false;
private paused = false;
constructor(private readonly events: PublisherEvents = {}) {}
@@ -772,6 +773,7 @@ export class SfuPublisher {
}
setPaused(paused: boolean): void {
this.paused = paused;
if (this.video) {
this.video.rawTrack.enabled = !paused;
}
@@ -843,10 +845,12 @@ export class SfuPublisher {
let retainedNextTrack = false;
try {
await applyVideoCaptureProfile(nextRawTrack, profile);
nextRawTrack.enabled = !this.paused;
await replacePublishedTrack(video, nextRawTrack);
if (!this.owns(room, generation)) {
return false;
}
nextRawTrack.enabled = !this.paused;
video.rawTrack = nextRawTrack;
retainedNextTrack = true;
previousRawTrack.stop();
@@ -1162,6 +1166,7 @@ export class SfuPublisher {
: {
qualityLimitationReason:
nativeQuality.qualityLimitationReason,
sampleTimestampMs: nativeQuality.sampleTimestampMs,
sampleWindowMs: nativeQuality.sampleWindowMs,
intervalFramesEncoded: nativeQuality.intervalFramesEncoded,
}),
+103 -42
View File
@@ -211,27 +211,27 @@ ul { margin: 0; padding: 0; list-style: none; }
to { opacity: 1; transform: scale(1); }
}
@keyframes lr-brand-eye-open {
0%, 24%, 34%, 100% { opacity: 0; transform: scaleY(.15); }
27%, 30% { opacity: 1; transform: scale(1); }
0%, 34% { opacity: 1; transform: scale(1); }
38%, 100% { opacity: 0; transform: scaleY(.15); }
}
@keyframes lr-brand-eye-wink {
0%, 24%, 35%, 100% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 0; }
27%, 30% { opacity: 0; stroke-dasharray: 1; stroke-dashoffset: 1; }
32% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 1; }
0%, 34% { opacity: 0; stroke-dasharray: 1; stroke-dashoffset: 1; }
38% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 1; }
44%, 100% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 0; }
}
@keyframes lr-brand-sparkle {
0%, 30% { opacity: 0; transform: translate(-1px, 2px) scale(.12) rotate(-20deg); }
36% { opacity: 1; transform: translate(0, 0) scale(.85) rotate(0); }
52% { opacity: .9; transform: translate(5px, -5px) scale(1) rotate(18deg); }
70% { opacity: .35; transform: translate(9px, -9px) scale(.55) rotate(34deg); }
78%, 100% { opacity: 0; transform: translate(12px, -12px) scale(.2) rotate(48deg); }
0%, 42% { opacity: 0; transform: translate(-1px, 2px) scale(.12) rotate(-20deg); }
48% { opacity: 1; transform: translate(0, 0) scale(.85) rotate(0); }
60% { opacity: .9; transform: translate(5px, -5px) scale(1) rotate(18deg); }
74% { opacity: .35; transform: translate(9px, -9px) scale(.55) rotate(34deg); }
82%, 100% { opacity: 0; transform: translate(12px, -12px) scale(.2) rotate(48deg); }
}
@keyframes lr-brand-sparkle-alt {
0%, 30% { opacity: 0; transform: translate(-1px, -1px) scale(.12) rotate(18deg); }
36% { opacity: .95; transform: translate(0, 0) scale(.75) rotate(0); }
52% { opacity: .8; transform: translate(4px, 4px) scale(.95) rotate(-16deg); }
70% { opacity: .3; transform: translate(8px, 8px) scale(.5) rotate(-30deg); }
78%, 100% { opacity: 0; transform: translate(11px, 11px) scale(.18) rotate(-42deg); }
0%, 42% { opacity: 0; transform: translate(-1px, -1px) scale(.12) rotate(18deg); }
48% { opacity: .95; transform: translate(0, 0) scale(.75) rotate(0); }
60% { opacity: .8; transform: translate(4px, 4px) scale(.95) rotate(-16deg); }
74% { opacity: .3; transform: translate(8px, 8px) scale(.5) rotate(-30deg); }
82%, 100% { opacity: 0; transform: translate(11px, 11px) scale(.18) rotate(-42deg); }
}
.lr-brand-sparkles { stroke: #d08a22; }
html[data-theme="dark"] .lr-brand-sparkles { stroke: #f4c14d; }
@@ -582,6 +582,7 @@ body.lr-theater-open { overflow: hidden; }
.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: 112px;
margin-top: -4px;
padding: 1px 6px;
border-radius: 7px;
@@ -589,9 +590,12 @@ body.lr-theater-open { overflow: hidden; }
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-pawns.is-crowded .lr-pawn-name { max-width: 80px; }
.lr-couch-count {
position: absolute;
right: 4%;
@@ -651,7 +655,8 @@ body.lr-theater-open { overflow: hidden; }
}
.lr-viewer-room-slot { flex: 0 0 auto; }
.lr-viewer-host-slot {
flex: 0 1 auto;
flex: 0 1 180px;
min-width: min(96px, 30vw);
max-width: 180px;
flex-wrap: nowrap;
font-size: 13px;
@@ -672,12 +677,33 @@ html[data-theme="dark"] .lr-viewer-host-slot svg { color: #9bb0c5; }
flex: 0 1 auto;
align-items: center;
gap: 8px;
margin-left: 0;
}
.lr-viewer-personal-controls {
display: flex;
min-width: 0;
margin-left: auto;
flex: 0 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.lr-viewer-actions-slot {
flex: 0 0 auto;
margin-left: 0;
}
.lr-viewer-action-cluster {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.lr-viewer-action-separator {
width: 2.5px;
align-self: stretch;
border-radius: 2px;
background: var(--wall-2);
}
.lr-viewer-self-slot .lr-name-tag { flex: 0 1 auto; width: auto; }
.lr-spacer { flex: 1; }
.lr-divider { width: 2.5px; align-self: stretch; border-radius: 2px; background: var(--wall-2); }
@@ -923,7 +949,9 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
height: 40px;
padding: 0 13px;
box-sizing: border-box;
border: 2.5px solid var(--ink);
border-radius: 999px;
background: var(--paper);
@@ -931,6 +959,14 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
font-weight: 700;
max-width: 100%;
}
.lr-stage-notices {
display: flex;
width: min(960px, 100%);
margin: 10px auto 0;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.lr-name-tag i { width: 9px; height: 9px; border-radius: 50%; background: var(--you); flex: 0 0 auto; }
.lr-name-tag span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1331,10 +1367,26 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-row-group { width: 100%; gap: 9px; }
.lr-viewer-summary-row { flex-direction: row; align-items: center; }
.lr-viewer-summary-row .lr-row-group { width: auto; }
.lr-viewer-host-slot { flex: 0 1 auto; max-width: 180px; }
.lr-viewer-host-slot {
flex: 0 1 180px;
min-width: min(96px, 30vw);
max-width: 180px;
}
.lr-viewer-state-slot { flex: 1 1 120px; }
.lr-viewer-self-slot { flex: 1 1 auto; gap: 6px; margin-left: 0; }
.lr-viewer-personal-controls {
width: 100%;
margin-left: 0;
}
.lr-viewer-actions-slot { flex: 1 1 100%; margin-left: 0; }
.lr-viewer-action-cluster { flex: 1 1 220px; }
.lr-viewer-action-cluster > .lr-btn,
.lr-viewer-action-cluster > .lr-comic-tip-wrap {
flex: 1 1 0;
min-width: 0;
}
.lr-viewer-action-cluster > .lr-comic-tip-wrap .lr-btn { width: 100%; }
.lr-viewer-action-separator { display: none; }
.lr-input.lr-name-editor { width: min(136px, 40vw); }
.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; }
@@ -1356,6 +1408,16 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-leds-label { display: none; }
.lr-header-controls .lr-btn .lr-cap { display: none; }
}
.lr-room.is-theater .lr-stage-notices {
position: absolute;
z-index: 3;
left: 50%;
bottom: max(18px, env(safe-area-inset-bottom));
width: min(92vw, 720px);
margin: 0;
transform: translateX(-50%);
}
@media (max-width: 360px) {
.lr-top { gap: 6px; padding-inline: 10px; }
.lr-top-right { gap: 6px; }
@@ -1389,16 +1451,15 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
/* ---------- comic tooltip ---------- */
/* Pure-CSS hover/focus panel carrying a paper Comic above its trigger. The
trigger inside the wrapper stays the focusable element (touch = focus), so
no tabindex here. Enter is delayed ~120ms, exit is immediate. Under
/* Stateful hover/focus panel carrying a paper Comic above its trigger. The
trigger stays focusable; when it is natively disabled, the wrapper owns the
explanation focus instead. Enter is delayed ~120ms, exit is immediate. Under
prefers-reduced-motion the global block collapses the transition to instant
and the comic's own RM block pins its final frame. */
/* Visibility is three-channel: hover on fine pointers only (touch would
leave sticky-hover stuck), keyboard focus via :has(:focus-visible) (touch
taps never match, so no stuck tooltip after activation), and .is-tip-open
from the component's Material-style long-press handling. */
/* Visibility is three-channel state owned by ComicTooltip: fine-pointer hover,
keyboard focus-visible, and Material-style touch long-press. */
.lr-comic-tip-wrap { position: relative; display: inline-flex; }
.lr-comic-tip-wrap.is-disabled-trigger > :disabled { pointer-events: none; }
.lr-comic-tip {
position: absolute;
bottom: calc(100% + 8px);
@@ -1444,12 +1505,12 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
/* channel 1: fine-pointer hover */
@media (hover: hover) and (pointer: fine) {
.lr-comic-tip-wrap:hover .lr-comic-tip { opacity: 1; transform: translateX(-50%) translateY(0); transition-delay: .12s; }
.lr-comic-tip-wrap:hover .lr-comic-tip * { animation-play-state: running !important; }
.lr-comic-tip-wrap:hover .lr-comic-tip.is-below { transform: translateX(-50%) translateY(0); }
.lr-comic-tip-wrap:hover .lr-comic-tip.is-start,
.lr-comic-tip-wrap:hover .lr-comic-tip.is-end { transform: translateX(0) translateY(0); }
.lr-comic-tip-wrap:hover::before { opacity: 1; transform: rotate(45deg) scale(1); transition-delay: .12s; }
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip { opacity: 1; transform: translateX(-50%) translateY(0); transition-delay: .12s; }
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip * { animation-play-state: running !important; }
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip.is-below { transform: translateX(-50%) translateY(0); }
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip.is-start,
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip.is-end { transform: translateX(0) translateY(0); }
.lr-comic-tip-wrap.is-hover-open::before { opacity: 1; transform: rotate(45deg) scale(1); transition-delay: .12s; }
/* Panel mutex: keyboard focus (channel 2) can hold one wrap's panel open
while the pointer hovers a neighboring trigger, and two 320px panels at
@@ -1458,38 +1519,38 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
hover-open on the others; the suppressed wrap re-arms the moment focus
moves, so keyboard and pointer handoff stays a clean one-panel relay.
Scoped to the containers that hold adjacent wrapped triggers; the guard
keys on focus-visible inside a wrap, so plain mouse clicks (no panel
keys on the component-owned focus state, so plain mouse clicks (no panel
held open) never suppress anything. */
:is(.lr-row-group, .lr-pawn-detail, .lr-entry-actions):has(.lr-comic-tip-wrap:has(:focus-visible))
.lr-comic-tip-wrap:not(:has(:focus-visible)):hover .lr-comic-tip {
:is(.lr-row-group, .lr-pawn-detail, .lr-entry-actions):has(.lr-comic-tip-wrap.is-focus-open)
.lr-comic-tip-wrap:not(.is-focus-open).is-hover-open .lr-comic-tip {
opacity: 0;
transition-delay: 0s;
}
:is(.lr-row-group, .lr-pawn-detail, .lr-entry-actions):has(.lr-comic-tip-wrap:has(:focus-visible))
.lr-comic-tip-wrap:not(:has(:focus-visible)):hover::before {
:is(.lr-row-group, .lr-pawn-detail, .lr-entry-actions):has(.lr-comic-tip-wrap.is-focus-open)
.lr-comic-tip-wrap:not(.is-focus-open).is-hover-open::before {
opacity: 0;
transition-delay: 0s;
}
}
/* channels 2+3: keyboard focus-visible and touch long-press */
.lr-comic-tip-wrap:has(:focus-visible) .lr-comic-tip,
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip {
opacity: 1;
transform: translateX(-50%) translateY(0);
transition-delay: .12s;
}
.lr-comic-tip-wrap:has(:focus-visible) .lr-comic-tip *,
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip *,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip * {
animation-play-state: running !important;
}
.lr-comic-tip-wrap:has(:focus-visible) .lr-comic-tip.is-below,
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip.is-below,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip.is-below { transform: translateX(-50%) translateY(0); }
.lr-comic-tip-wrap:has(:focus-visible) .lr-comic-tip.is-start,
.lr-comic-tip-wrap:has(:focus-visible) .lr-comic-tip.is-end,
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip.is-start,
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip.is-end,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip.is-start,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip.is-end { transform: translateX(0) translateY(0); }
.lr-comic-tip-wrap:has(:focus-visible)::before,
.lr-comic-tip-wrap.is-focus-open::before,
.lr-comic-tip-wrap.is-tip-open::before {
opacity: 1;
transform: rotate(45deg) scale(1);
+5 -1
View File
@@ -89,11 +89,15 @@ if (typeof window !== "undefined") {
// Non-React setter (used by the mode pill and by tests).
export const setCopy = commit;
export function isCopyKey(value: string): value is CopyKey {
return Object.prototype.hasOwnProperty.call(catalogs.zh, value);
}
export function t(lang: Lang, key: CopyKey, vars?: Record<string, string>): string {
let value = catalogs[lang][key];
if (vars) {
for (const [name, replacement] of Object.entries(vars)) {
value = value.replace(`{${name}}`, replacement);
value = value.replace(`{${name}}`, () => replacement);
}
}
return value;
+1
View File
@@ -18,6 +18,7 @@ const PATHS: Record<string, { body: ReactNode; solid?: boolean }> = {
pause: { solid: true, body: (<><rect pathLength={1} x="6" y="4" width="4.2" height="16" rx="1.2"/><rect pathLength={1} x="13.8" y="4" width="4.2" height="16" rx="1.2"/></>) },
stop: { solid: true, body: (<><rect pathLength={1} x="6" y="6" width="12" height="12" rx="2"/></>) },
share: { body: (<><rect pathLength={1} x="2" y="4" width="20" height="13" rx="2"/><path pathLength={1} d="M12 17v-7m0 0-3 3m3-3 3 3"/><path pathLength={1} d="M8 21h8"/></>) },
switchSource: { body: (<><rect pathLength={1} x="2" y="4" width="20" height="14" rx="2"/><path pathLength={1} d="M7 9h9m0 0-2.5-2.5M16 9l-2.5 2.5M17 13H8m0 0 2.5-2.5M8 13l2.5 2.5"/><path pathLength={1} d="M8 22h8M12 18v4"/></>) },
hash: { body: (<><path pathLength={1} d="M9 3 7 21M17 3l-2 18M4 8h17M3 16h17"/></>) },
users: { body: (<><circle pathLength={1} cx="9" cy="8" r="3.5"/><path pathLength={1} d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><circle pathLength={1} cx="17" cy="9" r="2.5"/><path pathLength={1} d="M16.2 15.2c2.5.4 4.6 1.8 5.3 4.8"/></>) },
network: { body: (<><rect pathLength={1} x="9" y="2" width="6" height="6" rx="1.5"/><rect pathLength={1} x="2" y="16" width="6" height="6" rx="1.5"/><rect pathLength={1} x="16" y="16" width="6" height="6" rx="1.5"/><path pathLength={1} d="M12 8v3.5M12 11.5 5.5 16M12 11.5l6.5 4.5"/></>) },
+12 -1
View File
@@ -80,6 +80,7 @@ export function collectNativeSenderQualityFromReport(
accumulator: NativeSenderQualityAccumulator,
): Pick<
ConnectionMetrics,
| "sampleTimestampMs"
| "nativeEdgeQualityState"
| "qualityLimitationReason"
| "sampleWindowMs"
@@ -145,7 +146,14 @@ export function collectNativeSenderQualityFromReport(
} else {
accumulator.previousByStatsId.delete(record.id);
}
return { state, reason, windowMs, intervalFramesEncoded, bitrateKbps };
return {
timestamp,
state,
reason,
windowMs,
intervalFramesEncoded,
bitrateKbps,
};
});
const bitrateKbps =
samples.length > 0 && samples.every((sample) => sample.bitrateKbps !== null)
@@ -156,11 +164,13 @@ export function collectNativeSenderQualityFromReport(
samples.some(
(sample) =>
sample.state === "unknown" ||
sample.timestamp === null ||
sample.windowMs === null ||
sample.intervalFramesEncoded === null,
)
) {
return {
sampleTimestampMs: null,
nativeEdgeQualityState: "unknown",
qualityLimitationReason: null,
sampleWindowMs: null,
@@ -170,6 +180,7 @@ export function collectNativeSenderQualityFromReport(
}
const degraded = samples.filter((sample) => sample.state === "degraded");
return {
sampleTimestampMs: Math.min(...samples.map((sample) => sample.timestamp!)),
nativeEdgeQualityState: degraded.length > 0 ? "degraded" : "healthy",
qualityLimitationReason:
degraded.some((sample) => sample.reason === "bandwidth")
+1
View File
@@ -309,6 +309,7 @@ export class ViewerPeer {
})) {
throw new Error(say("viewer.msg.serverError"));
}
this.clearRecoveryTimer();
this.offerRecoveryAttempts = 0;
this.scheduleInitialConnectionDeadline(connection, connectionId);
} catch (error) {
+14 -12
View File
@@ -611,18 +611,20 @@ export class HybridMediaRouter {
const childToParent =
input.sourcePeerId === operation.childPeerId &&
input.targetPeerId === current.tuple.parentPeerId;
return Boolean(
parent &&
child &&
parent.sessionId === (parentToChild ? input.sourceSessionId : input.targetSessionId) &&
child.sessionId === (childToParent ? input.sourceSessionId : input.targetSessionId) &&
input.connectionId === current.connectionId &&
(input.signalKind === "candidate" ||
(parentToChild && input.descriptionType === "offer") ||
(childToParent && input.descriptionType === "answer")),
)
? "probe"
: false;
if (parentToChild || childToParent) {
return Boolean(
parent &&
child &&
parent.sessionId === (parentToChild ? input.sourceSessionId : input.targetSessionId) &&
child.sessionId === (childToParent ? input.sourceSessionId : input.targetSessionId) &&
input.connectionId === current.connectionId &&
(input.signalKind === "candidate" ||
(parentToChild && input.descriptionType === "offer") ||
(childToParent && input.descriptionType === "answer")),
)
? "probe"
: false;
}
}
const childPeerId = this.childForPair(snapshot, input.sourcePeerId, input.targetPeerId);
+6 -3
View File
@@ -884,6 +884,7 @@ export class RoomRouteController<Resource = unknown> {
}
publication.physicalActive = false;
publication.usable = false;
this.hostPublication = null;
this.clearSfuQuality();
this.revision = this.allocateRevision();
if (this.operation) this.operation.baseRevision = this.revision;
@@ -3843,9 +3844,11 @@ export class RoomRouteController<Resource = unknown> {
edge.usable = false;
}
if (edge.kind === "sfu" && !this.hasSfuSubscribers() && this.hostPublication) {
if (this.hostPublication.physicalActive) released.push(this.hostPublication.resource);
this.hostPublication.physicalActive = false;
this.hostPublication.usable = false;
const publication = this.hostPublication;
if (publication.physicalActive) released.push(publication.resource);
publication.physicalActive = false;
publication.usable = false;
this.hostPublication = null;
}
return true;
}
+4 -1
View File
@@ -427,7 +427,10 @@ export class RoomStore {
expectedSalt,
mayConnect,
);
if (derived.kind !== "value") {
if (derived.kind === "busy") {
throw new RoomStoreError("ROOM_BUSY");
}
if (derived.kind === "cancelled") {
throw new RoomStoreError("INVALID_TOKEN");
}
const matches = timingSafeEqual(derived.value, expectedVerifier);
+4
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
getTitleFrames,
isCopyKey,
say,
setCopy,
t,
@@ -20,6 +21,8 @@ describe("copy catalog", () => {
expect(t("en", "join.title")).toBe("Join a room");
expect(t("zh", "host.advanced.route.peerOnly")).toBe("隐私模式");
expect(t("en", "host.advanced.route.peerOnly")).toBe("Privacy mode");
expect(isCopyKey("viewer.reconnect")).toBe(true);
expect(isCopyKey("Reconnect media")).toBe(false);
});
it("interpolates variables", () => {
@@ -27,6 +30,7 @@ describe("copy catalog", () => {
expect(t("en", "host.onlineCount", { n: "4", max: "20" })).toBe(
"4 / 20 online",
);
expect(t("zh", "viewer.title", { name: "$&" })).toBe("$& 的屏幕");
});
it("keeps zh and en key sets identical", () => {
+87
View File
@@ -656,6 +656,93 @@ describe("HybridMediaRouter v9 runtime", () => {
}
});
it("keeps committed-edge signaling authorized during another Peer candidate", async () => {
const { store, sent, router } = harness(2);
try {
const room = await store.createRoom();
const host = connectHost(store, room);
complete(router, host);
const roots = [
connectViewer(store, room, "stable-root-a"),
connectViewer(store, room, "stable-root-b"),
];
for (const root of roots) {
complete(router, root);
await vi.waitFor(() => expect(preparedFor(sent, root.sessionId)).toBeDefined());
const prepared = preparedFor(sent, root.sessionId)!;
router.handleRouteReady(root, {
type: "route-ready",
revision: prepared.revision,
phase: "prepare",
});
router.setViewerRelayCapacity(root, 1);
}
const candidateChild = connectViewer(store, room, "candidate-child");
complete(router, candidateChild);
const prepared = await vi.waitFor(() => {
const update = preparedFor(sent, candidateChild.sessionId);
expect(update?.assignment.upstream.kind).toBe("peer");
return update!;
});
const candidateParentPeerId =
prepared.assignment.upstream.kind === "peer"
? prepared.assignment.upstream.peerId
: "";
const candidateParent = roots.find(
(root) => root.peerId === candidateParentPeerId,
)!;
const activeParentEdge = router.resolveActiveViewerMediaEdge(
room.roomId,
candidateParent.peerId,
)!;
expect(
router.peerSignalAuthorization({
roomId: room.roomId,
sourcePeerId: host.peerId,
sourceSessionId: host.sessionId,
targetPeerId: candidateParent.peerId,
targetSessionId: candidateParent.sessionId,
connectionId: activeParentEdge.connectionId,
signalKind: "description",
descriptionType: "offer",
}),
).toBe(true);
expect(
router.peerSignalAuthorization({
roomId: room.roomId,
sourcePeerId: candidateParent.peerId,
sourceSessionId: candidateParent.sessionId,
targetPeerId: candidateChild.peerId,
targetSessionId: candidateChild.sessionId,
connectionId: prepared.candidate.connectionId,
signalKind: "description",
descriptionType: "offer",
}),
).toBe("probe");
const unrelatedRoot = roots.find(
(root) => root.peerId !== candidateParent.peerId,
)!;
expect(
router.peerSignalAuthorization({
roomId: room.roomId,
sourcePeerId: unrelatedRoot.peerId,
sourceSessionId: unrelatedRoot.sessionId,
targetPeerId: candidateChild.peerId,
targetSessionId: candidateChild.sessionId,
connectionId: prepared.candidate.connectionId,
signalKind: "description",
descriptionType: "offer",
}),
).toBeUndefined();
} finally {
await router.close();
}
});
it("commits P2P quality convergence after client relative approval", async () => {
const { store, sent, router } = harness(2);
try {
+62 -9
View File
@@ -2,10 +2,8 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it } from "vitest";
import {
Couch,
pawnColor,
} from "../src/client/components/living/Couch.tsx";
import { Couch } from "../src/client/components/living/Couch.tsx";
import { participantColor } from "../src/client/components/living/participant-color.ts";
import {
BrandLoader,
BrandMark,
@@ -15,9 +13,9 @@ import { ComicTooltip } from "../src/client/components/living/ComicTooltip.tsx";
import { HintComic } from "../src/client/components/living/hints/index.tsx";
import { StageOverlay } from "../src/client/components/living/Stage.tsx";
import { ViewerOverview } from "../src/client/components/living/ViewerOverview.tsx";
import { NameTag } from "../src/client/components/living/primitives.tsx";
import { Btn, NameTag } from "../src/client/components/living/primitives.tsx";
import { EMPTY_METRICS } from "../src/client/types.ts";
import { setCopy } from "../src/client/ui/copy.ts";
import { setCopy, t } from "../src/client/ui/copy.ts";
describe("living-room presentation", () => {
afterEach(() => setCopy({ lang: "zh", vis: false }));
@@ -97,11 +95,13 @@ describe("living-room presentation", () => {
it("keeps the current display name visible in visual mode", () => {
setCopy({ lang: "en", vis: true });
const identity = "viewer-name-tag";
const html = renderToStaticMarkup(
createElement(NameTag, { name: "TNT" }),
createElement(NameTag, { name: "TNT", identity }),
);
expect(html).toContain(">TNT<");
expect(html).toContain(participantColor(identity));
expect(html).not.toContain("visually-hidden");
});
@@ -121,6 +121,27 @@ describe("living-room presentation", () => {
expect(html).not.toContain('viewBox="0 0 320 96"');
});
it("keeps a hint-wrapped disabled control explainable by keyboard", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(
ComicTooltip,
{
kind: "hint-join-go",
children: createElement(Btn, {
icon: "refresh",
title: "viewer.reconnect",
disabled: true,
}),
},
),
);
expect(html).toContain('class="lr-comic-tip-wrap is-disabled-trigger"');
expect(html).toContain('tabindex="0"');
expect(html).toContain(`aria-label="${t("en", "viewer.reconnect")}"`);
});
it("animates the private-room hint around its door and lock", () => {
const html = renderToStaticMarkup(
createElement(HintComic, { kind: "hint-policy-private", size: 240 }),
@@ -133,6 +154,14 @@ describe("living-room presentation", () => {
expect(html).toContain("vlsPrivLock");
});
it("keeps the Host-offline comic free of the waiting-room moon", () => {
const html = renderToStaticMarkup(
createElement(Comic, { kind: "host-offline", theme: "stage" }),
);
expect(html).not.toContain("M229 39.5a8.5");
});
it("keeps every crowded and relay Viewer name visible in visual mode", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
@@ -152,12 +181,13 @@ describe("living-room presentation", () => {
expect(html).toContain(
'<span class="lr-pawn-name">Relay Viewer With A Full Name</span>',
);
expect(html).toContain('title="Relay Viewer With A Full Name"');
expect(html).not.toContain("max-width:72px");
});
it("derives every participant color only from the peer identity", () => {
const peerId = "viewer-stable-identity";
const color = pawnColor(peerId);
const color = participantColor(peerId);
const html = renderToStaticMarkup(
createElement(Couch, {
entries: [
@@ -171,7 +201,7 @@ describe("living-room presentation", () => {
}),
);
expect(pawnColor(peerId)).toBe(color);
expect(participantColor(peerId)).toBe(color);
expect(html).toContain(color);
expect(html).not.toContain("var(--you)");
});
@@ -217,4 +247,27 @@ describe("living-room presentation", () => {
expect(html).toContain("18 ms");
expect(html).toContain("Bob");
});
it("uses route glyphs without visible route text in visual mode", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(ViewerOverview, {
entries: [
{
key: "viewer-p2p",
name: "Alice",
connected: true,
statusLabel: "Connected",
route: "p2p",
metrics: null,
},
],
selectedKey: null,
onSelect: () => undefined,
}),
);
expect(html).not.toContain("<b>P2P</b>");
expect(html).toContain(t("en", "state.route.p2p"));
});
});
+21 -2
View File
@@ -1,15 +1,16 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { MetricCells } from "../src/client/components/living/Metrics.tsx";
import { setCopy } from "../src/client/ui/copy.ts";
import { setCopy, t } from "../src/client/ui/copy.ts";
import {
EMPTY_METRICS,
type ConnectionMetrics,
} from "../src/client/types.ts";
setCopy({ lang: "zh", vis: false });
afterEach(() => setCopy({ lang: "zh", vis: false }));
const metrics = {
...EMPTY_METRICS,
@@ -44,6 +45,24 @@ const metrics = {
} satisfies ConnectionMetrics;
describe("MetricCells progressive disclosure", () => {
it("keeps visual metric names available to assistive technology", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(MetricCells, {
metrics,
direction: "receive",
expanded: true,
onToggle: () => undefined,
}),
);
for (const key of ["stats.resolution", "stats.fps", "stats.loss", "stats.rtt"] as const) {
expect(html).toContain(
`<span class="visually-hidden">${t("en", key)}</span>`,
);
}
});
it("keeps one-glance picture results above an accessible detail expansion", () => {
const collapsed = renderToStaticMarkup(
createElement(MetricCells, {
+132
View File
@@ -0,0 +1,132 @@
#!/bin/bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
release_script="$repo_root/deploy/release-app.sh"
extract_function() {
awk -v name="$1" '
$0 == name "() {" { capture = 1 }
capture { print }
capture && $0 == "}" { exit }
' "$release_script"
}
eval "$(extract_function cleanup_stage)"
if grep -q '^cleanup_release() {' "$release_script"; then
eval "$(extract_function cleanup_release)"
fi
eval "$(extract_function wait_for_health)"
eval "$(extract_function recover)"
test_root="$(mktemp -d)"
trap 'rm -rf --one-file-system -- "$test_root"' EXIT
release_root="$test_root/releases"
current="$test_root/current"
release_id='abcdef0'
old_release="$release_root/old"
release="$release_root/$release_id"
stage=''
mkdir -p "$old_release"
systemctl_start_fails=0
rm_release_fails=0
systemctl() {
if [ "${1:-}" = 'start' ] && [ "$systemctl_start_fails" -eq 1 ]; then
return 1
fi
if [ "${1:-}" = 'show' ]; then
printf 'active\n'
fi
return 0
}
wait_for_health() { return 0; }
rm() {
local path="${!#}"
if [ "$rm_release_fails" -eq 1 ] && [ "$path" = "$release" ]; then
return 1
fi
command rm "$@"
}
readlink() {
local path="${!#}"
if [ "$path" = "$current" ] && [ -f "$current" ]; then
cat "$current"
return
fi
command readlink "$@"
}
ln() {
if [ "${1:-}" = '-s' ]; then
printf '%s\n' "$2" > "$3"
return
fi
command ln "$@"
}
mv() {
if [ "${1:-}" = '-Tf' ]; then
command mv -f -- "$2" "$3"
return
fi
command mv "$@"
}
set_current() {
printf '%s\n' "$1" > "$current"
}
reset_release() {
rm -rf --one-file-system -- "$release"
rm -rf --one-file-system -- "$current"
mkdir -p "$release"
set_current "$old_release"
stage=''
cutover_started=0
systemctl_start_fails=0
}
run_recover() {
local expected="$1" code
set +e
(recover 37) >/dev/null 2>&1
code=$?
set -e
test "$code" -eq "$expected"
}
reset_release
release_owned=1
run_recover 37
test ! -e "$release"
reset_release
release_owned=0
run_recover 37
test -d "$release"
reset_release
release_owned=1
rm_release_fails=1
run_recover 90
test -d "$release"
rm_release_fails=0
reset_release
release_owned=1
cutover_started=1
set_current "$release"
run_recover 37
test "$(readlink -f -- "$current")" = "$old_release"
test ! -e "$release"
reset_release
release_owned=1
cutover_started=1
systemctl_start_fails=1
set_current "$release"
run_recover 90
test -d "$release"
+24
View File
@@ -0,0 +1,24 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const bash =
process.platform === "win32"
? [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
].find(existsSync)
: "/bin/bash";
const testWithBash = bash && existsSync(bash) ? it : it.skip;
describe("application release recovery", () => {
testWithBash("removes only the unactivated release owned by this run", () => {
const script = fileURLToPath(
new URL("./release-app-recovery.sh", import.meta.url),
);
const result = spawnSync(bash!, [script], { encoding: "utf8" });
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
});
});
+46
View File
@@ -3668,6 +3668,52 @@ describe("RoomRouteController", () => {
expect(bootstrap.reconcile(4).operation?.childPeerId).toBe(B);
});
it("reopens SFU bootstrap after the physical publication retires", () => {
const routes = controller(2, { sfuEnabled: true });
addViewer(routes, A, 0);
routes.hydrateHostPublication("retired_publication", "publication_resource");
routes.hydrateEdge(A, {
kind: "sfu",
publicationGeneration: "retired_publication",
transport: "sfu",
connectionId: "a_from_sfu",
usable: true,
physicalActive: true,
resource: "a_subscription",
});
const publication = routes.snapshot().hostPublication!;
expect(
routes.retireHostPublication({
hostSessionId: publication.hostSessionId,
routeRevision: routes.snapshot().revision,
generation: publication.generation,
connectionId: publication.connectionId,
}),
).toEqual(expect.arrayContaining(["a_subscription", "publication_resource"]));
expect(routes.snapshot().hostPublication).toBeNull();
for (const [peerId, connectionId] of [
[B, "b_from_host"],
[C, "c_from_host"],
] as const) {
addViewer(routes, peerId, 0);
routes.hydrateEdge(peerId, peerEdge(HOST, connectionId));
}
expect(routes.reconcile(1)).toMatchObject({
failedPeerIds: [],
operation: {
demandPeerId: A,
reason: "sfu-bootstrap",
candidates: [
{
tuple: { kind: "sfu", publication: "create" },
},
],
},
});
});
it("binds reserve and skip results to the exact candidate cursor", () => {
const reserve = controller(2);
addViewer(reserve, A, 2);
+24 -1
View File
@@ -350,6 +350,22 @@ describe("RoomStore", () => {
);
});
it("keeps password-gate saturation distinct from invalid credentials", async () => {
const { store: roomStore } = store();
const room = await roomStore.createRoom("private", "room-password");
await expect(
withSaturatedPasswordGate(roomStore, () =>
roomStore.connectViewerWithPassword({
roomId: room.roomId,
password: "room-password",
clientId: "busy-password-client",
sessionId: "busy-password-session",
}),
),
).rejects.toEqual(new RoomStoreError("ROOM_BUSY"));
});
it("keeps room replacement and password updates unchanged when busy", async () => {
const { store: roomStore } = store({
maxRooms: 3,
@@ -439,7 +455,14 @@ describe("RoomStore", () => {
result.status === "rejected" &&
result.reason.code === "INVALID_TOKEN",
),
).toHaveLength(38);
).toHaveLength(16);
expect(
results.filter(
(result) =>
result.status === "rejected" &&
result.reason.code === "ROOM_BUSY",
),
).toHaveLength(22);
});
it("rotates and revokes Viewer grants without changing code entry", async () => {
+87
View File
@@ -587,6 +587,48 @@ describe("minimal route transition contracts", () => {
expect(reconciledChildren.at(-1)).toEqual(["retained-child"]);
});
it("rebuilds a failed Host SFU publisher while sharing is paused", async () => {
const messages: ClientMessage[] = [];
const publishers: ReturnType<typeof createFakePublisher>[] = [];
const disconnects: Array<() => void> = [];
const route = new HostSfuRoute({
getStream: () => ({}) as MediaStream,
getProfile: () => QUALITY_PROFILES["720p30"],
getVideoCodec: () => "vp8",
reconcileChildren: () => undefined,
send: (message) => {
messages.push(message);
return true;
},
createPublisher: (onDisconnected) => {
disconnects.push(onDisconnected);
const publisher = createFakePublisher(
[],
`publisher-${publishers.length + 1}`,
);
publishers.push(publisher);
return publisher;
},
});
await route.acceptAndWait({
revision: 1,
phase: "active",
assignment: hostAssignment("publication-paused"),
});
await route.acceptConfig(sfuConfig(1));
route.setPaused(true);
disconnects[0]!();
expect(messages).toContainEqual({ type: "refresh-sfu", revision: 1 });
await route.acceptConfig({ ...sfuConfig(1), token: "recovery-token" });
expect(publishers).toHaveLength(2);
expect(publishers[1]!.setPaused).toHaveBeenCalledWith(true);
route.setPaused(false);
expect(publishers[1]!.setPaused).toHaveBeenLastCalledWith(false);
});
it("does not activate a fresh SFU publisher after its exact route becomes stale", async () => {
let releaseFreshPublisher!: () => void;
const freshPublisherGate = new Promise<void>((resolve) => {
@@ -1242,6 +1284,51 @@ describe("minimal route transition contracts", () => {
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
});
it("rebuilds a failed Viewer SFU subscriber while sharing is paused", async () => {
const messages: ClientMessage[] = [];
const streams: MediaStream[] = [];
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
const route = new ViewerSfuRoute("viewer_12345678", {
activatePeer: () => true,
reconcileSfuChildren: () => undefined,
onSfuStream: (stream) => streams.push(stream),
send: (message) => {
messages.push(message);
return true;
},
createSubscriber: (events) => {
const subscriber = createFakeSubscriber(
events,
[],
`subscriber-${subscribers.length + 1}`,
);
subscribers.push(subscriber);
return subscriber;
},
});
route.accept({
revision: 7,
phase: "active",
assignment: viewerSfuAssignment(),
});
await route.acceptConfig(sfuConfig(7));
subscribers[0]!.events.onStream({} as MediaStream);
subscribers[0]!.events.onFirstDecodedFrame();
await vi.waitFor(() => expect(streams).toHaveLength(1));
route.setPaused(true);
subscribers[0]!.events.onDisconnected();
expect(messages).toContainEqual({ type: "refresh-sfu", revision: 7 });
await route.acceptConfig({ ...sfuConfig(7), token: "recovery-token" });
expect(subscribers).toHaveLength(2);
expect(subscribers[1]!.armDecodedFrameProof).not.toHaveBeenCalled();
route.setPaused(false);
expect(subscribers[1]!.armDecodedFrameProof).toHaveBeenCalledOnce();
});
it("retires only an unreferenced paused Viewer SFU slot", async () => {
const messages: ClientMessage[] = [];
+15
View File
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it } from "vitest";
import { RouteTree } from "../src/client/components/living/RouteTree.tsx";
import { participantColor } from "../src/client/components/living/participant-color.ts";
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";
@@ -195,6 +196,20 @@ describe("RouteTree", () => {
expect(html).not.toContain("<title");
});
it("uses the Host identity color in the topology", () => {
const hostPeerId = "host-color-identity";
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId,
hostLabel: "Host",
viewers: [],
}),
);
expect(html).toContain(participantColor(hostPeerId));
expect(html).not.toContain('fill="var(--couch)"');
});
it("fits a one-Viewer topology inside a narrow container", () => {
const hostName = "这是一个合法的二十四字中文分享者显示名称甲乙丙丁";
const { host, viewers } = labelParticipantSnapshot([
+55
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUALITY_PROFILES } from "../src/client/media/quality.ts";
import { sfuPublisherQualityEvidenceFromMetrics } from "../src/client/media/sender-quality-evidence.ts";
import { SfuPublisher } from "../src/client/sfu/publisher.ts";
import { SfuSubscriber } from "../src/client/sfu/subscriber.ts";
import type { ConnectionMetrics } from "../src/client/types.ts";
@@ -546,8 +547,26 @@ describe("SfuPublisher", () => {
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)?.bitrateKbps).toBeNull();
expect(
sfuPublisherQualityEvidenceFromMetrics(
updates.at(-1)!,
7,
"simulcast_publication_12345678",
),
).toMatchObject({ state: "unknown", sampleTimestampMs: null });
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)?.bitrateKbps).toBe(2_400);
expect(updates.at(-1)?.sampleTimestampMs).toBe(3_000);
expect(
sfuPublisherQualityEvidenceFromMetrics(
updates.at(-1)!,
7,
"simulcast_publication_12345678",
),
).toMatchObject({
state: "healthy",
sampleTimestampMs: 3_000,
});
});
it("enables balanced SFU adaptation after startup frames", async () => {
@@ -1182,6 +1201,42 @@ describe("SfuPublisher", () => {
expect(publisher.getQualityWarning()).toBeNull();
});
it("reconciles pause state onto a video clone committed after reconnect", async () => {
const publisher = new SfuPublisher();
const sourceVideo = track("video", "video-pause-race");
await publisher.connect(connection);
await publisher.activate(stream(sourceVideo), qualityProfile);
publisher.setPaused(true);
sourceVideo.enabled = false;
const room = livekit.state.rooms[0];
await room.localParticipant.republishForReconnect();
const publication = room.localParticipant.getTrackPublication(
Track.Source.ScreenShare,
)!;
const replaceGate = deferred();
publication.track.replaceTrack.mockImplementationOnce(
async (nextTrack: MediaStreamTrack) => {
await replaceGate.promise;
publication.track.currentTrack = nextTrack;
publication.track.sender.track = nextTrack;
},
);
room.emit(RoomEvent.Reconnected);
await vi.waitFor(() =>
expect(publication.track.replaceTrack).toHaveBeenCalledOnce(),
);
sourceVideo.enabled = true;
publisher.setPaused(false);
replaceGate.resolve();
await vi.waitFor(() =>
expect(publication.track.currentTrack).not.toBe(publication.rawTrack),
);
expect(publication.track.currentTrack.enabled).toBe(true);
});
it("retains video sender readback across a signal-only reconnect", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
+26 -1
View File
@@ -57,7 +57,32 @@ describe("composited video frame proof", () => {
expect(onFrame).not.toHaveBeenCalled();
cancel();
expect(video.cancelVideoFrameCallback).toHaveBeenCalledWith(7);
expect(video.cancelVideoFrameCallback).not.toHaveBeenCalled();
});
it("keeps observing when the first composited callback arrives before readiness", () => {
const stream = {} as MediaStream;
const video = fakeVideo(stream);
const callbacks: Array<() => void> = [];
video.requestVideoFrameCallback = (callback) => {
callbacks.push(callback);
return callbacks.length;
};
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
callbacks[0]!();
expect(onFrame).not.toHaveBeenCalled();
expect(callbacks).toHaveLength(2);
video.readyState = 2;
callbacks[1]!();
expect(onFrame).toHaveBeenCalledOnce();
cancel();
});
it("uses decoded progress plus readiness in the event fallback", () => {
+25
View File
@@ -629,6 +629,31 @@ describe("ViewerPeer connection generations", () => {
peer.dispose();
});
it("retires the recovery deadline when a rebuilt connection is answered", async () => {
const exhausted: string[] = [];
const peer = new ViewerPeer(
{ iceServers: [] },
{
sendSignal: () => true,
sendRestartRequest: () => true,
onStream: () => undefined,
onUpdate: () => undefined,
onRecoveryExhausted: (_peerId, connectionId) => {
exhausted.push(connectionId);
return true;
},
},
);
await peer.acceptSignal("relay-parent", offer("connection-before-rebuild"));
expect(peer.requestRecovery(true)).toBe(true);
await peer.acceptSignal("relay-parent", offer("connection-after-rebuild"));
expect([...timeoutDelays.values()]).toEqual([15_000]);
expect(exhausted).toEqual([]);
peer.dispose();
});
it("does not send an old answer or candidate after replacing the connection", async () => {
const oldLocalDescription = createDeferred<void>();
FakePeerConnection.plans.push({
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { prepareViewerPlayback } from "../src/client/media/viewer-playback.ts";
describe("Viewer playback binding", () => {
it("arms every media generation even when the MediaStream identity is reused", () => {
const stream = {} as MediaStream;
const video = {
srcObject: null as MediaProvider | null,
pause: vi.fn(),
};
expect(prepareViewerPlayback(video, stream, false)).toBe(true);
expect(video.srcObject).toBe(stream);
expect(prepareViewerPlayback(video, stream, false)).toBe(true);
expect(video.pause).not.toHaveBeenCalled();
expect(prepareViewerPlayback(video, stream, true)).toBe(false);
expect(video.pause).toHaveBeenCalledOnce();
});
});
+178 -23
View File
@@ -46,6 +46,7 @@ describe("Viewer presentation reducer", () => {
const playing = reduceViewerPresentation(receiving, {
type: "frame-presented",
generation: 1,
proofEpoch: 0,
revision: 1,
});
expect(deriveViewerPresentation(playing)).toMatchObject({
@@ -134,7 +135,7 @@ describe("Viewer presentation reducer", () => {
{ type: "route", revision: 2, phase: "active", kind: "p2p" },
{ type: "connection", revision: 2, connection: "connected" },
{ type: "media-bound", generation: 4, revision: 2 },
{ type: "frame-presented", generation: 4, revision: 2 },
{ type: "frame-presented", generation: 4, proofEpoch: 0, revision: 2 },
);
const rebased = reduceViewerPresentation(playing, {
type: "route",
@@ -146,6 +147,7 @@ describe("Viewer presentation reducer", () => {
expect(rebased.media).toEqual({
generation: 4,
boundAtRevision: 2,
proofEpoch: 0,
framePresented: true,
});
expect(rebased.connection).toBe("connected");
@@ -180,7 +182,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 5, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 5, revision: 5 },
{ type: "frame-presented", generation: 5, revision: 5 },
{ type: "frame-presented", generation: 5, proofEpoch: 0, revision: 5 },
);
const preparing = reduceViewerPresentation(oldMedia, {
type: "route",
@@ -202,9 +204,8 @@ describe("Viewer presentation reducer", () => {
expect(staleAutoplay).toMatchObject({
autoplayBlockedGeneration: 5,
failure: "AUTOPLAY_BLOCKED",
});
expect(staleFailure).toMatchObject({ failure: "PLAYBACK_FAILED" });
expect(staleFailure).toMatchObject({ playbackFailedGeneration: 5 });
expect(deriveViewerPresentation(preparing)).toMatchObject({
stage: "playing",
overlay: "none",
@@ -227,7 +228,6 @@ describe("Viewer presentation reducer", () => {
});
expect(preparing.autoplayBlockedGeneration).toBe(5);
expect(preparing.failure).toBe("AUTOPLAY_BLOCKED");
expect(deriveViewerPresentation(preparing)).toMatchObject({
stage: "receiving",
});
@@ -239,7 +239,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 5, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 5, revision: 5 },
{ type: "frame-presented", generation: 5, revision: 5 },
{ type: "frame-presented", generation: 5, proofEpoch: 0, revision: 5 },
);
const recovering = reduceViewerPresentation(oldFrame, {
type: "route",
@@ -257,6 +257,7 @@ describe("Viewer presentation reducer", () => {
const continuingProof = reduceViewerPresentation(recovering, {
type: "frame-presented",
generation: 5,
proofEpoch: 0,
revision: 5,
});
expect(continuingProof).toMatchObject({
@@ -271,6 +272,7 @@ describe("Viewer presentation reducer", () => {
const proven = reduceViewerPresentation(rebound, {
type: "frame-presented",
generation: 6,
proofEpoch: 0,
revision: 6,
});
expect(deriveViewerPresentation(proven)).toMatchObject({
@@ -287,7 +289,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 0, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 1, revision: 0 },
{ type: "frame-presented", generation: 1, revision: 0 },
{ type: "frame-presented", generation: 1, proofEpoch: 0, revision: 0 },
{ type: "signal", signal: "reconnecting" },
);
expect(deriveViewerPresentation(state)).toMatchObject({
@@ -310,6 +312,7 @@ describe("Viewer presentation reducer", () => {
const resumed = reduceViewerPresentation(recovering, {
type: "frame-presented",
generation: 2,
proofEpoch: 0,
revision: 3,
});
expect(resumed.connection).toBe("connected");
@@ -327,7 +330,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 3 },
{ type: "route-status", revision: 3, state: "failed" },
);
const recovering = reduceViewerPresentation(failed, {
@@ -339,13 +342,13 @@ describe("Viewer presentation reducer", () => {
expect(recovering).toMatchObject({
connection: "reconnecting",
routeStatus: null,
failure: null,
media: { generation: 2, framePresented: false },
});
const resumed = reduceViewerPresentation(recovering, {
type: "frame-presented",
generation: 2,
proofEpoch: 1,
revision: 3,
});
expect(resumed.connection).toBe("connected");
@@ -355,6 +358,43 @@ describe("Viewer presentation reducer", () => {
});
});
it("re-proves committed media across background route attempts", () => {
const playing = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 74, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 9, revision: 74 },
{ type: "frame-presented", generation: 9, proofEpoch: 0, revision: 74 },
{ type: "frame-proof-reset", generation: 9, revision: 74 },
{ type: "route", revision: 75, phase: "prepare", kind: "p2p" },
{ type: "route", revision: 76, phase: "active", kind: "sfu" },
{ type: "route", revision: 77, phase: "prepare", kind: "p2p" },
);
expect(
reduceViewerPresentation(playing, {
type: "frame-presented",
generation: 9,
proofEpoch: 0,
revision: 74,
}),
).toBe(playing);
const reproved = reduceViewerPresentation(playing, {
type: "frame-presented",
generation: 9,
proofEpoch: 1,
revision: 74,
});
expect(deriveViewerPresentation(reproved)).toMatchObject({
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
hasRetainedFrame: false,
});
});
it("keeps a browser playback failure across unrelated active authority", () => {
const failed = apply(
{ type: "access", access: "ready" },
@@ -370,10 +410,55 @@ describe("Viewer presentation reducer", () => {
kind: "sfu",
});
expect(rebased.failure).toBe("PLAYBACK_FAILED");
expect(rebased.playbackFailedGeneration).toBe(2);
expect(deriveViewerPresentation(rebased).stage).toBe("playback-failed");
});
it("keeps Host, route, and playback facts orthogonal", () => {
const playbackFailed = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "playback-failed", generation: 2, revision: 3 },
{ type: "host", host: "offline" },
{ type: "host", host: "online" },
);
expect(playbackFailed.playbackFailedGeneration).toBe(2);
expect(deriveViewerPresentation(playbackFailed).stage).toBe(
"playback-failed",
);
const routeFailed = applyFrom(
playbackFailed,
{ type: "route-status", revision: 3, state: "failed" },
{ type: "host", host: "offline" },
{ type: "host", host: "online" },
);
expect(routeFailed.routeStatus).toEqual({ revision: 3, state: "failed" });
expect(deriveViewerPresentation(routeFailed).stage).toBe("route-failed");
});
it("does not retain a frame that remains current while a route waits", () => {
const waiting = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 3 },
{ type: "route-status", revision: 4, state: "waiting" },
);
expect(waiting.media?.framePresented).toBe(true);
expect(waiting.retainedFrame).toBe(false);
expect(deriveViewerPresentation(waiting)).toMatchObject({
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
hasRetainedFrame: false,
});
});
it("keeps a healthy Host-offline frame but invalidates it on upstream failure", () => {
const offlineWithMedia = apply(
{ type: "access", access: "ready" },
@@ -381,7 +466,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 3 },
{ type: "host", host: "offline" },
);
expect(deriveViewerPresentation(offlineWithMedia)).toMatchObject({
@@ -392,13 +477,15 @@ describe("Viewer presentation reducer", () => {
});
expect(
reduceViewerPresentation(offlineWithMedia, {
type: "media-invalidated",
revision: 2,
type: "frame-proof-reset",
generation: 1,
revision: 3,
}),
).toBe(offlineWithMedia);
const failed = reduceViewerPresentation(offlineWithMedia, {
type: "media-invalidated",
type: "frame-proof-reset",
generation: 2,
revision: 3,
});
expect(deriveViewerPresentation(failed)).toMatchObject({
@@ -410,6 +497,53 @@ describe("Viewer presentation reducer", () => {
});
});
it("lets current media prove a frame while Host signaling is offline", () => {
const offlineBeforeFirstFrame = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "host", host: "offline" },
);
const lateFrame = reduceViewerPresentation(offlineBeforeFirstFrame, {
type: "frame-presented",
generation: 2,
proofEpoch: 0,
revision: 3,
});
expect(deriveViewerPresentation(lateFrame)).toMatchObject({
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
noticeKey: "viewer.notice.hostOffline",
});
const previouslyPlaying = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 3 },
{ type: "host", host: "offline" },
{ type: "frame-proof-reset", generation: 2, revision: 3 },
);
const reproved = reduceViewerPresentation(previouslyPlaying, {
type: "frame-presented",
generation: 2,
proofEpoch: 1,
revision: 3,
});
expect(deriveViewerPresentation(reproved)).toMatchObject({
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
noticeKey: "viewer.notice.hostOffline",
});
});
it("retains the current frame while signaling reconnects", () => {
const reconnecting = apply(
{ type: "access", access: "ready" },
@@ -417,7 +551,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 3 },
{ type: "signal", signal: "reconnecting" },
);
@@ -436,7 +570,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 3, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, revision: 3 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 3 },
);
const reset = reduceViewerPresentation(playing, {
type: "frame-proof-reset",
@@ -468,6 +602,7 @@ describe("Viewer presentation reducer", () => {
reduceViewerPresentation(reset, {
type: "frame-presented",
generation: 2,
proofEpoch: 1,
revision: 3,
}).media?.framePresented,
).toBe(true);
@@ -480,7 +615,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 4, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 3, revision: 4 },
{ type: "frame-presented", generation: 3, revision: 4 },
{ type: "frame-presented", generation: 3, proofEpoch: 0, revision: 4 },
);
const failed = reduceViewerPresentation(playing, {
type: "route-status",
@@ -501,6 +636,7 @@ describe("Viewer presentation reducer", () => {
const lateFrame = reduceViewerPresentation(failed, {
type: "frame-presented",
generation: 3,
proofEpoch: 0,
revision: 4,
});
expect(lateFrame).toBe(failed);
@@ -518,19 +654,19 @@ describe("Viewer presentation reducer", () => {
kind: "sfu",
});
expect(restored.routeStatus).toBeNull();
expect(restored.failure).toBeNull();
expect(
reduceViewerPresentation(restored, {
type: "frame-presented",
generation: 3,
proofEpoch: 1,
revision: 4,
}).media?.framePresented,
).toBe(true);
const localFailure = reduceViewerPresentation(playing, {
type: "failure",
failure: "ROUTE_EXHAUSTED",
type: "route-status",
revision: 4,
state: "failed",
});
expect(
reduceViewerPresentation(localFailure, {
@@ -599,6 +735,26 @@ describe("Viewer presentation reducer", () => {
expect(viewerFailureFromServerCode("ROOM_ACCESS_DENIED")).toBe(
"ROOM_ACCESS_DENIED",
);
expect(viewerFailureFromServerCode("FORBIDDEN")).toBeNull();
expect(viewerFailureFromServerCode("INVALID_MESSAGE")).toBeNull();
});
it("keeps an intentional stop authoritative over the following offline fact", () => {
const stopped = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "sharing-stopped" },
{ type: "host", host: "offline" },
);
expect(stopped).toMatchObject({
host: "stopped",
});
expect(deriveViewerPresentation(stopped)).toMatchObject({
stage: "waiting-host",
messageKey: "viewer.msg.waitingHost",
failureCode: "HOST_STOPPED",
});
});
it("starts a fresh route revision namespace after sharing stops", () => {
@@ -607,7 +763,7 @@ describe("Viewer presentation reducer", () => {
{ type: "host", host: "online" },
{ type: "route", revision: 8, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 2, revision: 8 },
{ type: "frame-presented", generation: 2, revision: 8 },
{ type: "frame-presented", generation: 2, proofEpoch: 0, revision: 8 },
);
const stopped = reduceViewerPresentation(previousShare, {
type: "sharing-stopped",
@@ -619,14 +775,13 @@ describe("Viewer presentation reducer", () => {
connection: "idle",
media: null,
retainedFrame: false,
failure: "HOST_STOPPED",
});
const restarted = applyFrom(stopped,
{ type: "host", host: "online" },
{ type: "route", revision: 1, phase: "prepare", kind: "p2p" },
{ type: "media-bound", generation: 3, revision: 1 },
{ type: "frame-presented", generation: 3, revision: 1 },
{ type: "frame-presented", generation: 3, proofEpoch: 0, revision: 1 },
);
expect(deriveViewerPresentation(restarted)).toMatchObject({
stage: "playing",
+73 -13
View File
@@ -207,7 +207,7 @@ describe("viewer quality evidence", () => {
);
const first = snapshot("connection_first_12345678");
expect(reporter.offer(first, 0, true)).toBe(false);
expect(reporter.offer(first, 0, true)).toBe(true);
expect(reporter.offer(first, 0, true)).toBe(false);
now += 2_000;
expect(
@@ -272,7 +272,7 @@ describe("viewer quality evidence", () => {
2,
false,
),
).toBe(false);
).toBe(true);
expect(
reporter.offer(
snapshot(
@@ -293,7 +293,7 @@ describe("viewer quality evidence", () => {
2,
true,
),
).toBe(false);
).toBe(true);
now += 2_000;
expect(
reporter.offer(
@@ -314,6 +314,7 @@ describe("viewer quality evidence", () => {
revision: message.guard.routeRevision,
epoch: message.guard.presentationEpoch,
sequence: message.sequence,
diagnostic: message.metrics.freezeCountDelta === null,
}
: null,
),
@@ -323,34 +324,63 @@ describe("viewer quality evidence", () => {
revision: 0,
epoch: 0,
sequence: 0,
diagnostic: true,
},
{
connectionId: "connection_first_12345678",
revision: 0,
epoch: 0,
sequence: 1,
diagnostic: false,
},
{
connectionId: "connection_first_12345678",
revision: 2,
epoch: 0,
sequence: 1,
sequence: 2,
diagnostic: false,
},
{
connectionId: "connection_second_12345678",
revision: 2,
epoch: 0,
sequence: 0,
diagnostic: false,
},
{
connectionId: "connection_second_12345678",
revision: 2,
epoch: 1,
sequence: 0,
diagnostic: true,
},
{
connectionId: "connection_second_12345678",
revision: 2,
epoch: 1,
sequence: 1,
diagnostic: true,
},
{
connectionId: "connection_second_12345678",
revision: 2,
epoch: 1,
sequence: 2,
diagnostic: false,
},
]);
});
it("reports SFU metrics with the exact route identity", () => {
let now = 10_000;
const sent: ClientMessage[] = [];
const reporter = new ViewerQualityEvidenceReporter((message) => {
sent.push(message);
return true;
});
const reporter = new ViewerQualityEvidenceReporter(
(message) => {
sent.push(message);
return true;
},
() => now,
);
expect(
reporter.offerMetrics(
@@ -359,7 +389,8 @@ describe("viewer quality evidence", () => {
3,
true,
),
).toBe(false);
).toBe(true);
now += 2_000;
expect(
reporter.offerMetrics(
"sfu_connection_12345678",
@@ -377,6 +408,17 @@ describe("viewer quality evidence", () => {
presentationEpoch: 0,
},
sequence: 0,
metrics: expect.objectContaining({ freezeCountDelta: null }),
}),
expect.objectContaining({
type: "viewer-quality-evidence",
guard: {
connectionId: "sfu_connection_12345678",
routeRevision: 3,
presentationEpoch: 0,
},
sequence: 1,
metrics: expect.objectContaining({ freezeCountDelta: 0 }),
}),
]);
});
@@ -393,7 +435,7 @@ describe("viewer quality evidence", () => {
);
const connectionId = "decoded_progress_connection_12345678";
expect(reporter.offer(snapshot(connectionId), 4, true)).toBe(false);
expect(reporter.offer(snapshot(connectionId), 4, true)).toBe(true);
now += 2_000;
expect(
reporter.offer(
@@ -420,7 +462,7 @@ describe("viewer quality evidence", () => {
4,
true,
),
).toBe(false);
).toBe(true);
now += 2_000;
expect(
reporter.offer(
@@ -436,6 +478,21 @@ describe("viewer quality evidence", () => {
true,
),
).toBe(true);
now += 2_000;
expect(
reporter.offer(
snapshot(
connectionId,
receiveMetrics({
sampleTimestampMs: 18_000,
intervalFreezeCount: 1,
intervalFreezeDurationMs: 2_000,
}),
),
4,
true,
),
).toBe(true);
expect(
sent.flatMap((message) =>
@@ -450,8 +507,11 @@ describe("viewer quality evidence", () => {
: [],
),
).toEqual([
{ epoch: 0, sequence: 0, freezeDurationMs: 0 },
{ epoch: 0, sequence: 1, freezeDurationMs: 2_000 },
{ epoch: 0, sequence: 0, freezeDurationMs: null },
{ epoch: 0, sequence: 1, freezeDurationMs: 0 },
{ epoch: 1, sequence: 0, freezeDurationMs: null },
{ epoch: 1, sequence: 1, freezeDurationMs: null },
{ epoch: 1, sequence: 2, freezeDurationMs: 2_000 },
]);
});