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

@1sept/player (0.2.1)

Published 2026-09-08 20:24:56 +03:00 by naumso

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

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.

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. 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-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 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 what drives the ended state.

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_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 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
Details
npm
2026-09-08 20:24:56 +03:00
4
naumso
MIT
94 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