mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
design: media contracts (#1297)
This commit is contained in:
@@ -0,0 +1,832 @@
|
||||
---
|
||||
status: draft
|
||||
date: 2026-04-08
|
||||
---
|
||||
|
||||
# Media Contracts
|
||||
|
||||
A capability-based contract system for media. Custom media authors implement
|
||||
plain getters/setters against typed interfaces — no mixins, no proxy magic,
|
||||
no DOM recreation.
|
||||
|
||||
## Problem
|
||||
|
||||
The core media contract is tangled up with platform-specific machinery.
|
||||
Mixins, proxies, prototype-walking, and event monkey-patching all live at
|
||||
the contract level — so integrators who want to build their own media
|
||||
element can't separate "what the API is" from "how the DOM wiring works."
|
||||
They look at our HLS implementation as a reference and find four
|
||||
interlocking abstractions baked into core that are tough to reason through.
|
||||
We want a gold standard like HLS that people can read, understand, and
|
||||
replicate without absorbing the full DOM stack.
|
||||
|
||||
Proxying isn't inherently a problem — but proxying the entire
|
||||
`HTMLMediaElement` API at the core contract level is. Types like
|
||||
`HTMLMediaElement`, `TextTrackList`, `Event`, and `EventTarget` make it very difficult to
|
||||
build implementations for platforms with DOM limitations or no DOM at all
|
||||
(React Native, SSR). We don't need to mirror the DOM 1:1 — just a specific
|
||||
subset covering what our player actually uses. Right now there isn't really
|
||||
a clear contract; it's an all-or-nothing `HTMLMediaElement` type that ends
|
||||
up nudging our own store features into reaching deeper into the DOM than
|
||||
they should. And because media creation is fully DOM-coupled in core, we
|
||||
wind up needing separate class hierarchies for different platforms (one for
|
||||
custom elements, one for React/SSR) — which is the kind of leaky
|
||||
architecture that the mixin system was trying to solve but ended up
|
||||
compounding. Forwarding a well-defined set of contract properties at the
|
||||
HTML element level is fine — the issue is doing it for a platform-specific
|
||||
extensive API at the core contract level.
|
||||
|
||||
A few things we'd like to address:
|
||||
|
||||
1. **Dynamic contract in core** — `defineClassPropHooks` dynamically adds
|
||||
properties by walking prototype chains at runtime, and this happens at
|
||||
the core contract level. The API surface ends up being unpredictable:
|
||||
`in` checks behave inconsistently, `Object.keys()` returns different
|
||||
results depending on which delegate is composed, enumerability varies.
|
||||
It's hard to know the media object's shape from reading any single class.
|
||||
|
||||
2. **Inherited complexity** — Media implementors inherit four abstractions
|
||||
(`ProxyMixin`, `DelegateMixin`, `defineClassPropHooks`, `bridgeEvents`)
|
||||
that they don't really need to understand but have to work within. When
|
||||
something breaks, tracing where a property value comes from is tricky —
|
||||
`get(prop)` bounces through delegate → proxy → native element via
|
||||
prototype forwarding that doesn't really show up in the debugger. It's
|
||||
hard to follow, harder to debug, and tough to walk new contributors
|
||||
through.
|
||||
|
||||
3. **Misleading store type** — `PlayerTarget.media` is typed as
|
||||
`HTMLMediaElement`, so store features naturally treat it as a full DOM
|
||||
element — calling `querySelectorAll`, accessing `shadowRoot`, referencing
|
||||
`HTMLMediaElement.HAVE_FUTURE_DATA`. The type kind of invites this, and
|
||||
new features tend to reach deeper into the DOM because it looks like
|
||||
they can.
|
||||
|
||||
4. **No clear capabilities** — Today it's all-or-nothing. There's no good
|
||||
way to express that a media implementation supports play/pause but not
|
||||
text tracks, or volume but not fullscreen. Engine-specific properties
|
||||
(config, debug, preferPlayback) also end up leaking onto the media
|
||||
object via `defineClassPropHooks`, which muddies what "the media API"
|
||||
actually is.
|
||||
|
||||
5. **No async lifecycle** — Engine teardown is synchronous. When switching
|
||||
sources, the old engine's MSE cleanup may not have finished before the
|
||||
new engine starts, which can lead to race conditions.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the mixin architecture with:
|
||||
|
||||
- **No dynamic contracts or proxies in core** — the core contract is
|
||||
static, typed interfaces. Forwarding a well-defined set of properties
|
||||
happens at the HTML element level, not in core
|
||||
- **Capability contracts** — small interfaces that each carry their own
|
||||
typed events
|
||||
- **Explicit getters/setters** — no prototype walking or `get`/`set`/`call`
|
||||
indirection
|
||||
- **`.engine` property** — engine access through `MediaEngineHost`, not
|
||||
top-level props
|
||||
- **Async engine lifecycle** — with hooks for configuration, creation,
|
||||
source loading, and destruction
|
||||
- **No DOM in contracts** — `EventLike` instead of `Event`,
|
||||
`TextTrackListLike` instead of `TextTrackList`
|
||||
|
||||
---
|
||||
|
||||
## Contracts
|
||||
|
||||
Everything starts with two primitives: a minimal event and a typed event
|
||||
target. No DOM dependency.
|
||||
|
||||
### EventLike
|
||||
|
||||
```ts
|
||||
interface EventLike<Detail = void> {
|
||||
readonly type: string;
|
||||
readonly timeStamp: number;
|
||||
readonly detail?: Detail;
|
||||
}
|
||||
```
|
||||
|
||||
DOM `Event` satisfies this shape. The `detail` bag allows typed payloads
|
||||
without needing a second event system — useful for engine-specific events
|
||||
(e.g., level switching with data) or any event that carries more than just
|
||||
a type.
|
||||
|
||||
### EventTargetLike
|
||||
|
||||
```ts
|
||||
interface EventTargetLike<Events extends Record<string, EventLike>> {
|
||||
addEventListener<K extends keyof Events & string>(
|
||||
type: K,
|
||||
listener: (event: Events[K]) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): void;
|
||||
removeEventListener<K extends keyof Events & string>(
|
||||
type: K,
|
||||
listener: (event: Events[K]) => void,
|
||||
): void;
|
||||
dispatchEvent(event: EventLike): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Only option is `{ signal }` for cleanup. DOM `EventTarget` satisfies this
|
||||
contract — it accepts more params, the contract doesn't require them.
|
||||
|
||||
Helper to create a typed `EventTarget` base class for a given event map:
|
||||
|
||||
```ts
|
||||
function TypedEventTarget<Events extends Record<string, EventLike>>() {
|
||||
return EventTarget as unknown as { new (): EventTargetLike<Events> };
|
||||
}
|
||||
```
|
||||
|
||||
Usage: `extends TypedEventTarget<VideoEvents>()` gives you a native
|
||||
`EventTarget` with typed `addEventListener`.
|
||||
|
||||
---
|
||||
|
||||
## Capabilities
|
||||
|
||||
Each capability is a small interface with its own typed events. Capabilities
|
||||
are independent — a media implementation picks the ones it supports.
|
||||
|
||||
### MediaPlaybackCapability
|
||||
|
||||
The base contract. If it can play, it's media.
|
||||
|
||||
```ts
|
||||
interface MediaPlaybackEvents {
|
||||
play: EventLike;
|
||||
playing: EventLike;
|
||||
waiting: EventLike;
|
||||
}
|
||||
|
||||
interface MediaPlaybackCapability {
|
||||
play(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaPauseCapability
|
||||
|
||||
Not all media can pause — some live streams don't support it. Separate
|
||||
from playback so implementations can express this.
|
||||
|
||||
```ts
|
||||
interface MediaPauseEvents {
|
||||
pause: EventLike;
|
||||
ended: EventLike;
|
||||
}
|
||||
|
||||
interface MediaPauseCapability {
|
||||
pause(): void;
|
||||
readonly paused: boolean;
|
||||
readonly ended: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Note: capabilities declare their events but don't extend
|
||||
`EventTargetLike`. The composite interfaces (`Video`, `Audio`) extend
|
||||
`EventTargetLike` once with the merged event map. This avoids ten
|
||||
overlapping `addEventListener` signatures.
|
||||
|
||||
### MediaSeekCapability
|
||||
|
||||
```ts
|
||||
interface MediaSeekEvents {
|
||||
timeupdate: EventLike;
|
||||
durationchange: EventLike;
|
||||
seeking: EventLike;
|
||||
seeked: EventLike;
|
||||
loadedmetadata: EventLike;
|
||||
}
|
||||
|
||||
interface MediaSeekCapability {
|
||||
currentTime: number;
|
||||
readonly duration: number;
|
||||
readonly seeking: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaSourceCapability
|
||||
|
||||
```ts
|
||||
interface MediaSourceEvents {
|
||||
loadstart: EventLike;
|
||||
emptied: EventLike;
|
||||
canplay: EventLike;
|
||||
canplaythrough: EventLike;
|
||||
loadeddata: EventLike;
|
||||
}
|
||||
|
||||
interface MediaSourceCapability {
|
||||
src: string;
|
||||
readonly currentSrc: string;
|
||||
readonly readyState: MediaReadyStateValue;
|
||||
load(): void;
|
||||
}
|
||||
|
||||
const MediaReadyState = {
|
||||
HAVE_NOTHING: 0,
|
||||
HAVE_METADATA: 1,
|
||||
HAVE_CURRENT_DATA: 2,
|
||||
HAVE_FUTURE_DATA: 3,
|
||||
HAVE_ENOUGH_DATA: 4,
|
||||
} as const;
|
||||
|
||||
type MediaReadyStateValue = typeof MediaReadyState[keyof typeof MediaReadyState];
|
||||
```
|
||||
|
||||
### MediaVolumeCapability
|
||||
|
||||
```ts
|
||||
interface MediaVolumeEvents { volumechange: EventLike; }
|
||||
|
||||
interface MediaVolumeCapability {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
getVolumeAvailability?(): Promise<MediaFeatureAvailability>;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaPlaybackRateCapability
|
||||
|
||||
```ts
|
||||
interface MediaPlaybackRateEvents { ratechange: EventLike; }
|
||||
|
||||
interface MediaPlaybackRateCapability {
|
||||
playbackRate: number;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaBufferCapability
|
||||
|
||||
```ts
|
||||
interface MediaBufferEvents { progress: EventLike; }
|
||||
|
||||
interface TimeRangeLike {
|
||||
readonly length: number;
|
||||
start(index: number): number;
|
||||
end(index: number): number;
|
||||
}
|
||||
|
||||
interface MediaBufferCapability {
|
||||
readonly buffered: TimeRangeLike;
|
||||
readonly seekable: TimeRangeLike;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaErrorCapability
|
||||
|
||||
```ts
|
||||
interface MediaErrorEvents { error: EventLike; }
|
||||
|
||||
interface ErrorLike {
|
||||
readonly code: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface MediaErrorCapability {
|
||||
readonly error: ErrorLike | null;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaTextTrackCapability
|
||||
|
||||
Non-DOM text track contract. Compatible with native `TextTrackList` but
|
||||
doesn't require DOM types.
|
||||
|
||||
```ts
|
||||
interface TextCueLike {
|
||||
readonly startTime: number;
|
||||
readonly endTime: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
interface TextCueListLike {
|
||||
readonly length: number;
|
||||
[Symbol.iterator](): Iterator<TextCueLike>;
|
||||
getCueById?(id: string): TextCueLike | null;
|
||||
}
|
||||
|
||||
interface TextTrackLike {
|
||||
readonly kind: string;
|
||||
readonly label: string;
|
||||
readonly language: string;
|
||||
readonly id: string;
|
||||
readonly src?: string;
|
||||
mode: 'showing' | 'disabled' | 'hidden';
|
||||
readonly cues: TextCueListLike | null;
|
||||
addCue?(cue: TextCueLike): void;
|
||||
}
|
||||
|
||||
interface TextTrackListEvents {
|
||||
addtrack: EventLike;
|
||||
removetrack: EventLike;
|
||||
changetrack: EventLike;
|
||||
trackmodechange: EventLike;
|
||||
}
|
||||
|
||||
interface TextTrackListLike {
|
||||
readonly length: number;
|
||||
[Symbol.iterator](): Iterator<TextTrackLike>;
|
||||
getTrackById?(id: string): TextTrackLike | null;
|
||||
}
|
||||
|
||||
interface MediaTextTrackCapability {
|
||||
readonly textTracks: TextTrackListLike;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaFullscreenCapability
|
||||
|
||||
```ts
|
||||
interface MediaFullscreenCapability {
|
||||
requestFullscreen(): Promise<void>;
|
||||
exitFullscreen?(): Promise<void>;
|
||||
readonly fullscreen?: boolean;
|
||||
getFullscreenAvailability?(): Promise<MediaFeatureAvailability>;
|
||||
}
|
||||
```
|
||||
|
||||
### MediaPictureInPictureCapability
|
||||
|
||||
```ts
|
||||
interface MediaPictureInPictureCapability {
|
||||
requestPictureInPicture(): Promise<void>;
|
||||
exitPictureInPicture?(): Promise<void>;
|
||||
readonly pip?: boolean;
|
||||
getPictureInPictureAvailability?(): Promise<MediaFeatureAvailability>;
|
||||
}
|
||||
```
|
||||
|
||||
Availability methods (`get*Availability`) return
|
||||
`Promise<MediaFeatureAvailability>` — `'available'`, `'unavailable'`, or
|
||||
`'unsupported'`. They're optional on the capability (not all implementations
|
||||
need them) and async so they can probe the platform without blocking. Store
|
||||
features call them once at attach time and cache the result.
|
||||
|
||||
---
|
||||
|
||||
## Media — the full contract
|
||||
|
||||
The minimum viable media is `MediaPlaybackCapability`:
|
||||
|
||||
```ts
|
||||
interface MediaEvents extends MediaPlaybackEvents {}
|
||||
|
||||
interface Media extends
|
||||
MediaPlaybackCapability,
|
||||
EventTargetLike<MediaEvents> {
|
||||
readonly engine?: unknown;
|
||||
readonly nativeElement?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
That's it — `play()` and optional untyped references to engine and native
|
||||
element. A native `HTMLVideoElement` satisfies this. So does a bare-bones
|
||||
custom implementation.
|
||||
|
||||
Everything else is opt-in via capabilities. `Video` and `Audio` are the
|
||||
standard full contracts — each extends `EventTargetLike` once with a
|
||||
merged event map:
|
||||
|
||||
```ts
|
||||
interface VideoEvents extends
|
||||
MediaPlaybackEvents, MediaPauseEvents, MediaSeekEvents, MediaSourceEvents,
|
||||
MediaVolumeEvents, MediaPlaybackRateEvents, MediaBufferEvents,
|
||||
MediaErrorEvents, TextTrackListEvents {}
|
||||
|
||||
interface Video extends
|
||||
Media,
|
||||
MediaPauseCapability,
|
||||
MediaSeekCapability,
|
||||
MediaSourceCapability,
|
||||
MediaVolumeCapability,
|
||||
MediaPlaybackRateCapability,
|
||||
MediaBufferCapability,
|
||||
MediaErrorCapability,
|
||||
MediaTextTrackCapability,
|
||||
MediaFullscreenCapability,
|
||||
MediaPictureInPictureCapability,
|
||||
EventTargetLike<VideoEvents> {}
|
||||
|
||||
interface AudioEvents extends
|
||||
MediaPlaybackEvents, MediaPauseEvents, MediaSeekEvents, MediaSourceEvents,
|
||||
MediaVolumeEvents, MediaPlaybackRateEvents, MediaBufferEvents,
|
||||
MediaErrorEvents {}
|
||||
|
||||
interface Audio extends
|
||||
Media,
|
||||
MediaPauseCapability,
|
||||
MediaSeekCapability,
|
||||
MediaSourceCapability,
|
||||
MediaVolumeCapability,
|
||||
MediaPlaybackRateCapability,
|
||||
MediaBufferCapability,
|
||||
MediaErrorCapability,
|
||||
EventTargetLike<AudioEvents> {}
|
||||
```
|
||||
|
||||
`Audio` doesn't include fullscreen, PiP, or text tracks. Each composite
|
||||
extends `EventTargetLike` once — one `addEventListener` signature, one
|
||||
emitter, clean composition.
|
||||
|
||||
### In the store
|
||||
|
||||
`PlayerTarget.media` is typed as `Media` — the minimal contract. Store
|
||||
features can only access `play()` and `engine?` by default. Everything
|
||||
else — including `readyState` — requires a type guard.
|
||||
|
||||
```ts
|
||||
interface PlayerTarget {
|
||||
media: Media;
|
||||
container: MediaContainer | null;
|
||||
}
|
||||
```
|
||||
|
||||
Features that need optional capabilities use type guards:
|
||||
|
||||
```ts
|
||||
attach({ target, signal, set }) {
|
||||
const { media } = target;
|
||||
|
||||
// Source capability includes readyState — guard first
|
||||
if (isMediaSourceCapable(media)) {
|
||||
const waiting = media.readyState < MediaReadyState.HAVE_FUTURE_DATA;
|
||||
}
|
||||
|
||||
// Volume is optional — guard first
|
||||
if (isMediaVolumeCapable(media)) {
|
||||
set({ volume: media.volume, muted: media.muted });
|
||||
listen(media, 'volumechange', () => {
|
||||
set({ volume: media.volume, muted: media.muted });
|
||||
}, { signal });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package split
|
||||
|
||||
### Core DOM (`@videojs/core/dom`)
|
||||
|
||||
Element host classes forward the contract to a native element. The base
|
||||
is generic over both the element type and the event map, extending
|
||||
`EventTargetLike` through the type parameter.
|
||||
|
||||
```ts
|
||||
// Base — receives a native element, forwards Media contract to it
|
||||
class HTMLMediaElementHost<
|
||||
T extends HTMLMediaElement,
|
||||
Events extends Record<string, EventLike>
|
||||
> extends TypedEventTarget<Events>() {
|
||||
|
||||
readonly nativeElement: T | null;
|
||||
attachElement(nativeElement: T): void;
|
||||
|
||||
// Shared forwarding (Media contract)
|
||||
get paused() { return this.nativeElement?.paused ?? true; }
|
||||
play() { return this.nativeElement?.play() ?? Promise.reject(); }
|
||||
get readyState() { return this.nativeElement?.readyState ?? 0; }
|
||||
// ...
|
||||
}
|
||||
|
||||
// Implements Video — events extensible by subclasses
|
||||
class HTMLVideoElementHost<Events extends VideoEvents = VideoEvents>
|
||||
extends HTMLMediaElementHost<HTMLVideoElement, Events>
|
||||
implements Video {
|
||||
|
||||
get video(): HTMLVideoElement | null { return this.nativeElement; }
|
||||
// Video-specific: fullscreen, PiP, text tracks forwarded
|
||||
}
|
||||
|
||||
// Implements Audio — events extensible by subclasses
|
||||
class HTMLAudioElementHost<Events extends AudioEvents = AudioEvents>
|
||||
extends HTMLMediaElementHost<HTMLAudioElement, Events>
|
||||
implements Audio {
|
||||
|
||||
get audio(): HTMLAudioElement | null { return this.nativeElement; }
|
||||
// No fullscreen, PiP, or text tracks
|
||||
}
|
||||
```
|
||||
|
||||
Media implementations extend these and add engine support:
|
||||
|
||||
```ts
|
||||
interface HlsVideoEvents extends VideoEvents {
|
||||
hlserror: EventLike<{ fatal: boolean; details: string }>;
|
||||
}
|
||||
|
||||
class HlsVideo extends HTMLVideoElementHost<HlsVideoEvents>
|
||||
implements MediaEngineHost<Hls, HTMLVideoElement> {
|
||||
|
||||
readonly engine: Hls | null;
|
||||
attachEngine(target: HTMLVideoElement) { /* ... */ }
|
||||
detachEngine() { /* ... */ }
|
||||
destroyEngine(): Promise<void> { /* ... */ }
|
||||
|
||||
override get src() { return this.#src; }
|
||||
override set src(src: string) { this.#requestLoad(); }
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### HTML (`@videojs/html`)
|
||||
|
||||
```ts
|
||||
abstract class VideoElement extends MediaElementMixin(HTMLElement) {
|
||||
abstract readonly media: Video;
|
||||
// shadow DOM, <video> template, slots, attribute forwarding, events
|
||||
}
|
||||
|
||||
abstract class AudioElement extends MediaElementMixin(HTMLElement) {
|
||||
abstract readonly media: Audio;
|
||||
// shadow DOM, <audio> template, slots, attribute forwarding, events
|
||||
}
|
||||
```
|
||||
|
||||
Forwarding the `Media` contract from the host to the custom element is
|
||||
based on a well-defined, static set of properties — not dynamic prototype
|
||||
walking.
|
||||
|
||||
Subclasses provide the core media host:
|
||||
|
||||
```ts
|
||||
export class HlsVideoElement extends VideoElement {
|
||||
static readonly tagName = 'hls-video';
|
||||
readonly media = new HlsVideo();
|
||||
}
|
||||
```
|
||||
|
||||
### What custom media authors implement
|
||||
|
||||
- Extend `HTMLVideoElementHost` or `HTMLAudioElementHost` in core
|
||||
- Implement `MediaEngineHost` for engine lifecycle
|
||||
- Override `attachEngine()`, `detachEngine()`, `destroyEngine()`
|
||||
- Override `src` and any getters that route through their engine
|
||||
- Extend `VideoElement` or `AudioElement` in HTML package, provide the host instance
|
||||
|
||||
---
|
||||
|
||||
## Engine
|
||||
|
||||
### MediaEngineHost
|
||||
|
||||
```ts
|
||||
interface MediaEngineHost<Engine = unknown, Target = unknown> {
|
||||
readonly engine: Engine | null;
|
||||
attachEngine?(target: Target): void;
|
||||
detachEngine?(): void;
|
||||
destroyEngine(): Promise<void>;
|
||||
setEngineCallbacks?(callbacks: Partial<MediaEngineCallbacks<Engine, Target>>): void;
|
||||
}
|
||||
```
|
||||
|
||||
`attachEngine()` wires the engine to its target. Internally calls
|
||||
`attachElement()` on the host — so the host gets the native element
|
||||
and the engine gets attached in one step.
|
||||
|
||||
`destroyEngine()` is async — MSE cleanup, network abort, SourceBuffer
|
||||
removal. Must complete before a new engine is created.
|
||||
|
||||
### MediaEngineCallbacks
|
||||
|
||||
Lifecycle hooks the media class calls *out* to during transitions.
|
||||
Set externally via `setEngineCallbacks()`.
|
||||
|
||||
```ts
|
||||
interface MediaEngineCallbacks<Engine = unknown, Target = unknown> {
|
||||
onEngineConfig(config: Record<string, unknown>): void;
|
||||
onEngineCreate(engine: Engine): void;
|
||||
onEngineAttach(engine: Engine, target: Target): void;
|
||||
onSourceChange(engine: Engine, src: string): void;
|
||||
onEngineDetach(engine: Engine): void;
|
||||
onEngineDestroy(engine: Engine): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Engine lifecycle
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 1. Configure onEngineConfig(config) │
|
||||
│ 2. Create onEngineCreate(engine) │
|
||||
│ 3. Attach onEngineAttach(engine, target) │
|
||||
│ 4. Source onSourceChange(engine, src) │
|
||||
│ 5. Detach onEngineDetach(engine) │
|
||||
│ 6. Destroy onEngineDestroy(engine) → Promise │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Methods (`attachEngine`, `detachEngine`, `destroyEngine`) perform the
|
||||
work. Callbacks fire at each step for external hooks.
|
||||
|
||||
Source change flow:
|
||||
|
||||
```
|
||||
src = "new.m3u8"
|
||||
→ microtask coalesce
|
||||
→ await destroyEngine() (if pending)
|
||||
→ engine config changed?
|
||||
├─ yes → detachEngine() → destroyEngine() → await
|
||||
│ onEngineConfig → onEngineCreate → attachEngine
|
||||
└─ no → reuse engine
|
||||
→ onSourceChange(engine, src)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type guards and helpers
|
||||
|
||||
### Capability type guards
|
||||
|
||||
Duck-typing — `isObject(value) && 'propName' in value`.
|
||||
|
||||
```ts
|
||||
isMediaPauseCapable(value) // → value is MediaPauseCapability
|
||||
isMediaSourceCapable(value) // → value is MediaSourceCapability
|
||||
isMediaSeekCapable(value) // → value is MediaSeekCapability
|
||||
isMediaVolumeCapable(value) // → value is MediaVolumeCapability
|
||||
isMediaBufferCapable(value) // → value is MediaBufferCapability
|
||||
isMediaFullscreenCapable(value) // → value is MediaFullscreenCapability
|
||||
isMediaPictureInPictureCapable(value) // → value is MediaPictureInPictureCapability
|
||||
isMediaTextTrackCapable(value) // → value is MediaTextTrackCapability
|
||||
isMediaEngineHost(value) // → value is MediaEngineHost
|
||||
```
|
||||
|
||||
### Media type guards
|
||||
|
||||
Narrow to a specific implementation or host type:
|
||||
|
||||
```ts
|
||||
// Core host
|
||||
isHTMLVideoElementHost(media) // → media is HTMLVideoElementHost
|
||||
isHTMLAudioElementHost(media) // → media is HTMLAudioElementHost
|
||||
|
||||
// HTML custom elements
|
||||
isVideoElement(media) // → media is VideoElement
|
||||
isAudioElement(media) // → media is AudioElement
|
||||
|
||||
// Specific implementations
|
||||
isHlsVideo(media) // → media is HlsVideo
|
||||
isDashVideo(media) // → media is DashVideo
|
||||
```
|
||||
|
||||
### Helpers
|
||||
|
||||
```ts
|
||||
resolveHTMLVideoElement(media): HTMLVideoElement | null
|
||||
resolveHTMLAudioElement(media): HTMLAudioElement | null
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
attach({ target, signal, set }) {
|
||||
const { media } = target;
|
||||
|
||||
if (isHlsVideo(media)) {
|
||||
media.engine?.on(Hls.Events.LEVEL_SWITCHING, (_, data) => {
|
||||
set({ currentLevel: data.level });
|
||||
});
|
||||
}
|
||||
|
||||
const video = resolveHTMLVideoElement(media);
|
||||
if (video) {
|
||||
const stream = video.captureStream();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HLS — The Reference Implementation
|
||||
|
||||
The HLS media is the primary example. It's a plain class in core — no
|
||||
`HTMLElement`, no shadow DOM.
|
||||
|
||||
### Core class
|
||||
|
||||
```ts
|
||||
class HlsVideo extends HTMLVideoElementHost<HlsVideoEvents>
|
||||
implements MediaEngineHost<Hls, HTMLVideoElement> {
|
||||
|
||||
override attachEngine(target: HTMLVideoElement) {
|
||||
this.engine?.attachMedia(target);
|
||||
}
|
||||
|
||||
override detachEngine() {
|
||||
this.engine?.detachMedia();
|
||||
}
|
||||
|
||||
override async destroyEngine() {
|
||||
const engine = this.engine;
|
||||
if (!engine) return;
|
||||
engine.detachMedia();
|
||||
engine.destroy();
|
||||
// TODO: await actual MSE SourceBuffer cleanup, not just a microtask
|
||||
}
|
||||
|
||||
// src routes through engine
|
||||
get src() { return this.#src; }
|
||||
set src(src: string) {
|
||||
this.#src = src;
|
||||
this.#requestLoad();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```html
|
||||
<!-- Standard: shadow DOM creates the <video> -->
|
||||
<video-player>
|
||||
<hls-video src="stream.m3u8" playsinline></hls-video>
|
||||
</video-player>
|
||||
|
||||
<script>
|
||||
const el = document.querySelector('hls-video');
|
||||
el.engine; // → Hls instance
|
||||
el.nativeElement; // → inner <video> element (HTMLVideoElement | null)
|
||||
el.play(); // → media contract
|
||||
el.paused; // → media contract
|
||||
</script>
|
||||
```
|
||||
|
||||
`VideoElement` forwards native video attributes (`playsinline`,
|
||||
`poster`, `crossorigin`, `loop`, `autoplay`, `muted`, `preload`) to
|
||||
the inner `<video>`. These are element configuration, not media
|
||||
capabilities.
|
||||
|
||||
### Slotting your own video element
|
||||
|
||||
```html
|
||||
<hls-video src="stream.m3u8">
|
||||
<video slot="media" playsinline></video>
|
||||
</hls-video>
|
||||
```
|
||||
|
||||
The slotted `<video>` replaces the default shadow DOM `<video>`.
|
||||
`VideoElement` detects it via the `media` slot and uses it as
|
||||
the engine target. The media contract still works.
|
||||
|
||||
### Store integration — unchanged
|
||||
|
||||
```ts
|
||||
const playbackFeature = definePlayerFeature({
|
||||
name: 'playback',
|
||||
state: ({ target }): MediaPlaybackState => ({
|
||||
paused: true,
|
||||
play() { return target().media.play(); },
|
||||
pause() { target().media.pause(); },
|
||||
}),
|
||||
attach({ target, signal, set }) {
|
||||
const { media } = target;
|
||||
const sync = () => set({ paused: media.paused, ended: media.ended });
|
||||
listen(media, 'play', sync, { signal });
|
||||
listen(media, 'pause', sync, { signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HlsVideoElement — HTML package registration
|
||||
|
||||
`HlsVideo` is a plain class in `@videojs/core`. The HTML package wraps
|
||||
it as a custom element:
|
||||
|
||||
```ts
|
||||
// packages/html/src/media/hls-video/index.ts
|
||||
export class HlsVideoElement extends VideoElement {
|
||||
static readonly tagName = 'hls-video';
|
||||
readonly media = new HlsVideo();
|
||||
}
|
||||
|
||||
safeDefine(HlsVideoElement);
|
||||
```
|
||||
|
||||
`VideoElement` creates the shadow DOM with a `<video>` template,
|
||||
resolves the video target (default or slotted), calls `attachElement(video)`
|
||||
on the media host, forwards contract events and attributes, and handles
|
||||
store context registration.
|
||||
|
||||
```html
|
||||
<video-player>
|
||||
<hls-video src="stream.m3u8" playsinline></hls-video>
|
||||
</video-player>
|
||||
```
|
||||
|
||||
The split:
|
||||
|
||||
| Package | Class | Responsibility |
|
||||
|---------|-------|----------------|
|
||||
| `@videojs/core/dom` | `HTMLMediaElementHost` (base scaffold) | Shared element forwarding |
|
||||
| `@videojs/core/dom` | `HTMLVideoElementHost implements Video` | Full `Video` contract + typed `VideoEventTarget` |
|
||||
| `@videojs/core/dom` | `HTMLAudioElementHost implements Audio` | Full `Audio` contract + typed `AudioEventTarget` |
|
||||
| `@videojs/core/dom` | `HlsVideo extends HTMLVideoElementHost` | HLS engine + contract overrides |
|
||||
| `@videojs/html` | `MediaElementMixin` | Store context registration |
|
||||
| `@videojs/html` | `VideoElement` (abstract) | Shadow DOM, video template, slots, attributes, events, store context |
|
||||
| `@videojs/html` | `AudioElement` (abstract) | Shadow DOM, audio template, slots, attributes, events, store context |
|
||||
| `@videojs/html` | `HlsVideoElement extends VideoElement` | Provides `HlsVideo` host, registered as `<hls-video>` |
|
||||
Reference in New Issue
Block a user