Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion doc/lib/js/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ new Publish.Audio.Encoder("audio", { broadcast, capture: audioCapture, enabled:
Standalone components start enabled unless you pass `enabled: false` (or a
signal). An enabled camera or microphone prompts for permission on
construction, and a screen source must be built inside the user gesture that
authorizes it. Audio waits for the page's first click or keypress, since
authorizes it. A screen source's `out.state` is `off`, `prompting`, `live`, or
`ended` with a reason (`stopped`, `dismissed`, or `error`); an ended share stays
ended until the next enable, which prompts again. Audio waits for the page's first click or keypress, since
browsers suspend Web Audio until then. Every input and output is a signal from
[`@moq/signals`](/lib/js/signals). A failed encoder exposes the cause through
`out.error` and disables only its rendition; siblings and the broadcast keep
Expand Down
43 changes: 43 additions & 0 deletions js/moq-boy/src/element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,46 @@ for (const attribute of ["prefix", "prefix-game"] as const) {
}
});
}

// A page's explicit disable wins: correcting a refused path must not turn the element back on.
test("an explicit disable survives an invalid path being corrected", async () => {
const el = new MoqBoy();
try {
el.connectedCallback();
await settle();
expect(el.connection.enabled.peek()).toBe(true);

el.connection.enabled.set(false);
expect(() => el.attributeChangedCallback("prefix", null, "/boy")).toThrow();
await settle();
el.attributeChangedCallback("prefix", "/boy", "boy");
await settle();
expect(el.connection.enabled.peek()).toBe(false);

// A valid edit leaves the page's choice alone too.
el.attributeChangedCallback("prefix", "boy", "other");
await settle();
expect(el.connection.enabled.peek()).toBe(false);
} finally {
el.disconnectedCallback();
}
});

