From f8eb747e39a0c15b738a0087ffe1e3fa5d03ab33 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Oct 2026 20:49:57 -0700 Subject: [PATCH 1/2] chore(quest): claim quest/m1/api-audit/resolve/watch.md Quest-Claim: 538f5f411d73b73f From 7472b2f7fdb972cba913ce27d46367ff391b8964 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 28 Oct 2026 20:55:38 -0700 Subject: [PATCH 2/2] feat(watch)!: name the resolution choice `follow` instead of `announced` `Watch.Broadcast` and `Watch.Player` take `follow` (default true), and `` has a `follow` attribute and property, mirroring the `@moq/net` method each choice picks: `origin.follow(name)` or `origin.request(name)`. Passing `announced` (or `reload`) throws a rename error, and an `announced` attribute is refused like an invalid value until removed. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/watch.md | 4 +- js/watch/README.md | 2 +- js/watch/api/element.api.md | 8 ++-- js/watch/api/index.api.md | 4 +- js/watch/src/broadcast.test.ts | 42 +++++++++---------- js/watch/src/broadcast.ts | 31 +++++++------- js/watch/src/element.ts | 27 +++++++----- js/watch/src/player.test.ts | 5 +++ js/watch/src/player.ts | 9 ++-- quest/m1/api-audit/resolve/README.md | 1 - quest/m1/api-audit/resolve/watch.md | 20 --------- .../clients/js/src/capture.test.ts | 6 +-- test/interop/clients/js/publish-ui.browser.ts | 33 +++++++++++---- 13 files changed, 102 insertions(+), 90 deletions(-) delete mode 100644 quest/m1/api-audit/resolve/watch.md diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index fa2ec11648e..248381f2460 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -37,7 +37,7 @@ in sync at the latency you ask for. | `captions` | The caption track to show. | | `visible` | Only download video while the element is on screen (or near it). | | `backend` | How the canvas is drawn; see [@moq/video](/lib/js/video). `auto` (default), `webgpu`, or `2d`. | -| `announced` | Wait for the broadcast to be announced before subscribing (default on), so a player can mount before the stream exists. | +| `follow` | Follow the broadcast's announcements (default on): wait for one before subscribing, so a player can mount before the stream exists, and move to each restart. Off requests the name once. | | `catalog-format` | `hang`, `msf`, or `manual`; detected from the name by default. | Every attribute is also a reactive property, and property or UI changes reflect @@ -126,7 +126,7 @@ const dispose = el.signals.run((effect) => { Call `dispose()` on unmount. The effect re-runs when the catalog or the active broadcast changes, so a reconnect resubscribes on its own: the -broadcast's announced request moves to each start or restart, and switches only +broadcast's follower moves to each start or restart, and switches only when that resolves another broadcast. A request ending is never the trigger, so following a restart needs a relay with discovery. diff --git a/js/watch/README.md b/js/watch/README.md index 10fcac96b23..8a9111f6416 100644 --- a/js/watch/README.md +++ b/js/watch/README.md @@ -71,7 +71,7 @@ The simplest way to watch a stream: | `visible` | never, distance, or always | `20%` | When to download video (see below) | | `volume` | number | 0.5 | Audio level (0-1), independent of mute | | `backend` | auto, webgpu, 2d | `auto` | Graphics API for the canvas. `auto` picks WebGPU where it can render video, else Canvas2D. | -| `announced` | boolean | true | Wait for (re)announcement before subscribing. Ignored when the relay does not support broadcast discovery. | +| `follow` | boolean | true | Follow the broadcast's announcements: wait for one before subscribing, and move to each restart. Ignored when the relay does not support broadcast discovery. | | `delay` | `auto`, duration, `instant` | `auto` | Distance from the live edge. `instant` paints frames as they decode and disables audio. | | `buffer` | duration | `0ms` | Future-dated media held before playback skips ahead. | | `captions` | string | off | Text rendition to render. | diff --git a/js/watch/api/element.api.md b/js/watch/api/element.api.md index e205c9144b4..e3ea8a99964 100644 --- a/js/watch/api/element.api.md +++ b/js/watch/api/element.api.md @@ -16,9 +16,7 @@ import { Video } from "@moq/watch"; export default class extends HTMLElement { constructor(); - get announced(): boolean; - set announced(value: boolean); - attributeChangedCallback(name: "delay" | "buffer" | "url" | "name" | "announced" | "volume" | "muted" | "paused" | "visible" | "backend" | "versions" | "catalog-format" | "captions", oldValue: string | null, newValue: string | null): void; + attributeChangedCallback(name: "delay" | "buffer" | "url" | "name" | "follow" | "volume" | "muted" | "paused" | "visible" | "backend" | "versions" | "announced" | "catalog-format" | "captions", oldValue: string | null, newValue: string | null): void; readonly audio: Audio.Decoder; get backend(): Video.Backend; set backend(value: Video.Backend); @@ -55,11 +53,13 @@ export default class extends HTMLElement { set delay(value: Delay); disconnectedCallback(): void; emitter: Audio.Emitter; + get follow(): boolean; + set follow(value: boolean); get muted(): boolean; set muted(value: boolean); get name(): Net.Path.Valid; set name(value: string | Net.Path.Valid); - static observedAttributes: readonly ["url", "versions", "name", "paused", "volume", "muted", "visible", "backend", "announced", "delay", "buffer", "catalog-format", "captions"]; + static observedAttributes: readonly ["url", "versions", "name", "paused", "volume", "muted", "visible", "backend", "follow", "announced", "delay", "buffer", "catalog-format", "captions"]; get paused(): boolean; set paused(value: boolean); readonly player: Player; diff --git a/js/watch/api/index.api.md b/js/watch/api/index.api.md index a82d17b04aa..1683a82b913 100644 --- a/js/watch/api/index.api.md +++ b/js/watch/api/index.api.md @@ -101,10 +101,10 @@ export class Broadcast { } export type BroadcastInput = { - announced: Signals.Getter; catalog: Signals.Getter; catalogFormat: Signals.Getter; enabled: Signals.Getter; + follow: Signals.Getter; name: Signals.Getter; origin: Signals.Getter; }; @@ -148,7 +148,6 @@ export class Player { } export type PlayerInput = { - announced: Signals.Getter; audio: { muted: Signals.Getter; target: Signals.Getter; @@ -159,6 +158,7 @@ export type PlayerInput = { catalogFormat: Signals.Getter; delay: Signals.Getter; enabled: Signals.Getter; + follow: Signals.Getter; name: Signals.Getter; origin: Signals.Getter; paused: Signals.Getter; diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 17f8d134cff..048784b3f0a 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -15,7 +15,7 @@ function publish(origin: Origin.Producer, path: Path.Valid) { } // A real origin with local broadcasts at the given paths. Resolution is proven by -// discrimination: `relativeBroadcast` resolves blind against the table (announced: false), so +// discrimination: `relativeBroadcast` resolves blind against the table (follow: false), so // a defined result means the reference resolved to a published path and nothing else. function origin(paths: string[]): Origin.Producer { const producer = new Origin.Producer(); @@ -29,7 +29,7 @@ function broadcast(name: string, paths: string[] = [name]): { source: Broadcast; origin: owner, name: Path.from(name), enabled: true, - announced: false, + follow: false, catalogFormat: "manual", }); return { source, owner }; @@ -65,8 +65,9 @@ const videoRenditions = (source: Broadcast): string[] => const video = (codec: string, broadcast?: string): Catalog.VideoConfig => ({ codec, container: { kind: "legacy" }, broadcast }) as Catalog.VideoConfig; -it("refuses the released reload input", () => { - expect(() => new Broadcast({ reload: true } as never)).toThrow("renamed to `announced`"); +it("refuses the renamed reload and announced inputs", () => { + expect(() => new Broadcast({ reload: true } as never)).toThrow("`reload` was renamed to `follow`"); + expect(() => new Broadcast({ announced: true } as never)).toThrow("`announced` was renamed to `follow`"); }); describe("unsupported catalog", () => { @@ -242,7 +243,7 @@ describe("relativeBroadcast", () => { origin: owner, name: Path.from("a/b"), enabled: true, - announced: false, + follow: false, catalogFormat: "manual", catalog, }); @@ -346,7 +347,7 @@ describe("relativeBroadcast", () => { origin: owner, name: Path.from("a/b"), enabled: true, - announced: false, + follow: false, catalogFormat: "manual", }); const effect = new Effect(); @@ -475,7 +476,7 @@ describe("relativeBroadcast", () => { describe("blind resolution", () => { it("holds a resolved request steady instead of flapping", async () => { - // announced: false with nothing routed stands a request; when a session answers, the + // follow: false with nothing routed stands a request; when a session answers, the // effect that read `request.active` reruns. That rerun must re-acquire the same // answer, not close the request and re-dial forever. const owner = new Origin.Producer(); @@ -483,7 +484,7 @@ describe("blind resolution", () => { origin: owner, name: Path.from("blind.hang"), enabled: true, - announced: false, + follow: false, catalogFormat: "manual", }); @@ -518,7 +519,7 @@ it("a blind broadcast does not follow another publisher instance", async () => { const owner = new Origin.Producer(); const name = Path.from("blind.hang"); const first = publish(owner, name); - const source = new Broadcast({ origin: owner, name, announced: false, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, follow: false, catalogFormat: "manual" }); await settle(); const second = publish(owner, name); await settle(); @@ -532,11 +533,10 @@ it("a blind broadcast does not follow another publisher instance", async () => { describe("restart", () => { it("follows a republish through its follower", async () => { - const announced = true; const owner = new Origin.Producer(); const name = Path.from("live.hang"); const first = publish(owner, name); - const source = new Broadcast({ origin: owner, name, enabled: true, announced, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); await settle(); const old = source.out.active.peek(); expect(old).toBeDefined(); @@ -591,7 +591,7 @@ describe("restart", () => { }; // The initial start settles just before teardown, so its continuation runs after it. - const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); queueMicrotask(() => queueMicrotask(() => source.close())); await settle(); expect(open).toBe(0); @@ -605,7 +605,7 @@ describe("restart", () => { const name = Path.from("pool/job.hang"); const pool = owner.requests(Path.from("pool")); const answers = serve(pool); - const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); await settle(); const old = source.out.active.peek(); expect(old).toBeDefined(); @@ -639,7 +639,7 @@ describe("restart", () => { const exactPublication = owner.createBroadcast(name); const exact = exactPublication.broadcast; exactPublication.announce({ epoch }); - const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); await settle(); const old = source.out.active.peek(); expect(old).toBeDefined(); @@ -668,7 +668,7 @@ describe("restart", () => { const pool = owner.requests(Path.from("pool")); const answers = serve(pool); const exact = publish(owner, name); - const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); await settle(); const old = source.out.active.peek(); expect(old).toBeDefined(); @@ -700,7 +700,7 @@ describe("refusal", () => { const name = Path.from("room/live.hang"); const first = owner.requests(Path.from("room")); const answers = serve(first); - const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); + const source = new Broadcast({ origin: owner, name, enabled: true, follow: true, catalogFormat: "manual" }); await settle(); expect(source.out.active.peek()).toBeDefined(); @@ -727,7 +727,7 @@ describe("refusal", () => { origin: scoped, name: Path.from("lobby/live.hang"), enabled: true, - announced: true, + follow: true, catalogFormat: "manual", }); await settle(); @@ -740,14 +740,14 @@ describe("refusal", () => { await settle(); }); - for (const announced of [true, false]) { - it(`reports a refusal until a fresh request (announced: ${announced})`, async () => { + for (const follow of [true, false]) { + it(`reports a refusal until a fresh request (follow: ${follow})`, async () => { const owner = new Origin.Producer(); const route = owner.requests(Path.from("room")); const requests = route.requested(); const name = new Signal(Path.from("room/refused.hang")); const enabled = new Signal(true); - const source = new Broadcast({ origin: owner, name, enabled, announced, catalogFormat: "hang" }); + const source = new Broadcast({ origin: owner, name, enabled, follow, catalogFormat: "hang" }); const first = await requests.next(); expect(first.value?.path).toBe(Path.from("room/refused.hang")); @@ -1139,7 +1139,7 @@ it("subscribes a cross-broadcast MSF rendition on its namespace", async () => { origin: owner, name: Path.from("room/catalog.msf"), enabled: true, - announced: false, + follow: false, }); const effect = new Effect(); try { diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 51615ed7fb9..4edded91170 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -110,9 +110,10 @@ export type BroadcastInput = { // The broadcast name. name: Getter; - // Whether to wait for the broadcast to be announced before subscribing. - // Defaults to true; pass false to subscribe immediately without waiting for an announcement. - announced: Getter; + // Whether to follow the broadcast's announcements (`Origin.follow`), waiting for one before + // subscribing and moving to each restart. Defaults to true; pass false to request it once + // (`Origin.request`), subscribing immediately without waiting for an announcement. + follow: Getter; // Which catalog format to use. When `undefined` (the default), the format is // auto-detected from the broadcast name extension (`.hang`, `.msf`), falling @@ -133,7 +134,7 @@ export type BroadcastOutput = { active: Signal; // Why the origin refused the broadcast, while `status` is "error". A refusal is final: - // only a fresh request (a new `name`, `origin`, or `announced`, or re-enabling) clears it and asks again. + // only a fresh request (a new `name`, `origin`, or `follow`, or re-enabling) clears it and asks again. error: Signal; // The effective catalog: the fetched one, or a copy of input.catalog in manual mode, minus @@ -168,7 +169,7 @@ export class Broadcast { // Requests are shared by callers; each owns its restart watch in the origin. readonly #references = new Map< Moq.Path.Valid, - { origin: Moq.Origin.Table; announced: boolean; request: Moq.Origin.Requesting; users: number } + { origin: Moq.Origin.Table; follow: boolean; request: Moq.Origin.Requesting; users: number } >(); // The catalog as published, before renditions this consumer cannot use are dropped. Kept @@ -182,15 +183,15 @@ export class Broadcast { #signals: Effect; constructor(props?: Inputs) { - if (props && "reload" in props) { - throw new Error("Watch.Broadcast: `reload` was renamed to `announced`"); + for (const renamed of ["reload", "announced"]) { + if (props && renamed in props) throw new Error(`Watch.Broadcast: \`${renamed}\` was renamed to \`follow\``); } this.#signals = new Effect(); this.in = { origin: getter(props?.origin), name: getter(props?.name ?? Path.empty()), enabled: getter(props?.enabled ?? true), - announced: getter(props?.announced ?? true), + follow: getter(props?.follow ?? true), catalogFormat: getter(props?.catalogFormat), catalog: getter(props?.catalog), }; @@ -248,11 +249,11 @@ export class Broadcast { origin: Moq.Origin.Table, path: Moq.Path.Valid, ): Moq.Broadcast.Consumer | undefined { - const announced = effect.get(this.in.announced); + const follow = effect.get(this.in.follow); let entry = this.#references.get(path); - if (!entry || entry.origin !== origin || entry.announced !== announced) { + if (!entry || entry.origin !== origin || entry.follow !== follow) { entry?.request.close(); - entry = { origin, announced, request: announced ? origin.follow(path) : origin.request(path), users: 0 }; + entry = { origin, follow, request: follow ? origin.follow(path) : origin.request(path), users: 0 }; this.#references.set(path, entry); } @@ -279,11 +280,11 @@ export class Broadcast { if (!origin) return; const name = effect.get(this.in.name); - const announced = effect.get(this.in.announced); + const follow = effect.get(this.in.follow); let request: Moq.Origin.Requesting; try { - request = announced ? origin.follow(name) : origin.request(name); + request = follow ? origin.follow(name) : origin.request(name); } catch (err) { effect.set(this.#out.error, err instanceof Error ? err : new Error(String(err)), undefined); return; @@ -301,7 +302,7 @@ export class Broadcast { // A follower outlives its instances, so only a refusal ends it. A plain request // ends with its accepted instance, which reads as offline rather than an error. const offline = - !announced && resolved && closed instanceof NetError.Stream && closed.code === StreamCode.Unroutable; + !follow && resolved && closed instanceof NetError.Stream && closed.code === StreamCode.Unroutable; if (!offline) nested.set(this.#out.error, closed, undefined); }); } @@ -435,7 +436,7 @@ export class Broadcast { // resolve blind rather than waiting for an announcement that never comes. With the // gate, only report the path usable once it is announced: the request then resolves // from the table, never blind. - if (effect.get(this.in.announced) && effect.get(origin.discovery) !== false) { + if (effect.get(this.in.follow) && effect.get(origin.discovery) !== false) { if (!this.#isPathAnnounced(effect, origin, resolved)) return undefined; } diff --git a/js/watch/src/element.ts b/js/watch/src/element.ts index cb3d094484f..4ec31b00af6 100644 --- a/js/watch/src/element.ts +++ b/js/watch/src/element.ts @@ -58,6 +58,8 @@ const OBSERVED = [ "muted", "visible", "backend", + "follow", + // Renamed to `follow`, observed only to refuse it. "announced", "delay", "buffer", @@ -112,7 +114,7 @@ export function parseCatalogFormat(value: string | null): CatalogFormat | undefi /** * Parse a boolean attribute: absent uses `defaultValue`, bare presence is true, and an explicit * `"false"`/`"0"` is false. Presence alone can't express false, and attributes that default to - * true (`announced`) need to, so every boolean attribute accepts the explicit form. + * true (`follow`) need to, so every boolean attribute accepts the explicit form. */ function parseBoolean(value: string | null, defaultValue: boolean): boolean { if (value === null) return defaultValue; @@ -190,7 +192,7 @@ export default class MoqWatch extends HTMLElement { // Broadcast configuration owned here and wired into `broadcast` as inputs. #name = new Signal(Moq.Path.empty()); - #announced = new Signal(true); + #follow = new Signal(true); #catalogFormat = new Signal(undefined); #catalog = new Signal(undefined); @@ -232,7 +234,7 @@ export default class MoqWatch extends HTMLElement { probe: this.connection.probe, enabled: this.#enabled, name: this.#name, - announced: this.#announced, + follow: this.#follow, catalogFormat: this.#catalogFormat, catalog: this.#catalog, ...this.controls, @@ -306,7 +308,7 @@ export default class MoqWatch extends HTMLElement { ); this.signals.run((effect) => this.#reflect(effect, "visible", effect.get(this.controls.video.visible))); this.signals.run((effect) => this.#reflect(effect, "backend", effect.get(this.controls.video.backend))); - this.signals.run((effect) => this.#reflect(effect, "announced", effect.get(this.#announced) ? "" : "false")); + this.signals.run((effect) => this.#reflect(effect, "follow", effect.get(this.#follow) ? "" : "false")); this.signals.run((effect) => this.#reflect(effect, "catalog-format", effect.get(this.#catalogFormat))); this.signals.run((effect) => this.#reflect(effect, "captions", effect.get(this.controls.text.target))); this.signals.run((effect) => { @@ -384,7 +386,10 @@ export default class MoqWatch extends HTMLElement { else if (name === "muted") this.muted = parseBoolean(newValue, false); else if (name === "visible") this.visible = parseVisible(newValue); else if (name === "backend") this.backend = parseBackend(newValue); - else if (name === "announced") this.announced = parseBoolean(newValue, true); + else if (name === "follow") this.follow = parseBoolean(newValue, true); + else if (name === "announced" && newValue !== null) + throw new Error("moq-watch: `announced` was renamed to `follow`"); + else if (name === "announced") this.#accept("announced"); else if (name === "delay") this.delay = parseDelay(newValue); else if (name === "buffer") this.buffer = parseBuffer(newValue); else if (name === "catalog-format") this.catalogFormat = parseCatalogFormat(newValue); @@ -509,14 +514,14 @@ export default class MoqWatch extends HTMLElement { this.#accept("backend"); } - /** Whether to wait for the broadcast's announcement before subscribing, mirrored by the `announced` attribute. */ - get announced(): boolean { - return this.#announced.peek(); + /** Whether to follow the broadcast's announcements, waiting for one before subscribing, mirrored by the `follow` attribute. */ + get follow(): boolean { + return this.#follow.peek(); } - set announced(value: boolean) { - this.#announced.set(value); - this.#accept("announced"); + set follow(value: boolean) { + this.#follow.set(value); + this.#accept("follow"); } /** diff --git a/js/watch/src/player.test.ts b/js/watch/src/player.test.ts index 5d7f2a25b77..4e593d23a01 100644 --- a/js/watch/src/player.test.ts +++ b/js/watch/src/player.test.ts @@ -10,6 +10,11 @@ async function flush() { for (let i = 0; i < 10; i++) await Promise.resolve(); } +test("Player refuses the renamed reload and announced inputs", () => { + expect(() => new Player({ reload: true } as never)).toThrow("`reload` was renamed to `follow`"); + expect(() => new Player({ announced: true } as never)).toThrow("`announced` was renamed to `follow`"); +}); + test("Player owns the shared pipeline and feeds output policy into decoders", async () => { const enabled = new Signal(true); const paused = new Signal(false); diff --git a/js/watch/src/player.ts b/js/watch/src/player.ts index c921cdaea98..b31ac65fb28 100644 --- a/js/watch/src/player.ts +++ b/js/watch/src/player.ts @@ -17,8 +17,8 @@ export type PlayerInput = { name: Getter; /** Whether the pipeline may subscribe. */ enabled: Getter; - /** Wait for a broadcast announcement before subscribing. */ - announced: Getter; + /** Follow the broadcast's announcements, waiting for one before subscribing. */ + follow: Getter; /** Catalog format override, or automatic detection when absent. */ catalogFormat: Getter; /** Catalog supplied when the format is `manual`. */ @@ -97,11 +97,14 @@ export class Player { #signals = new Effect(); constructor(props: PlayerProps = {}) { + for (const renamed of ["reload", "announced"]) { + if (renamed in props) throw new Error(`Watch.Player: \`${renamed}\` was renamed to \`follow\``); + } this.in = { origin: getter(props.origin), name: getter(props.name ?? Path.empty()), enabled: getter(props.enabled ?? true), - announced: getter(props.announced ?? true), + follow: getter(props.follow ?? true), catalogFormat: getter(props.catalogFormat), catalog: getter(props.catalog), probe: getter(props.probe), diff --git a/quest/m1/api-audit/resolve/README.md b/quest/m1/api-audit/resolve/README.md index 4c92dbfc369..06bbee60d73 100644 --- a/quest/m1/api-audit/resolve/README.md +++ b/quest/m1/api-audit/resolve/README.md @@ -52,7 +52,6 @@ Wire: none. - [Rust request and follow](/quest/m1/api-audit/resolve/rust.md) - Rust names the two resolutions `request` and `follow`, deletes `routed` and `routed_broadcast`, and makes the route-level `Follow` private - [Bindings request and follow](/quest/m1/api-audit/resolve/bindings.md) - moq-ffi, the C ABI, and every wrapper mirror `request` and `follow` with an optional epoch -- [The watch player's follow prop](/quest/m1/api-audit/resolve/watch.md) - `@moq/watch`'s `Broadcast` names its resolution choice `follow` instead of `announced` - [Callers resolve once](/quest/m1/api-audit/resolve/callers.md) - no caller hand-rolls synced resolution or decides table absence by wall clock ## Related diff --git a/quest/m1/api-audit/resolve/watch.md b/quest/m1/api-audit/resolve/watch.md deleted file mode 100644 index 63787a5dc33..00000000000 --- a/quest/m1/api-audit/resolve/watch.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] The watch player's follow prop - -## Goal - -`@moq/watch`'s `Broadcast` names its resolution choice `follow` (default -true), mirroring `@moq/net`'s `follow` and `request`, instead of -`announced`. Passing `announced` throws a rename error, as `reload` already -does. - -## Plan - -Decided 2026-10-29 (maintainer), a follow-up of -[moq-dev/moq#7173](https://github.com/moq-dev/moq/pull/7173), which renamed -the `@moq/net` side and left the player surface out of scope. One vocabulary -across `@moq/net` and the player, so the prop reads as the method it picks. - -Update the watch docs and README, and any in-tree caller that passes the prop. - -Public API: breaking in `@moq/watch`, with an Impact migration note. Wire: -none. diff --git a/test/audio-quality/clients/js/src/capture.test.ts b/test/audio-quality/clients/js/src/capture.test.ts index 11b0b278d2e..33409905b34 100644 --- a/test/audio-quality/clients/js/src/capture.test.ts +++ b/test/audio-quality/clients/js/src/capture.test.ts @@ -78,7 +78,7 @@ test("the observer shares playback demand and closes without ending playback", a const published = publishedPublication.broadcast; const track = published.createTrack("audio", { timescale: Time.Timescale.MILLI }); publishedPublication.announce(); - const source = new Broadcast({ origin, name: Path.from("tone.hang"), announced: false, catalogFormat: "manual" }); + const source = new Broadcast({ origin, name: Path.from("tone.hang"), follow: false, catalogFormat: "manual" }); const capture = new Capture({ broadcast: new Signal(source), track: new Signal("audio"), @@ -121,7 +121,7 @@ test("at auto the observer asks for the decoder's 2 s budget, not the target", a const published = publishedPublication.broadcast; const track = published.createTrack("audio", { timescale: Time.Timescale.MILLI }); publishedPublication.announce(); - const source = new Broadcast({ origin, name: Path.from("tone.hang"), announced: false, catalogFormat: "manual" }); + const source = new Broadcast({ origin, name: Path.from("tone.hang"), follow: false, catalogFormat: "manual" }); const capture = new Capture({ broadcast: new Signal(source), track: new Signal("audio"), @@ -151,7 +151,7 @@ async function captureGroupEnd(end: (group: Group.Producer) => void): Promise error.includes("catalog-format"))).toBe(true); expect(result.enabled).toBe(false); expect(result.raw).toBe("guess"); + + const renamed = await page.evaluate(() => { + const errors: string[] = []; + window.addEventListener("error", (event) => { + errors.push(event.message); + event.preventDefault(); + }); + const watch = document.createElement("moq-watch"); + watch.setAttribute("announced", "false"); + document.body.append(watch); + const refused = + !watch.connection.enabled.peek() && errors.some((error) => error.includes("renamed to `follow`")); + watch.removeAttribute("announced"); + const recovered = watch.connection.enabled.peek(); + watch.remove(); + watch.signals.close(); + return { refused, recovered }; + }); + expect(renamed).toEqual({ refused: true, recovered: true }); } finally { await browser.close(); await server.close(); @@ -382,14 +401,14 @@ test("version lists keep their defaults and explicit false booleans round-trip", else watch.setAttribute("versions", value); versions.push(watch.versions); } - watch.setAttribute("announced", "false"); - const announced = watch.announced; + watch.setAttribute("follow", "false"); + const follow = watch.follow; watch.signals.close(); - return { versions, announced }; + return { versions, follow }; }); expect(result).toEqual({ versions: [["moq-transport-14"], ["moq-lite-06", "moq-transport-22"], undefined, undefined, undefined], - announced: false, + follow: false, }); } finally { await browser.close();