@1sept/player (0.2.1)
Installation
@1sept:registry=https://git.1sept.ru/api/packages/1sept/npm/npm install @1sept/player@0.2.1"@1sept/player": "0.2.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.
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.
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 |
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
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-bg |
Player letterbox background |
--sp-surface |
Control bar scrim, menu panels |
--sp-surface-strong |
Help dialog, error wash |
--sp-card-bg |
Error and countdown cards |
--sp-card-text / --sp-card-text-muted |
Card title and body |
--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 |
--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-overlay, sp-card, …) are public API.
The design rationale and measured contrast ratios live in
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, cors, stream-not-started, or config.
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 what drives theendedstate.
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 over the poster, ticking each second | the start time arrives |
probing |
"Waiting for signal…" | 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 | — |
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.
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 or a version that is already on the registry, because a published version can never be replaced.
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 |
| @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 |