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: 2 additions & 2 deletions doc/lib/js/watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion js/watch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
8 changes: 4 additions & 4 deletions js/watch/api/element.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions js/watch/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@ export class Broadcast {
}

export type BroadcastInput = {
announced: Signals.Getter<boolean>;
catalog: Signals.Getter<Hang.Catalog.Root | undefined>;
catalogFormat: Signals.Getter<CatalogFormat | undefined>;
enabled: Signals.Getter<boolean>;
follow: Signals.Getter<boolean>;
name: Signals.Getter<Net.Path.Valid>;
origin: Signals.Getter<Net.Origin.Table | undefined>;
};
Expand Down Expand Up @@ -148,7 +148,6 @@ export class Player {
}

export type PlayerInput = {
announced: Signals.Getter<boolean>;
audio: {
muted: Signals.Getter<boolean>;
target: Signals.Getter<Audio.Target | undefined>;
Expand All @@ -159,6 +158,7 @@ export type PlayerInput = {
catalogFormat: Signals.Getter<CatalogFormat | undefined>;
delay: Signals.Getter<Delay>;
enabled: Signals.Getter<boolean>;
follow: Signals.Getter<boolean>;
name: Signals.Getter<Net.Path.Valid>;
origin: Signals.Getter<Net.Origin.Table | undefined>;
paused: Signals.Getter<boolean>;
Expand Down
42 changes: 21 additions & 21 deletions js/watch/src/broadcast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 };
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -242,7 +243,7 @@ describe("relativeBroadcast", () => {
origin: owner,
name: Path.from("a/b"),
enabled: true,
announced: false,
follow: false,
catalogFormat: "manual",
catalog,
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -475,15 +476,15 @@ 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();
const source = new Broadcast({
origin: owner,
name: Path.from("blind.hang"),
enabled: true,
announced: false,
follow: false,
catalogFormat: "manual",
});

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand All @@ -727,7 +727,7 @@ describe("refusal", () => {
origin: scoped,
name: Path.from("lobby/live.hang"),
enabled: true,
announced: true,
follow: true,
catalogFormat: "manual",
});
await settle();
Expand All @@ -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"));
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 16 additions & 15 deletions js/watch/src/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ export type BroadcastInput = {
// The broadcast name.
name: Getter<Moq.Path.Valid>;

// 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<boolean>;
// 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<boolean>;

// Which catalog format to use. When `undefined` (the default), the format is
// auto-detected from the broadcast name extension (`.hang`, `.msf`), falling
Expand All @@ -133,7 +134,7 @@ export type BroadcastOutput = {
active: Signal<Moq.Broadcast.Consumer | undefined>;

// 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<Error | undefined>;

// The effective catalog: the fetched one, or a copy of input.catalog in manual mode, minus
Expand Down Expand Up @@ -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
Expand All @@ -182,15 +183,15 @@ export class Broadcast {
#signals: Effect;

constructor(props?: Inputs<BroadcastInput>) {
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<CatalogFormat | undefined>(props?.catalogFormat),
catalog: getter(props?.catalog),
};
Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
Expand All @@ -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);
});
}
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading