feat: stream private office screens
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
|
||||
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
|
||||
|
||||
export interface EphemeralIceConfiguration {
|
||||
readonly configuration: RTCConfiguration;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface FetchEphemeralIceConfigurationOptions {
|
||||
authenticatedFetch: typeof globalThis.fetch;
|
||||
endpoint?: string;
|
||||
now?: () => number;
|
||||
requestId?: () => string;
|
||||
}
|
||||
|
||||
/** A validated, correlated server backoff response. No credential is retained. */
|
||||
export class IceConfigurationUnavailableError extends Error {
|
||||
readonly name = "IceConfigurationUnavailableError";
|
||||
readonly retryAfterMs: number;
|
||||
readonly status: number;
|
||||
|
||||
constructor(retryAfterMs: number, status: number) {
|
||||
super("ICE configuration is temporarily unavailable");
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one short-lived WebRTC configuration after the caller has established
|
||||
* authenticated, explicit media intent. The result is never cached or stored;
|
||||
* its lifetime remains visible so the caller can discard it on expiry.
|
||||
*/
|
||||
export async function fetchEphemeralIceConfiguration(
|
||||
options: FetchEphemeralIceConfigurationOptions,
|
||||
): Promise<EphemeralIceConfiguration> {
|
||||
if (typeof options.authenticatedFetch !== "function") {
|
||||
throw new TypeError("ICE configuration: authenticated fetch is required");
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const id = (options.requestId ?? (() => `tera-ice-${crypto.randomUUID()}`))();
|
||||
const response = await options.authenticatedFetch(options.endpoint ?? "/api/v1/media/ice", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "ice-config-request",
|
||||
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
||||
requestId: id,
|
||||
}),
|
||||
});
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json() as unknown;
|
||||
} catch {
|
||||
if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`);
|
||||
throw new Error("ICE configuration response is not valid JSON");
|
||||
}
|
||||
const parsed = parseIceConfigResponse(body);
|
||||
if (!parsed.ok) {
|
||||
if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`);
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
if (parsed.value.requestId !== id) throw new Error("ICE configuration response did not match the request");
|
||||
if (parsed.value.type === "ice-config-unavailable") {
|
||||
if (response.ok || response.status === 429 || response.status === 503) {
|
||||
throw new IceConfigurationUnavailableError(parsed.value.retryAfterMs, response.status);
|
||||
}
|
||||
throw new Error(`ICE configuration request failed (${response.status})`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`);
|
||||
if (!isIceConfigGrantActive(parsed.value, now())) {
|
||||
throw new Error("ICE configuration grant is not active");
|
||||
}
|
||||
return {
|
||||
configuration: {
|
||||
iceServers: parsed.value.iceServers.map((server) => "username" in server
|
||||
? {
|
||||
urls: [...server.urls],
|
||||
username: server.username,
|
||||
credential: server.credential,
|
||||
credentialType: server.credentialType,
|
||||
}
|
||||
: { urls: [...server.urls] }),
|
||||
},
|
||||
expiresAtMs: parsed.value.expiresAtMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */
|
||||
|
||||
export const ICE_CONFIG_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export interface IceConfigRequest {
|
||||
type: "ice-config-request";
|
||||
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface StunIceServer {
|
||||
urls: readonly string[];
|
||||
}
|
||||
|
||||
export interface TurnIceServer {
|
||||
urls: readonly string[];
|
||||
username: string;
|
||||
credential: string;
|
||||
credentialType: "password";
|
||||
}
|
||||
|
||||
export interface IceConfigGrant {
|
||||
type: "ice-config-grant";
|
||||
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
|
||||
requestId: string;
|
||||
issuedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
iceServers: readonly (StunIceServer | TurnIceServer)[];
|
||||
}
|
||||
|
||||
export interface IceConfigUnavailable {
|
||||
type: "ice-config-unavailable";
|
||||
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
|
||||
requestId: string;
|
||||
retryAfterMs: number;
|
||||
}
|
||||
|
||||
export type IceConfigResponse = IceConfigGrant | IceConfigUnavailable;
|
||||
|
||||
export type IceConfigValidationResult<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: string };
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ICE_CONFIG_PROTOCOL_VERSION,
|
||||
type IceConfigGrant,
|
||||
type IceConfigRequest,
|
||||
type IceConfigResponse,
|
||||
type IceConfigValidationResult,
|
||||
type StunIceServer,
|
||||
type TurnIceServer,
|
||||
} from "./iceTypes.ts";
|
||||
|
||||
export const MAX_ICE_URLS = 8;
|
||||
export const MAX_ICE_CREDENTIAL_LIFETIME_MS = 3_600_000;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function isSafeIceUrl(value: unknown): value is string {
|
||||
if (typeof value !== "string" || value.length < 6 || value.length > 512) return false;
|
||||
if (/[\u0000-\u0020\u007f]/.test(value) || /[/#@]/.test(value)) return false;
|
||||
// TURN's one useful query parameter is the transport selector. Accept that
|
||||
// exact grammar so deployments can offer UDP, TCP, and TLS fallbacks without
|
||||
// opening a generic query-string channel for credentials or vendor options.
|
||||
const match = /^(stun|stuns|turn|turns):([^?]+)(?:\?transport=(udp|tcp))?$/.exec(value);
|
||||
if (!match) return false;
|
||||
const scheme = match[1] as string;
|
||||
const authority = match[2] as string;
|
||||
const transport = match[3];
|
||||
if (transport !== undefined && scheme !== "turn" && scheme !== "turns") return false;
|
||||
if (authority.startsWith("[") && /^\[[0-9a-fA-F:.]+\](?::[1-9][0-9]{0,4})?$/.test(authority)) {
|
||||
return validPort(authority);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9.-]+(?::[1-9][0-9]{0,4})?$/.test(authority)) return false;
|
||||
const host = authority.replace(/:[0-9]+$/, "");
|
||||
return host.length <= 253 && !host.startsWith(".") && !host.endsWith(".") &&
|
||||
!host.includes("..") && validPort(authority);
|
||||
}
|
||||
|
||||
export function parseIceConfigRequest(value: unknown): IceConfigValidationResult<IceConfigRequest> {
|
||||
if (!exact(value, ["type", "protocolVersion", "requestId"]) ||
|
||||
value.type !== "ice-config-request" || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION ||
|
||||
!identifier(value.requestId)) return failure("invalid request");
|
||||
return success(value as unknown as IceConfigRequest);
|
||||
}
|
||||
|
||||
export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
|
||||
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
|
||||
return failure("invalid response envelope");
|
||||
}
|
||||
if (value.type === "ice-config-unavailable") {
|
||||
if (!exact(value, ["type", "protocolVersion", "requestId", "retryAfterMs"]) ||
|
||||
!boundedInteger(value.retryAfterMs, 1_000, 3_600_000)) return failure("invalid unavailable response");
|
||||
return success(value as unknown as IceConfigResponse);
|
||||
}
|
||||
if (value.type !== "ice-config-grant" ||
|
||||
!exact(value, ["type", "protocolVersion", "requestId", "issuedAtMs", "expiresAtMs", "iceServers"]) ||
|
||||
!timestamp(value.issuedAtMs) || !timestamp(value.expiresAtMs) ||
|
||||
value.expiresAtMs <= value.issuedAtMs ||
|
||||
value.expiresAtMs - value.issuedAtMs > MAX_ICE_CREDENTIAL_LIFETIME_MS ||
|
||||
!Array.isArray(value.iceServers) || value.iceServers.length < 1 || value.iceServers.length > 2) {
|
||||
return failure("invalid grant");
|
||||
}
|
||||
const servers = value.iceServers;
|
||||
if (!servers.every(iceServer) || !servers.some(isTurnIceServer)) return failure("grant requires TURN");
|
||||
return success(value as unknown as IceConfigGrant);
|
||||
}
|
||||
|
||||
export function isIceConfigGrantActive(value: IceConfigGrant, nowMs: number): boolean {
|
||||
return parseIceConfigResponse(value).ok && timestamp(nowMs) &&
|
||||
nowMs >= value.issuedAtMs && nowMs < value.expiresAtMs;
|
||||
}
|
||||
|
||||
function iceServer(value: unknown): value is StunIceServer | TurnIceServer {
|
||||
if (!plain(value)) return false;
|
||||
if (exact(value, ["urls"])) return urls(value.urls, ["stun:", "stuns:"]);
|
||||
return isTurnIceServer(value);
|
||||
}
|
||||
|
||||
function isTurnIceServer(value: unknown): value is TurnIceServer {
|
||||
return exact(value, ["urls", "username", "credential", "credentialType"]) &&
|
||||
urls(value.urls, ["turn:", "turns:"]) && opaque(value.username, 16, 256) &&
|
||||
opaque(value.credential, 20, 256) && value.credentialType === "password";
|
||||
}
|
||||
|
||||
function urls(value: unknown, schemes: readonly string[]): value is readonly string[] {
|
||||
return Array.isArray(value) && value.length > 0 && value.length <= MAX_ICE_URLS &&
|
||||
value.every((url) => isSafeIceUrl(url) && schemes.some((scheme) => url.startsWith(scheme))) &&
|
||||
new Set(value).size === value.length;
|
||||
}
|
||||
|
||||
function validPort(authority: string): boolean {
|
||||
const match = /:([0-9]+)$/.exec(authority);
|
||||
return match === null || Number(match[1]) <= 65_535;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 8_640_000_000_000_000;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, minimum: number, maximum: number): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum;
|
||||
}
|
||||
|
||||
function identifier(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value);
|
||||
}
|
||||
|
||||
function opaque(value: unknown, minimum: number, maximum: number): value is string {
|
||||
return typeof value === "string" && value.length >= minimum && value.length <= maximum &&
|
||||
/^[a-zA-Z0-9_:+/=.-]+$/.test(value);
|
||||
}
|
||||
|
||||
function plain(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype;
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[]): value is JsonRecord {
|
||||
if (!plain(value)) return false;
|
||||
const actual = Object.keys(value);
|
||||
return actual.length === keys.length && actual.every((key) => keys.includes(key));
|
||||
}
|
||||
|
||||
function success<T>(value: T): IceConfigValidationResult<T> { return { ok: true, value }; }
|
||||
function failure<T>(error: string): IceConfigValidationResult<T> { return { ok: false, error: `ice config: ${error}` }; }
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
createOfficeScreenPanel,
|
||||
type OfficeScreenPanel,
|
||||
type OfficeScreenPanelOptions,
|
||||
type OfficeScreenRemoteStatus,
|
||||
type OfficeScreenPanelState,
|
||||
} from "./officeScreenPanel.ts";
|
||||
export {
|
||||
@@ -43,3 +44,16 @@ export {
|
||||
parseScreenShareClientMessage,
|
||||
parseScreenShareServerMessage,
|
||||
} from "./signalingValidation.ts";
|
||||
export * from "./iceTypes.ts";
|
||||
export {
|
||||
isIceConfigGrantActive,
|
||||
isSafeIceUrl,
|
||||
parseIceConfigRequest,
|
||||
parseIceConfigResponse,
|
||||
} from "./iceValidation.ts";
|
||||
export {
|
||||
fetchEphemeralIceConfiguration,
|
||||
IceConfigurationUnavailableError,
|
||||
type EphemeralIceConfiguration,
|
||||
type FetchEphemeralIceConfigurationOptions,
|
||||
} from "./iceClient.ts";
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import type { MediaSurfaceDescriptor } from "./presentation.ts";
|
||||
|
||||
export type OfficeScreenRemoteStatus = "off" | "connecting" | "live" | "reconnecting" | "stopped" | "unavailable" | "error";
|
||||
|
||||
export interface OfficeScreenPanelOptions {
|
||||
container: HTMLElement;
|
||||
surfaces: readonly MediaSurfaceDescriptor[];
|
||||
@@ -19,6 +21,7 @@ export interface OfficeScreenPanelState {
|
||||
/** Explicit, local viewing choices. Empty initially and after disposal. */
|
||||
optedInScreenIds: string[];
|
||||
surfaces: MediaSurfaceDescriptor[];
|
||||
remoteStatusByScreen: Record<string, OfficeScreenRemoteStatus>;
|
||||
}
|
||||
|
||||
export interface OfficeScreenPanel {
|
||||
@@ -27,6 +30,7 @@ export interface OfficeScreenPanel {
|
||||
update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState;
|
||||
close(): OfficeScreenPanelState;
|
||||
state(): OfficeScreenPanelState;
|
||||
setRemoteStatus(screenId: string, status: OfficeScreenRemoteStatus): OfficeScreenPanelState;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -53,6 +57,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
let surfaces = validateSurfaces(options.surfaces);
|
||||
let selectedId: string | null = surfaces[0]?.screenId ?? null;
|
||||
const optedIn = new Set<string>();
|
||||
const remoteStatuses = new Map<string, OfficeScreenRemoteStatus>();
|
||||
let isOpen = false;
|
||||
let disposed = false;
|
||||
let invoker: HTMLElement | null = null;
|
||||
@@ -79,7 +84,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
const intro = doc.createElement("p");
|
||||
intro.id = introId;
|
||||
intro.className = "tera-screen-panel__intro";
|
||||
intro.textContent = "Media is off by default. Choose a screen, then explicitly opt in to view or request sharing.";
|
||||
intro.textContent = "Media is off by default. Opt in to view a remote screen, or choose Share screen to present your own.";
|
||||
const list = doc.createElement("div");
|
||||
list.className = "tera-screen-panel__list";
|
||||
list.setAttribute("role", "list");
|
||||
@@ -97,6 +102,11 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
return surfaces.find((surface) => surface.screenId === selectedId) ?? null;
|
||||
}
|
||||
|
||||
function remoteCanStop(screenId: string): boolean {
|
||||
const status = remoteStatuses.get(screenId);
|
||||
return status === "connecting" || status === "live" || status === "reconnecting";
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
for (const child of [...list.children]) child.remove();
|
||||
if (surfaces.length === 0) {
|
||||
@@ -117,7 +127,15 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
meta.className = "tera-screen-panel__meta";
|
||||
const room = surface.roomId ?? "Unassigned room";
|
||||
const status = surface.bound ? "Media active" : "Media off";
|
||||
meta.textContent = `${surface.levelId} · ${room} · ${status}`;
|
||||
const remote = remoteStatuses.get(surface.screenId) ?? "off";
|
||||
const remoteLabel = remote === "off" ? "Remote off"
|
||||
: remote === "connecting" ? "Remote connecting"
|
||||
: remote === "live" ? "Remote live"
|
||||
: remote === "reconnecting" ? "Remote reconnecting"
|
||||
: remote === "stopped" ? "Remote stopped"
|
||||
: remote === "unavailable" ? "No remote share"
|
||||
: "Remote error";
|
||||
meta.textContent = `${surface.levelId} · ${room} · ${status} · ${remoteLabel}`;
|
||||
row.append(name, meta);
|
||||
row.addEventListener("click", () => {
|
||||
selectedId = surface.screenId;
|
||||
@@ -129,15 +147,26 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
const surface = selected();
|
||||
const viewing = surface ? optedIn.has(surface.screenId) : false;
|
||||
optButton.disabled = surface === null;
|
||||
shareButton.disabled = surface === null || !viewing;
|
||||
stopButton.disabled = surface === null || !surface.bound;
|
||||
optButton.textContent = viewing ? "Stop viewing" : "Opt in to view";
|
||||
// Presenting is its own explicit action. Requiring viewer opt-in here would
|
||||
// start a remote viewer join immediately before replacing it with capture.
|
||||
shareButton.disabled = surface === null;
|
||||
stopButton.disabled = surface === null || (!surface.bound && !remoteCanStop(surface.screenId));
|
||||
const retry = surface !== null && viewing && ["stopped", "unavailable", "error"]
|
||||
.includes(remoteStatuses.get(surface.screenId) ?? "off");
|
||||
optButton.textContent = retry ? "Retry remote" : viewing ? "Stop viewing" : "Opt in to view";
|
||||
optButton.setAttribute("aria-pressed", String(viewing));
|
||||
}
|
||||
|
||||
optButton.addEventListener("click", () => {
|
||||
const surface = selected();
|
||||
if (!surface) return;
|
||||
const remote = remoteStatuses.get(surface.screenId);
|
||||
if (optedIn.has(surface.screenId) &&
|
||||
(remote === "stopped" || remote === "unavailable" || remote === "error")) {
|
||||
render();
|
||||
options.onViewerOptIn?.(copySurface(surface), true);
|
||||
return;
|
||||
}
|
||||
const next = !optedIn.has(surface.screenId);
|
||||
if (next) optedIn.add(surface.screenId);
|
||||
else optedIn.delete(surface.screenId);
|
||||
@@ -150,7 +179,9 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
});
|
||||
stopButton.addEventListener("click", () => {
|
||||
const surface = selected();
|
||||
if (surface?.bound) options.onStopShare?.(copySurface(surface));
|
||||
if (surface && (surface.bound || remoteCanStop(surface.screenId))) {
|
||||
options.onStopShare?.(copySurface(surface));
|
||||
}
|
||||
});
|
||||
closeButton.addEventListener("click", () => close());
|
||||
|
||||
@@ -160,6 +191,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
selectedId,
|
||||
optedInScreenIds: [...optedIn],
|
||||
surfaces: surfaces.map(copySurface),
|
||||
remoteStatusByScreen: Object.fromEntries(remoteStatuses),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,17 +253,29 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
|
||||
surfaces = validateSurfaces(next);
|
||||
const ids = new Set(surfaces.map((surface) => surface.screenId));
|
||||
for (const id of optedIn) if (!ids.has(id)) optedIn.delete(id);
|
||||
for (const id of remoteStatuses.keys()) if (!ids.has(id)) remoteStatuses.delete(id);
|
||||
if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null;
|
||||
render();
|
||||
return snapshot();
|
||||
},
|
||||
close,
|
||||
state: snapshot,
|
||||
setRemoteStatus(screenId, status) {
|
||||
if (disposed || !surfaces.some((surface) => surface.screenId === screenId)) return snapshot();
|
||||
if (!["off", "connecting", "live", "reconnecting", "stopped", "unavailable", "error"].includes(status)) {
|
||||
throw new TypeError("office screen panel: invalid remote status");
|
||||
}
|
||||
if (status === "off") remoteStatuses.delete(screenId);
|
||||
else remoteStatuses.set(screenId, status);
|
||||
render();
|
||||
return snapshot();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
close();
|
||||
disposed = true;
|
||||
optedIn.clear();
|
||||
remoteStatuses.clear();
|
||||
surfaces = [];
|
||||
selectedId = null;
|
||||
root.remove();
|
||||
|
||||
@@ -60,11 +60,22 @@ export interface PresenterStart {
|
||||
stream: MediaStream;
|
||||
video: HTMLVideoElement;
|
||||
}
|
||||
export interface ViewerStart {
|
||||
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
|
||||
decision: MediaAuthorizationDecision;
|
||||
video: HTMLVideoElement;
|
||||
}
|
||||
export type ViewerStart =
|
||||
| {
|
||||
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
|
||||
decision: MediaAuthorizationDecision;
|
||||
viewerOptIn?: never;
|
||||
video: HTMLVideoElement;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Literal user action for signaling servers that perform authorization
|
||||
* during join and intentionally expose no source locator to the client.
|
||||
*/
|
||||
decision?: never;
|
||||
viewerOptIn: true;
|
||||
video: HTMLVideoElement;
|
||||
};
|
||||
export interface RemoteOfficeMedia {
|
||||
startPresenter(input: PresenterStart): Promise<void>;
|
||||
startViewer(input: ViewerStart): Promise<void>;
|
||||
@@ -310,6 +321,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
cursor = advanced.value;
|
||||
if (message.type === "screen-share-stopped") {
|
||||
closeTransport();
|
||||
// The server has already destroyed the signaling session. Forget the
|
||||
// capability now so caller cleanup cannot issue a redundant leave that
|
||||
// is guaranteed to fail authorization.
|
||||
grant = null;
|
||||
setStatus("stopped");
|
||||
return;
|
||||
}
|
||||
@@ -412,7 +427,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
if (!message.continuous) resetPeerMesh();
|
||||
await reconcile(message.participants);
|
||||
scheduleRenewal();
|
||||
setStatus("connecting");
|
||||
// A presenter with no viewers is still live: the hosted signaling session
|
||||
// is accepting authorized viewers. A viewer remains connecting until its
|
||||
// receive-only peer actually reaches the connected state.
|
||||
setStatus(options.role === "presenter" && peers.size === 0 ? "live" : "connecting");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -492,6 +510,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
|
||||
await reconcile(response.participants);
|
||||
scheduleRenewal();
|
||||
if (role === "presenter" && peers.size === 0) setStatus("live");
|
||||
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); });
|
||||
}
|
||||
|
||||
@@ -525,9 +544,12 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
async function startViewer(input: ViewerStart): Promise<void> {
|
||||
if (options.role !== "viewer") throw new Error("remote media: adapter is not a viewer");
|
||||
if (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
|
||||
if (!input.decision.authorized || !input.decision.optedIn || !input.decision.canView ||
|
||||
input.decision.surface.screenId !== binding.screenId || input.decision.surface.officeId !== binding.officeId ||
|
||||
input.decision.surface.source?.kind !== "live-stream") {
|
||||
const ready = input.decision === undefined
|
||||
? input.viewerOptIn === true
|
||||
: input.decision.authorized && input.decision.optedIn && input.decision.canView &&
|
||||
input.decision.surface.screenId === binding.screenId && input.decision.surface.officeId === binding.officeId &&
|
||||
input.decision.surface.source?.kind === "live-stream";
|
||||
if (!ready) {
|
||||
throw new Error("remote media: a ready live-stream authorization decision is required");
|
||||
}
|
||||
input.video.autoplay = false;
|
||||
|
||||
Reference in New Issue
Block a user