@1sept/player (0.4.1)
Installation
@1sept:registry=https://git.1sept.ru/api/packages/1sept/npm/npm install @1sept/player@0.4.1"@1sept/player": "0.4.1"About this package
@1sept/player
A fully custom, skinnable Video.js 8 player: HLS and MP4 with adaptive streaming, a unified quality selector, designed error states, structured error reporting, keyboard control, and EN/RU localization.
Video.js is a peer dependency — the player never bundles it, so a page carries exactly one copy.
Integrating the player into another project? USAGE.md is the step-by-step guide, written for a coding agent: install, framework lifecycle, the rules that break playback when ignored, and a verification checklist. This file is the reference it points back to.
Install
Published to the 1sept package registry, so the scope needs pointing at it once:
npm config set @1sept:registry=https://git.1sept.ru/api/packages/1sept/npm/
npm config set -- '//git.1sept.ru/api/packages/1sept/npm/:_authToken' "{token}"
For Yarn, add the scope to .yarnrc.yml instead:
npmScopes:
1sept:
npmAlwaysAuth: true
npmRegistryServer: https://git.1sept.ru/api/packages/1sept/npm/
Then install it alongside its peer:
npm install @1sept/player video.js
# or: yarn add @1sept/player video.js
import { createSeptPlayer } from '@1sept/player'
import '@1sept/player/style.css'
import '@1sept/player/skins/video.css'
video.js is a peer dependency and is never bundled, so a page carries exactly one copy. The
player brings no runtime dependencies of its own.
Markup-only usage
The player reads its configuration from the <video> element it attaches to, so a page can embed
it without writing options in JavaScript:
<video id="p" class="video-js" poster="/poster.jpg" data-sept='{"locale":"ru","skin":"sept"}'>
<source src="/vod/1080.mp4" type="video/mp4" data-label="1080p" data-height="1080" />
<source src="/vod/720.mp4" type="video/mp4" data-label="720p" data-height="720" />
<track kind="subtitles" src="/subs.ru.vtt" srclang="ru" label="Русские" default />
</video>
const player = createSeptPlayer('#p')
Precedence, first match wins per field: explicit JS options → data-sept JSON → DOM attributes
and children (src, <source>, poster, <track>, preload, muted, loop, autoplay) →
built-in defaults.
Type detection uses the type attribute when present, otherwise the extension of the URL
pathname, so master.m3u8?token=… still resolves. .m3u8 → HLS, .mpd → DASH, .mp4 /
.webm / .ogv → progressive. A URL with no recognisable extension and no type is rejected with
a SeptConfigError rather than guessed.
.mpd is recognised but not playable: no DASH tech is registered, so the source is accepted
and then fails as a media error. Repackage to HLS, or register a DASH tech on Video.js before
creating the player.
Not supported: RTMP
rtmp:// and rtmps:// sources throw a SeptConfigError. No browser can play RTMP — it requires
Flash, which no longer exists. Repackage the stream to HLS on the server and pass the .m3u8 URL;
see the HLS contract below.
Options
Every option may also be given as a key in the data-sept JSON attribute.
| Option | Type | Default | Notes |
|---|---|---|---|
sources |
SeptSource[] |
from markup | { src, type?, label?, height?, bitrate? } |
poster |
string |
poster attribute |
Also used behind errors and countdowns |
locale |
'en' | 'ru' |
navigator language, else en |
|
skin |
string |
'video' |
Adds sp-skin--<name> to the root |
hotkeys |
'focused' | 'global' | false |
'focused' |
|
autoplay |
boolean | 'muted' |
false |
muted when the element has both autoplay and muted |
playbackRates |
number[] |
[0.5, 0.75, 1, 1.25, 1.5, 2] |
|
seekStep |
number |
15 |
Seconds for the ± buttons and arrow keys |
abr.limitByDimensions |
boolean |
true |
Never fetch a rendition larger than the player |
abr.persistBandwidth |
boolean |
true |
Reuse the last bandwidth estimate |
abr.initialBandwidth |
number |
unset | Starting estimate in bits per second |
telemetry |
boolean |
true |
Emit sp:telemetry QoS events |
onError |
(e: SeptError) => void |
unset | |
errorReporter |
(r: SeptErrorReport) => void |
unset | Full report with breadcrumbs |
features |
Feature[] |
[] |
Opt-in installers, e.g. live |
startsAt |
string | Date |
unset | Requires the live feature |
live |
boolean |
unset | Requires the live feature |
startsAt or live without the live feature throws a SeptConfigError naming
@1sept/player/live.
Events
player.on(event, cb) returns an unsubscribe function.
| Event | Payload |
|---|---|
sp:ready |
{ mediaType, isLive } |
sp:error |
SeptError |
sp:qualitychange |
{ from, to, mode: 'auto' | 'manual' } |
sp:ratechange |
{ rate } |
sp:telemetry |
QoSEvent |
sp:live:countdown |
{ remainingMs, startsAt } (live feature) |
sp:live:probing |
{ attempt, nextInMs } (live feature) |
sp:live:started |
{ latencyMs? } (live feature) |
sp:live:ended |
{} (live feature) |
Methods
| Method | Description |
|---|---|
loadSource(src) |
Swap the source; accepts a URL, a SeptSource, or an array |
retry() |
Reload the current source, preserving rate and pinned quality |
setPoster(url) |
Swap the poster, including the blurred error backdrop |
setLocale(locale) |
Relabel every control immediately |
getQualities() / setQuality(id) |
The unified level list; 'auto' returns to ABR |
togglePlay() / seekBy(d) / seekTo(t) / seekToPercent(p) |
Playback |
changeVolume(d) / toggleMute() |
Volume |
toggleFullscreen() / togglePip() |
Presentation |
stepRate(1 | -1) |
Move one step along playbackRates |
currentSource() |
The active ResolvedSource |
showHelp() / closeOverlays() |
Shortcut dialog |
destroy() |
Dispose the player and every listener |
player.videojs is the underlying Video.js player if you need something this facade does not cover.
Controls
Two layouts
The chrome takes one of two shapes, chosen by the pointer and by nothing else. There is no
JavaScript in the switch — it is @media (pointer: coarse) in structure.css.
With a mouse: a row of bubbles along the bottom edge. Play, the two seeks sharing one oval between them, volume, the clock, then captions, picture-in-picture, settings and fullscreen pushed to the far end, with the seek rail above them running the full width of the frame. Hovering any of them lightens its bubble — one answer for every control, and a marked one: the glyph goes from 3.5:1 at rest to 1.7:1 under it, so it carries a shadow through the hover to hold together on a bright frame. Deepening instead is one value in each skin, noted where it is set. A short, predictable trip to a target already in view, and the picture keeps its middle. The row has a width, so as the frame narrows controls leave in the order of what a keyboard or a menu still reaches without them — picture-in-picture below 560px, the two seeks below 440px, the duration below 340px. Play, volume, the current time, captions, settings and fullscreen stay at every width.
Under a thumb: the frame layout. A hand holding a phone rests near the centre of the screen, not on its bottom edge, so the play button moves to the middle of the picture as a bare glyph, the clock takes the bottom-left corner and fullscreen the bottom-right, captions/PiP/settings sit top right, and the seek rail lies on the frame's own bottom edge.
There are no seek buttons there. Flanking the play button they crowded the middle of the picture,
and a double tap does the same job with none of the frame: the left half of the frame goes back a
seekStep, the right half forward, flashing the same indicator the arrow keys do. With a mouse
that same double press is fullscreen, as it has always been. The volume slider is dropped outright: every touch device has volume keys, and
↑ ↓ M cover the rest.
The picture is never dimmed in either. Each control carries a translucent ground of its own — the
bubbles — all of one weight, glyphs and clock alike, because a row where one bubble is heavier
than its neighbours reads as a mistake before it reads as a hierarchy. In sept and video that
ground is tinted from the skin's own --sp-accent, so the chrome carries the brand; minimal
keeps it neutral, having no brand to tint it with.
Measured on the hardest frame there is, a white one, 55% accent in black at 0.62 holds a white glyph at 3.5:1, past the 3:1 WCAG asks of a non-text UI component. The clock lands at the same 3.5:1 against the 4.5:1 it asks of text — a deliberate trade for one uniform bubble, and the reason its numerals are full-strength rather than dimmed, with the duration set apart by weight instead. A plate dark enough for the text threshold is 0.82, where the bubbles are visibly heavy.
The one exception is the transport where it stands in the middle of the picture: there it is the glyph alone, because a disc would be the only plated thing in the frame's centre.
Quality and speed live behind the gear, in a two-level panel: a root list showing each with its
current value, then a detail list for the one being changed. Only settings that are a choice
among values go there — captions, picture-in-picture and fullscreen are switches, and one press
does the whole thing, so they stay out where a press reaches them. The SpQualityMenu and
SpRateMenu components are still exported and still read the same controllers, for a consumer
who wants those inline too.
The rail has no handle on the playhead — the fill's own edge already says where playback is. What a pointer over a seek bar is actually asking is where a press would land, so hovering fills the rail pale from its start to the pointer, under what has played, and raises a time bubble above it. The bubble is opaque and painted over the rest of the chrome, because at either end of the rail it covers a control.
The first screen
Before the first play the chrome is already where it goes when a viewer stops moving: the row tucked away, the seek rail left on the frame's own bottom edge showing where playback stands. What is on screen is the poster, the brand mark and the big play button. Moving the pointer over the player brings the rest out, exactly as it does mid-playback — Video.js does not begin tracking activity until the first play, so the trigger before it is the pointer being over the player at all rather than a timeout.
In the frame layout the transport stays away even then: revealed, it would land on the big play button and take the press meant for it. In the bar it sits in the corner, nowhere near.
Auto-hide
The chrome fades out three seconds after the last pointer or key activity, but only while the video is playing, and only while nothing needs it: it stays up as long as the pointer rests on it (an open menu included) or the keyboard focus sits inside it. Any movement, keypress, or click brings it straight back, and a pause holds it there. The mouse cursor hides and returns with it.
In the frame layout the transport is the one exception to the hold: it sits exactly where the pointer is left after pressing the big play button, so holding there would mean the chrome never left. In the bar it is one control in a row and holds like any other.
The seek rail stays behind, docked to the frame's bottom edge, so the viewer keeps a read on position with nothing else on screen. It takes no clicks there, and travels back above the row of controls — with the buffered track and the hover preview — as soon as they return. A live stream without DVR has no rail to leave.
Keyboard
| Key | Action |
|---|---|
Space, K |
Play / pause |
←, → |
−seekStep / +seekStep |
J, L |
−seekStep / +seekStep |
↑, ↓ |
Volume ±10% |
M |
Mute toggle |
F |
Fullscreen toggle |
P |
Picture-in-Picture toggle |
0–9 |
Seek to 0–90% (VOD only) |
<, > |
Playback rate down / up |
Home, End |
Seek to start / end |
? |
Shortcut help overlay |
Esc |
Close overlay / exit fullscreen |
On a touch screen ↑ ↓ and M are the only way to reach volume, which is what the device's
own volume keys are for, and a double tap on either half of the frame seeks the same seekStep
the arrows do.
Bindings are matched on the physical key, so a Russian keyboard layout triggers the same actions.
Keys never fire while focus is in an input, textarea, select, or contenteditable, and
preventDefault() is called only for keys actually handled, so page scrolling and browser
shortcuts are otherwise untouched.
Skins and the token contract
This package ships no
video-js.css, and must not be paired with one. Everything Video.js would have styled is re-declared instructure.css, which is why half of that file exists — the poster's hidden state,box-sizing, the error state, the captions inset, the full-window fullscreen fallback. Loading upstream's stylesheet alongside it produces duplicate and conflicting rules at equal specificity.
style.css carries the tokens and the structural layout. A skin is colour and decoration only,
scoped under .sp-skin--<name>, so a new skin needs no JavaScript. Three ship:
| Skin | Matches | Accent |
|---|---|---|
video (default) |
video.1sept.ru | #0376b4 blue |
sept |
1sept.ru | #cc4c29 terracotta |
minimal |
nothing — proves the token contract | white on flat black |
Import the one you want alongside style.css:
import '@1sept/player/style.css'
import '@1sept/player/skins/sept.css'
Upgrading from 0.1.0.
septused to be the blue video.1sept.ru skin; it is now the terracotta 1sept.ru one, and the blue skin is calledvideo. The change is silent — the old import path still resolves — so a 0.1.0 consumer that wants the blue skin must switch both the import and theskinoption tovideo.
| Token | Used by |
|---|---|
--sp-accent |
Progress fill, focus ring, selected menu item, card button |
--sp-accent-contrast |
Text on the card button |
--sp-accent-on-dark |
The accent where it lands on video, not on chrome |
--sp-accent-deep |
The pressed / hovered end of the accent |
--sp-brand |
The 1 September mark shown over the poster before play |
--sp-bg |
Player letterbox background |
--sp-surface |
Unused since the bar became a layer; kept, the name is API |
--sp-surface-strong |
Menu panels |
--sp-card-bg |
The unmute pill (cards are translucent panels now) |
--sp-card-text |
Text on --sp-card-bg |
--sp-text / --sp-text-muted |
Chrome text and icons |
--sp-radius / --sp-radius-lg |
Controls and menus / overlay cards |
--sp-control-size / --sp-icon-size |
Button box / icon; the frame layout raises both for a thumb |
--sp-plate / --sp-plate-hover |
The bubble under a control; skins tint it from the accent |
--sp-transport-shadow |
What separates the bare transport glyphs from the frame |
--sp-transport-size |
The centre play disc; the two seeks derive from it |
--sp-font / --sp-font-size |
Player root |
--sp-ease / --sp-duration / --sp-duration-slow |
Every transition |
--sp-overlay-blur |
Blurred poster behind cards |
--sp-shadow |
Cards and menu panels |
--sp-focus-ring |
Every focusable control |
--sp-progress-height |
Seek rail |
--sp-poster |
Set by the player for the blurred backdrop |
Class names (sp-controlbar, sp-btn, sp-menu, sp-settings, sp-overlay, sp-card, …)
are public API, and so are the state classes the player sets on its root: sp--live and
sp--dvr for a broadcast, sp--modal while a dialog owns the frame.
The design rationale and measured contrast ratios live in the repository (not in the published
package) at
docs/superpowers/design/2026-09-07-video-skin-direction.md and
docs/superpowers/design/2026-09-08-sept-skin-direction.md.
Errors and reporting
Video.js reports only four MediaError codes, which cannot tell a missing file from an expired
token. On failure the player probes the failing URL (HEAD, falling back to a zero-byte GET
range for CDNs that reject HEAD) and classifies the result into one SeptErrorKind:
not-found, forbidden, network, unsupported, or cors. Two further members of
SeptErrorKind are never emitted: stream-not-started is the pre-air 404, which the live
feature suppresses so nothing paints over the countdown, and config faults are thrown as
SeptConfigError rather than reported as playback errors.
Each renders a designed, localized card. Network errors retry three times with backoff (2s, 4s, 8s)
and show the attempt count before offering a manual button; playback also resumes automatically
when the browser comes back online.
errorReporter receives the full picture:
interface SeptErrorReport {
kind: SeptErrorKind
message: string
src: string
httpStatus?: number
videojsCode?: number
mediaType: 'hls' | 'mp4' | 'dash' | null
currentTime: number
isLive: boolean
quality?: string
browser: { name: string; version: string; os: string }
playerVersion: string
sessionId: string
timestamp: string
recentEvents: Array<{ at: string; event: string; data?: Record<string, unknown> }>
}
recentEvents is a rolling trail of the last 20 player events, which is usually what makes a
report reproducible.
HLS contract for your RTMP→HLS pipeline
The player expects the RTMP→HLS pipeline (outside this project) to produce:
- A master playlist with multiple variants, each carrying
RESOLUTIONandBANDWIDTH, so the quality menu can label levels meaningfully. - Segment duration of 2–4 seconds for a reasonable live latency/stability balance.
EXT-X-PROGRAM-DATE-TIMEtags, so live wall-clock positioning and DVR are correct.- For DVR: a sliding window longer than 30 seconds (the auto-detection threshold); otherwise the player is live-edge only.
Access-Control-Allow-Originon manifests and segments, plusAccess-Control-Expose-Headers: Dateso the countdown's server-clock offset can be read.- A stable manifest URL that returns 404 before the broadcast starts and 200 once live — this is exactly what the
probingstate depends on. EXT-X-ENDLISTon completion, which is one of the two things that drive theendedstate.
What live.1sept.ru actually sends
The deployed ingest is nginx-rtmp-module, and two clauses above are beyond it — verified
against the module's source, which contains no code for either tag:
- No
EXT-X-PROGRAM-DATE-TIME. The countdown therefore corrects itself from theDateresponse header instead, which is why exposing that header is not optional. - No
EXT-X-ENDLIST. The end of a broadcast is signalled only by the manifest starting to 404 again, so the end card rests entirely on that 404 being readable — i.e. on CORS headers being sent withalways. Without them a finished broadcast is indistinguishable from a dropped connection and the player retries instead of ending.
Closing either gap means a module fork that emits the tags, or repackaging through FFmpeg. The player works without them; this is the cost.
Browser notes
Chrome, Edge, Firefox and Safari current plus two versions back; iOS Safari 15+; Android Chrome. Divergences degrade visibly rather than silently:
| Platform | Divergence | Handling |
|---|---|---|
| iOS Safari | Native HLS, no representation API | Quality menu hidden for HLS |
| iOS Safari | Fullscreen is the OS video player | Native fullscreen; custom controls not shown in it |
| iOS Safari | WebKit presentation-mode PiP | Mapped to the same button |
| Any browser without a page-callable PiP API | Feature-detected at runtime | Button absent |
| macOS Safari | Native HLS by default | overrideNative: true, so VHS drives it and the quality menu works |
Live
Scheduled-start countdown, manifest probing, DVR, a LIVE badge and an end card are an opt-in subpath import, so a VOD-only page never pays for them. The build test asserts the main entry and every chunk it loads contain no live code.
import { createSeptPlayer } from '@1sept/player'
import { live } from '@1sept/player/live'
createSeptPlayer('#p', { startsAt: '2026-09-01T08:00:00Z', features: [live] })
startsAt in the future starts a countdown; startsAt in the past, or live: true with no
startsAt, goes straight to probing. Either one without features: [live] throws a
SeptConfigError naming the subpath.
States
| State | What the viewer sees | Leaves when |
|---|---|---|
scheduled |
Countdown, ticking each second; no player chrome | the start time arrives, or a stream answers early |
probing |
"Waiting for signal…"; still no chrome | a manifest request returns 2xx |
live |
Playback, LIVE badge, GO LIVE when DVR is available | the stream ends |
ended |
End card, poster restored | — |
error |
The usual error card | — |
Before air the player shows the card and nothing else: no control bar, no seek rail, no big play
button, no LIVE badge. There is no stream to control, and a bar reading 0:00 beside a dead
play button is worse than no bar. Fullscreen still works by double-click and by F. Everything
returns when the state reaches live.
The countdown runs in every waiting state, in days and hours when it is that far out. The start moment is spelled out beside it: the time alone when the broadcast is today, a date and time otherwise, and the full form with weekday and year once the wait is measured in days.
Inside the last ten minutes of a countdown the player checks the manifest once a minute and switches the moment it answers. Broadcasters routinely bring a stream up early — a test card, a title slate, someone checking levels — and a viewer should not sit out a clock against a stream that is already running. Before air the manifest 404s, which is an answer rather than a failure, so a check costs one request and a whole window costs ten.
Probing backs off 2s, 4s, 8s, then every 15s. The countdown is corrected against the server clock
using the Date header of a single HEAD on the manifest; its status is ignored, because a 404
before air is exactly what the contract expects. That request never blocks the countdown — the
clock starts at offset 0 and reconciles if and when the header arrives.
Events
| Event | Payload |
|---|---|
sp:live:countdown |
{ remainingMs, startsAt }, once per second while scheduled |
sp:live:probing |
{ attempt, nextInMs }, after each failed probe |
sp:live:started |
{ latencyMs }, once playback begins |
sp:live:ended |
{} |
Behaviour worth knowing
- Live starts muted. Browsers block autoplay with sound, so the stream begins muted with a prominent unmute prompt rather than not starting at all.
- DVR is detected, not configured. A seekable window longer than 30 seconds enables seeking, the ±15s buttons and GO LIVE; below that the player is live-edge only and hides those controls along with the playback-rate menu.
- A 404 during a live stream is the end of the broadcast, not an error. It routes to the end card. Before air, the same 404 reads as "stream has not started".
Your ingest has to hold up its end of this; see the HLS contract above, in particular the
404-before-air / 200-when-live manifest URL, Access-Control-Expose-Headers: Date, and
EXT-X-ENDLIST on completion.
CORS must cover error responses too. The end card depends on the player being able to read the 404. nginx drops
add_headeron error responses unless told otherwise, so a finished broadcast then looks identical to the viewer's network dropping, and the player keeps retrying instead of showing "the broadcast has ended". Both directives needalways:add_header Access-Control-Allow-Origin * always; add_header Access-Control-Expose-Headers Date always;
Development
make help lists every target.
make install # dependencies
make dev # dev server with the demo page, every state on demand
make check # the full release gate: typecheck, lint, unit, build, dist tests
make e2e # Playwright, VOD and live
The demo page carries a scenario per state (MP4, HLS, 404, 403, slow network, live), a skin select that applies to all of them, and a URL bar for playing any manifest. Switching skins there is a class swap on the player root with no reload, which is exactly how you would do it at runtime.
The URL bar needs no type hint — the player reads the extension of the URL pathname, so .m3u8
and .mp4 both just work. Tick live to treat the URL as a broadcast (probe the manifest, then
start muted). Both the URL and the skin persist, and the page URL carries the choice, so a demo
link can be shared.
Releasing bumps, tags, pushes and publishes in one step:
make release-patch # or release-minor / release-major
Both publish paths refuse to run on a dirty tree, and make publish also refuses a version
that is already on the registry — release-* bumps to a fresh one, so the guard has nothing to
check. Each publishes before pushing the tag, so a failed publish leaves a purely local commit
and tag rather than a remote tag for a version that never shipped; a published version can never
be replaced.
That local leftover is what make release-abort takes back — the commit npm version made and
the tag it made with it, in one step rather than two:
make release-abort # undo a release that never left this machine
It refuses if the version is on the registry, if the tag is already on origin, if HEAD is not
the release commit for the current version, or if the tree is dirty. Doing it by hand and stopping
halfway is how a tag outlives the commit it named and takes the version number with it: the next
release-* then fails on a name that is already taken.
Updating dependencies
make deps # what is behind
make deps-update # update within the ranges, then run the full gate
make audit # security advisories
The package has no runtime dependencies, so an update can only break the build or the tests — never a consumer's bundle. Everything else is a devDependency and safe to move with the gate as the check.
video.js is the exception: it is a peer dependency, so its range is part of the public contract.
Raising the floor forces every consumer to upgrade, which is a breaking change for the package.
V=8.25.0 make deps-videojs # bumps the peer range and the dev copy together
P=vite@latest make deps-upgrade # move a devDependency past its range
License
MIT
Dependencies
Development Dependencies
| ID | Version |
|---|---|
| @eslint/js | ^10.0.1 |
| @microsoft/api-extractor | ^7.59.0 |
| @playwright/test | ^1.63.0 |
| @types/node | ^26.5.0 |
| eslint | ^10.10.0 |
| eslint-plugin-import-x | ^4.17.1 |
| jsdom | ^30.0.1 |
| prettier | ^3.9.6 |
| typescript | ~5.9.3 |
| typescript-eslint | ^8.70.0 |
| video.js | ^8.24.0 |
| vite | ^8.2.2 |
| vite-plugin-dts | ^5.1.0 |
| vitest | ^5.0.0 |
Peer Dependencies
| ID | Version |
|---|---|
| video.js | ^8.24.0 |