@moq/watch
Subscribe to and render MoQ broadcasts. Provides both a JavaScript API and a <moq-watch> Web Component, plus an optional <moq-watch-ui> overlay.
Installation
bun add @moq/watch
# or
npm add @moq/watchNo-build CDN usage
For quick demos or single-page embeds where a bundler is overkill, load the package straight from esm.sh. esm.sh serves the package as a browser-ready ESM module and rewrites bare imports (like @moq/hang, @moq/net) to other esm.sh URLs, so it loads in the browser with no import map or local build step:
<script type="module">
import "https://esm.sh/@moq/watch/element";
import "https://esm.sh/@moq/watch/ui";
</script>
<moq-watch-ui>
<moq-watch url="https://relay.example.com/anon" name="room/alice.hang">
<canvas></canvas>
</moq-watch>
</moq-watch-ui>Pin a version range in the URL for production, e.g. https://esm.sh/@moq/watch@0.2/element. jsDelivr's +esm endpoint (https://cdn.jsdelivr.net/npm/@moq/watch/element.js/+esm) works the same way if you prefer it.
For anything beyond embedding on a static page, install the package and use a real bundler (the examples below).
Web Component
<script type="module">
import "@moq/watch/element";
</script>
<moq-watch
url="https://relay.example.com/anon"
name="room/alice.hang"
controls>
<canvas></canvas>
</moq-watch>Attributes:
url(required): Relay server URLname(required): Broadcast namecontrols: Show playback controls (boolean)paused: Pause playback (boolean)muted: Mute audio (boolean)volume: Audio volume (0 to 1, default: 1)reload: Wait for (re)announcement before subscribing (default: true). Defaults off formediaoverquic.comrelays until they support broadcast discovery.latency: Latency target."real-time"(default) derives it from RTT, or a number sets a fixed jitter buffer in ms. Collapseslatency-minandlatency-maxto one value (minimize latency).latency-min: Latency floor (the jitter/startup buffer). Same units aslatency; leaves the ceiling untouched.latency-max: Latency ceiling."real-time"(default) minimizes latency; a number caps at that many ms. A ceiling above the floor enables buffered playback: build up a buffer from future-dated frames instead of skipping ahead.catalog-format: Catalog format. One of"hang","hangz"(the DEFLATE-compressedcatalog.json.ztrack),"msf"(see MSF), or"manual"(supply the catalog yourself). When omitted, the format is auto-detected from the broadcastnameextension (.hangor.msf), falling back to"hang"."hangz"is opt-in only and never auto-detected (it shares the.hangsuffix).
Catalog Formats
@moq/watch can consume either the default hang catalog or MSF (MoQ Streaming Format). The format is detected from the broadcast name extension by default. room/alice.hang uses hang, room/alice.msf uses MSF. Set catalog-format explicitly to override:
<moq-watch
url="https://relay.example.com/anon"
name="room/alice.hang"
catalog-format="msf">
<canvas></canvas>
</moq-watch>import * as Watch from "@moq/watch";
const broadcast = new Watch.Broadcast({
connection,
enabled: true,
name: "alice.hang",
catalogFormat: "msf",
});
// or toggle at runtime
broadcast.catalogFormat.set("msf");Manual catalogs
Use catalog-format="manual" (or catalogFormat: "manual") to skip the catalog track entirely and supply a Catalog.Root directly. The connection and broadcast name are still required, since they're used to subscribe to the media tracks named by the catalog. Update the catalog at any time by writing to the signal:
import * as Watch from "@moq/watch";
const broadcast = new Watch.Broadcast({
connection,
enabled: true,
name: "alice.hang",
catalogFormat: "manual",
catalog: {
video: { renditions: { hd: { codec: "vp09.00.10.08", container: { kind: "legacy" } } } },
},
});
// Replace at runtime
broadcast.catalog.set(nextCatalog);The web component exposes the same field as a JS property:
const el = document.querySelector("moq-watch")!;
el.catalogFormat = "manual";
el.catalog = myCatalog;Switching
catalogFormatbetween"manual"and a fetched format ("hang"/"msf") tears down the previous fetch loop, which clearscatalog. Set the catalog after switching to"manual", not before.
Buffered playback
Latency is a range, [latency-min, latency-max]. By default the range is collapsed (latency sets both to one value) and @moq/watch minimizes latency: it anchors playback to the earliest frame seen relative to its timestamp and skips ahead whenever the buffer grows past the target. That is right for live conferencing, but wrong for content written faster than real-time with timestamps in the future, such as a text-to-speech response streamed all at once.
Open the range, by setting a latency-max above the floor, to anchor playback to the first frame received and play through at the encoded pace. The buffer is allowed to float anywhere between the floor and the ceiling without skipping:
<moq-watch url="https://relay.example.com/anon" name="bot/tts.hang"
latency-min="100" latency-max="30000">
<canvas></canvas>
</moq-watch>In JavaScript, latency takes either a scalar (collapsed, minimize) or a range object. The latencyMin / latencyMax properties are read-modify-write sugar over the same latency value:
const el = document.querySelector("moq-watch")!;
el.latency = { min: 100, max: 30_000 }; // floor 100ms, ceiling 30s
// equivalently, set the bounds independently:
el.latencyMin = 100; // floor: start after 100ms buffered
el.latencyMax = 30_000; // ceiling: never skip until 30s bufferedlatency-min is the jitter/startup buffer (it can also be "real-time" for an adaptive floor). latency-max is the ceiling, and it has two forms:
- a number (ms): buffer freely up to the cap, then skip ahead, so latency stays at most that far behind the newest frame.
"real-time"(the default) or any value<= latency-min: collapsed, i.e. today's minimize-latency behavior.
The ceiling is always finite: the buffer is bounded by latency-max rather than growing without limit. The mechanism is the same in every case: the playhead is anchored on the first frame and only re-anchored (skipped forward) when keeping it would push latency past latency-max. Minimize is just the degenerate case where the ceiling equals the floor.
The buffered lookahead is held cheaply. The decoded audio ring only holds the floor (latency-min) worth of PCM; everything above it stays upstream as encoded frames, and the decoder applies backpressure (stops decoding ahead) until the playhead nears each frame. So a large latency-max costs encoded bytes, not seconds of decoded PCM.
At each new utterance, call reset() to flush the audio buffer and re-anchor playback to the next frame. The producer can interrupt by writing a new utterance (optionally on a new track) and the viewer calls reset() to drop whatever was still buffered:
el.reset();This removes the need to pace writes on the producer: emit the whole response as fast as possible with correct (future) timestamps, and reset() on interruption.
Buffered playback uses the WebCodecs path (a
<canvas>child). It does not apply to the MSE<video>path. Set the range before the broadcast starts decoding; changing it mid-stream takes effect on the next decoder.
Custom tracks and catalog sections
A broadcast can carry arbitrary application tracks (for example a meta.json metadata track) alongside the media. An application advertises them in its own catalog section (the catalog root is a loose object, so unknown sections pass through to broadcast.catalog). subscribeTrack(name, priority, consume) follows the active broadcast across reconnects and runs consume(track, effect) each time it becomes active. Decode the payload yourself with the re-exported @moq/json:
import { Json } from "@moq/watch";
import { Signals } from "@moq/watch";
// The app's own catalog section, read back from the loose catalog.
const section = broadcast.catalog.peek()?.scte35;
const scte35 = new Signals.Signal<{ splices: number[] } | undefined>(undefined);
broadcast.subscribeTrack("scte35.json", 100, (track, effect) => {
const consumer = new Json.Consumer<{ splices: number[] }>(track);
effect.spawn(async () => {
for (;;) {
const next = await Promise.race([effect.cancel, consumer.next()]);
if (next === undefined) break;
scte35.set(next);
}
});
});The component exposes everything via its broadcast property (el.broadcast.subscribeTrack(...)).
UI Overlay
Import @moq/watch/ui for a Web Component overlay with buffering indicator, stats panel, and playback controls:
<script type="module">
import "@moq/watch/element";
import "@moq/watch/ui";
</script>
<moq-watch-ui>
<moq-watch
url="https://relay.example.com/anon"
name="room/alice.hang">
<canvas></canvas>
</moq-watch>
</moq-watch-ui>The <moq-watch-ui> element automatically discovers the nested <moq-watch> and wires up reactive controls.
JavaScript API
import * as Watch from "@moq/watch";
const broadcast = new Watch.Broadcast({
connection,
enabled: true,
name: "alice.hang",
});
// Disable the announcement gate for relays without broadcast discovery.
broadcast.reload.set(false);Related Packages
- @moq/publish: Publish media to MoQ broadcasts
- @moq/hang: Core media library (catalog, container, support)
- @moq/net: Core pub/sub transport protocol