FERRETGame engine over Babylon.js
Ferret

@ferret/media

Audio sources and ambient zones, video surfaces (HLS/MP4 to texture) and HTML surfaces; recording, and frame-by-frame capture on simulated time to WebM or PNG.

48 exports · source in packages/media/src

Classes

AmbientZones class

class AmbientZones implements Service

Ambient sound by zone. map({ Yard: 'wind', Hall: 'room-tone' }) names the sound each zone plays (sounds defined in the AudioService, on the ambient bus); when the player's zone changes the old one fades out and the new one fades in. A zone with no entry keeps what was playing; map it to null for silence.

readonly name: "ambient"
constructor(readonly opts: AmbientOptions = {})
init(world: World)
map(table: Record<string, string | null>): this
get playing(): { zone: string; sound: string | null } | nullThe zone whose ambience is playing and the sound.
dispose()

packages/media/src/ambient.ts:16

AudioPlayer class

class AudioPlayer extends MediaComponent<AudioPlayerParams>

A sound that plays, pauses and seeks, with a queue of tracks (next, previous, repeat, shuffle): a radio, a jukebox, the music of a place. Streams from its source, so long tracks do not sit decoded in memory. Goes through the music bus by default, positioned at the entity when spatial is given. Controlled through the MediaPlayer API and reports through the media:* events; nothing here is bound to a key or a button. (Short sounds that fire and are done stay AudioService.play.)

audio: HTMLAudioElement
onAttach()
onDetach()

packages/media/src/audio-player.ts:14

AudioService class

class AudioService implements Service

Sound for a game: a master gain and four buses (music, sfx, ambient, voice) with their own volumes, buffers decoded once, sounds that sit in the world (a panner that follows a node, the listener follows the active camera). Browsers refuse sound before the first user action, so the context is made and resumed then (audio:unlocked); loops asked for earlier start at that moment, one-shots asked for earlier are dropped. sound:play events { name, entity? } play registered sounds.

