Первое сентября
Москва

@1sept/player (0.4.1)

Published 2026-09-09 11:58:07 +03:00 by naumso

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

Custom 1sept video player on Video.js 8

@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 in structure.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. sept used to be the blue video.1sept.ru skin; it is now the terracotta 1sept.ru one, and the blue skin is called video. 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 the skin option to video.

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 RESOLUTION and BANDWIDTH, 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-TIME tags, 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-Origin on manifests and segments, plus Access-Control-Expose-Headers: Date so 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 probing state depends on.
  • EXT-X-ENDLIST on completion, which is one of the two things that drive the ended state.

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 the Date response 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 with always. 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_header on 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 need always:

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
Details
npm
2026-09-09 11:58:07 +03:00
3
naumso
MIT
54 KiB
Assets (1)
Versions (15) View all
0.6.2 2026-10-12
0.6.1 2026-09-16
0.6.0 2026-09-09
0.5.1 2026-09-09
0.5.0 2026-09-09