Skip to main content

Watching Streams

This guide covers how to subscribe to and watch Moq broadcasts, from simple web components to custom players.

Quick Start: Web Component

The easiest way to watch a Moq stream is with the <moq-watch> Web Component:
<!DOCTYPE html>
<html>
  <head>
    <script type="module">
      import '@moq/watch/ui'
    </script>
  </head>
  <body>
    <moq-watch 
      url="https://relay.moq.dev/demo" 
      name="bbb"
    ></moq-watch>
  </body>
</html>
This provides a complete player with:
  • Video/audio playback
  • Quality selection
  • Volume control
  • Playback statistics
  • Error handling
The @moq/watch/ui import includes a SolidJS-based UI. For a headless component without UI, use @moq/watch/element.

Installation

npm
npm install @moq/watch @moq/hang @moq/lite
bun
bun add @moq/watch @moq/hang @moq/lite
pnpm
pnpm add @moq/watch @moq/hang @moq/lite

Web Component API

Basic Usage

<moq-watch 
  url="https://relay.example.com/demo"
  name="my-stream"
  autoplay
></moq-watch>

Attributes

AttributeTypeDescription
urlstringWebTransport URL of the relay
namestringName/path of the broadcast
autoplaybooleanStart playing automatically
mutedbooleanStart muted
controlsbooleanShow player controls (default: true)

Methods

const watch = document.querySelector('moq-watch')

// Play/pause
await watch.play()
await watch.pause()

// Volume control
watch.volume = 0.5  // 0.0 to 1.0
watch.muted = true

// Quality selection
const tracks = watch.getTracks()
watch.selectTrack('video/720p')

// Get statistics
const stats = watch.getStats()
console.log('Bitrate:', stats.bitrate)
console.log('Latency:', stats.latency)

Events

const watch = document.querySelector('moq-watch')

// Playback events
watch.addEventListener('play', () => console.log('Playing'))
watch.addEventListener('pause', () => console.log('Paused'))
watch.addEventListener('ended', () => console.log('Ended'))

// Connection events
watch.addEventListener('connected', () => console.log('Connected'))
watch.addEventListener('disconnected', () => console.log('Disconnected'))

// Error events
watch.addEventListener('error', (e) => console.error('Error:', e.detail))

// Track events
watch.addEventListener('tracks', (e) => console.log('Tracks:', e.detail))

JavaScript API

For custom player implementation:

Basic Example

import { Client } from '@moq/lite'
import { Catalog, Watch } from '@moq/hang'

// Connect to relay
const client = await Client.connect('https://relay.example.com/demo')

// Subscribe to broadcast
const broadcast = await client.subscribe('my-stream')

// Read catalog to discover tracks
const catalog = await Catalog.fetch(broadcast)
console.log('Available tracks:', catalog.tracks())

// Create watcher
const watch = new Watch(broadcast, catalog)

// Attach to video element
const video = document.querySelector('video')
watch.attach(video)

// Start playback
await watch.play()

Custom Track Selection

// Get available video tracks
const videoTracks = catalog.tracks()
  .filter(t => t.kind === 'video')
  .sort((a, b) => b.bitrate - a.bitrate)

console.log('Available qualities:')
for (const track of videoTracks) {
  console.log(`  ${track.name}: ${track.width}x${track.height} @ ${track.bitrate}bps`)
}

// Select specific quality
await watch.selectTrack('video/720p')

// Or select by resolution
const hd = videoTracks.find(t => t.height >= 720)
if (hd) {
  await watch.selectTrack(hd.name)
}

Manual Decoding

For complete control:
import { Client } from '@moq/lite'
import { Catalog } from '@moq/hang'

const client = await Client.connect('https://relay.example.com/demo')
const broadcast = await client.subscribe('my-stream')
const catalog = await Catalog.fetch(broadcast)

// Subscribe to specific track
const track = broadcast.getTrack('video/1080p')

// Setup decoder
const decoder = new VideoDecoder({
  output: (frame) => {
    // Render frame to canvas or video element
    ctx.drawImage(frame, 0, 0)
    frame.close()
  },
  error: (e) => console.error('Decode error:', e)
})

// Get codec info from catalog
const trackInfo = catalog.tracks().find(t => t.name === 'video/1080p')
decoder.configure({
  codec: trackInfo.codec,
  codedWidth: trackInfo.width,
  codedHeight: trackInfo.height
})

// Decode frames
for await (const group of track.groups()) {
  for await (const frame of group.frames()) {
    const chunk = new EncodedVideoChunk({
      type: frame.isKeyframe ? 'key' : 'delta',
      timestamp: frame.timestamp,
      data: frame.data
    })
    decoder.decode(chunk)
  }
}

Adaptive Streaming

Automatic Quality Selection

import { AdaptiveSelector } from '@moq/watch'

const selector = new AdaptiveSelector(watch)