readonly name: "audio"
ctx: AudioContext | null
constructor(readonly opts: AudioOptions = {})
init(world: World)
get unlocked(): boolean
unlock(): voidMake the context and resume it. Call from a user action (the service does it on the first key, click or touch).
volume(bus: Bus | 'master'): number
setVolume(bus: Bus | 'master', v: number): void
tap(): MediaStream | nullWhat the player hears (after the master gain) as a MediaStream with one audio track, for a recording. Null until the context is running: a suspended context delivers no data, and a recorder given such a track writes nothing at all.
busNode(bus: Bus): AudioNode | nullThe bus node for routing other sources (a video's sound) through the same volume control.
define(name: string, def: SoundDef): this
has(name: string): boolean
load(url: string): Promise<AudioBuffer>Decode a sound file once.
play(nameOrUrl: string, o: PlayOptions = {}): SoundHandlePlay a defined sound (by name) or a file (by url). Returns a handle at once; the sound starts when decoded.
update()Called once per rendered frame.
_bus(b: Bus): AudioNode
_register(v: Voice)
_unregister(v: Voice)
get activeVoices(): number
dispose()

packages/media/src/audio.ts:49

HtmlScreen class

class HtmlScreen extends Component<HtmlScreenParams>

A web page drawn onto a 3D plane, as a texture: the screen lies in the world like any mesh (hidden behind walls, seen from any angle, many of them are cheap), but it is a picture: no scripts run and nothing can be clicked. For notice boards and posters. Make it again with refresh() when the page changed. Events: html:ready { entity }, html:error { entity, message }. The page is fetched and made self-contained (see inlinePage), then the browser draws it through an SVG <foreignObject>.

schema: { readonly url: { readonly type: "string"; }; readonly html: { readonly type: "string"; }; readonly base: { readonly type: "string"; }; readonly width: { readonly type: "number"; readonly min: 0.01; }; readonly height: { readonly type: "number"; readonly min: 0.01; }; readonly pxWidth: { readonly type: "number"; readonly min: 16; readonly max: 4096; }; readonly pxHeight: { readonly type: "number"; readonly min: 16; readonly max: 4096; }; readonly pixelsPerMetre: { readonly type: "number"; readonly min: 1; readonly max: 4096; }; readonly pixelRatio: { readonly type: "number"; readonly min: 0.25; readonly max: 4; }; readonly lit: { readonly type: "boolean"; }; }
screen: BABYLON.Mesh
texture: BABYLON.DynamicTexture
state: 'loading' | 'ready' | 'error'
warnings: string[]
error: string
onAttach()
async refresh(): Promise<void>Fetch the page again and redraw it. Resolves when it is on the screen (or has failed: see state).
onDetach()

packages/media/src/html.ts:120

LampSounds class

class LampSounds

Positional sounds for the lamps a light budget says are near the viewer (a torch's crackle): a small pool of looping AudioPlayer entities that is moved from lamp to lamp. Only the nearest few lamps sound at a time (LightBudgetDef.sounds.budget), so a village of hundreds of torches costs a handful of audio elements. A player that is let go is paused and kept for the next lamp; its Web Audio graph is built once, not per lamp. Nothing is bound to a key; the sound goes through the AudioService (bus, master volume, panner that follows the entity) like every AudioPlayer.

dropped: numberstarts that found no free player (the pool is at max)
constructor(readonly world: World, readonly source: LampSoundSource, readonly opts: LampSoundsOptions = {})
get active(): number[]the lamps that sound now
get players(): readonly Entity[]the players made so far
playerOf(i: number): AudioPlayer | undefinedthe player of lamp i, if it sounds
apply(start: readonly number[], stop: readonly number[])
dispose()

packages/media/src/lamp-sounds.ts:34

MediaComponent class

abstract class MediaComponent<P extends MediaPlayerParams = MediaPlayerParams> extends Component<P> implements MediaPlayer

Shared by the media components: owns an HTMLMediaElement, routes its sound through the AudioService (bus volume, master, positional falloff), keeps a queue, and emits on world.events, always with { entity }: media:state { state }, media:play, media:pause, media:ended (the queue ran out), media:track { index, src }, media:error { message }.

get state(): PlayerState
get playing(): boolean
get currentTime(): numberseconds; duration is NaN until known and Infinity for a live stream
get duration(): number
play(): void
pause(): void
toggle(): void
stop(): voidpause and go back to the start
seek(seconds: number): void
get volume(): number0..1
set volume(v: number)0..1
get muted(): boolean
set muted(m: boolean)
get rate(): numberplayback speed, 1 = normal
set rate(r: number)playback speed, 1 = normal
get tracks(): readonly string[]
get index(): numberindex into tracks of the one that is current, -1 for none
get track(): string | null
get repeat(): Repeat
set repeat(r: Repeat)
get shuffle(): boolean
set shuffle(s: boolean)
async setTracks(tracks: string[], start = 0): Promise<void>replace the queue and make start current
async select(index: number): Promise<void>make this one current (it plays if autoplay or it was playing)
async next(): Promise<boolean>the next track; false when the queue is at its end (and repeat is not 'all')
async previous(): Promise<boolean>back to the one before when the current is near its start (true); otherwise back to its start (false)
onUpdate()

packages/media/src/player.ts:81

SceneRecorder class

class SceneRecorder implements Service

Records the scene: the engine's canvas through captureStream and MediaRecorder, with the sound the player hears when the AudioService is unlocked. Results as one file (MP4 where the browser writes it, else WebM with its Duration put in). Events on world.events: record:start { mime, audio }, record:stop { mime, bytes, durationMs, audio }, record:error { message }. Bound to no key or button. Sound cannot join a recording that is running: a locked audio context delivers nothing, and a recorder given such a track writes an empty file, so a recording that started before the first user action has no sound. With software rendering and encoding (the simulators, a headless browser) the first data comes about a second after the start, so a recording shorter than that can be empty: stop() then rejects with a message saying so.

readonly name: "recorder"
constructor(readonly opts: RecorderOptions = {})
init(world: World)
get state(): RecorderState
get elapsedMs(): numbermilliseconds recorded so far, pauses left out
static get supported(): booleanWhether this browser can record a canvas at all.
start(o: { fps?: number; audio?: boolean; videoBitsPerSecond?: number } = {}): { mime: string; audio: boolean }
pause()
resume()
stop(): Promise<Recording>Ends the recording and resolves with the file. Rejects when the recorder failed or delivered nothing.
dispose()

packages/media/src/recorder.ts:136

VideoScreen class

class VideoScreen extends MediaComponent<VideoScreenParams>

A video on a flat screen in the world: a plane with the video as an emissive texture. The picture starts at once (muted: browsers allow that); the sound begins on the first user action, through the AudioService's bus so the volume control and the positional falloff apply. Controlled through the MediaPlayer API (play, pause, seek, volume, a queue of sources) and reports through the media:* events; nothing here is bound to a key or a button.

video: HTMLVideoElement
screen: BABYLON.Mesh
texture: BABYLON.VideoTexture
onAttach()
onDetach()

packages/media/src/video.ts:17

WorldText class

class WorldText extends Component<WorldTextParams>

Text in the world: a plane with the text drawn on it, readable from the entity's +Z side (the entity's forward is the way the text faces, as an Unreal TextRender faces its +X). Unlit, with alpha; no collision. Draws with a canvas font (default Arial), so the letter shapes are the browser's.

schema: { readonly text: { readonly type: "string"; readonly required: true; }; readonly size: { readonly type: "number"; readonly min: 0.001; }; readonly xScale: { readonly type: "number"; readonly min: 0.01; }; readonly yScale: { readonly type: "number"; readonly min: 0.01; }; readonly color: { readonly type: "array"; }; readonly align: { readonly type: "string"; }; readonly valign: { readonly type: "string"; }; readonly font: { readonly type: "string"; }; readonly bold: { readonly type: "boolean"; }; }
plane?: BABYLON.Mesh
onAttach()
onDetach()

packages/media/src/text.ts:31

Functions

attachSource function

async function attachSource(media: HTMLMediaElement, src: string, hlsUrl?: string, onError?: (m: string) => void): Promise<SourceResult>

Point a media element (video or audio) at a source. HLS plays natively where the browser can (Safari, iOS, WPE WebKit, the Android WebView) and through hls.js elsewhere; any other file is just set as src.

packages/media/src/source.ts:23

captureFrames function

async function captureFrames(engine: BABYLON.AbstractEngine, scene: BABYLON.Scene, world: World | null, o: CaptureOptions = {}): Promise<Capture>

Captures the scene frame by frame on simulated time, not on the wall clock: before every frame the simulation moves exactly 1/fps seconds (the world's fixed steps), and the engine's frame time reads 1/fps, so animations, physics and per-frame code all see the same steady clock however slowly a frame is drawn. Nothing is dropped and a loop closes exactly. The render loop is held for the length of the capture and given back after; the canvas is drawn at supersample times the size and scaled down. Video is encoded in the browser (WebCodecs) into WebM; pictures come as PNG. The scene's sound is not captured.

packages/media/src/capture.ts:174

captureSupported function

function captureSupported(): { webm: boolean; png: boolean }

Whether this browser can capture video files (WebCodecs); pictures need only a canvas.

packages/media/src/capture.ts:152

defaultMimeTypes function

function defaultMimeTypes(audio: boolean): string[]

MP4 first (it plays and seeks everywhere), then WebM; with sound the codecs are named so the audio is not dropped.

packages/media/src/recorder.ts:35

drawPage function

async function drawPage(xhtml: string, ctx: CanvasRenderingContext2D, viewport: { width: number; height: number }, size: { width: number; height: number }): Promise<void>

Draw a self-contained page into a canvas context at the given texture size. Throws when the browser refuses (it taints or fails).

packages/media/src/html.ts:104

inlinePage function

async function inlinePage(html: string, base: string, get: HtmlFetch): Promise<{ xhtml: string; warnings: string[] }>

Make a page self-contained: stylesheets become <style>, url(...) in CSS and <img src> become data URIs, scripts, frames and media are dropped. Returns the XHTML of the page and the problems (a resource that could not be fetched is left out, not fatal).

packages/media/src/html.ts:42

loadHls function

function loadHls(url = ((globalThis as any).FERRET_LIB ?? 'lib/ferret/') + 'hls/hls.min.js'): Promise<HlsLike | null>

Load hls.js once from a script URL (it sets the global Hls). Resolves null if it cannot be loaded.

packages/media/src/source.ts:5

pageSvg function

function pageSvg(xhtml: string, width: number, height: number): string

The page inside an SVG image of the viewport's size (the browser lays it out in a <foreignObject>).

packages/media/src/html.ts:99

pickMime function

function pickMime(types: string[], isSupported: (m: string) => boolean): string | undefined

The first of types that isSupported accepts, or undefined (then the browser picks its own).

packages/media/src/recorder.ts:42

setWebmDuration function

function setWebmDuration(data: Uint8Array, durationMs: number): Uint8Array

MediaRecorder writes WebM with no Duration, so players show a nonsense length and cannot seek. Puts the Duration element into the Segment's Info. Returns the bytes unchanged when the file is not a WebM this can read, or already has a Duration.

packages/media/src/recorder.ts:76

spatialFromUnreal function

function spatialFromUnreal(att: { attenuation_shape_extents?: number[]; falloff_distance?: number; distance_algorithm?: string; attenuate?: boolean }): { min: number; max: number; exact: boolean } | undefined

Unreal's sound attenuation (Linear distance algorithm, sphere shape) as the metres of spatial: full volume inside the inner radius (attenuation_shape_extents.x), silent after the falloff distance beyond it. Centimetres in, metres out. The panner falls off linearly, so another algorithm (Logarithmic, Inverse) is only approximated (exact: false). Undefined when the sound does not attenuate.

packages/media/src/lamp-sounds.ts:103

textOffset function

function textOffset(w: number, h: number, align: WorldTextParams['align'] = 'left', valign: WorldTextParams['valign'] = 'bottom'): [number, number]

Where a block of text of the given measured size sits so that the anchor is at the entity's origin: the plane's centre in the reader's frame (x to the reader's right, y up).

packages/media/src/text.ts:21

writeWebm function

function writeWebm(frames: EncodedFrame[], t: WebmTrack): Uint8Array

One WebM file from encoded video frames: header, Info with the Duration, one track, the Cues (before the clusters, so a player can seek at once), and a cluster at every key frame (and at least every 30 s). The first frame must be a key frame.

packages/media/src/capture.ts:56

Interfaces

AmbientOptions interface

interface AmbientOptions
tag?: stringthe zones' tag for the listener: the entity whose zone picks the ambience (default "player")
fade?: numberseconds of cross-fade between ambiences (default 1.5)

packages/media/src/ambient.ts:4

AudioOptions interface

interface AudioOptions
createContext?: () => AudioContextmake the context (tests pass a fake; default new AudioContext())
gestureTarget?: EventTarget | falsewhere to wait for the first user action; false = do not listen (the game calls unlock() itself)
fetch?: (url: string) => Promise<{ ok: boolean; arrayBuffer(): Promise<ArrayBuffer> }>replace fetch (tests, a Platform file API)

packages/media/src/audio.ts:32

AudioPlayerParams interface

interface AudioPlayerParams extends MediaPlayerParams
audio?: HTMLAudioElementan audio element you made (a MediaStream, a test double)

packages/media/src/audio-player.ts:3

Capture interface

interface Capture
format: 'webm' | 'png'
mime: stringe.g. video/webm;codecs=vp9, or image/png
frames: number
fps: number
width: number
height: number
durationMs: numbersimulated time covered, ms (frames / fps)
blob?: Blobthe video (webm)
pngs?: Blob[]the pictures (png without onFrame)
aborted: booleantrue when signal stopped it early

packages/media/src/capture.ts:127

CaptureOptions interface

interface CaptureOptions
fps?: numberframes per second of the result, and the simulated time between frames (default 30)
seconds?: numberhow long to capture, simulated seconds (default 1); or frames
frames?: number
width?: numbersize of the result (default: the canvas's render size)
height?: number
supersample?: numberrender at this many times the size and scale down: smooth edges and fine detail (1 to 4, default 1)
format?: 'webm' | 'png'webm: one video file (WebCodecs); png: a picture per frame (default webm)
codecs?: ('vp9' | 'vp8' | 'av1')[]video codec, the first the browser encodes is used (default vp9, then vp8)
bitrate?: numberbits per second (default about 0.12 bit per pixel and frame)
keyEvery?: numbera key frame every this many frames (default 2 s of frames)
advance?: (dt: number) => voidmove the simulation by dt seconds (default: the world's advance, which runs its fixed steps)
render?: () => voiddraw one frame (default: scene.render())
beforeFrame?: (i: number, t: number) => voidcalled before frame i is advanced and drawn, at simulated time t since the start (a turntable turns the camera here)
onFrame?: (i: number, png: Blob) => void | Promise<void>with png: each picture as it is made (else they are returned together)
signal?: AbortSignalstops the capture between two frames; what was made so far is returned
grab?: (canvas: HTMLCanvasElement, width: number, height: number) => CanvasImageSource & { convertToBlob?: (o: { type: string }) => Promise<Blob> }replace the browser parts (tests)
createEncoder?: (init: VideoEncoderInit) => VideoEncoder
isConfigSupported?: (c: VideoEncoderConfig) => Promise<{ supported?: boolean }>
makeFrame?: (source: CanvasImageSource, init: VideoFrameInit) => VideoFrame

packages/media/src/capture.ts:91

EncodedFrame interface

interface EncodedFrame
data: Uint8Array
timeMs: numberpresentation time of the frame, ms
key: boolean

packages/media/src/capture.ts:35

HtmlScreenParams interface

interface HtmlScreenParams
url?: stringthe page to show (its stylesheets, images and fonts are fetched too: they need CORS, or fetch that gets around it)
html?: stringor the page as text; base then resolves its relative links
base?: string
width?: numbersize of the screen in metres (default 4 x 2.25)
height?: number
pxWidth?: numberthe page's window in CSS pixels: the page lays out as in a browser of exactly this size. Give both, or one (the other follows the screen's shape), or none (see pixelsPerMetre)
pxHeight?: number
pixelsPerMetre?: numberwhen the window size is not given: window = screen size in metres x this (default 256), the larger side capped at 2048 with the shape kept
pixelRatio?: numbertexture size = viewport x this (default 1; 2 for sharper text on a screen seen close)
fetch?: HtmlFetchreplace fetch
lit?: booleanlit by the scene like a poster (default false: it glows like a screen)

packages/media/src/html.ts:6

LampSoundsOptions interface

interface LampSoundsOptions
spatial?: { min?: number; max?: number }the sound comes from the lamp: full volume inside min metres, silent beyond max (see spatialFromUnreal). Default 2 / 30
volume?: number0..1, default 1 (Unreal: the cue's volume multiplier x the component's)
bus?: NonNullable<MediaPlayerParams['bus']>default 'ambient'
offset?: Vec3metres added to a lamp's position (Unreal: the audio component's offset from the actor)
max?: numberhow many players at most (default: no limit, but the source's own sound budget keeps it small); a start beyond it is dropped
makeAudio?: () => HTMLAudioElement | undefinedthe element a player plays through (a test double); default a new Audio

packages/media/src/lamp-sounds.ts:13

LampSoundSource interface

interface LampSoundSource

What LampSounds needs of a light budget (LightBudget from

def: { items: readonly { position: Vec3; sound?: string; id?: string }[] }
onSounds?: (start: number[], stop: number[]) => void

packages/media/src/lamp-sounds.ts:8

MediaPlayer interface

interface MediaPlayer

What a game controls. Every media component (a video screen, an audio player) has this same surface, and the engine binds it to nothing: no key, button or gesture. A game calls it from its own code, from a stock behaviour's wiring or from its UI.

readonly state: PlayerState
readonly playing: boolean
readonly currentTime: numberseconds; duration is NaN until known and Infinity for a live stream
readonly duration: number
volume: number0..1
muted: boolean
rate: numberplayback speed, 1 = normal
play(): void
pause(): void
toggle(): void
stop(): voidpause and go back to the start
seek(seconds: number): void
readonly tracks: readonly string[]
readonly index: numberindex into tracks of the one that is current, -1 for none
readonly track: string | null
repeat: Repeat
shuffle: boolean
setTracks(tracks: string[], start?: number): Promise<void>replace the queue and make start current
select(index: number): Promise<void>make this one current (it plays if autoplay or it was playing)
next(): Promise<boolean>the next track; false when the queue is at its end (and repeat is not 'all')
previous(): Promise<boolean>back to the one before when the current is near its start (true); otherwise back to its start (false)

packages/media/src/player.ts:12

MediaPlayerParams interface

interface MediaPlayerParams
src?: stringone source: a file or an HLS playlist (.m3u8)
tracks?: string[]or a queue of them
start?: numberindex of the track to start with
repeat?: Repeat
loop?: booleanshorthand for repeat: 'one'
shuffle?: boolean
volume?: number0..1 (default 1)
muted?: boolean
rate?: number
bus?: Busthe bus its sound goes through (video 'sfx', audio 'music')
spatial?: { min?: number; max?: number }the sound comes from the entity: louder near it. min/max distances in metres
hlsUrl?: stringwhere hls.js is, for browsers that cannot play HLS themselves (default hls/hls.min.js under the library folder)
autoplay?: booleanstart by itself (default true; the sound waits for the first user action)
random?: () => numberreplaces Math.random for the shuffle (a seeded one for tests, replays)

packages/media/src/player.ts:48

PlayOptions interface

interface PlayOptions
at?: BABYLON.TransformNode | BABYLON.Vector3where the sound comes from; a node it follows. Without it the sound is not positioned (UI, music)
volume?: number
loop?: boolean
bus?: Bus
fadeIn?: numberseconds to fade in
spatial?: { min?: number; max?: number }

packages/media/src/audio.ts:15

RecorderOptions interface

interface RecorderOptions
canvas?: HTMLCanvasElementthe canvas to record (default: the engine's rendering canvas)
fps?: numberframes per second asked of the capture (default 30)
videoBitsPerSecond?: numbervideo bit rate (default: the browser's)
audio?: booleanadd the sound the player hears when the audio is unlocked (default true)
mimeTypes?: string[]formats in order of preference; the first the browser can write is used
timeslice?: numberask for a data chunk this often, ms (default 1000): a long recording is not held back in the encoder
createRecorder?: (stream: MediaStream, o: { mimeType?: string; videoBitsPerSecond?: number }) => MediaRecorderreplace the browser's MediaRecorder (tests)
isTypeSupported?: (mime: string) => boolean

packages/media/src/recorder.ts:6

Recording interface

interface Recording
blob: Blob
mime: stringthe container and codecs the file was written with, e.g. video/mp4;codecs=avc1.42E01E
durationMs: number
bytes: number
audio: booleanfalse when the audio was still locked at the start (the file has no sound) or not asked for

packages/media/src/recorder.ts:24

SoundDef interface

interface SoundDef
url: string
bus?: Bus
volume?: number0..1, default 1
loop?: boolean
spatial?: { min?: number; max?: number }spatial: louder near the source; min is the distance of full volume, max where it is silent (metres)

packages/media/src/audio.ts:5

SoundHandle interface

interface SoundHandle
readonly playing: boolean
setVolume(v: number, fade?: number): void
stop(fade?: number): void

packages/media/src/audio.ts:26

SourceResult interface

interface SourceResult
mode: 'native' | 'hls.js' | 'none'
destroy(): void

packages/media/src/source.ts:17

VideoScreenParams interface

interface VideoScreenParams extends MediaPlayerParams
video?: HTMLVideoElementor a video element you made (a MediaStream, a canvas capture)
width?: numbersize of the screen in metres (default 4 x 2.25)
height?: number

packages/media/src/video.ts:3

WebmTrack interface

interface WebmTrack
codec: 'V_VP9' | 'V_VP8' | 'V_AV1'Matroska codec id: V_VP9, V_VP8 or V_AV1
width: number
height: number
fps: number
durationMs?: numberthe length of the video, ms (default: the last frame's time plus one frame)

packages/media/src/capture.ts:42

WorldTextParams interface

interface WorldTextParams
text: stringthe text; \n starts a new line
size?: numberheight of a letter's em box in metres (Unreal's TextRender WorldSize / 100)
xScale?: numberextra scale across and along the lines (Unreal's XScale, YScale)
yScale?: number
color?: number[]colour as [r, g, b] or [r, g, b, a], 0..1
align?: 'left' | 'center' | 'right'how the text sits on the entity's origin, as a reader sees it
valign?: 'top' | 'center' | 'bottom'
font?: string
bold?: boolean

packages/media/src/text.ts:3

Types

Bus type

type Bus = 'music' | 'sfx' | 'ambient' | 'voice'

packages/media/src/audio.ts:3

HlsLike type

type HlsLike = { isSupported(): boolean; new (cfg?: unknown): { loadSource(u: string): void; attachMedia(v: HTMLMediaElement): void; destroy(): void; on(ev: string, fn: (...a: any[]) => void): void } }

packages/media/src/source.ts:1

HtmlFetch type

type HtmlFetch = (url: string) => Promise<{ ok: boolean; status: number; text(): Promise<string>; blob(): Promise<Blob> }>

What HtmlScreen needs from fetch (a Platform file API or a proxy fits): the page and its pieces come through it.

packages/media/src/html.ts:4

PlayerState type

type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error'

packages/media/src/player.ts:5

RecorderState type

type RecorderState = 'idle' | 'recording' | 'paused'

packages/media/src/recorder.ts:4

Repeat type

type Repeat = 'none' | 'one' | 'all'

packages/media/src/player.ts:6