Files
netris-nestri/packages/moq/playback/index.ts
Wanjohi bae089e223 feat: Host a relay on Hetzner (#114)
We are hosting a [MoQ](https://quic.video) relay on a remote (bare
metal) server on Hetzner

With a lot of help from @victorpahuus
2024-09-26 21:34:42 +03:00

191 lines
4.8 KiB
TypeScript

import * as Message from "./worker/message"
import { Connection } from "../transport/connection"
import * as Catalog from "../media/catalog"
import { asError } from "../common/error"
import Backend from "./backend"
import { Client } from "../transport/client"
import { GroupReader } from "../transport/objects"
export type Range = Message.Range
export type Timeline = Message.Timeline
export interface PlayerConfig {
url: string
namespace: string
fingerprint?: string // URL to fetch TLS certificate fingerprint
canvas: HTMLCanvasElement
}
// This class must be created on the main thread due to AudioContext.
export class Player {
#backend: Backend
// A periodically updated timeline
//#timeline = new Watch<Timeline | undefined>(undefined)
#connection: Connection
#catalog: Catalog.Root
// Running is a promise that resolves when the player is closed.
// #close is called with no error, while #abort is called with an error.
#running: Promise<void>
#close!: () => void
#abort!: (err: Error) => void
private constructor(connection: Connection, catalog: Catalog.Root, backend: Backend) {
this.#connection = connection
this.#catalog = catalog
this.#backend = backend
const abort = new Promise<void>((resolve, reject) => {
this.#close = resolve
this.#abort = reject
})
// Async work
this.#running = Promise.race([this.#run(), abort]).catch(this.#close)
}
static async create(config: PlayerConfig): Promise<Player> {
const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "subscriber" })
const connection = await client.connect()
const catalog = await Catalog.fetch(connection, config.namespace)
console.log("catalog", catalog)
const canvas = config.canvas.transferControlToOffscreen()
const backend = new Backend({ canvas, catalog })
return new Player(connection, catalog, backend)
}
async #run() {
const inits = new Set<[string, string]>()
const tracks = new Array<Catalog.Track>()
for (const track of this.#catalog.tracks) {
if (!track.namespace) throw new Error("track has no namespace")
if (track.initTrack) inits.add([track.namespace, track.initTrack])
tracks.push(track)
}
// Call #runInit on each unique init track
// TODO do this in parallel with #runTrack to remove a round trip
await Promise.all(Array.from(inits).map((init) => this.#runInit(...init)))
// Call #runTrack on each track
await Promise.all(tracks.map((track) => this.#runTrack(track)))
}
async #runInit(namespace: string, name: string) {
const sub = await this.#connection.subscribe(namespace, name)
try {
const init = await Promise.race([sub.data(), this.#running])
if (!init) throw new Error("no init data")
// We don't care what type of reader we get, we just want the payload.
const chunk = await init.read()
if (!chunk) throw new Error("no init chunk")
if (!(chunk.payload instanceof Uint8Array)) throw new Error("invalid init chunk")
this.#backend.init({ data: chunk.payload, name })
} finally {
await sub.close()
}
}
async #runTrack(track: Catalog.Track) {
if (!track.namespace) throw new Error("track has no namespace")
const sub = await this.#connection.subscribe(track.namespace, track.name)
try {
for (;;) {
const segment = await Promise.race([sub.data(), this.#running])
if (!segment) break
if (!(segment instanceof GroupReader)) {
throw new Error(`expected group reader for segment: ${track.name}`)
}
const kind = Catalog.isVideoTrack(track) ? "video" : Catalog.isAudioTrack(track) ? "audio" : "unknown"
if (kind == "unknown") {
throw new Error(`unknown track kind: ${track.name}`)
}
if (!track.initTrack) {
throw new Error(`no init track for segment: ${track.name}`)
}
const [buffer, stream] = segment.stream.release()
this.#backend.segment({
init: track.initTrack,
kind,
header: segment.header,
buffer,
stream,
})
}
} catch (error) {
console.error("Error in #runTrack:", error)
} finally {
await sub.close()
}
}
getCatalog() {
return this.#catalog
}
#onMessage(msg: Message.FromWorker) {
if (msg.timeline) {
//this.#timeline.update(msg.timeline)
}
}
async close(err?: Error) {
if (err) this.#abort(err)
else this.#close()
if (this.#connection) this.#connection.close()
if (this.#backend) await this.#backend.close()
}
async closed(): Promise<Error | undefined> {
try {
await this.#running
} catch (e) {
return asError(e)
}
}
/*
play() {
this.#backend.play({ minBuffer: 0.5 }) // TODO configurable
}
seek(timestamp: number) {
this.#backend.seek({ timestamp })
}
*/
async play() {
await this.#backend.play()
}
/*
async *timeline() {
for (;;) {
const [timeline, next] = this.#timeline.value()
if (timeline) yield timeline
if (!next) break
await next
}
}
*/
}