// Enable adaptive streaming
selector.enable({
  // Target buffer size (ms)
  targetBuffer: 2000,
  
  // Bandwidth estimation window (ms)
  bandwidthWindow: 10000,
  
  // Switch up threshold (% of bandwidth)
  switchUpThreshold: 0.8,
  
  // Switch down threshold (% of bandwidth)
  switchDownThreshold: 1.2
})

// Monitor quality changes
selector.addEventListener('qualitychange', (e) => {
  console.log('Switched to:', e.detail.track)
})

Manual Quality Control

// Get available qualities
const qualities = catalog.tracks()
  .filter(t => t.kind === 'video')
  .map(t => ({
    name: t.name,
    label: `${t.height}p`,
    bitrate: t.bitrate
  }))

// Let user select
const select = document.querySelector('#quality-select')
for (const q of qualities) {
  const option = document.createElement('option')
  option.value = q.name
  option.textContent = q.label
  select.appendChild(option)
}

select.addEventListener('change', async () => {
  await watch.selectTrack(select.value)
})

Statistics & Monitoring

Real-time Statistics

// Get current stats
const stats = watch.getStats()

console.log({
  // Playback
  currentTime: stats.currentTime,
  buffered: stats.buffered,
  
  // Network
  bitrate: stats.bitrate,
  bandwidth: stats.bandwidth,
  
  // Quality
  droppedFrames: stats.droppedFrames,
  decodedFrames: stats.decodedFrames,
  
  // Latency
  latency: stats.latency,
  jitter: stats.jitter
})

// Monitor continuously
setInterval(() => {
  const stats = watch.getStats()
  updateUI(stats)
}, 1000)

Performance Monitoring

// Track quality changes
let qualityChanges = 0
watch.addEventListener('qualitychange', () => {
  qualityChanges++
})

// Track buffering events
let bufferingEvents = 0
let bufferingTime = 0
watch.addEventListener('waiting', () => {
  bufferingEvents++
  bufferStart = Date.now()
})
watch.addEventListener('playing', () => {
  if (bufferStart) {
    bufferingTime += Date.now() - bufferStart
  }
})

// Report metrics
setInterval(() => {
  analytics.track('playback_quality', {
    quality_changes: qualityChanges,
    buffering_events: bufferingEvents,
    buffering_time_ms: bufferingTime,
    average_bitrate: watch.getStats().bitrate
  })
}, 60000)

Authentication

Watch protected broadcasts with JWT tokens:
// Generate token (server-side or CLI)
// moq-token --key secret.jwk sign --root demo --subscribe my-stream

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'

// Include in URL
const url = `https://relay.example.com/demo?jwt=${token}`
const client = await Client.connect(url)
See Authentication for detailed setup.

Advanced Topics

Multi-track Playback

Play video, audio, and subtitles together:
const broadcast = await client.subscribe('my-stream')
const catalog = await Catalog.fetch(broadcast)

// Create separate watchers for each type
const videoWatch = new Watch(broadcast, catalog)
const audioWatch = new Watch(broadcast, catalog)

// Select tracks
await videoWatch.selectTrack('video/1080p')
await audioWatch.selectTrack('audio/en')

// Sync to same video element
const video = document.querySelector('video')
videoWatch.attachVideo(video)
audioWatch.attachAudio(video)

// Play both
await Promise.all([
  videoWatch.play(),
  audioWatch.play()
])

Time-shift / DVR

If the relay caches content, you can seek backward:
// Check if DVR is available
const dvr = watch.getDVRWindow()
if (dvr) {
  console.log(`Can seek back ${dvr.duration}ms`)
  
  // Seek backward
  await watch.seek(watch.currentTime - 30000) // 30s back
}

Thumbnail Preview

Generate thumbnails for seeking:
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

const decoder = new VideoDecoder({
  output: (frame) => {
    // Draw first frame of each GOP as thumbnail
    if (frame.isKeyframe) {
      canvas.width = frame.displayWidth
      canvas.height = frame.displayHeight
      ctx.drawImage(frame, 0, 0)
      
      const thumbnail = canvas.toDataURL()
      saveThumbnail(frame.timestamp, thumbnail)
    }
    frame.close()
  },
  error: console.error
})

Troubleshooting

  • Check browser console for errors
  • Verify WebTransport is supported (Chrome 97+, Edge 97+)
  • Check relay URL is correct
  • Verify broadcast name exists
  • Check network connectivity
  • Verify JWT token is valid
  • Check token has subscribe permission
  • Ensure token hasn’t expired
  • Check URL includes ?jwt= parameter
  • Check network bandwidth
  • Try lower quality rendition
  • Check CPU usage (decoding overhead)
  • Enable hardware acceleration
  • Check relay distance (use closer relay)
  • Verify publisher is using low-latency settings
  • Disable quality auto-switching temporarily
  • Check for network congestion
  • Ensure both use same clock reference
  • Check timestamp alignment in catalog
  • Verify decoder latency is similar

Next Steps

Publishing

Learn how to publish media streams

Authentication

Setup JWT authentication

hang Protocol

Understand the media layer

moq-lite Protocol

Learn about the transport layer

Build docs developers (and LLMs) love