mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ feat: Add streaming support (#125)
This adds: - [x] Keyboard and mouse handling on the frontend - [x] Video and audio streaming from the backend to the frontend - [x] Input server that works with Websockets Update - 17/11 - [ ] Master docker container to run this - [ ] Steam runtime - [ ] Entrypoint.sh --------- Co-authored-by: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com> Co-authored-by: Kristian Ollikainen <DatCaptainHorse@users.noreply.github.com>
This commit is contained in:
@@ -1,190 +1,2 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
export { Player } from "./player"
|
||||
export type { PlayerConfig } from "./player"
|
||||
|
||||
Reference in New Issue
Block a user