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

@1sept/player (0.6.2)

Published 2026-10-12 22:10:21 +03:00 by naumso

Installation

@1sept:registry=https://git.1sept.ru/api/packages/1sept/npm/
npm install @1sept/player@0.6.2
"@1sept/player": "0.6.2"

About this package

Custom 1sept video player on Video.js 8

@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: к моменту seeking currentTime уже равен цели, прочитать «откуда» там нечего. Троттлинг относится только к эмиссии, к выборке — никогда.
  • 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 поток заканчивается
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 {}

Поведение, о котором стоит знать

  • Эфир стартует со звуком, если браузер позволяет. Когда автозапуск со звуком заблокирован, поток начинается приглушённым с заметным предложением включить звук — вместо того чтобы не начаться вовсе. Атрибут muted на <video> сразу запускает эфир без звука.
  • DVR определяется, а не настраивается. Окно перемотки длиннее 30 секунд включает перемотку, кнопки ±15с; ниже этого плеер работает только по живому краю и прячет эти элементы вместе с меню скорости воспроизведения.
  • 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
@typescript/native npm:typescript@^7.0.2
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-10-12 22:10:21 +03:00
2
naumso
MIT
latest
78 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