mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus
191 lines
4.8 KiB
TypeScript
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
|
|
}
|
|
}
|
|
*/
|
|
}
|