// The element is already off while a path is refused, so the page's disable there must still count.
test("an explicit disable while a path is refused survives the fix", async () => {
const el = new MoqBoy();
try {
el.connectedCallback();
expect(() => el.attributeChangedCallback("prefix", null, "/boy")).toThrow();
el.connection.enabled.set(false);
el.attributeChangedCallback("prefix", "/boy", "boy");
await settle();
expect(el.connection.enabled.peek()).toBe(false);

el.connection.enabled.set(true);
await settle();
expect(el.connection.enabled.peek()).toBe(true);
} finally {
el.disconnectedCallback();
}
});
65 changes: 48 additions & 17 deletions js/moq-boy/src/element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ function viewerId(): string {

const cleanup = new FinalizationRegistry<Moq.Signals.Effect>((signals) => signals.close());

/**
* The element's `connection.enabled`, which the page may also write. The page's writes set what it
* wants and the element sets whether it can run (connected to a document, every path valid); it
* reads as on only while both hold. Kept apart, a page's disable survives the element suspending and
* resuming, whenever the page writes it: an explicit page choice wins.
*/
class Enabled extends Moq.Signals.Signal<boolean> {
#wanted = true;
#running = false;

constructor() {
super(false);
}

override set(value: boolean, notify?: boolean): void {
this.#wanted = value;
super.set(value && this.#running, notify);
}

run(running: boolean): void {
this.#running = running;
super.set(this.#wanted && running);
}
}

/**
* `<moq-boy>` web component - discovers and manages Game Boy streaming sessions,
* and renders the interactive UI in a Shadow DOM.
Expand Down Expand Up @@ -58,11 +83,11 @@ export default class MoqBoy extends HTMLElement {

readonly #signals = new Moq.Signals.Effect();
// Set when the element is connected to the DOM.
readonly #connected = new Moq.Signals.Signal(false);
#connected = false;
// The path attributes holding an invalid path, which idle the element until each is fixed.
readonly #refused = new Moq.Signals.Signal<ReadonlySet<Observed>>(new Set());
// Whether to run: connected, with nothing refused.
readonly #enabled = new Moq.Signals.Signal(false);
readonly #refused = new Set<Observed>();
// Whether to run: `#update` gates it on the element's state, and the page may turn it off.
readonly #enabled = new Enabled();
readonly #prefix = new Moq.Signals.Signal<Moq.Path.Valid>(DEFAULT_PREFIX);
readonly #gamePrefixOverride = new Moq.Signals.Signal<Moq.Path.Valid | undefined>(undefined);
readonly #sessions = new Map<string, Game>();
Expand All @@ -72,10 +97,6 @@ export default class MoqBoy extends HTMLElement {
super();
cleanup.register(this, this.#signals);

this.#signals.run((effect) => {
this.#enabled.set(effect.get(this.#connected) && effect.get(this.#refused).size === 0);
});

// One origin, both directions: the viewer's endpoint is published into it and the
// relay's announced games and endpoints arrive in it, with no risk of echoing either back.
this.connection = new Moq.Connection({
Expand All @@ -94,7 +115,8 @@ export default class MoqBoy extends HTMLElement {

/** Called by the browser when the element joins a document. */
connectedCallback() {
this.#connected.set(true);
this.#connected = true;
this.#update();

// Render the UI into a Shadow DOM (reuse existing on reconnect).
const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
Expand All @@ -103,7 +125,8 @@ export default class MoqBoy extends HTMLElement {

/** Called by the browser when the element leaves its document. */
disconnectedCallback() {
this.#connected.set(false);
this.#connected = false;
this.#update();
this.#dispose?.();
this.#dispose = undefined;
}
Expand All @@ -115,9 +138,14 @@ export default class MoqBoy extends HTMLElement {
return;
}

// Refused until parsed, so an invalid path leaves the element idle and the error rethrown.
this.#refuse(name, true);
const path = newValue === null ? undefined : Moq.Path.from(newValue);
// An invalid path leaves the element idle until fixed, with the error rethrown.
let path: Moq.Path.Valid | undefined;
try {
path = newValue === null ? undefined : Moq.Path.from(newValue);
} catch (error) {
this.#refuse(name, true);
throw error;
}
this.#refuse(name, false);

if (name === "prefix") {
Expand All @@ -128,10 +156,13 @@ export default class MoqBoy extends HTMLElement {
}

#refuse(name: Observed, refused: boolean) {
const next = new Set(this.#refused.peek());
if (refused) next.add(name);
else next.delete(name);
this.#refused.set(next);
if (refused) this.#refused.add(name);
else this.#refused.delete(name);
this.#update();
}

#update() {
this.#enabled.run(this.#connected && this.#refused.size === 0);
}

/** The relay URL, mirrored by the `url` attribute. */
Expand Down
54 changes: 54 additions & 0 deletions js/moq-boy/src/game.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,57 @@ test("input opens a session that carries commands up and latency down until deac
else Reflect.deleteProperty(globalThis, "document");
}
});

// Input given while offline would land late, out of context; a reset pressed then must not fire on reconnect.
test("a command sent while disconnected is dropped, not replayed on reconnect", async () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
Object.defineProperty(globalThis, "document", { configurable: true, value: new EventTarget() });

const connection = new Moq.Connection({ enabled: false });
const status = new Moq.Signals.Signal<string>("connecting");
Object.defineProperty(connection, "status", { value: status });
const origin = new Moq.Origin.Producer();
const emulator = new Moq.Socket.Endpoint(origin, Moq.Path.from("game"), "test");
const endpoint = new Moq.Socket.Endpoint(origin, Moq.Path.from("game"), "viewer-test");
const game = new Game({
sessionId: "test",
connection,
origin,
endpoint,
expanded: new Moq.Signals.Signal<string | undefined>(undefined),
gamePrefix: "game",
});

try {
game.hovered.set(true);
game.sendCommand({ type: "reset" });
await settle();

let dialed = false;
const incoming = emulator.acceptTrack().then((track) => {
dialed = true;
return track;
});
status.set("connected");
await settle(50);
expect(dialed).toBe(false);

game.heldButtons.add("a");
game.sendButtons();
const session = await (await incoming).accept();
const commands = session.consumer.ordered();
expect(await (await commands.nextGroup())?.readJson()).toEqual({
type: "buttons",
buttons: ["a"],
timestamps: [],
});
} finally {
game.close();
endpoint.close();
emulator.close();
origin.close();
connection.close();
if (originalDocument) Object.defineProperty(globalThis, "document", originalDocument);
else Reflect.deleteProperty(globalThis, "document");
}
});
17 changes: 10 additions & 7 deletions js/moq-boy/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,17 @@ export class Game {
/** The Game Boy buttons currently held down. */
readonly heldButtons = new Set<string>();

// Internal command publishing state.
// The command waiting for the session to open, consumed once written.
#command = new Moq.Signals.Signal<Command | undefined>(undefined);
readonly #connection: Moq.Connection;
#feedbackActive = new Moq.Signals.Signal(false);

constructor(config: GameConfig) {
const { sessionId, connection, expanded, gamePrefix } = config;
this.sessionId = sessionId;
this.expanded = expanded;
this.#endpoint = config.endpoint;
this.#connection = connection;

// Derive active state from expanded + hovered.
this.#signals.run(this.#runActive.bind(this));
Expand Down Expand Up @@ -213,8 +215,10 @@ export class Game {
this.sendCommand({ type: "buttons", buttons: [...this.heldButtons] });
}

/** Send a command to the emulator. */
/** Send a command to the emulator. Dropped while disconnected, since it would land late and out of context. */
sendCommand(cmd: Record<string, unknown>) {
if (this.#connection.status.peek() !== "connected") return;

// Open the session on input, with idle timeout.
this.#feedbackActive.set(true);

Expand Down Expand Up @@ -306,11 +310,9 @@ export class Game {
}

#runSession(connection: Moq.Connection, effect: Moq.Signals.Effect) {
// Gate on a live connection: a dial is answered only through the relay.
if (effect.get(connection.status) !== "connected") return;

if (!effect.get(this.active)) {
// Clear feedback state when deactivating.
// Gate on a live connection, since a dial is answered only through the relay. Losing it or
// deactivating drops pending input, which would otherwise replay on the next session.
if (effect.get(connection.status) !== "connected" || !effect.get(this.active)) {
this.#feedbackActive.set(false);
this.#command.set(undefined);
return;
Expand Down Expand Up @@ -364,6 +366,7 @@ export class Game {
// One command per group, so a late one never holds up the next.
const value = { ...command.cmd, timestamps: command.timestamps };
open.producer.writeFrame({ payload: new TextEncoder().encode(JSON.stringify(value)) });
this.#command.set(undefined);
}

// Read the emulator's replies until the session ends.
Expand Down
4 changes: 2 additions & 2 deletions js/publish/api/element.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type SourceType = "camera" | "screen" | "file";

export default class extends HTMLElement {
constructor();
get announce(): AnnounceMode | undefined;
get announce(): AnnounceMode;
set announce(value: AnnounceMode);
attributeChangedCallback(name: "source" | "url" | "stats" | "announce" | "name" | "capture" | "backend" | "versions" | "muted" | "invisible" | "preview" | "echo-path", oldValue: string | null, newValue: string | null): void;
readonly audio: Audio.Encoder;
Expand All @@ -29,7 +29,7 @@ export default class extends HTMLElement {
connectedCallback(): void;
readonly connection: Net.Connection;
readonly controls: {
announce: Signals.Signal<AnnounceMode | undefined>;
announce: Signals.Signal<AnnounceMode>;
backend: Signals.Signal<Preview.Backend>;
capture: Signals.Signal<CaptureMode>;
echoPath: Signals.Signal<Net.Path.Relative | undefined>;
Expand Down
21 changes: 20 additions & 1 deletion js/publish/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,35 @@ export namespace Source {
readonly out: Signals.Readonlys<Source.ScreenOutput>;
video: Signals.Signal<Video.Constraints | boolean | undefined>;
}
export type ScreenEnd = {
reason: "stopped";
} | {
reason: "dismissed";
} | {
error: Error;
reason: "error";
};
export type ScreenInput = {
enabled: Signals.Getter<boolean>;
};
export type ScreenOutput = {
source: Signals.Signal<Source.Media | undefined>;
source: Signals.Getter<Source.Media | undefined>;
state: Signals.Getter<Source.ScreenState>;
};
export interface ScreenProps extends Signals.Inputs<Source.ScreenInput> {
audio?: Audio.Constraints | boolean | Signals.Signal<Audio.Constraints | boolean | undefined>;
video?: Video.Constraints | boolean | Signals.Signal<Video.Constraints | boolean | undefined>;
}
export type ScreenState = {
kind: "off";
} | {
kind: "prompting";
} | {
kind: "live";
media: Source.Media;
} | {
kind: "ended";
} & Source.ScreenEnd;
}

export namespace Video {
Expand Down
7 changes: 2 additions & 5 deletions js/publish/src/announce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function setup(initial: SourceType = "camera") {
const announcing = new Signal(false);
const connected = new Signal(true);
const source = new Signal<SourceType | undefined>(initial);
const mode = new Signal<AnnounceMode | undefined>("source");
const mode = new Signal<AnnounceMode>("source");
const broadcast = new Broadcast({
enabled: connected,
origin: new Origin.Producer(),
Expand Down Expand Up @@ -247,12 +247,9 @@ test.each(Object.keys(boundaries) as (keyof typeof boundaries)[])(
},
);

test("an invalid mode stops a latched announcement while always needs no selection", async () => {
test("always needs no selection", async () => {
using env = setup();
await start(env);
env.mode.set(undefined);
await settle();
expect(env.announcing.peek()).toBe(false);
env.source.set(undefined);
env.mode.set("always");
await settle();
Expand Down
3 changes: 1 addition & 2 deletions js/publish/src/announce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ interface Track {
}

export interface Props {
// Undefined refuses to announce.
mode: Getter<AnnounceMode | undefined>;
mode: Getter<AnnounceMode>;
// The selected source while the element is connected; it stays set across capture gaps.
source: Getter<SourceType | File | undefined>;
video: Track;
Expand Down
11 changes: 10 additions & 1 deletion js/publish/src/audio/encoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,8 @@ test("muting sends nothing and marks where the audio ends, keeping the rendition
});

// With `capture="release"` a mute closes the microphone, taking its format away. The rendition stays
// advertised, still enabled, through the mute and while the reopened microphone resolves.
// advertised, still enabled, through the mute. Once unmuted it is disabled until the microphone
// reopens, so one that never does isn't advertised as sending.
test("a muted rendition keeps its config enabled while its capture is released", async () => {
using _webcodecs = installFakeWebCodecs();
using env = await setup();
Expand All @@ -642,7 +643,15 @@ test("a muted rendition keeps its config enabled while its capture is released",

encoder.muted.set(false);
await settle();
expect(encoder.out.catalog.peek()).toEqual({ ...before, enabled: false } as never);

// Muting again while the reopen is pending is muted, not disabled.
encoder.muted.set(true);
await settle();
expect(encoder.out.catalog.peek()).toEqual(before);
encoder.muted.set(false);
await settle();
expect(encoder.out.catalog.peek()).toEqual({ ...before, enabled: false } as never);

capture.out.format.set(format);
await settle(10);
Expand Down
Loading
Loading