@1sept/player (0.6.0)
Installation
@1sept:registry=https://git.1sept.ru/api/packages/1sept/npm/npm install @1sept/player@0.6.0"@1sept/player": "0.6.0"About this package
@1sept/player
Полностью кастомный, скинуемый плеер на Video.js 8: HLS и MP4 с адаптивным стримингом, единый выбор качества, продуманные состояния ошибок, структурированная отправка отчётов об ошибках, управление с клавиатуры и локализация EN/RU.
Video.js — peer-зависимость: плеер никогда не включает его в сборку, поэтому на странице ровно одна копия.
Встраиваете плеер в другой проект? USAGE.md — пошаговое руководство, написанное для кодинг-агента: установка, жизненный цикл во фреймворках, правила, нарушение которых ломает воспроизведение, и проверочный список. Этот файл — справочник, на который оно ссылается.
Установка
Пакет публикуется в реестр 1sept, поэтому скоуп нужно один раз на него направить:
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}"
Для Yarn вместо этого добавьте скоуп в .yarnrc.yml:
npmScopes:
1sept:
npmAlwaysAuth: true
npmRegistryServer: https://git.1sept.ru/api/packages/1sept/npm/
Затем установите пакет вместе с его peer-зависимостью:
npm install @1sept/player video.js
# или: yarn add @1sept/player video.js
import { createSeptPlayer } from '@1sept/player'
import '@1sept/player/style.css'
import '@1sept/player/skins/video.css'
video.js — peer-зависимость и никогда не попадает в бандл, поэтому на странице ровно одна его
копия. Собственных runtime-зависимостей у плеера нет.
Конфигурация только через разметку
Плеер читает настройки из элемента <video>, к которому подключается, поэтому страница может
встроить его, не описывая опции в 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')
Приоритет, поле за полем — побеждает первое совпадение: явные JS-опции → JSON в data-sept →
атрибуты и дети DOM (src, <source>, poster, <track>, preload, muted, loop,
autoplay) → встроенные значения по умолчанию.
Определение типа идёт по атрибуту type, если он есть, иначе по расширению в pathname URL —
так что master.m3u8?token=… тоже разрешается. .m3u8 → HLS, .mpd → DASH, .mp4 / .webm /
.ogv → прогрессивное скачивание. URL без узнаваемого расширения и без type отклоняется с
SeptConfigError, а не угадывается.
.mpd распознаётся, но не воспроизводится: DASH-tech не зарегистрирован, поэтому источник
принимается, а затем падает с ошибкой медиа. Перепакуйте в HLS или зарегистрируйте DASH-tech в
Video.js до создания плеера.
Не поддерживается: RTMP
Источники rtmp:// и rtmps:// бросают SeptConfigError. Ни один браузер не умеет играть RTMP —
для этого нужен Flash, которого больше нет. Перепакуйте поток в HLS на сервере и передайте URL
.m3u8; см. контракт HLS ниже.
Опции
Любую опцию можно задать и ключом в JSON-атрибуте data-sept.
| Опция | Тип | По умолчанию | Примечания |
|---|---|---|---|
sources |
SeptSource[] |
из разметки | { src, type?, label?, height?, bitrate? } |
poster |
string |
атрибут poster |
Используется и как фон под ошибками и обратным отсчётом |
locale |
'en' | 'ru' |
язык navigator, иначе en |
|
skin |
string |
'video' |
Добавляет sp-skin--<name> на корень |
hotkeys |
'focused' | 'global' | false |
'focused' |
|
autoplay |
boolean | 'muted' |
false |
muted, если у элемента есть и autoplay, и muted |
playbackRates |
number[] |
[0.5, 0.75, 1, 1.25, 1.5, 2] |
|
seekStep |
number |
15 |
Секунды для кнопок ± и стрелок |
startPosition |
number |
не задано | Секунда, с которой открыть видео; применяется один раз |
viewing |
boolean | ViewingOptions |
false |
Слой сессий просмотра; true — со значениями по умолчанию |
abr.limitByDimensions |
boolean |
true |
Никогда не грузить рендицию крупнее плеера |
abr.persistBandwidth |
boolean |
true |
Переиспользовать последнюю оценку полосы |
abr.initialBandwidth |
number |
не задано | Стартовая оценка в битах в секунду |
telemetry |
boolean |
true |
Отправлять QoS-события sp:telemetry |
exclusive |
boolean |
true |
Играет только один плеер на странице; false — вне механизма |
onError |
(e: SeptError) => void |
не задано | |
errorReporter |
(r: SeptErrorReport) => void |
не задано | Полный отчёт с хлебными крошками |
features |
Feature[] |
[] |
Подключаемые установщики, например live |
startsAt |
string | Date |
не задано | Требует фичу live |
live |
boolean |
не задано | Требует фичу live |
startsAt или live без фичи live бросают SeptConfigError с указанием @1sept/player/live.
Вход в позицию: startPosition
Конечное число секунд ≥ 0; всё прочее — включая строку "90" из поля CMS — отклоняется
SeptConfigError, называющим поле, а не превращается в перемотку в NaN. То же значение принимает
loadSource(src, { startPosition }), и там оно сильнее опции: смена источника гасит прежнее
ожидание, а аргумент вызова занимает его место.
Значение применяется один раз, на первом loadedmetadata после назначения источника, и затем
сбрасывается — второй loadedmetadata (например, смена MP4-рендиции) не перематывает повторно.
Позиция зажимается в [0, duration - 1]: значение за концом файла не бросает исключение и не
паркует плеер на последнем кадре. Это точка входа, а не перемотка зрителя, поэтому sp:seeked за
ней не идёт — sp:view:start сразу несёт итоговую позицию, и аналитика не видит фантомного прыжка
с нуля. sp:timeupdate при этом всё равно флашится, его сбрасывает нативный seeked.
В эфире, где длительность не конечна, значение применяется только при настоящем DVR — окне
seekable длиннее 30 секунд, по тому же порогу, по которому фича live решает показывать перемотку,
— и зажимается в это окно. Без DVR короткая кромка живого края буфера окном перемотки не является,
и startPosition игнорируется.
Имя намеренно не startAt: startsAt уже занято и означает ISO-момент начала трансляции. Две
опции, различающиеся одной буквой и не связанные по смыслу, — это дефект, которого никто не найдёт
чтением.
Сессии просмотра: viewing
viewing: true включает слой со значениями по умолчанию; объектная форма их переопределяет.
createSeptPlayer('#p', {
viewing: { pauseTimeoutMs: 30_000, heartbeatMs: 0, milestones: [25, 50, 75, 100] },
})
| Поле | По умолчанию | Принимается |
|---|---|---|
pauseTimeoutMs |
30000 |
конечное число ≥ 0 |
heartbeatMs |
0 (пульс выключен) |
конечное число ≥ 0 |
milestones |
[25, 50, 75, 100] |
непустой массив чисел в (0, 100] |
Опция приходит из того же недоверенного JSON data-sept, что и все остальные, поэтому каждое поле
проверяется отдельно и нарушение бросает SeptConfigError, называющий именно его. Отсутствующая
опция или false — это буквально ничего: ни таймеров, ни слушателей страницы, ни кода на горячем
пути. Что именно эмитит слой — ниже, в разделе «События».
События
player.on(event, cb) возвращает функцию отписки.
| Событие | Payload |
|---|---|
sp:ready |
{ mediaType, isLive } |
sp:error |
SeptError |
sp:recovered |
{ afterKind, attempts, downtimeMs } |
sp:qualitychange |
{ from, to, mode: 'auto' | 'manual' } |
sp:ratechange |
{ rate } |
sp:telemetry |
QoSEvent |
sp:play |
{ at, trigger: PlayTrigger } |
sp:pause |
{ at } |
sp:ended |
{ at } |
sp:seeked |
{ from, to } |
sp:timeupdate |
{ currentTime, duration, bufferedEnd } |
sp:statechange |
{ from: PlaybackState, to: PlaybackState } |
sp:view:start |
{ viewId, src, at, trigger } |
sp:view:pause |
{ viewId, position, watchedMs } |
sp:view:resume |
{ viewId, position, watchedMs } |
sp:view:milestone |
{ viewId, percent, position } |
sp:view:heartbeat |
{ viewId, position, watchedMs } |
sp:view:end |
{ viewId, watchedMs, position, percent?, reason } |
sp:live:countdown |
{ remainingMs, startsAt } (фича live) |
sp:live:probing |
{ attempt, nextInMs } (фича live) |
sp:live:started |
{ latencyMs? } (фича live) |
sp:live:ended |
{} (фича live) |
Шесть событий sp:view:* появляются только при включённой опции viewing; всё остальное эмитится
всегда. Позиции и длительности везде в секундах, watchedMs и downtimeMs — в миллисекундах.
Сырые события воспроизведения
sp:playприходит наplaying, а не наplay— когда кадры действительно пошли, а не когда объявлено намерение. Первыйplayingпосле загрузки эмитит всегда; дальше — только если это возобновление после паузы, конца медиа или ошибки. Восстановление после подвисания новым стартом не считается, а вот возобновление после ошибки — считается:retry(), добравшийся до кадров, даёт ещё одинsp:playс триггеромrecovery, и открытая сессия просмотра его игнорирует, а не начинает вторую.sp:pauseподавляется, когда пауза — это конец медиа. HTML5 шлётpauseвплотную передended, и два сигнала подряд дали бы бэкенду «пауза» за миллисекунду до «досмотрено». Состояние черезpausedпри этом всё равно проходит, так чтоsp:statechangeвиден; событием конца остаётсяsp:ended.sp:seeked.from— последняя позиция из сырого потокаtimeupdate: к моментуseekingcurrentTimeуже равен цели, прочитать «откуда» там нечего. Троттлинг относится только к эмиссии, к выборке — никогда.sp:timeupdateидёт раз в секунду — нативное событие приходит 4–15 раз в секунду, и без троттлинга это шум, а в реактивном фреймворке ещё и шторм перерисовок. Дополнительно оно флашится вне очереди на нативныхseeked,pauseиended, чтобы потребитель не показывал устаревшую позицию. Частота не настраивается; кому нужно точное значение в точный момент — тот зовётcurrentTime().bufferedEnd— конец буферизованного диапазона, накрывающего головку, или сама позиция, когда не буферизовано ничего.- Смена MP4-рендиции снаружи невидима. Пока она идёт, не эмитится ни одно событие этого раздела и ни одно событие просмотра, а состояние после свапа равно состоянию до него.
trigger у sp:play и sp:view:start отвечает на вопрос «кто это начал» и разрешается в момент
эмиссии:
PlayTrigger |
Когда |
|---|---|
api |
Старт из play() |
recovery |
Старт после retry() — ручного или автоматического после сетевой ошибки |
autoplay |
Опция autoplay взведена, и жеста (pointerdown или keydown) на элементе плеера с тех пор не было. Заблокированный автозапуск и клик за ним читаются как user, а не как autoplay |
user |
Всё остальное: кнопка, горячая клавиша, togglePlay() |
Сессии просмотра
Сессия открывается на первом sp:play для источника, а не на его загрузке: открытая, но не
запущенная страница просмотром не является. viewId — это ${sessionId}:${n}, где sessionId тот
же, что и в SeptErrorReport: одна связка сшивает упавший просмотр с его отчётом об ошибке.
watchedMs— время по стенным часам, проведённое в состоянииplaying, а не позиция и не разность позиций. Перемотка вперёд его не увеличивает; пауза, буферизация и простой из-за ошибки в него не идут; скорость воспроизведения его не масштабирует — 60 секунд на 2× этоwatchedMs: 60000при позиции, ушедшей на 120 секунд. Оба числа уходят в событии, а что из них считать просмотром — политика бэкенда, не плеера.percent— этоposition / duration * 100; поле отсутствует целиком, когда длительность не конечна (эфир) и когда медиа зациклено.- Веха срабатывает один раз за сессию, когда воспроизведение до неё дошло. Перемотка вперёд
гасит пройденные вехи без события: зритель, скакнувший с 10% на 90%, середины не смотрел, и
отчитываться иначе — врать воронке. Так же гасит вехи и вход по
startPosition. При не конечной длительности и при зацикленном медиа вехи выключены. - Пауза.
sp:view:pauseуходит сразу; сессию закрывает только истёкшийpauseTimeoutMs, и вsp:view:endуходят позиция иwatchedMsна момент паузы, а не на момент срабатывания таймера — тридцать секунд простоя не просмотрены. Возврат до таймаута даётsp:view:resumeс тем жеviewId. - Ошибка и автоматический ретрай сессию не закрывают — пятисекундный обрыв это не два
просмотра. Простой в
watchedMsне идёт и приходит отдельным числомdowntimeMsвsp:recovered. sp:view:heartbeatидёт только в состоянииplayingи только приheartbeatMs > 0; стоящая на паузе открытая сессия пульса не шлёт. Единственное исключение — уход вкладки в фон.- Смена источника.
loadSource()с другим первымsrcзакрывает сессию причинойsource-change, до назначения нового источника.retry()передаёт тот же список и сменой источника не считается, а смена MP4-рендиции через меню качества не проходит черезloadSource()вовсе. destroy()закрывает сессию причинойdestroyиз обычного диспозера — тот бежит до очистки эмиттера, поэтому событие ещё доходит до слушателей.
reason у sp:view:end |
Что произошло |
|---|---|
ended |
Медиа доиграло до конца |
pause-timeout |
Пауза длилась дольше pauseTimeoutMs либо вкладка ушла в фон, стоя на паузе |
source-change |
loadSource() с другим источником |
destroy |
destroy() |
pagehide |
Страница закрывается или уходит в bfcache |
Уход вкладки в фон и закрытие страницы. Ровно ради этого слой живёт в плеере, а не в каждой интеграции по отдельности.
pagehideзакрывает сессию сreason: 'pagehide'. Обработчик синхронный, и событие эмитится внутри него, поэтому слушатель успевает отдать payload вnavigator.sendBeacon— единственный транспорт, переживающий закрытие документа.pagehideпокрывает и выгрузку в bfcache, которойunloadне видит.visibilitychange→ hidden во время воспроизведения сессию не закрывает. Уходит одинsp:view:heartbeat, даже когдаheartbeatMsравен нулю, — чтобы у невернувшейся вкладки осталась свежая точка. На мобильном браузер вскоре ставит воспроизведение на паузу, и дальше работает ветка ниже.visibilitychange→ hidden на паузе, пока тикает таймаут, закрывает сессию немедленно, сreason: 'pause-timeout'и числами на момент паузы. Фоновым вкладкам зажимают таймеры (Chrome доходит до одного пробуждения в минуту), а фоновую вкладку на мобильном выгружают и безpagehide, так что иначе такая сессия не прислала бы конца вовсе. Размен сознательный: переключение вкладок на мобильном дробит один просмотр на две сессии, но фрагменты бэкенд склеит поsessionId, а не пришедшую сессию не восстановит ничто.
Гарантия здесь ровно такая: конец сессии эмитится в каждом случае, когда браузер даёт странице шанс что-то выполнить. Не «всегда» — убитый процесс не даёт ничего, и никакой клиентский код этого не меняет.
Зацикленное медиа (loop в разметке): браузер начинает файл заново, не эмитя ended, поэтому
сессия живёт через циклы — watchedMs продолжает копиться, позиция сворачивается, а вехи и
percent выключены, потому что «дошёл до 100%» срабатывало бы на каждом круге и не значило бы
ничего. Такая сессия закрывается любой из остальных причин.
Методы
| Метод | Описание |
|---|---|
loadSource(src, opts?) |
Сменить источник; принимает URL, SeptSource или массив, а вторым аргументом — { startPosition } |
retry() |
Перезагрузить текущий источник, сохранив скорость, выбранное качество и позицию |
play() |
Начать воспроизведение; возвращает промис Video.js с непроглоченным отказом |
pause() / isPaused() |
Остановить; узнать, стоит ли плеер |
currentTime() |
Позиция в секундах; 0 до метаданных |
duration() |
Значение Video.js как есть: NaN до метаданных, Infinity в эфире |
getState() |
Текущий PlaybackState |
getWatchStats() |
Снимок открытой сессии просмотра или null |
setPoster(url) |
Сменить постер, включая размытый фон под ошибкой |
setLocale(locale) |
Мгновенно переподписать все элементы управления |
getQualities() / setQuality(id) |
Единый список уровней; 'auto' возвращает к ABR |
togglePlay() / seekBy(d) / seekTo(t) / seekToPercent(p) |
Воспроизведение |
changeVolume(d) / toggleMute() |
Громкость |
toggleFullscreen() / togglePip() |
Режим показа |
stepRate(1 | -1) |
Шаг по playbackRates |
currentSource() |
Активный ResolvedSource |
showHelp() / closeOverlays() |
Диалог горячих клавиш |
destroy() |
Уничтожить плеер и все слушатели |
player.videojs — нижележащий плеер Video.js, если нужно что-то, чего этот фасад не покрывает.
play() и его отказы
Отказ не глотается — в этом весь смысл метода рядом с togglePlay(), который висит на кнопке и
отказ глотает намеренно. Вызывающий из кода должен уметь на него ответить:
| Что пришло | Причина |
|---|---|
DOMException с name === 'NotAllowedError' |
Браузер запретил воспроизведение — обычно автозапуск со звуком |
DOMException с name === 'AbortError' |
Запрос перебит сменой источника или рендиции |
Error от фасада |
Источник ещё не назначен: трансляция в состоянии scheduled или probing |
Error от фасада |
destroy() уже вызван |
Первые два приходят от Video.js как есть, без перезаворачивания. togglePlay() не изменился: он
по-прежнему глотает отказ, а его старт читается как trigger: 'user'.
getState() и PlaybackState
| Состояние | Значение |
|---|---|
idle |
Источник не назначен либо его загрузка ещё не началась |
loading |
Источник назначен, метаданных ещё нет |
playing |
Кадры идут |
buffering |
Воспроизведение подразумевалось и встало на данных |
paused |
Медиа готово и не движется |
ended |
Медиа доиграло до конца |
error |
Стоит sp:error — восстановимая или нет; снимается retry() или loadSource() |
Каждый переход приходит и событием sp:statechange { from, to }.
- Перемотка состояния не меняет. Video.js шлёт
waitingи во время сика; считать это буферизацией значило бы мигать состоянием на каждом драге рельсы. - В
errorуходит любая ошибка, а не только фатальная. Пока тикает автоматический ретрай, элемент стоит на паузе и поднята карточка ошибки; хост, опрашивающийgetState()во время обрыва, не должен читатьpaused. Признак «ретрая не будет» — этоfatalна payload ошибки, а не состояние. - В
idleплеер стоит и на VOD, но недолго.loadSource()переводит машину именно вidle, аloadingприходит с нативнымloadstart, который браузер шлёт асинхронно — уже после того, какcreateSeptPlayer()вернул ссылку. Так что VOD-плеер в момент возвратаidleи становитсяloadingмгновением позже; запланированная же трансляция ждёт вidleдо эфира. Кому важно не пропустить переход — подписывайтесь наsp:statechange, а не опрашивайтеgetState()сразу после создания. - Машина состояний эфира живёт отдельно.
scheduled,probing,live,endedостаются наliveStateи событияхsp:live:*; это независимая ось.
Элементы управления
Две раскладки
Хром принимает одну из двух форм, и выбирает её указатель — больше ничего. В этом переключении нет
JavaScript: это @media (pointer: coarse) в structure.css.
С мышью: ряд пузырьков вдоль нижнего края. Play, две перемотки, делящие между собой один овал, громкость, часы, затем субтитры, «картинка в картинке», настройки и полный экран, отжатые к дальнему краю, а над ними — рельса перемотки во всю ширину кадра. Наведение на любой из них осветляет пузырёк — один ответ на все элементы, и заметный: глиф уходит с 3.5:1 в покое до 1.7:1 под указателем, поэтому он несёт тень сквозь наведение, чтобы держаться на светлом кадре. Затемнение вместо осветления — это одно значение в каждом скине, отмечено там, где оно задаётся. Короткий предсказуемый путь к цели, уже находящейся в поле зрения, и картинка сохраняет середину. У ряда есть ширина, поэтому по мере сужения кадра элементы уходят в порядке того, до чего клавиатура или меню дотягиваются и без них: «картинка в картинке» ниже 560px, две перемотки ниже 440px, длительность ниже 340px. Play, громкость, текущее время, субтитры, настройки и полный экран остаются на любой ширине.
Под большим пальцем: раскладка по кадру. Рука, держащая телефон, лежит ближе к центру экрана, а не у его нижнего края, поэтому кнопка play переезжает в середину картинки голым глифом, часы занимают левый нижний угол, полный экран — правый нижний, субтитры/PiP/настройки садятся справа сверху, а рельса перемотки ложится на собственный нижний край кадра.
Кнопок перемотки там нет. По бокам от play они загромождали середину картинки, а двойное касание
делает ту же работу и не занимает кадра вовсе: левая половина кадра отматывает на seekStep,
правая — вперёд, вспыхивая тем же индикатором, что и стрелки. С мышью то же двойное нажатие — это
полный экран, как было всегда. Слайдер громкости убран совсем: у любого сенсорного устройства есть
клавиши громкости, а остальное закрывают ↑ ↓ M.
Картинка не затемняется ни в одной из раскладок. Каждый элемент несёт собственную полупрозрачную
подложку — пузырьки — все одного веса, и глифы, и часы, потому что ряд, где один пузырёк тяжелее
соседей, читается как ошибка раньше, чем как иерархия. В sept и video эта подложка тонируется
собственным --sp-accent скина, так что хром несёт бренд; minimal оставляет её нейтральной — ему
нечем тонировать.
Измерено на самом трудном кадре — белом: акцент 55% в чёрном при 0.62 держит белый глиф на 3.5:1, выше 3:1, которые WCAG требует от нетекстового элемента интерфейса. Часы попадают на те же 3.5:1 против 4.5:1, требуемых от текста, — сознательный размен ради единого пузырька и причина, по которой их цифры полной насыщенности, а не приглушённые, а длительность отделена весом. Подложка, достаточно тёмная для текстового порога, — это 0.82, где пузырьки заметно тяжелеют.
Единственное исключение — транспорт там, где он стоит в середине картинки: там это только глиф, потому что диск был бы единственной вещью с подложкой в центре кадра.
Качество и скорость живут за шестерёнкой, в двухуровневой панели: корневой список показывает каждое
с его текущим значением, затем детальный список для того, что меняют. Туда попадают только
настройки, которые представляют собой выбор из значений, — субтитры, «картинка в картинке» и полный
экран являются переключателями, и одно нажатие делает всё целиком, поэтому они остаются снаружи,
где нажатие до них дотянется. Компоненты SpQualityMenu и SpRateMenu по-прежнему экспортируются
и по-прежнему читают те же контроллеры — для потребителя, который хочет их и инлайном.
У рельсы нет ручки на голове воспроизведения — край самой заливки уже говорит, где идёт воспроизведение. Указатель над полосой перемотки на самом деле спрашивает, куда попадёт нажатие, поэтому наведение заливает рельсу бледным от её начала до указателя, под тем, что уже проиграно, и поднимает над ней пузырёк времени. Пузырёк непрозрачный и рисуется поверх остального хрома, потому что на любом из концов рельсы он накрывает элемент управления.
Первый экран
До первого запуска хром уже там, куда он уходит, когда зритель перестаёт двигаться: ряд убран, рельса перемотки оставлена на собственном нижнем крае кадра и показывает, где стоит воспроизведение. На экране — постер, фирменный знак и большая кнопка play. Движение указателя над плеером выводит остальное, ровно так же, как во время воспроизведения: Video.js не начинает отслеживать активность до первого запуска, поэтому до него триггером служит само присутствие указателя над плеером, а не таймаут.
В раскладке по кадру транспорт и тогда остаётся в стороне: показавшись, он попал бы на большую кнопку play и перехватил нажатие, предназначенное ей. В ряду он сидит в углу, далеко от неё.
Автоскрытие
Хром гаснет через три секунды после последней активности указателя или клавиатуры, но только пока видео играет и только пока он никому не нужен: он остаётся, пока указатель лежит на нём (включая открытое меню) или пока внутри него находится фокус клавиатуры. Любое движение, нажатие клавиши или клик возвращают его немедленно, а пауза удерживает. Курсор мыши прячется и возвращается вместе с ним.
В раскладке по кадру транспорт — единственное исключение из удержания: он стоит ровно там, где остаётся указатель после нажатия большой кнопки play, поэтому удержание там означало бы, что хром не уйдёт никогда. В ряду он такой же элемент, как остальные, и удерживает как все.
Рельса перемотки остаётся, пришвартованная к нижнему краю кадра, чтобы зритель сохранял понимание позиции, когда на экране больше ничего нет. Клики она там не принимает и уезжает обратно над рядом элементов управления — с буферизованной дорожкой и предпросмотром при наведении — как только они возвращаются. У прямого эфира без DVR рельсы, которую можно оставить, нет.
Клавиатура
| Клавиша | Действие |
|---|---|
Space, K |
Воспроизведение / пауза |
←, → |
−seekStep / +seekStep |
J, L |
−seekStep / +seekStep |
↑, ↓ |
Громкость ±10% |
M |
Переключить звук |
F |
Переключить полный экран |
P |
Переключить «картинку в картинке» |
0–9 |
Перемотка на 0–90% (только VOD) |
<, > |
Скорость воспроизведения ниже / выше |
Home, End |
Перемотка в начало / конец |
? |
Оверлей со списком горячих клавиш |
Esc |
Закрыть оверлей / выйти из полного экрана |
На сенсорном экране ↑ ↓ и M — единственный способ добраться до громкости, для чего и нужны
собственные клавиши громкости устройства, а двойное касание любой половины кадра перематывает на
тот же seekStep, что и стрелки.
Одно и то же действие выглядит одинаково, чем бы его ни вызвали. Клик по кадру, большая кнопка
play, кнопка play в ряду и Space — это один переключатель, и все четыре вспыхивают тем же глифом
поверх кадра; кнопки перемотки, двойное касание половины кадра и стрелки — одна перемотка с одним
индикатором. Клик по кадру Video.js обрабатывает сам и молча, поэтому он у него отобран
(userActions.click: false) и живёт в install-pointer рядом с двойным нажатием.
Сочетания сопоставляются по физической клавише, поэтому русская раскладка вызывает те же действия.
Клавиши никогда не срабатывают, пока фокус находится в input, textarea, select или
contenteditable, а preventDefault() вызывается только для реально обработанных клавиш — так что
прокрутка страницы и горячие клавиши браузера в остальном не затронуты.
Скины и контракт токенов
Этот пакет не поставляет
video-js.cssи не должен использоваться вместе с ним. Всё, что стилизовал бы Video.js, переобъявлено вstructure.css— именно поэтому половина того файла и существует: скрытое состояние постера,box-sizing, состояние ошибки, отступ субтитров, запасной полноэкранный режим на всё окно. Загрузка апстримной таблицы стилей рядом с ней даёт дублирующиеся и конфликтующие правила равной специфичности.
style.css несёт токены и структурную раскладку. Скин — это только цвет и оформление, ограниченные
.sp-skin--<name>, поэтому новому скину не нужен JavaScript. В поставке три:
| Скин | Соответствует | Акцент |
|---|---|---|
video (по умолчанию) |
video.1sept.ru | #0376b4, синий |
sept |
1sept.ru | #cc4c29, терракота |
minimal |
ничему — доказывает контракт токенов | белое на плоском чёрном |
Импортируйте нужный вместе со style.css:
import '@1sept/player/style.css'
import '@1sept/player/skins/sept.css'
Обновление с 0.1.0. Раньше
septбыл синим скином video.1sept.ru; теперь это терракотовый скин 1sept.ru, а синий называетсяvideo. Изменение молчаливое — старый путь импорта по-прежнему разрешается, — поэтому потребителю на 0.1.0, которому нужен синий скин, придётся сменить и импорт, и опциюskinнаvideo.
| Токен | Используется для |
|---|---|
--sp-accent |
Заливка прогресса, кольцо фокуса, выбранный пункт меню, кнопка карточки |
--sp-accent-contrast |
Текст на кнопке карточки |
--sp-accent-on-dark |
Акцент там, где он ложится на видео, а не на хром |
--sp-accent-deep |
Нажатый / наведённый край акцента |
--sp-brand |
Знак «1 сентября», показываемый над постером до запуска |
--sp-bg |
Фон леттербоксинга плеера |
--sp-surface |
Не используется с тех пор, как бар стал слоем; оставлен, имя — это API |
--sp-surface-strong |
Панели меню |
--sp-card-bg |
Плашка включения звука (карточки теперь полупрозрачные панели) |
--sp-card-text |
Текст на --sp-card-bg |
--sp-text / --sp-text-muted |
Текст и иконки хрома |
--sp-radius / --sp-radius-lg |
Элементы управления и меню / карточки оверлеев |
--sp-control-size / --sp-icon-size |
Бокс кнопки / иконка; раскладка по кадру поднимает оба под палец |
--sp-plate / --sp-plate-hover |
Пузырёк под элементом; скины тонируют его от акцента |
--sp-transport-shadow |
То, что отделяет голые глифы транспорта от кадра |
--sp-transport-size |
Центральный диск play; две перемотки выводятся из него |
--sp-font / --sp-font-size |
Корень плеера |
--sp-ease / --sp-duration / --sp-duration-slow |
Все переходы |
--sp-overlay-blur |
Размытый постер за карточками |
--sp-shadow |
Карточки и панели меню |
--sp-focus-ring |
Все фокусируемые элементы |
--sp-progress-height |
Рельса перемотки |
--sp-poster |
Задаётся плеером для размытого фона |
Имена классов (sp-controlbar, sp-btn, sp-menu, sp-settings, sp-overlay, sp-card, …) —
публичный API, как и классы состояний, которые плеер ставит на свой корень: sp--live и sp--dvr
для эфира, sp--modal, пока кадром владеет диалог.
Обоснование дизайна и измеренные коэффициенты контраста лежат в репозитории (не в опубликованном
пакете) в
docs/superpowers/design/2026-09-07-video-skin-direction.md и
docs/superpowers/design/2026-09-08-sept-skin-direction.md.
Ошибки и отчёты
Video.js сообщает всего четыре кода MediaError, которые не отличают отсутствующий файл от
протухшего токена. При сбое плеер прощупывает упавший URL (HEAD, с откатом на GET с нулевым
диапазоном для CDN, отклоняющих HEAD) и классифицирует результат в один SeptErrorKind:
not-found, forbidden, network, unsupported или cors. Ещё два члена SeptErrorKind не
отправляются никогда: stream-not-started — это 404 до эфира, который фича live подавляет, чтобы
ничего не закрасило обратный отсчёт, а неполадки config бросаются как SeptConfigError, а не
сообщаются как ошибки воспроизведения.
Каждый вид рисует продуманную локализованную карточку. Сетевые ошибки повторяются трижды с
задержкой (2с, 4с, 8с) и показывают номер попытки, прежде чем предложить кнопку вручную;
воспроизведение также возобновляется само, когда браузер снова выходит в сеть.
errorReporter получает полную картину:
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 — скользящий след последних 20 событий плеера, и обычно именно он делает отчёт
воспроизводимым.
SeptError.fatal означает ровно одно: автоматического ретрая для этой ошибки не запланировано.
Пока у контроллера тикает таймер отката — false, на последней попытке — true, и всегда true
для вида config. Это не «окончательно»: слушатель online и ручной retry() всё ещё могут
вытащить фатальную ошибку, и тогда придёт sp:recovered. Прежде восстановимость приходилось
выводить из наличия необязательного поля retry — неявный контракт, который хост разбирал сам.
fatal — обязательное поле SeptError, а не опциональное: фича, переписывающая ошибку через
errorFilter, должна выставлять его сама, если строит новый объект, а не расширяет спредом
пришедший (как делает фича live) — иначе TypeScript откажется компилировать такой фильтр.
sp:recovered { afterKind, attempts, downtimeMs } приходит, когда воспроизведение возобновилось
после ошибки, стоявшей в этом цикле загрузки: afterKind — вид последней ошибки, attempts —
сколько автоматических попыток было сделано, downtimeMs — миллисекунды от первого sp:error
цикла до возобновления. Именно это событие снимает баннер, который поставил sp:error: раньше
после успешного автоматического ретрая хосту ничто не сообщало, что всё снова играет.
retry()теперь возвращает VOD на прерванную позицию, а не на ноль. Обрыв сети на сороковой минуте прежде отбрасывал зрителя в начало файла. Теперьretry()захватывает позицию до перезагрузки и восстанавливает её тем же механизмом отложенного входа, что иstartPosition, — рядом со скоростью и закреплённым качеством, которые он восстанавливал и раньше. Эфира это не касается: трансляция возвращается на живой край, а не на позицию, которая могла выпасть из окна DVR.
Контракт HLS для вашего конвейера RTMP→HLS
Плеер ожидает, что конвейер RTMP→HLS (вне этого проекта) выдаёт:
- Мастер-плейлист с несколькими вариантами, у каждого —
RESOLUTIONиBANDWIDTH, чтобы меню качества могло осмысленно подписать уровни. - Длительность сегмента 2–4 секунды ради разумного баланса задержки и стабильности эфира.
- Теги
EXT-X-PROGRAM-DATE-TIME, чтобы позиционирование по стенным часам и DVR были верны. - Для DVR — скользящее окно длиннее 30 секунд (порог автоопределения); иначе плеер работает только по живому краю.
Access-Control-Allow-Originна манифестах и сегментах, плюсAccess-Control-Expose-Headers: Date, чтобы обратный отсчёт мог прочитать смещение серверных часов.- Стабильный URL манифеста, отдающий 404 до начала эфира и 200 после его старта, — именно от этого зависит состояние
probing. EXT-X-ENDLISTпо завершении — одна из двух вещей, приводящих к состояниюended.
Что live.1sept.ru отдаёт на самом деле
Развёрнутый ingest — это nginx-rtmp-module, и два пункта выше ему не по силам; проверено по
исходникам модуля, в которых нет кода ни для одного из этих тегов:
- Нет
EXT-X-PROGRAM-DATE-TIME. Поэтому обратный отсчёт корректирует себя по заголовку ответаDate, и именно поэтому отдавать этот заголовок не опционально. - Нет
EXT-X-ENDLIST. О конце эфира сигнализирует только то, что манифест снова начинает отдавать 404, поэтому финальная карточка целиком держится на читаемости этого 404 — то есть на том, что CORS-заголовки отправляются сalways. Без них завершившийся эфир неотличим от оборвавшегося соединения, и плеер уходит в повторы вместо завершения.
Закрыть любой из этих пробелов — значит форкнуть модуль так, чтобы он отдавал теги, или перепаковывать через FFmpeg. Плеер работает и без них; это цена.
Замечания по браузерам
Chrome, Edge, Firefox и Safari текущей версии и двух предыдущих; iOS Safari 15+; Android Chrome. Расхождения деградируют заметно, а не молча:
| Платформа | Расхождение | Как обрабатывается |
|---|---|---|
| iOS Safari | Нативный HLS, нет API рендиций | Меню качества скрыто для HLS |
| iOS Safari | Полный экран — системный видеоплеер | Нативный полный экран; свои элементы управления в нём не показываются |
| iOS Safari | PiP через WebKit presentation-mode | Замаплен на ту же кнопку |
| Любой браузер без вызываемого со страницы PiP API | Определяется в рантайме | Кнопки нет |
| macOS Safari | Нативный HLS по умолчанию | overrideNative: true, поэтому ведёт VHS и меню качества работает |
Эфир
Обратный отсчёт до запланированного старта, прощупывание манифеста, DVR, значок LIVE и финальная карточка — это подключаемый импорт по подпути, поэтому страница только с VOD за них не платит. Тест сборки утверждает, что основная точка входа и все загружаемые ею чанки не содержат кода эфира.
import { createSeptPlayer } from '@1sept/player'
import { live } from '@1sept/player/live'
createSeptPlayer('#p', { startsAt: '2026-09-01T08:00:00Z', features: [live] })
startsAt в будущем запускает обратный отсчёт; startsAt в прошлом или live: true без
startsAt идут сразу к прощупыванию. Любое из них без features: [live] бросает
SeptConfigError с указанием подпути.
Состояния
| Состояние | Что видит зритель | Выходит, когда |
|---|---|---|
scheduled |
Обратный отсчёт, тикающий каждую секунду; хрома нет | наступает время старта или поток отвечает раньше |
probing |
«Ожидание сигнала…»; хрома по-прежнему нет | запрос манифеста возвращает 2xx |
live |
Воспроизведение, значок LIVE, GO LIVE при наличии DVR | поток заканчивается |
ended |
Финальная карточка, постер восстановлен | — |
error |
Обычная карточка ошибки | — |
До эфира плеер показывает карточку и ничего больше: ни панели управления, ни рельсы перемотки, ни
большой кнопки play, ни значка LIVE. Управлять нечем, а панель, показывающая 0:00 рядом с мёртвой
кнопкой play, хуже, чем её отсутствие. Полный экран по-прежнему работает двойным кликом и по F.
Всё возвращается, когда состояние доходит до live.
Обратный отсчёт идёт во всех состояниях ожидания — в днях и часах, когда до старта так далеко. Момент начала выписан рядом: только время, если эфир сегодня, дата и время в остальных случаях и полная форма с днём недели и годом, когда ожидание измеряется днями.
В последние десять минут обратного отсчёта плеер проверяет манифест раз в минуту и переключается в тот момент, когда тот отвечает. Вещатели регулярно поднимают поток заранее — испытательная таблица, заставка, кто-то проверяет уровни, — и зрителю не следует досиживать часы против уже идущего потока. До эфира манифест отдаёт 404, что является ответом, а не сбоем, поэтому проверка стоит одного запроса, а всё окно — десяти.
Прощупывание отступает на 2с, 4с, 8с, затем каждые 15с. Обратный отсчёт корректируется по серверным
часам через заголовок Date одного HEAD на манифест; его статус игнорируется, потому что 404 до
эфира — это ровно то, чего ждёт контракт. Этот запрос никогда не блокирует обратный отсчёт: часы
стартуют со смещением 0 и сверяются, если и когда заголовок придёт.
События
| Событие | Payload |
|---|---|
sp:live:countdown |
{ remainingMs, startsAt }, раз в секунду в состоянии scheduled |
sp:live:probing |
{ attempt, nextInMs }, после каждой неудачной попытки |
sp:live:started |
{ latencyMs }, единожды при начале воспроизведения |
sp:live:ended |
{} |
Поведение, о котором стоит знать
- Эфир стартует без звука. Браузеры блокируют автозапуск со звуком, поэтому поток начинается приглушённым с заметным предложением включить звук — вместо того чтобы не начаться вовсе.
- DVR определяется, а не настраивается. Окно перемотки длиннее 30 секунд включает перемотку, кнопки ±15с и GO LIVE; ниже этого плеер работает только по живому краю и прячет эти элементы вместе с меню скорости воспроизведения.
- 404 во время эфира — это конец трансляции, а не ошибка. Он ведёт к финальной карточке. До эфира тот же 404 читается как «поток не начался».
Ваш ingest обязан держать свою сторону этого; см. контракт HLS выше, в особенности URL манифеста
404-до-эфира / 200-в-эфире, Access-Control-Expose-Headers: Date и EXT-X-ENDLIST по завершении.
CORS должен покрывать и ответы с ошибками. Финальная карточка зависит от того, сможет ли плеер прочитать 404. nginx выбрасывает
add_headerиз ответов с ошибками, если ему не сказать иначе, — и тогда завершившийся эфир выглядит для зрителя точно так же, как обрыв сети, а плеер продолжает повторять попытки вместо показа «трансляция завершена». Обеим директивам нуженalways:add_header Access-Control-Allow-Origin * always; add_header Access-Control-Expose-Headers Date always;
Разработка
make help перечисляет все цели.
make install # зависимости
make dev # dev-сервер с демо-страницей, любое состояние по запросу
make check # полный релизный гейт: типы, линт, юнит-тесты, сборка, тесты dist
make e2e # Playwright, VOD и эфир
На демо-странице есть сценарий на каждое состояние (MP4, HLS, 404, 403, медленная сеть, эфир), выбор скина, применяющийся ко всем, и строка URL для проигрывания любого манифеста. Переключение скинов там — это подмена класса на корне плеера без перезагрузки, ровно так, как вы сделали бы это в рантайме.
Строке URL не нужна подсказка типа: плеер читает расширение из pathname URL, поэтому и .m3u8, и
.mp4 просто работают. Отметьте live, чтобы обращаться с URL как с эфиром (прощупать манифест,
затем стартовать без звука). И URL, и скин сохраняются, а URL страницы несёт этот выбор, так что
ссылкой на демо можно поделиться.
Релиз поднимает версию, ставит тег, пушит и публикует одним шагом:
make release-patch # или release-minor / release-major
Оба пути публикации отказываются работать на грязном дереве, а make publish вдобавок отказывается
публиковать версию, которая уже есть в реестре, — release-* поднимает до свежей, поэтому этой
проверке нечего проверять. Каждый публикует до пуша тега, так что неудачная публикация оставляет
чисто локальные коммит и тег, а не удалённый тег для версии, которая никуда не уехала;
опубликованную версию заменить нельзя никогда.
Именно этот локальный остаток и забирает обратно make release-abort — коммит, сделанный
npm version, и тег, сделанный вместе с ним, за один шаг вместо двух:
make release-abort # откатить релиз, который не покинул эту машину
Он отказывается, если версия есть в реестре, если тег уже на origin, если HEAD — не релизный
коммит текущей версии или если дерево грязное. Делать это руками и останавливаться на полпути —
именно так тег переживает коммит, который назвал, и забирает с собой номер версии: следующий
release-* тогда падает на уже занятом имени.
Обновление зависимостей
make deps # что отстало
make deps-update # обновить в пределах диапазонов, затем прогнать полный гейт
make audit # уязвимости
У пакета нет runtime-зависимостей, поэтому обновление может сломать только сборку или тесты — но не бандл потребителя. Всё остальное — devDependency, и его безопасно двигать, используя гейт как проверку.
video.js — исключение: это peer-зависимость, поэтому её диапазон является частью публичного
контракта. Подъём нижней границы заставляет обновиться каждого потребителя, а это ломающее
изменение для пакета.
V=8.25.0 make deps-videojs # поднимает peer-диапазон и dev-копию вместе
P=vite@latest make deps-upgrade # передвинуть devDependency за пределы её диапазона
Лицензия
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 |