Video

A player with the controls a video is expected to have - shaped after Plyr, with subtitles, casting and a right-click menu, the element as the source of truth, and a theme you restyle through data attributes.

<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 video

Installs 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.

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>