<video controls> gives you the browser’s player: a different control bar on every platform,
themable nowhere, and nothing you can put a button into. This is the same control set drawn as
markup - the shape Plyr settled on, which is the one people already know -
with the element left as the source of truth underneath it.
Usage
import { Video } from "@enigmax/primitives/react/video";
<Video src="/demo.mp4" poster="/demo.jpg" />
Several files, and the browser takes the first one it can play:
<Video
src={[{ src: "/demo.webm" }, { src: "/demo.mp4" }]}
poster="/demo.jpg"
tracks={[{ src: "/demo.en.vtt", srcLang: "en", label: "English", default: true }]}
/>
type is what lets the browser skip a file it cannot play without fetching it, which is the
only reason to list several - so it is read from the extension when you leave it out. Write it
yourself when the URL does not carry one:
<Video src={[{ src: "/stream/1234", type: "video/webm" }, { src: "/demo.mp4" }]} />
A URL with an extension that means nothing gets no type at all rather than a guess. That is
the safe way round: with no type the browser fetches and sniffs, which always works, while a
WRONG type makes it skip the source without trying it - and a video with every source skipped
does not play.
Native props pass straight through - autoPlay, loop, muted, preload, crossOrigin,
and every media event (onPlay, onEnded, onTimeUpdate) - because the element is still an
element.
The controls
controls takes an object, so a bar can be cut down to what a page needs:
<Video src="/loop.mp4" controls={{ volume: false, settings: false, pip: false }} />
<Video src="/loop.mp4" controls={false} /> {/* the picture, and nothing over it */}
| Control | Default | |
|---|---|---|
play |
on | The bar’s button, and the round one over the poster |
progress |
on | Scrubber, with what is buffered behind it |
currentTime, duration |
on | The two clocks |
volume |
on | Mute, and a rail that unfolds when reached |
captions |
on | Only where the element has subtitle tracks |
settings |
on | Playback speed, and the subtitle language |
pip |
on | Feature-detected: absent where the browser has no picture in picture |
cast |
on | Feature-detected twice: the browser must have the API and report a screen |
fullscreen |
on | Including the iOS path, which is the video’s own |
download |
off | A video a page shows is not always one it means to hand over |
The keyboard
The platform’s, not an invention: space or K plays, J and L jump ten seconds, the arrows nudge five and set the volume, M mutes, F is fullscreen, C captions, P picture in picture, and a digit seeks to that tenth of the video.
It stands down inside anything that takes text, so a space bar in a comment box is a space and
not a pause. keyboard={false} turns the lot off.
Subtitles
The button and the menu report the element, not a flag of their own. That distinction is
the whole feature: a <track default> is already showing before anybody pressed anything, a
page can add a track after mount, and <track> children written by hand are subtitles the
component was never told about. All three are in textTracks, which is what is read.
So the settings panel lists Off and every subtitle track the element carries, one is showing at a time, and C comes back to the language that was chosen rather than to the first in the list.
<Video
src="/demo.mp4"
tracks={[
{ src: "/demo.en.vtt", srcLang: "en", label: "English", default: true },
{ src: "/demo.es.vtt", srcLang: "es", label: "Espanol" }
]}
/>
Only subtitles and captions tracks are offered. Chapters and metadata draw nothing over
the picture, so listing them would be a language row that does nothing.
Casting
cast uses the browser’s own Remote Playback API,
with Safari’s older AirPlay picker behind it - so it is a real cast to a Chromecast or an Apple
TV, not a script from a CDN. The device list belongs to the platform: nothing in a page can
enumerate the screens on a network, so the button opens the browser’s picker.
It appears only where there is somewhere to cast to. The button follows the availability
the browser reports, disableRemotePlayback on the element removes it, and the player carries
data-casting while the video is playing on the other screen.
The menu a right-click opens
Shaped the way YouTube’s is: play, loop, the speed, the subtitle language, and the two rows
that put the link on the clipboard - the second with #t= at the second you are watching.
Picture in picture, casting and fullscreen are under it where the browser has them.
It is the context menu - the same panel, keyboard model and roles, rather
than a second popup written for one player. contextMenu={false} gives the browser’s own menu
back, and an object adds rows of your own:
<Video
src="/demo.mp4"
contextMenu={{
title: "Release notes, 2.4",
items: [{ id: "transcript", label: "Open the transcript" }],
onSelect: (item) => { if (item.id === "transcript") openTranscript(); }
}}
/>
Its code is a chunk of its own, mounted the first time a pointer reaches the player - so a page
of embedded videos nobody right-clicks downloads none of it. A press that beats the chunk falls
through to the browser’s menu, which is a better answer than swallowing it. The player carries
data-menu once its own menu will answer.
The element is the source of truth
Every control reads the video back through its own events rather than keeping a second copy of what is playing. It is the difference between a bar that is right and one that is usually right: the media keys, picture in picture, the operating system and any code of yours all change playback without going through the component, and a player that believes its own state says “playing” over a video the browser refused to autoplay.
Two more things that follow from it. play() returns a promise that rejects - no gesture
yet, an autoplay policy, no source - and that rejection is handled rather than left to a
console. And the clock is sized by the duration, so a video over an hour prints hours from the
first second instead of growing from 9:59 to 1:00:00 and shoving every control along.
The bar that gets out of the way
It fades after a couple of seconds of playing and comes back on the first pointer move, key
press, focus, or pause. autoHide={false} keeps it, and autoHideDelay changes the wait.
Three things it will not do. It does not go while the settings menu is open, because a bar that vanishes under the pointer takes the panel being read with it. It does not go while the pointer is resting on it - the controls used to disappear under the cursor that was on its way to them, which reads as the buttons vanishing rather than as a bar getting out of the way, and fullscreen is where that trip is longest. And a key brings it back like a pointer does, so seeking from the keyboard in fullscreen is not done blind.
Installation
Adds the package and prints the import to use. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.
enigma add videoInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react/video.
It brings a theme, because an unstyled player is not a plain player - it is a column of buttons
under a video. styles={false} turns it off and @enigmax/primitives/video.css is the same
sheet as an import. Most projects override one property:
:root {
--enigma-video-accent: #3b82f6;
--enigma-video-panel-bg: rgba(23, 23, 23, 0.96);
--enigma-video-control-size: 2rem;
--enigma-video-icon-size: 1.125rem;
--enigma-video-rail-height: 0.3125rem;
}
The icons are sized by `--enigma-video-icon-size` rather than in `em` of the bar's own text:
`1.25em` of a 13px label is a 16px icon, and the same markup in a project with larger type is
silently a different player.Styling hooks
| Attribute | On |
|---|---|
[data-enigma-video] |
The wrapper - carries data-playing, data-hidden, data-fullscreen, data-casting, data-menu |
[data-enigma-video-big] |
The round play button over the poster |
[data-enigma-video-controls] |
The bar |
[data-enigma-video-button] |
One control. aria-pressed tracks captions and casting |
[data-enigma-video-rail] |
A slider - the scrubber and the volume are the same control |
[data-enigma-video-buffer], [data-enigma-video-played], [data-enigma-video-knob] |
Inside the rail |
[data-enigma-video-panel] |
The settings menu. Bounded to the player and scrolls: it opens upward inside a box that clips |
[data-enigma-video-option="speed"], [data-enigma-video-option="captions"] |
Its rows, by the list they belong to |
[data-enigma-video-spinner] |
Shown while the video is waiting for data |
Anything you render as a child sits over the picture and under the bar, which is where a title, a badge or a watermark belongs:
<Video src="/demo.mp4">
<span className="badge">Live</span>
</Video>