MoQ is under active development. APIs and protocols may change between releases.

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" (derived from RTT, the default), 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.

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.

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.

Licensed under MIT or Apache-2.0