fix: fix presentation and audited ownership invariants (#338)
* Fix presentation and frontend ownership invariants * Fix audited backend ownership invariants
This commit is contained in:
1 parent
c4b59412f2
commit
87d9aad487
43 files changed
+1791
-677
No files matched your search
@@ -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' \
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }}>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"/></>) },
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user