Skip to content

Commit c7410f3

Browse files
committed
feat(publish): expose the underlying broadcast as Broadcast.net
The publish element's `Broadcast` created its `Moq.Broadcast` producer internally and never exposed it, so an application couldn't serve its own tracks alongside the built-in catalog/audio/video. Expose it as a `net` signal, (re)created on each (re)connection. A statically inserted track (`net.createTrack`/`insertTrack`) is served via the producer's fast path, so it bypasses the element's `requested()` loop instead of being rejected as an unknown track. Use it in the watch demo: the per-stream metadata now rides on a separate `meta.json` track (advertised in the catalog's `metadata` list) served via `net` and a `@moq/json` Producer, instead of the catalog `meta`-section workaround. The watch inspector subscribes to it off `broadcast.output.active`. This restores the separate-track design from the demo without the backwards-compat `publishTrack` shim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQ9o9paZnRLpYRgbyjUwFV
1 parent b10deb4 commit c7410f3

5 files changed

Lines changed: 92 additions & 34 deletions

File tree

demo/web/src/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ <h3>How it works:</h3>
126126
watch.backend.video.output.stats.subscribe((stats) =&gt; { /* decoded frames, bytes */ });</code></pre>
127127
</p>
128128
<p>
129-
The metadata panel reads a custom <code>meta</code> section carried <em>within</em> the
130-
broadcast's catalog (see the <a href="publish.html">publish demo</a>), which arrives with every
131-
catalog frame, reconstructed from a snapshot plus merge-patch deltas via
129+
The metadata panel subscribes to a custom <code>meta.json</code> track carried <em>within</em> the
130+
broadcast (see the <a href="publish.html">publish demo</a>), advertised in the catalog's
131+
<code>metadata</code> list and reconstructed from a snapshot plus merge-patch deltas via
132132
<code class="language-typescript">@moq/json</code>.
133133
</p>
134134
<p>

demo/web/src/index.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
* active (and is the user gesture that lets its audio start); every other tile
1212
* is muted.
1313
*
14-
* The per-stream metadata is carried in a custom `meta` section of the active
15-
* broadcast's catalog, read straight off the tile's `broadcast.output.catalog`
16-
* signal so it follows the broadcast across reconnects.
14+
* The per-stream metadata rides on a separate `meta.json` track within the active
15+
* broadcast (advertised in the catalog's `metadata` list); we subscribe to it off
16+
* the tile's `broadcast.output.active` consumer and decode it with @moq/json.
1717
*/
1818

1919
import "./highlight";
2020
import "@moq/watch/element"; // defines <moq-watch>
2121
import "@moq/watch/ui"; // defines <moq-watch-ui>
22-
import { Net, Signals } from "@moq/watch";
22+
import { Json, Net, Signals } from "@moq/watch";
2323
import type MoqWatch from "@moq/watch/element";
2424
import MoqWatchSupport from "@moq/watch/support/element";
2525
import { bufferBars, formatBitrate, formatFps, graph, renderRows } from "./viz";
@@ -57,8 +57,8 @@ const active = new Signals.Signal<string | undefined>(undefined);
5757
// The right-hand stats panel reads everything off this.
5858
const activeWatch = new Signals.Signal<MoqWatch | undefined>(undefined);
5959

60-
// The metadata carried in the active catalog's custom `meta` section, or
61-
// undefined when the broadcast advertises none.
60+
// The decoded value of the active broadcast's `meta.json` track, or undefined when
61+
// the broadcast advertises none.
6262
const metaSignal = new Signals.Signal<unknown>(undefined);
6363

6464
// The relay URL, editable at runtime. Both the discovery connection and every
@@ -377,15 +377,42 @@ ui.run((effect) => {
377377
// Metadata
378378
// ---------------------------------------------------------------------------
379379
//
380-
// The publish demo carries its metadata in a custom `meta` section *inside* the
381-
// broadcast's catalog (the hang catalog is a loose schema, so the extra key
382-
// passes through and base consumers ignore it). We read it straight off the
383-
// active catalog signal, so it updates with every catalog frame and follows the
384-
// broadcast across reconnects without a separate subscription.
380+
// The publish demo serves its metadata as a separate `meta.json` track, advertised
381+
// in the catalog's `metadata` list. We read the active broadcast off
382+
// `broadcast.output.active`, subscribe to that track, and decode the JSON value,
383+
// re-subscribing whenever the broadcast (or the advertised track) changes.
385384
ui.run((effect) => {
386385
const watch = effect.get(activeWatch);
387-
const catalog = watch ? (effect.get(watch.broadcast.output.catalog) as { meta?: unknown } | undefined) : undefined;
388-
metaSignal.set(catalog?.meta);
386+
if (!watch) {
387+
metaSignal.set(undefined);
388+
return;
389+
}
390+
391+
const broadcast = effect.get(watch.broadcast.output.active);
392+
const catalog = effect.get(watch.broadcast.output.catalog) as { metadata?: string[] } | undefined;
393+
const trackName = catalog?.metadata?.[0];
394+
if (!broadcast || !trackName) {
395+
metaSignal.set(undefined);
396+
return;
397+
}
398+
399+
const track = broadcast.track(trackName).subscribe();
400+
effect.cleanup(() => track.close());
401+
const consumer = new Json.Consumer<unknown>(track);
402+
403+
effect.spawn(async () => {
404+
try {
405+
for (;;) {
406+
const value = await Promise.race([effect.cancel, consumer.next()]);
407+
if (value === undefined) break;
408+
metaSignal.set(value);
409+
}
410+
} catch (err) {
411+
console.warn("error reading metadata", err);
412+
} finally {
413+
metaSignal.set(undefined);
414+
}
415+
});
389416
});
390417

391418
// Metadata view - only shown when the active broadcast is live AND has actually

demo/web/src/publish.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,8 @@ <h3>How it works:</h3>
245245
picks whichever fits the player size.
246246
</p>
247247
<p>
248-
The metadata editor carries a custom JSON section <em>within</em> the broadcast's catalog via
249-
<code class="language-typescript">catalog.mutate</code>; the watch inspector reads it back live.
248+
The metadata editor serves a custom <code class="language-typescript">meta.json</code> track <em>within</em> the
249+
broadcast via <code class="language-typescript">broadcast.net</code>; the watch inspector subscribes and reads it back live.
250250
</p>
251251
<p>
252252
You're not limited to the browser. Try <code class="language-bash">just pub tos</code> in a terminal

demo/web/src/publish.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import "./highlight";
1919
import "@moq/publish/element"; // defines <moq-publish>
2020
import "@moq/publish/ui"; // defines <moq-publish-ui>
21-
import { type Audio, Net, Signals } from "@moq/publish";
21+
import { type Audio, Json, Net, Signals } from "@moq/publish";
2222
import type MoqPublish from "@moq/publish/element";
2323
import MoqPublishSupport from "@moq/publish/support/element";
2424
import { formatBitrate, formatFps, graph } from "./viz";
@@ -300,23 +300,42 @@ ui.run((effect) => {
300300
// Custom metadata carried within the broadcast
301301
// ---------------------------------------------------------------------------
302302
//
303-
// We carry the metadata as a custom `meta` section *inside* the broadcast's
304-
// catalog rather than as a separate track. The hang catalog is a loose schema,
305-
// so the extra key passes through validation untouched: base consumers ignore
306-
// it, and the watch inspector reads it straight off `broadcast.output.catalog`.
307-
// `catalog.mutate` edits only the `meta` key, so it composes with the base
308-
// `video`/`audio` sections the encoders own.
309-
310-
// A custom catalog section is anything outside the base video/audio keys.
311-
type MetaCatalog = { meta?: unknown };
303+
// We serve the metadata as a separate `meta.json` track *within* the broadcast,
304+
// using `broadcast.net` (the underlying producer the element exposes). `net` is
305+
// recreated on each (re)connection, so an effect (re)creates the track and seeds
306+
// it with the latest value; a long cache window lets a late viewer replay the
307+
// most recent snapshot. The track is advertised in the catalog's `metadata` list
308+
// so the watch side knows to subscribe.
309+
const META_TRACK = "meta.json";
310+
311+
// The latest metadata, retained across reconnects so each fresh track is seeded with it.
312+
let currentMeta: unknown = { title: "My Broadcast", location: "earth", note: "edit me" };
313+
let activeMeta: Json.Producer<unknown> | undefined;
312314

313315
const setMeta = (value: unknown) => {
314-
publish.broadcast.catalog.mutate((catalog) => {
315-
(catalog as typeof catalog & MetaCatalog).meta = value;
316-
});
316+
currentMeta = value;
317+
activeMeta?.update(value);
317318
};
318319

319-
setMeta({ title: "My Broadcast", location: "earth", note: "edit me" });
320+
new Signals.Effect().run((effect) => {
321+
const net = effect.get(publish.broadcast.net);
322+
if (!net) return;
323+
324+
// A day-long cache so a viewer joining long after the last edit still replays the value.
325+
const track = net.createTrack(META_TRACK, { cache: 86_400_000 });
326+
effect.cleanup(() => track.close());
327+
328+
const producer = new Json.Producer<unknown>(track);
329+
producer.update(currentMeta);
330+
activeMeta = producer;
331+
effect.cleanup(() => {
332+
if (activeMeta === producer) activeMeta = undefined;
333+
});
334+
});
335+
336+
publish.broadcast.catalog.mutate((catalog) => {
337+
(catalog as typeof catalog & { metadata?: string[] }).metadata = [META_TRACK];
338+
});
320339

321340
const metaTextEl = $<HTMLTextAreaElement>("metadata");
322341
const metaBtn = $<HTMLButtonElement>("send-meta");
@@ -327,8 +346,8 @@ metaTextEl.addEventListener("input", () => {
327346

328347
metaBtn.addEventListener("click", () => {
329348
try {
330-
// mutate() re-publishes the catalog; the CatalogProducer emits a snapshot
331-
// to seed late joiners, then merge-patch deltas, a no-op if unchanged.
349+
// Publishes a fresh snapshot on the meta.json track (a no-op if unchanged); the
350+
// cache window seeds late joiners.
332351
setMeta(JSON.parse(metaTextEl.value));
333352
metaTextEl.setCustomValidity("");
334353
metaBtn.disabled = true;

js/publish/src/broadcast.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export class Broadcast {
2828
// sections (e.g. `scte35`) by locking it too.
2929
readonly catalog = new CatalogProducer();
3030

31+
// The underlying network broadcast, (re)created on each (re)connection and `undefined` while
32+
// offline. Exposed so an application can serve its own tracks alongside the built-in
33+
// catalog/audio/video, e.g. `net.createTrack("meta.json")` plus a matching `catalog` section.
34+
// Reacquire it via an effect, since reconnecting swaps in a fresh producer.
35+
readonly net = new Signal<Moq.Broadcast | undefined>(undefined);
36+
3137
signals = new Effect();
3238

3339
constructor(props?: BroadcastProps) {
@@ -72,6 +78,12 @@ export class Broadcast {
7278
const broadcast = new Moq.Broadcast();
7379
effect.cleanup(() => broadcast.close());
7480

81+
// Publish it before serving so an application reacting to `net` can insert its own tracks.
82+
this.net.set(broadcast);
83+
effect.cleanup(() => {
84+
if (this.net.peek() === broadcast) this.net.set(undefined);
85+
});
86+
7587
connection.publish(name, broadcast);
7688

7789
effect.spawn(this.#runBroadcast.bind(this, broadcast, effect));

0 commit comments

Comments
 (0)