Skip to content

Media engine

approved

What Video.js 10 is, layer by layer, and where the LT Player sits in that story. This page exists because the media engine is not a monolithic "player" but a kit of packages, and understanding the kit is prerequisite to implementing anything above it.

History: three generations, three engines

The LT Player has been rebuilt on a new media engine roughly every two years. The repository still carries the full history (114 tags), and the two previous generations shaped what version 3 deliberately does differently.

Version 1: Plyr (2022 to 2024)

  • Started November 2022; first release candidate 1.0.0rc1 in March 2023; tags through 1.2.0-d.
  • Built on Plyr 3.7, a polished wrapper around the native video element with a fixed interface. Implementation: webpack 5 and TypeScript, interface pieces added around Plyr through Handlebars and Mustache templates, gestures via hammer.js, SCSS styling.
  • The approach then: Plyr owned both playback and the interface. LT features lived around its edges: extra controls injected as templates next to Plyr's fixed UI, styling done by overriding Plyr's own CSS classes, gestures hand-wired with a separate library. The player was configured and decorated, never truly owned.
  • What version 3 does better: no foreign interface to fight. Capabilities are first-class modules instead of bolt-ons around someone else's controls, styling styles our own markup instead of overriding somebody's classes, and behavior lives in one configuration contract instead of scattered options and patches.

Version 2: Vidstack (2024 to 2026)

  • A ground-up rewrite started in January 2024; 2.0.0 tagged in March 2024; final release 2.0.0-q in June 2026. This generation still serves lt.org until the version 3 cutover.
  • Built on Vidstack 1.10 web components, bundled with Vite 5, mounted on pages as the <lt-player> custom element reading a data-config JSON attribute.
  • The approach then: a newer engine, same pattern, plus a framework of our own making. Around Vidstack's components grew hand-rolled service classes: an elements service, a reactivity service, a render service, component classes: in effect a small DIY framework for DOM state that had to be maintained alongside the player itself. The configuration grew its own version counter, reaching 3 independently of the 2.x player, which is why configs in the wild say "version": 3 inside a body wrapper: the exact legacy shape the new contract rejects.
  • What version 3 does better: React replaces the DIY reactivity and rendering machinery with a battle-tested one we do not maintain; the headless engine removes the ready-made interface we kept adapting; the specification precedes the code instead of living only inside it; and versioning is unified: one number for the player and its contract.

Version 3: Video.js 10 (now)

The break with the pattern. The two previous generations skinned complete players; version 3 composes a headless kit (the layers below): every pixel of the interface is ours, specified in these documents before implementation, and the configuration version is unified with the player major, reusing the number 3 as a sanctioned breaking change while both sides are controlled by the same team.

Not a monolithic player

The old Video.js (version 8) was one Player class extended by plugins. Video.js 10 is a different animal: a set of composable packages, where "the player" is not a visual thing at all but a reactive store wired to the browser's media element. The visible interface is a separate, replaceable layer. Three distinct things carry the roles:

RoleWhat it is
The pictureThe browser's <video> element: decodes and displays the video
The brainThe player store: holds state (paused, currentTime, volume, playbackRate) and actions (play, seek), wired to the element
The skinThe visible controls: components that subscribe to the brain and draw it

The skin can be swapped or removed and the brain keeps working. That separation is exactly what lets the LT Player own its entire appearance while Video.js does the playback thinking underneath.

The layers

Bottom up:

PackageRole
hls.js via @videojs/mediaThe on-demand streaming engine: HLS playback, adaptive bitrate switching, and MSE on every non-Apple engine. Manifest subtitles and embedded captions are deliberately not loaded; captions come only from configured content.tracks. Only Apple engines (Safari on macOS and iOS) use the browser HLS path; Chromium's built-in HLS is bypassed because it resolves redirected playlists against the wrong base URL. @videojs/spf is unused and is not part of the LT Player bundle
@videojs/mediaMedia contracts and state types: the slices that mirror the element (playback, time, volume, rate, tracks, fullscreen, error), and playback engine adapters
@videojs/storeThe reactive store machinery, built for state owned by external systems. The player object is a store over { media element, container }, composed of the media feature slices
@videojs/coreHeadless control cores: the behavior of each control without pixels (play button, time slider with chapter segments, menus, captions button), plus gesture and hotkey machinery and the i18n key set. Runtime-agnostic by design, so the same logic can power DOM, React, and React Native skins
@videojs/reactThe React layer: components and hooks (VideoPlayer, VideoSkin, Video) that give the cores pixels. This is the layer the LT Player mounts

The player project pins one exact beta version of these packages and upgrades deliberately; the media engine never surfaces in the public contract.

Where the LT Player sits

The LT Player is the top layer: layouts, capabilities, the config contract, strings, and storage, implemented as plain React, one layer. We deliberately do not mirror the engine's core-plus-bindings split with an LT core of our own:

  • That split exists at Video.js to serve multiple runtimes (DOM, React, React Native). We ship exactly one artifact, a bundled web player; consumers never see React through the mount API.
  • A parallel LT core would tax every story now for a hypothetical second runtime later. If that runtime ever becomes real, cores get extracted then, paying the refactor cost when it is justified.

Composition over inheritance

The binding rule at every seam: compose, never subclass.

  • No LT class ever extends a Video.js component or core. Our components wrap theirs, pass props, use their hooks, and combine their cores.
  • Their internals stay their private business, so engine upgrades cannot break a subclass contract that does not exist.
  • Any piece we outgrow (a button, a menu) is swapped for our own without touching the rest.
  • The same rule repeats at our own seams: layouts compose capabilities, capabilities compose cores and primitives.

No escape hatch

The public contract hides the engine entirely, with no exceptions: there is no proxy to the underlying player store or media element. When a hosting page needs something the contract does not offer, the answer is a player release: the capability lands in the contract, versioned, documented, and supported, instead of leaking through a side door that would turn engine internals into a de facto API. The release pipeline is built to make this fast.