MoQ is under active development. APIs will change, but we keep backwards wire compatibility.

Skip to content

@moq/watch ​

npm

A player: subscribes to a broadcast, picks renditions, decodes with WebCodecs, renders video to a canvas and audio through WebAudio, and keeps them in sync at the latency you ask for.

html
<script type="module">
    import "@moq/watch/element";
    import "@moq/watch/ui";       // optional overlay
</script>

<moq-watch-ui>
    <moq-watch url="https://relay.example.com/anon" name="room/alice.hang">
        <canvas></canvas>
    </moq-watch>
</moq-watch-ui>

Attributes ​

Attribute
url, nameRelay URL (with ?jwt= if needed) and broadcast name.
paused, muted, volumeThe usual player controls, mirrored as reactive properties.
delayHow far playback trails the live edge: "auto" (the default, sized from how late frames actually arrive; see audio jitter), a duration like "300ms", or "instant" to paint frames as they decode with no pacing at all.
bufferFuture-dated media held beyond the live edge before playback skips ahead, e.g. "30s". Defaults to none.
captionsThe caption track to show, or absent for off. el.text.out.available lists the renditions for a picker.
visibleOnly subscribe to video while the element is on screen: a margin ("20%" default, "200px"), "always", or "never".
announcedWait for the broadcast to be announced before subscribing (default on), so a player can be mounted before the stream exists.
catalog-formathang (default, from the .hang suffix), hangz (compressed), msf, or manual to supply the catalog yourself.

A volume change ramps over el.emitter.fade, 200ms by default; 0 steps at once.

Video holds its last picture while paused, out of view, or waiting for a resumed rendition's first frame. Its reported timestamp stays with that picture. Going offline or closing the player clears it.

The overlay adds play/pause, volume, fullscreen, a quality selector, a buffering indicator, an unsupported-codec warning, and a stats panel. <moq-watch-support> shows what the browser can play.

el.broadcast.out.status is offline, loading, live, or error. It is error when the origin refuses the broadcast, such as a dynamic() handler rejecting the request, and el.broadcast.out.error then holds the refusal's Error; the overlay shows its message in place of the offline notice. A refusal is final: only a new name, a new origin, a changed announced, or re-enabling asks again, which clears both back to offline.

Binding from a framework ​

import "@moq/watch/element" registers <moq-watch> while the module evaluates, so a browser-only entrypoint that imports it before mounting gets an upgraded element. el.broadcast, el.video, el.audio, el.sync, and el.signals are assigned by the constructor and readable right away.

Defer the import and that guarantee goes with it. A dynamic import() inside onMount, one behind a browser guard, or a <script> that loads after the markup all leave the tag unregistered until they land, and an element of an unregistered tag is a plain HTMLElement. A framework binding (Svelte's bind:this, React's ref) hands you that un-upgraded node, where every property reads undefined:

ts
// TypeError: el.broadcast is undefined
el.broadcast.out.catalog.subscribe(handler);

The browser upgrades the same node once the definition arrives, applying the attributes it already has. Use a static import in browser-only entrypoints. For SSR applications, run the following in a client-side mount hook, after the node exists. The element module needs browser globals and must not be imported during server rendering. Start the import before waiting for registration:

ts
await import("@moq/watch/element");
await customElements.whenDefined("moq-watch");
el.broadcast.out.catalog.subscribe(handler);

Custom tracks ​

The catalog schema is loose: sections @moq/hang doesn't recognize are passed through to broadcast.out.catalog, a read-only signal you react to like any other. Subscribe to the track it names off broadcast.out.active, the live broadcast consumer, and decode JSON with @moq/json.

ts
import * as Json from "@moq/json";
import { Hang } from "@moq/watch";

// Run after the element module has loaded and the node has mounted.
const el = document.querySelector("moq-watch");
if (!el) throw new Error("Missing <moq-watch> element");

const dispose = el.signals.run((effect) => {
    const catalog = effect.get(el.broadcast.out.catalog) as { metadata?: unknown } | undefined;
    const active = effect.get(el.broadcast.out.active);

    const metadata = catalog?.metadata;
    if (metadata !== undefined &&
        (!Array.isArray(metadata) || !metadata.every((name): name is string => typeof name === "string"))) {
        throw new Error("Expected metadata to be an array of track names");
    }
    const name = metadata?.[0];
    if (!active || !name) return;

    const track = active.track(name).subscribe({ priority: Hang.Catalog.PRIORITY.catalog });
    effect.cleanup(() => track.close());

    const consumer = new Json.Snapshot.Consumer<unknown>({ track });
    effect.spawn(async () => {
        for (;;) {
            const value = await effect.race(consumer.next());
            if (value === undefined) break;
            console.log("metadata", value);
        }
    });
});

Call dispose() from your framework's unmount cleanup when this subscription is no longer needed. Removing the element disables playback but keeps its effects open so the same node can reconnect.

The effect re-runs whenever the catalog or the active broadcast changes, so a reconnect resubscribes on its own. A publisher that rewrites its catalog often (a live encoder tweak) re-runs it too; memoize the track name with effect.computed when that matters, as the watch demo does.

el.catalog is the same value read once, without subscribing. Reach the rest of the pipeline through el.broadcast, el.video, el.audio, and el.signals.

Without the element ​

ts
import * as Moq from "@moq/net";
import * as Watch from "@moq/watch";

// Shared with every other component pointed at the same relay; the broadcast
// handle reads from its origin and spans reconnects.
const connection = new Moq.Connection({ url: new URL("https://relay.example.com/anon") });
const player = new Watch.Player({
    origin: connection.origin,
    probe: connection.probe,
    name: Moq.Path.from("alice.hang"),
    canvas,
});
// player.broadcast, player.video, player.audio, player.text, player.sync,
// player.renderer, and player.emitter expose the pipeline.
// Call player.close() when playback ends.

Pass a signal from @moq/signals for any control you want to change later, such as muted or delay. Player owns the same pipeline as <moq-watch>; Watch.Broadcast, Sync, and the per-track components remain available for custom composition. Load the element from a CDN (https://esm.sh/@moq/watch/element) for a no-build embed.

Buffered playback ​

By default the player minimizes latency: it skips ahead whenever media piles up past the delay. Content produced faster than real time, such as a TTS response emitted in one burst with future timestamps, wants the opposite. Set buffer to how far ahead it may run and it plays through at the encoded pace:

html
<moq-watch url="..." name="bot/tts.hang" delay="100ms" buffer="30s"></moq-watch>

Durations need a unit; a bare number is rejected. Only the delay is held as decoded PCM; the buffer stays as encoded frames with backpressure on the decoder, so a large one is cheap. el.reset() flushes and re-anchors at the next frame, which is how a producer interrupts an utterance.

Strict CSP ​

The audio worklet loads from a blob: URL by default, so it needs no hosted files but a CSP must allow blob: in script-src. For a CSP that refuses blob:, copy node_modules/@moq/watch/assets/* into a directory your origin serves, and point the package at it before playback starts:

ts
import * as Watch from "@moq/watch";

Watch.assets("/moq/");

The URL must end with /. Copy the files again on every upgrade: the worklet changes with the package. @moq/room and @moq/boy play through @moq/watch, so this one call covers them.

Licensed under MIT or Apache-2.0