itube-modern-player
v0.10.0
Published
Lightweight, framework-agnostic HTML5 video player: HLS streams, playlists, chapters, VTT subtitles, sprite previews, VAST ads. Ships with a Vue 3 wrapper.
Maintainers
Readme
itube-modern-player
Лёгкий универсальный HTML5-видеоплеер на TypeScript для видео-сайтов и встраиваемых страниц. Ядро — чистый класс без фреймворк-зависимостей (new Player(...)), поверх него — Vue 3-компонент, IIFE-бандл для <script>-подключения и lazy-обёртка для отложенной загрузки. Сделан как замена fluid-player: та же область применения (контентные видео с рекламой, плейлистами и стримами), но без его архитектурных болячек — без глобального реестра инстансов, без россыпи absolute-элементов, с честным destroy() и полной типизацией.
Ядро ~19–23 КБ gzip (+ hls.js, который догружается динамически только для m3u8). Поддержка: все вечнозелёные браузеры, Safari/iOS (нативный HLS, playsinline, нативный fullscreen).
Содержание
- Подключить за 2 минуты (копипаст)
- Возможности
- Установка
- Быстрый старт (vanilla JS / TS)
- Быстрый старт (Vue 3)
- Описание видео:
VideoSource - Все опции:
PlayerOptions - Реклама
- Конструктор
- Свойства
- Методы
- События:
PlayerEventMap - Vue 3:
<ITubePlayer> - Меню в дровере приложения:
menus: 'external' - Экспортируемые типы
- Экспортируемые утилиты
- Темизация
- Горячие клавиши
- Стримы
- Локализации и вес бандла
- Ленивая загрузка бандла
- SSR и LCP (рекомендованный паттерн)
- Сборка и публикация
- История изменений
Подключить за 2 минуты (копипаст)
Три готовых рецепта. Скопируй подходящий целиком, подставь свои URL — работает.
Рецепт 1: просто HTML-страница (без npm и сборщика)
Сохрани как index.html, открой в браузере. Всё.
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://unpkg.com/itube-modern-player/dist/style.css">
</head>
<body>
<div id="player"></div>
<!-- hls.js нужен ТОЛЬКО если видео — .m3u8 (стрим). Для .mp4 эту строку можно удалить. -->
<script src="https://unpkg.com/hls.js"></script>
<script src="https://unpkg.com/itube-modern-player/dist/itube-modern-player.iife.js"></script>
<script>
new ITubePlayer('#player', {
source: {
src: 'https://example.com/video.m3u8', // ← твоё видео (.m3u8 или .mp4)
poster: 'https://example.com/poster.jpg', // ← твоя обложка
title: 'Название ролика',
},
})
</script>
</body>
</html>Что получится: обложка с кнопкой play; по клику — воспроизведение со всеми контролами.
Рецепт 2: проект со сборщиком (Vite / webpack / любой)
npm i itube-modern-playerimport { Player } from 'itube-modern-player'
import 'itube-modern-player/style.css'
new Player('#player', {
source: { src: '/video.m3u8', poster: '/poster.jpg', title: 'Название' },
})hls.js для .m3u8 подтянется сам, отдельным чанком — ставить и импортировать его не нужно.
Рецепт 3: Nuxt 3 / Vue 3 (рекомендуемый — быстрый LCP из коробки)
<script setup lang="ts">
import { ITubePlayer } from 'itube-modern-player/vue'
</script>
<template>
<!-- lazy = постер приходит в первом HTML (SSR), тяжёлый плеер качается
только когда пользователь потянулся к видео. Никаких ClientOnly. -->
<ITubePlayer
lazy
:source="{ src: '/video.m3u8', poster: '/poster.jpg', title: 'Название' }"
:options="{ muted: true }"
@ended="onEnded"
/>
</template>Если что-то не так
| Симптом | Причина и лечение |
| --- | --- |
| Видео не стартует само | Браузеры блокируют автоплей со звуком. Нужен автостарт — playOnInit: true плюс muted: true. Иначе — старт только по клику, это норма |
| Чёрный прямоугольник вместо плеера | Не подключён style.css (рецепт 1 — <link>, рецепт 2 — import) |
| .m3u8 не играет (в рецепте 1) | Удалил строку с hls.js — верни её |
| Контролы на английском | Добавь в опции language: 'ru' |
| Реклама не нужна | Просто не передавай adConfig — её и не будет |
Возможности
- Источники — MP4/WebM, HLS-стримы (
.m3u8, hls.js или нативно в Safari), live-потоки с бейджем LIVE, прогрессивные качества с переключением без потери позиции, HLS-уровни с пунктом Auto. - Плейлисты — массив видео без пересоздания плеера, prev/next, панель списка (сайдбар или горизонтальная лента снизу), имя плейлиста, перемешивание, повтор, автопереход.
- Тайм-коды (главы) — сегментированный таймлайн как у YouTube, название главы в контролах и тултипе, событие
chapterchange, источник: массив или VTT-файл. - Панель сцен — список глав с превью из VTT-спрайта, клик — переход к сцене.
- Типизированные сцены — несколько разбивок по типам (актёры/локации/действия…,
sceneGroups), контрол-дропдаун выбора типа перестраивает таймлайн и список сцен. - Спрайт-превью — кадр при наведении на таймлайн (WebVTT +
#xywh). - Диаграмма популярности — «most replayed»-кривая над таймлайном из разреженных точек.
- Субтитры — VTT-треки (строка/объект/массив), выключены по умолчанию, меню выбора.
- Реклама — VAST 2–4 (InLine + Wrapper), preroll/midroll/postroll, прямые
src-роллы, skip-таймер, click-through, impression/quartile/pause/resume-пиксели, отдельный<video>(контент не трогается), префетч контента во время преролла, watchdog на зависший креатив, частота показа для плейлистов, полный набор событий. - Метаданные видео — тайтл, описание, постер, канал (аватар/заглушка, форма, ссылка) в экране паузы.
- Экран паузы — дефолтный или полностью свой («слот»: элемент/фабрика в ядре,
<template #pauseScreen>во Vue). - Related-видео — сетка похожих по паузе и/или окончанию.
- Кнопки действий — лайк/дизлайк (с управляемым состоянием), «добавить в», шеринг (нативный share sheet с фолбэком на событие), жалоба, свои кнопки в ⋯-дропдауне (
customaction). - Кастомизация — каждая фича отключаема, каждая иконка заменяема (SVG), все строки переводимы, темизация CSS-переменными +
styling-проп (акцент, цвета лайков). - Локализации — 11 встроенных языков по коду
language, свои словари черезregisterLocale, облегчённый вход без словарей. - UX-мелочи — превью-постер, горячие клавиши (на контейнере, не на document), PiP, полноэкранный режим, спиннер буферизации, оверлей ошибок, авто-скрытие контролов.
- Интеграции — vanilla / Vue 3 /
<script>-тег / ленивая загрузка бандла по интерактиву. - Типизация — полные типы всех опций, событий и методов из коробки.
Чем отличается от fluid-player и подобных:
- Никакой каши из absolute-элементов. Поверх видео лежит один overlay-слой на CSS grid, всё внутри — обычный flex-поток. Верстать поверх и кастомизировать — легко.
- Нет глобального состояния.
new Player()сколько угодно раз на странице, никаких реестров инстансов и привязок по id. - Честный
destroy(). Плеер полностью убирает за собой DOM и слушатели. - Всё типизировано. События, опции, методы — полные типы из коробки.
- Всё отключаемо и заменяемо. Каждая кнопка, каждая надпись, каждая иконка.
Установка
npm install itube-modern-playerhls.js подтягивается автоматически и грузится только когда плееру дают m3u8-источник (динамический импорт) — в бандл проектов без стримов он не попадает. Vue — опциональная peer-зависимость, нужна только если используете itube-modern-player/vue.
Быстрый старт (vanilla JS / TS)
Ядро — обычный класс без фреймворков, это основной способ использования.
import { Player } from 'itube-modern-player'
import 'itube-modern-player/style.css'
const player = new Player('#mount', {
source: {
src: 'https://cdn.example.com/stream.m3u8',
title: 'Название ролика',
poster: 'https://cdn.example.com/poster.jpg',
},
})
player.on('ended', () => console.log('done'))
// ...
player.destroy()Первый аргумент — элемент или селектор контейнера, в который плеер отрендерит себя. destroy() возвращает контейнер в исходное состояние.
Без сборщика (script-tag / CDN)
Отдельный IIFE-бандл, глобальная переменная ITubePlayer — это сам класс:
<link rel="stylesheet" href="https://unpkg.com/itube-modern-player/dist/style.css">
<!-- hls.js нужен только если будут m3u8-источники: -->
<script src="https://unpkg.com/hls.js"></script>
<script src="https://unpkg.com/itube-modern-player/dist/itube-modern-player.iife.js"></script>
<script>
const player = new ITubePlayer('#mount', {
source: { src: 'https://cdn.example.com/stream.m3u8', title: 'Demo' },
})
</script>Быстрый старт (Vue 3)
<script setup lang="ts">
import { ITubePlayer } from 'itube-modern-player/vue'
import 'itube-modern-player/style.css'
import type { VideoSource } from 'itube-modern-player'
const video: VideoSource = {
src: '/stream.m3u8',
title: 'Название',
}
</script>
<template>
<ITubePlayer
:source="video"
:options="{ seekStep: 5, controls: { pip: false } }"
@ended="onEnded"
@timeupdate="onTime"
>
<!-- слот экрана паузы: реклама, промо, что угодно.
Scoped-слот даёт доступ к плееру и к close() ("Закрыть и продолжить") -->
<template #pauseScreen="{ player, close }">
<MyAdBlock @close="close" />
</template>
</ITubePlayer>
</template>Кастомный экран паузы (рекламный блок)
Вместо дефолтного оверлея (тайтл/описание/канал) можно показать свой блок — например рекламу с кнопкой «Закрыть и продолжить». Контент рендерится full-cover по центру плеера поверх контролов; клик по фону не возобновляет (только ваша кнопка). Доступ к плееру есть во всех сборках:
- Vue 3 — scoped-слот:
#pauseScreen="{ player, close }".player— инстанс (реактивный,nullдо монтирования),close()— возобновить воспроизведение и закрыть блок. - vanilla / IIFE — фабрика, получающая плеер:
pauseScreen: (player) => HTMLElement. Кнопка вызываетplayer.play(). - В рантайме —
player.setPauseScreenContent(el | null).
// vanilla / Vue2 (внутри компонента)
new Player('#mount', {
pauseScreen: (player) => {
const box = document.createElement('div')
box.innerHTML = '<!-- ваш рекламный блок -->'
const btn = document.createElement('button')
btn.textContent = 'Закрыть и продолжить'
btn.onclick = () => player.play() // возобновляет и скрывает блок
box.append(btn)
return box
},
}):source— одно видео или массив (массив включает режим плейлиста). Реактивен: смена объекта вызываетplayer.load(), инстанс плеера не пересоздаётся.:options— все остальныеPlayerOptions(см. ниже).- Все события плеера ретранслируются как события компонента.
- Доступ к ядру:
refна компонент →componentRef.value.player(ShallowRef<Player>).
Описание видео: VideoSource
const source: VideoSource = {
src: 'https://cdn.example.com/video.m3u8', // m3u8 определяется автоматически
type: 'application/x-mpegurl', // необязательно, для нестандартных URL
title: 'Заголовок',
description: 'Описание — показывается в экране паузы',
poster: 'poster.jpg', // превью-постер до первого запуска
duration: 1284, // для плашки в плейлисте до загрузки метаданных
// чанки для превью следующего ролика (ховер + end-оверлей), разделяются «·».
// строка — обычный чанк; { text, icon } — чанк с инлайн-иконкой слева (сырой SVG).
previewMeta: ['2.3K просмотров', { text: 'User uploaded', icon: '<svg…></svg>' }],
// канал/автор — рендерится в экране паузы
channel: {
name: 'Мой канал',
avatar: 'avatar.png', // без avatar — стилизованная заглушка из первой буквы
url: 'https://example.com/channel', // клик по каналу откроет URL
avatarShape: 'circle', // 'circle' (default) | 'rounded' | 'square'
},
// тайм-коды: массив или URL WebVTT-файла глав
chapters: [
{ start: 0, title: 'Интро' },
{ start: 120, title: 'Основная часть' }, // end заполняется автоматически
],
// типизированные сцены: несколько разбивок по типам (актёры/локации/действия…)
// переключаются контролом «тип сцен»; активная группа задаёт сегменты таймлайна
// и список сцен (имеет приоритет над chapters). Список типов открытый.
sceneGroups: [
{
id: 'actions', // стабильный id
title: 'Действия', // подпись на контроле и в дропдауне
icon: '<svg ...>...</svg>', // raw SVG или URL картинки (необязательно)
scenes: [ // те же {start, end?, title}, что у chapters
{ start: 0, title: 'Интро' },
{ start: 120, title: 'Погоня' },
],
},
{ id: 'locations', title: 'Локации', icon: '...', scenes: [/* … */] },
],
// субтитры: строка-URL, один трек или массив. По умолчанию ВЫКЛЮЧЕНЫ.
subtitles: [
{ src: 'subs-ru.vtt', label: 'Русский', srclang: 'ru' },
{ src: 'subs-en.vtt', label: 'English', srclang: 'en', default: true }, // default включает
],
// спрайт-превью при наведении на таймлайн (WebVTT с фрагментами #xywh=)
thumbnails: 'thumbs.vtt',
// диаграмма популярности над таймлайном (как "most replayed" у YouTube)
// разреженные точки {time: сек, value: сырое число кликов/досмотров};
// внутри: бакетирование → сглаживание → нормализация к максимуму с базовым полом,
// появляется при наведении на прогресс-бар; выключается controls.heatmap = false
heatmap: [
{ time: 95, value: 220 },
{ time: 290, value: 900 },
{ time: 430, value: 540 },
],
// альтернативные качества для прогрессивных файлов (для HLS уровни берутся из манифеста)
// переключение сохраняет позицию и состояние воспроизведения
qualities: [
{ quality: 1080, label: '1080p', src: 'video-1080.mp4' },
{ quality: 720, label: '720p', src: 'video-720.mp4' },
],
meta: { anyOwnData: true }, // ваши данные, плеер их не трогает
}Тайм-коды (главы)
Поддержаны нативно: сегментированный таймлайн (как у YouTube), название текущей главы в контрол-баре, название главы в ховер-тултипе, событие chapterchange. Источник — массив { start, end?, title } либо URL VTT-файла глав. end не обязателен — берётся начало следующей главы или конец видео.
Типизированные сцены (sceneGroups)
Несколько разбивок видео по типам — актёры, локации, действия и т.п. (список открытый, задаётся по id). Каждая группа: { id, title, icon?, scenes: Chapter[] }. В баре появляется контрол выбора типа (иконка + тайтл текущего типа, дропдаун); переключение перестраивает сегменты прогресс-бара и панель сцен. Активная группа имеет приоритет над chapters.
- Управление в рантайме:
player.setSceneGroup(id), геттерыplayer.sceneTypes/player.activeSceneType, событиеscenetypechange. - Отключить контрол:
controls.sceneTypes: false. Заголовок дропдауна — лейблsceneTypes(локализован; переопределяется черезlabels).
Спрайт-превью (thumbnails)
Стандартный формат — WebVTT, каждый cue указывает картинку и регион спрайта:
WEBVTT
00:00:00.000 --> 00:00:05.000
sprite.jpg#xywh=0,0,160,90
00:00:05.000 --> 00:00:10.000
sprite.jpg#xywh=160,0,160,90Относительные пути резолвятся от URL VTT-файла. Можно и без #xywh — по картинке на cue.
Все опции: PlayerOptions
new Player('#mount', {
source, // VideoSource | VideoSource[]
playOnInit: false, // стартовать воспроизведение сразу после инициализации
// (бывш. `autoplay` — алиас ещё работает; не путать с
// тоглером Autoplay в шестерёнке — это playlist.autoAdvance)
muted: false,
loop: false,
volume: 1,
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], // пункты меню скорости
seekStep: 15, // секунды для стрелок/кнопок перемотки (по умолчанию 15; показывается на кнопке)
keyboard: true, // горячие клавиши (на контейнере, не на document!)
// Кто рендерит меню контрол-бара: 'native' (дефолт) | 'external' | 'external-mobile'.
// 'external*' — вместо открытия меню плеер эмитит `menurequest`, рендерит приложение
// (см. раздел «Меню в дровере приложения»).
menus: 'native',
className: 'my-player', // свой класс на контейнер — хук для темизации
crossOrigin: 'anonymous', // если VTT/постеры на другом домене
playsInline: true,
// При старте воспроизведения, если плеер виден не целиком, страница
// доскролливается минимально — чтобы плеер попал во вьюпорт полностью
// (уважает prefers-reduced-motion). Дефолт true; false — отключить.
scrollIntoView: true,
// Стартовая позиция в секундах — «продолжить с места остановки». Применяется
// только к первому источнику; работает и до прихода метаданных (отложенный
// сик с clamp по реальной длительности), pre-start бар сразу показывает эту
// позицию (нужен source.duration). Позицию наружу отдаёт событие timeupdate.
startTime: 754,
// Пауза, когда плеер уехал из вьюпорта больше чем наполовину, и возобновление,
// когда вернулся на 70%+ (только если паузу поставил сам плеер).
// PiP и фуллскрин не затрагиваются. Дефолт true.
pauseOffscreen: true,
// ---- сохранение настроек в localStorage ----
// true — запоминать громкость/mute и автоплей и восстанавливать при следующей
// загрузке (восстановленные значения имеют приоритет над volume/muted/autoAdvance).
// Объект — точечно: { key?: 'itube-player', volume?: true, autoAdvance?: true }.
// По умолчанию выключено.
persist: true,
// ---- стилизация ----
styling: {
themeColor: '#6366f1', // акцентный цвет; с 0.8.6 дефолт — indigo из макета
likeColor: 'forestgreen', // цвет активного лайка (default forestgreen)
dislikeColor: '#e53935', // цвет активного дизлайка (не зависит от themeColor)
borderRadius: 14, // скругление углов плеера (число → px), '0' — квадратные
playButtonStyle: 'solid', // 'solid' (акцентный круг, белая иконка) | 'inverted' (полупрозрачный белый круг, иконка цвета темы)
fontFamily: 'Inter, sans-serif', // шрифт всего UI плеера (CSS font-family); по умолчанию системный стек
},
// ---- язык интерфейса ----
// встроенные статические локали: en, ru, de, es, it, ja, ko, zh, pt, ar, hi
// словарь терминов = тип PlayerLabels; defaultLabels (en) экспортируется как эталон
// точечные оверрайды — через labels (применяются поверх локали)
language: 'ru',
// ---- плейлист ----
playlist: {
title: 'Моя подборка', // имя плейлиста: маленький кикер "Плейлист" + имя приоритетным шрифтом
autoAdvance: true, // автопереход к следующему
loop: false, // репит списка (кнопка в панели плейлиста)
shuffle: false, // перемешивание (кнопка в панели плейлиста)
startIndex: 0,
layout: 'sidebar', // 'sidebar' — справа вертикально | 'bottom' — горизонтальная лента снизу
// Внешняя навигация: плеер НЕ переключает ролики сам — любое намерение
// (кнопка next/prev, выбор в панели, автопереход по окончании) только
// эмитит событие 'advancerequest'. Приложение обогащает данные и меняет
// ролик через player.load(source). next/prev-кнопки рендерятся и активны
// даже для одиночного источника. По умолчанию false.
externalAdvance: false,
},
// ---- панель сцен (тайм-коды с превью из VTT-спрайта) ----
scenes: {
layout: 'bottom', // 'bottom' (default) | 'sidebar'
},
// ---- контролы: каждая фича отключаемая ----
controls: {
play: true,
progress: true,
time: true,
// Громкость (mute + слайдер): true — только десктоп (на мобиле скрыта,
// там аппаратные кнопки); 'always' — показывать и на мобиле; false — нигде.
volume: true,
fullscreen: true,
pip: false, // с 0.8.6 выключена по умолчанию (в макете её нет)
// Настройки. Каждая: 'gear' (в едином дропдауне-шестерёнке, по умолчанию) | 'bar'
// (отдельной кнопкой в баре) | false (выкл). true = 'gear'. Шестерёнка скрыта,
// если внутри нет ни одной доступной опции.
speed: 'gear', // скорость воспроизведения (бывш. `settings`; алиас ещё работает)
quality: 'gear', // качество; пункт/кнопка появляется при source.qualities или HLS-уровнях
subtitles: 'gear', // субтитры; появляется только если у источника есть треки
scenes: false, // кнопка списка сцен; с 0.8.6 выключена по умолчанию
sceneTypes: true, // контрол выбора типа сцен (появляется при source.sceneGroups)
// Хитмап популярности (нужны данные source.heatmap): true — только десктоп
// (на мобиле скрыт); 'always' — и на мобиле; false — нигде.
heatmap: true,
seekButtons: true, // или { back: 5, forward: 15, label: (sec, dir) => `${dir==='back'?'−':'+'}${sec}s` }
// Меню «⋯» (экшены + динамический overflow-коллапс узкого бара).
// С 0.8.6 по умолчанию ВЫКЛЮЧЕНО (в макете «⋯» нет): контролы не
// сворачиваются, actions без placement: 'bar' не отображаются.
// true — вернуть «⋯» и overflow-коллапс.
more: false,
// порядок правых контролов слева направо; перечисленные идут первыми,
// остальные сохраняют встроенную позицию, ⋯ (если включён) всегда последняя.
order: ['sceneTypes', 'gear', 'fullscreen'], // дефолт с 0.8.6 (макетный порядок); ControlBarItem[]: like|dislike|speed|quality|subtitles|gear|scenes|sceneTypes|playlist|pip|fullscreen|`custom:<id>`
seekPlacement: 'overlay', // 'overlay' (default) — ±N и play поверх видео на всех экранах
// | 'bar' — seek-кнопки в нижнем баре (поведение до 0.3)
mobileLayout: 'bar', // мобильный (≤767px) лейаут:
// 'bar' (default, с 0.8.1) — поверх видео только ±N и play (как на десктопе),
// prev/next живут в нижнем баре слева, рядом с play;
// 'center' — прежний лейаут: prev · −N · play · +N · next большим
// кластером по центру видео, play/next в баре скрыты
playlist: true, // prev/next/список — рендерятся ТОЛЬКО в режиме плейлиста
hidePrev: true, // скрыть кнопку «prev» в баре (бар = только next); false — вернуть.
// При mobileLayout: 'center' центр-кластер всё равно показывает prev
playlistButton: false, // кнопка списка плейлиста; с 0.8.6 выключена по умолчанию
nextPreview: true, // ховер-превью следующего ролика над кнопкой next (десктоп).
// объект: { thumbnail?, title?, duration?, meta? } — какие поля показывать
// (иконки чанков задаются в самих source.previewMeta — см. { text, icon })
hideDelay: 2500, // мс до скрытия контролов при воспроизведении
// мс до скрытия ТОЛЬКО на старте: картинка очищается сразу, дальше по центру
// виден лоадер до первого кадра. Любое следующее раскрытие — снова hideDelay.
// Число — одно значение на всё; объект — раздельно по типу ввода.
hideDelayAfterStart: { touch: 0, mouse: 0 },
revealBeforePlay: true, // до старта показывать контрол-бар поверх постера: десктоп — по ховеру,
// мобайл — по тапу мимо play (скрытие через 3 c). false — выключить
},
// ---- экран паузы ----
// true (по умолчанию) — заголовок/описание/канал текущего видео (порядок: спонсор → тайтл → описание)
// false — выключить
// { title?, description?, sponsor? } — дефолтный экран, но скрыть отдельные части
// (напр. { title: false, description: false } — когда сайт выводит их вне плеера)
// HTMLElement или (player) => HTMLElement — свой контент (full-cover оверлей,
// напр. реклама с «Закрыть и продолжить»; фабрика получает плеер).
// Во Vue — scoped-слот #pauseScreen="{ player, close }". См. раздел про кастомный экран паузы.
pauseScreen: true,
// ---- related-видео ----
related: {
title: 'Смотрите также',
showOn: ['ended', 'pause'], // когда показывать; по умолчанию ['ended']
// что делает клик (событие relatedclick эмитится в любом случае):
// 'player' (default) — загрузить source в плеер | 'newWindow' — открыть url в новой вкладке | 'currentTab' — перейти в текущей
clickBehavior: 'player',
items: [
{
title: 'Другой ролик',
poster: 'p.jpg',
duration: '12:34',
source: { src: 'other.mp4', title: 'Другой ролик' }, // клик загрузит в плеер
// или url: 'https://...' — клик откроет ссылку
},
],
},
// ---- реклама (см. раздел «Реклама») ----
adConfig: {
adList: [
{ roll: 'preRoll', vastTag: 'https://ads.example.com/vast.xml' },
{ roll: 'midRoll', vastTag: 'https://ads.example.com/vast2.xml', timer: 300 },
{ roll: 'postRoll', src: 'https://cdn.example.com/ad.mp4', clickUrl: 'https://sponsor.example.com' },
],
skipDelay: 5, // сек до кнопки «Пропустить»; -1 — без пропуска
playOn: 'every', // 'every' — реклама на каждом видео плейлиста, 'first' — только на первом
maxWrapperDepth: 3, // лимит VAST Wrapper-редиректов
requestTimeout: 8000, // таймаут запроса VAST-тега, мс
mediaTimeout: 10000, // если креатив не стартовал/завис на столько мс — aderror и сразу контент
},
// ---- кнопки действий (все по умолчанию ВЫКЛЮЧЕНЫ) ----
actions: {
like: true, // видимая кнопка 👍 → событие action {id:'like'}
dislike: true, // видимая кнопка 👎 → событие action {id:'dislike'}
likeState: 'like', // начальное состояние оценки ('like' | 'dislike' | null);
// менять в рантайме: player.setLikeState(...)
addTo: true, // в дропдауне ⋯ → action {id:'addTo'}
share: true, // в дропдауне ⋯: нативный share-диалог устройства,
// если его нет — событие action {id:'share'}
report: true, // в дропдауне ⋯ → action {id:'report'}
// свои пункты: клик эмитит customaction {id}
custom: [
{ id: 'download', title: 'Скачать', icon: '<svg ...>...</svg>' }, // в дропдауне ⋯ (placement: 'menu' по умолчанию)
{ id: 'theater', title: 'Театр', icon: '<svg ...>', placement: 'bar' }, // отдельной кнопкой в баре (нужна icon, тултип = title)
],
},
// ---- кастомизация каждой кнопки ----
icons: {
// IconSource: { svg } — инлайн-SVG, { url } — картинка (<img>).
play: { svg: '<svg viewBox="0 0 24 24">...</svg>' },
pause: { url: 'https://cdn.example/pause.png' },
next: '<svg ...>...</svg>', // голая строка: <… → SVG, иначе URL
// полный список имён: тип IconName
},
// ---- все надписи (i18n) ----
labels: {
play: 'Смотреть',
skipAd: 'Пропустить рекламу',
related: 'Похожие видео',
// полный список: тип PlayerLabels
},
})IconName: play, pause, replay, bigPlay, volumeHigh, volumeLow, volumeMute, fullscreen, fullscreenExit, pip, settings, subtitles, list, next, previous, seekForward, seekBack, close.
Реклама
Формат списка совместим по духу с fluid-player: массив роллов, каждый — preRoll, midRoll (с timer — секундой срабатывания) или postRoll. Источник ролла:
vastTag— URL VAST-тега. Поддержано: VAST 2/3/4, InLine + Wrapper (редиректы с лимитом глубины), выбор MediaFile (прогрессивный mp4/webm приоритетнее),ClickThrough,Impression, квартильныеTrackingEvents(start / firstQuartile / midpoint / thirdQuartile / complete / skip / click),skipoffset(время или процент). VPAID не поддержан намеренно — это исполнение стороннего JS в вашей странице.src— прямой URL медиафайла без VAST (плюс опциональныйclickUrl).
Ключевое отличие от fluid-player: реклама играет в отдельном <video>, наложенном поверх. Контентное видео не трогается — его позиция, HLS-сессия, выбранные субтитры и качество переживают любой рекламный брейк без «восстановления состояния».
Поведение:
preRoll— перед стартом видео,midRoll— на секундеtimer,postRoll— после окончания; роллов каждого типа может быть несколько.playOn: 'first'— рекламный список отрабатывает только на первом видео плейлиста;'every'(по умолчанию) — на каждом.- Любой фейл (недоступный тег, битый XML, неиграющий медиафайл, таймаут) → событие
aderrorи немедленный переход к контенту. Реклама никогда не блокирует ролик. - Кнопка пропуска появляется через
skipDelayсекунд (илиskipoffsetиз VAST, если он есть). - Префетч контента: пока играет преролл, основное видео буферизует первые фрагменты в фоне (
preload="auto"на время брейка; для HLS hls.js грузит сегменты сразу после attach) — после рекламы контент стартует мгновенно.
События: adstart, adend, adskip, adclick, aderror.
player.on('aderror', ({ roll, error }) => report(roll, error))Справочник API
Конструктор
new Player(target: string | HTMLElement, options?: PlayerOptions)target — элемент-контейнер или CSS-селектор. Плеер рендерит свой DOM внутрь и полностью убирает его в destroy(). Инстансов на странице — сколько угодно, глобального состояния нет.
Свойства
| Свойство | Тип | Описание |
| --- | --- | --- |
| container | HTMLElement | Корневой элемент плеера (.imp-player) |
| video | HTMLVideoElement | Контентный видео-элемент (прямой доступ для нестандартных нужд) |
| paused | boolean | На паузе |
| ended | boolean | Дошло до конца |
| currentTime | number | Текущая секунда |
| duration | number | Длительность (0, пока метаданные не загружены) |
| live | boolean | Live-поток (duration === Infinity) |
| bufferedEnd | number | Конец буферизованного диапазона, сек |
| volume | number | Громкость 0..1 |
| muted | boolean | Заглушен |
| playbackRate | number | Текущая скорость |
| playbackRates | number[] | Доступные скорости (из опций) |
| qualityLevels | Level[] | { index, label }[] — HLS-уровни или прогрессивные качества |
| currentQuality | number | Индекс выбранного качества, -1 = auto |
| qualityAutoAvailable | boolean | Есть ли пункт Auto (только HLS) |
| subtitleTracks | SubtitleTrack[] | Треки текущего источника |
| activeSubtitle | number | Индекс включённого трека, -1 = выкл |
| playlist | VideoSource[] | Текущий список |
| index | number | Индекс играющего элемента |
| source | VideoSource \| null | Текущий источник |
| hasPlaylist | boolean | Больше одного элемента |
| hasNext / hasPrevious | boolean | Есть куда листать (учитывает playlist.loop) |
| chapterList | NormalizedChapter[] | Главы текущего видео (после загрузки метаданных) |
| chapter | NormalizedChapter \| null | Текущая глава |
| isFullscreen | boolean | Полноэкранный режим |
| adPlaying | boolean | Идёт рекламный брейк |
Методы
Воспроизведение
| Метод | Описание |
| --- | --- |
| play(): Promise<void> | Запуск. Если есть несыгранный преролл — сначала отыграет его |
| pause(): void | Пауза (во время рекламы игнорируется) |
| togglePlay(): void | Переключить |
| seek(sec: number): void | Абсолютная перемотка (клампится в 0..duration) |
| skip(delta: number): void | Относительная перемотка, например skip(-10) |
Громкость и скорость
| Метод | Описание |
| --- | --- |
| setVolume(v: number): void | 0..1; значение > 0 снимает mute |
| setMuted(m: boolean): void | Установить mute |
| toggleMute(): void | Переключить mute |
| setPlaybackRate(r: number): void | Скорость воспроизведения |
Качество и субтитры
| Метод | Описание |
| --- | --- |
| setQuality(index: number): void | Для HLS -1 = auto; для прогрессивных источников переключает файл с сохранением позиции |
| setSubtitle(index: number): void | Включить трек по индексу, -1 — выключить |
Плейлист и источники
| Метод | Описание |
| --- | --- |
| load(source, startIndex = 0): void | Заменить источник(и) без пересоздания плеера — громкость, скорость, подписки и DOM сохраняются. Массив включает режим плейлиста |
| next(): void / previous(): void | Переход по плейлисту (учитывает loop и shuffle) |
| playItem(i: number): void | Запустить конкретный элемент |
| togglePlaylistPanel(): void | Показать/скрыть панель списка |
| toggleScenesPanel(): void | Показать/скрыть панель сцен |
| setSceneGroup(id): void | Переключить тип сцен; sceneTypes / activeSceneType — геттеры |
| setShuffle(b) / shuffle | Режим перемешивания |
| setRepeat(b) / repeat | Режим повтора списка |
UI и прочее
| Метод | Описание |
| --- | --- |
| toggleFullscreen(): Promise<void> | Полный экран (на iOS — нативный fullscreen видео) |
| togglePip(): Promise<void> | Картинка-в-картинке |
| setPauseScreenContent(el: HTMLElement \| null): void | Подменить контент экрана паузы (то, что во Vue делает слот) |
| setLikeState(s: 'like' \| 'dislike' \| null): void | Подсветить лайк/дизлайк |
| share(): Promise<void> | Нативный share-диалог; без него — событие action {id:'share'} |
| destroy(): void | Полный демонтаж: DOM, слушатели, hls-сессия, реклама |
События
const off = player.on('timeupdate', fn) // вернёт функцию отписки
player.once('ready', fn)
player.off('timeupdate', fn)События: PlayerEventMap
| Событие | Payload | Когда |
| --- | --- | --- |
| ready | { player } | Плеер создан |
| play / pause / ended | — | Воспроизведение |
| timeupdate | { currentTime, duration } | Тик времени |
| progress | { buffered } | Буферизация |
| volumechange | { volume, muted } | Громкость |
| ratechange | { rate } | Скорость |
| seeking / seeked | { currentTime } | Перемотка |
| sourcechange | { source, index } | Источник заменён |
| playlistitemchange | { source, index } | Переход по плейлисту |
| advancerequest | { direction, index, source } | Намерение перехода при playlist.externalAdvance (direction: 'next'/'previous'/'select'/'ended'; index/source — линейная цель, null для одиночного источника) |
| chapterchange | { chapter \| null } | Сменилась глава |
| scenetypechange | { group \| null } | Сменился тип сцен (sceneGroups) |
| fullscreenchange | { active } | Полный экран |
| pipchange | { active } | PiP |
| qualitychange | { label } | Качество (в т.ч. авто-переключение HLS) |
| subtitlechange | { track \| null } | Субтитры |
| relatedshow | — | Показана сетка related |
| relatedclick | { item } | Клик по related-карточке |
| action | { id } | Кнопки: like, dislike, addTo, share (если нет нативного шеринга), report |
| customaction | { id } | Ваша кнопка из actions.custom |
| menurequest | MenuRequestPayload | Нажата кнопка меню при menus: 'external' \| 'external-mobile' — приложение рендерит меню само (раздел «Меню в дровере приложения») |
| adstart / adend / adskip / adclick | { ad: ResolvedAd } | Жизненный цикл рекламы |
| adpause / adresume | { ad: ResolvedAd } | Креатив поставлен на паузу / возобновлён (+ VAST-пиксели pause/resume) |
| aderror | { roll, error } | Ролл не отыграл (контент продолжается автоматически) |
| error | { message, cause? } | Ошибка медиа/сети/треков |
| destroy | — | Плеер демонтирован |
Vue 3: <ITubePlayer>
Ядро подтягивается динамическим импортом — сам компонент весит ~2 KB gzip и не тащит плеер в главный бандл приложения.
| Что | API |
| --- | --- |
| Пропсы | source: VideoSource \| VideoSource[] (реактивный — смена вызывает load()), options: Omit<PlayerOptions, 'source'> |
| lazy | poster-first, player-on-intent: компонент рендерит декларативную SSR-заглушку (постер уходит в первый HTML и становится LCP-кандидатом — <ClientOnly> не нужен), по триггеру качает чанк ядра + CSS и свапает через реактивное состояние. Клик по заглушке = загрузить и играть; фоновые триггеры не автоплеят |
| loadOn | триггер при lazy: 'placeholder' (по умолчанию — любой интерактив в зоне заглушки), 'interaction', 'visible', 'immediate' |
| adConfig | AdsOptions или async-фабрика () => Promise<AdsOptions \| undefined> (например, запрос VAST-тега) — выполняется параллельно загрузке чанка, плеер конструируется с готовым конфигом. Приоритетнее options.adConfig |
| События | Все события PlayerEventMap ретранслируются 1:1 (@timeupdate, @aderror, @customaction, …) |
| Слоты | #pauseScreen="{ player, close }" — контент экрана паузы (scoped: доступ к плееру и close()) |
| Expose | player: ShallowRef<Player \| null> — доступ к ядру: playerRef.value.player.seek(0) |
<!-- Nuxt 3: без ClientOnly, без ручного постера, без createLazyPlayer -->
<ITubePlayer lazy :source="video" :options="{ muted: true }" :ad-config="fetchVast" @ended="onEnded" />CSS компонент подгружает сам (self-reference itube-modern-player/style.css через exports пакета); ручной импорт style.css в приложении продолжает работать и дедуплицируется бандлером — это фолбэк для экзотических сборок.
Меню в дровере приложения: menus: 'external'
У контрол-бара три меню: настройки (шестерёнка: автоплей / скорость / качество / субтитры), тайм-коды (типы сцен с аккордеоном) и ⋯ (переполнившиеся контролы + ваши экшены). Кто их рисует — решает опция menus:
| Значение | Поведение |
| --- | --- |
| 'native' (по умолчанию) | Плеер рисует сам. Десктоп — попап у кнопки; мобила — дровер снизу экрана: открытое меню вместе с затемнением переезжает в фиксированный слой на <body> (порталится оно потому, что сам плеер обрезает fixed-потомков), страница под ним затемняется и не скроллится. Поверх шапки сайта дровер поднимает z-index: var(--imp-sheet-z, 999) — при конфликте задайте --imp-sheet-z на :root |
| 'external' | Плеер никогда не открывает меню — по нажатию любой меню-кнопки эмитит menurequest, рендер за вами |
| 'external-mobile' | Гибрид для приложений со своим дровером: десктоп — нативные попапы, мобильный вьюпорт (≤767px) — menurequest |
Контракт menurequest
Один payload — всё, что нужно для рендера, плюс колбэки, которые сами применяют выбор (плеер перемотает/переключит и отправит свои обычные события — ничего дёргать вручную не надо):
type MenuRequestPayload =
// Шестерёнка. Строки трёх видов: тогл (toggle), дрилл в опции (options+select), просто значение.
| { kind: 'settings'; title: string; entries: ExternalSettingsEntry[] }
// Тайм-коды: группы сцен; active — текущий тип (в макете он раскрыт).
| { kind: 'sceneTypes'; title: string; groups: ExternalSceneGroup[];
activate(groupId: string): void // раскрыли группу = сделали её активным типом
select(groupId: string, start: number): void } // тап по сцене: активирует группу, seek + play
// Всё остальное (⋯, отдельные кнопки скорости/качества/субтитров, если включены).
| { kind: 'menu'; title: string; sections: ExternalMenuSection[] }
interface ExternalSettingsEntry {
key: string // 'autoplay' | 'speed' | 'quality' | 'subtitles'
label: string // локализованная подпись
value?: string // текущее значение для дрилл-строки («720p», «Обычная»)
toggle?: { value: boolean; set(on: boolean): void }
options?: { label: string; value: string; active: boolean }[]
select?(value: string): void
}
interface ExternalSceneGroup {
id: string; title: string; icon?: IconSource
active: boolean // текущий тип сцен — подсветите/раскройте его
scenes: { label: string; time: string; start: number; current: boolean }[]
} // current — сцена, играющая сейчас (подсветка в макете)
interface ExternalMenuSection {
title: string
items: { label: string; value: string; active: boolean }[]
select(value: string): void
}Три правила, чтобы не отстрелить ногу:
- Payload — снимок на момент нажатия. Колбэки живые (замыкания на плеер), но
active/current/valueне обновляются сами. Послеselect/toggleлибо закрывайте дровер (обычный UX), либо обновите свою копию модели — по событиям плеераratechange,qualitychange,subtitlechange,scenetypechange,chapterchange. - Не дублируйте работу колбэков.
selectуsceneTypesуже делаетsetSceneGroup + seek + play— не вызывайтеplayer.seek()сами. menurequestэмитится на каждое нажатие кнопки — если ваш дровер уже открыт, просто перерисуйте контент.
Интеграция в Nuxt 3 (со своим дровером)
Пусть в приложении уже есть <AppDrawer v-model="open" :title="..."> (любой ваш bottom-sheet). Вся интеграция — один обработчик и один рендер по kind:
<script setup lang="ts">
import type { MenuRequestPayload } from 'itube-modern-player'
const video = { src: '...', sceneGroups: [/* ... */] }
const drawerOpen = ref(false)
const menu = shallowRef<MenuRequestPayload | null>(null)
// стек для дрилла «Настройки → Скорость» с кнопкой «назад»
const drill = shallowRef<{ label: string; options: { label: string; value: string; active: boolean }[]; select(v: string): void } | null>(null)
function onMenuRequest(req: MenuRequestPayload) {
menu.value = req
drill.value = null
drawerOpen.value = true
}
function pick(select: (v: string) => void, value: string) {
select(value) // плеер применит сам и отправит свои события
drawerOpen.value = false
}
</script>
<template>
<ITubePlayer
lazy
:source="video"
:options="{ menus: 'external-mobile' }"
@menurequest="onMenuRequest"
/>
<AppDrawer v-model="drawerOpen" :title="drill?.label ?? menu?.title">
<!-- дрилл: список опций одной настройки -->
<template v-if="drill">
<button v-for="o in drill.options" :key="o.value"
:class="{ active: o.active }"
@click="pick(drill.select, o.value)">
{{ o.label }}
</button>
</template>
<!-- корень шестерёнки -->
<template v-else-if="menu?.kind === 'settings'">
<template v-for="e in menu.entries" :key="e.key">
<label v-if="e.toggle">
{{ e.label }}
<AppSwitch :model-value="e.toggle.value" @update:model-value="e.toggle.set($event)" />
</label>
<button v-else @click="drill = { label: e.label, options: e.options!, select: e.select! }">
{{ e.label }} <span class="value">{{ e.value }}</span>
</button>
</template>
</template>
<!-- тайм-коды: аккордеон типов сцен -->
<template v-else-if="menu?.kind === 'sceneTypes'">
<details v-for="g in menu.groups" :key="g.id" :open="g.active"
@toggle="(e: any) => e.target.open && !g.active && menu!.activate(g.id)">
<summary>{{ g.title }}</summary>
<button v-for="s in g.scenes" :key="s.start"
:class="{ current: s.current }"
@click="menu!.select(g.id, s.start); drawerOpen = false">
{{ s.label }} <span class="time">{{ s.time }}</span>
</button>
</details>
</template>
<!-- ⋯ и одиночные меню -->
<template v-else-if="menu?.kind === 'menu'">
<section v-for="(s, i) in menu.sections" :key="i">
<h3 v-if="s.title">{{ s.title }}</h3>
<button v-for="it in s.items" :key="it.value"
:class="{ active: it.active }"
@click="pick(s.select, it.value)">
{{ it.label }}
</button>
</section>
</template>
</AppDrawer>
</template>Дизайн строк под макет (те же значения, что в нативном дровере): строки настроек — 52px / 15px, разделители rgba(39,39,42,.6); опции — 48px, активная rgba(255,255,255,.10) + белый бар 2px слева во всю высоту; шапка — 18px/700 uppercase на #0d0d0d.
Ванилла-версия без Vue — то же самое через player.on('menurequest', req => ...).
Изоморфный шаблон заглушки: itube-modern-player/placeholder
renderPlaceholder(options, flags?) → string — чистая строка без DOM-зависимостей, единый источник правды по разметке «постер + кнопка play». Используется внутри createLazyPlayer и Vue-lazy; экспортируется для SSR любого фреймворка:
import { renderPlaceholder } from 'itube-modern-player/placeholder'
// options: { poster?, icon?, styling?, className?, playLabel?,
// controls? (статичный pre-start бар, дефолт true), duration? (сек, для «0:00 / d»),
// next? (кнопка next), chapters? (сегменты таймлайна, [{start}]),
// sceneType? ({ title?, icon? } — кнопка типа сцен, false — скрыть) }
// flags: { inlineStyles?: true } — самодостаточные инлайн-стили (SSR без style.css)
const html = renderPlaceholder({ poster: { src: '/p.jpg', width: 1280, height: 720 } }, { inlineStyles: true })Там же posterSrc(poster) — нормализация PosterSource в URL.
Экспортируемые типы
PlayerOptions, VideoSource, ChannelInfo, SubtitleTrack, Chapter, QualityLevel, PlaylistOptions, ControlsOptions, ActionsOptions, CustomAction, BuiltinActionId, RelatedOptions, RelatedItem, AdsOptions, AdRoll, ResolvedAd, ThumbnailCue, PlayerLabels, IconName, PlayerEvent, PlayerEventMap, Level, SourceController, NormalizedChapter
Экспортируемые утилиты
| Экспорт | Что делает |
| --- | --- |
| formatTime(sec) | 3673 → "1:01:13" |
| locales, supportedLanguages, getLocale(code) | Встроенные локали (11 языков) и их коды |
| buildHeatmapValues(points, duration), heatmapPath(values) | Математика диаграммы популярности |
| isHlsSource(src, type?) | Определение m3u8 |
| normalizeChapters(chapters, duration) | Сортировка/заполнение end |
| loadChaptersVtt(url) | VTT-файл глав → Chapter[] |
| ThumbnailTrack.load(url) | Парсер спрайт-VTT (cueAt(time)) |
| resolveVast(roll, opts) | Самостоятельное использование VAST-резолвера |
| defaultIcons, defaultLabels | Дефолты для частичного переопределения |
Темизация
Внешний вид настраивается CSS-переменными — на контейнере плеера или любом родителе:
.my-player {
--imp-accent: #00bcd4; /* цвет прогресса, кнопки play, активных пунктов */
--imp-bg: #000;
--imp-text: #fff;
--imp-control-bg: rgba(20, 20, 20, 0.85); /* фон меню/панелей */
--imp-track: rgba(255, 255, 255, 0.25); /* фон таймлайна */
--imp-radius: 8px;
--imp-font: Inter, sans-serif;
--imp-transition: 180ms ease;
}Все классы стабильны и начинаются с imp- — можно дотюнить точечно обычным CSS.
Горячие клавиши
Слушаются на контейнере плеера (не на document — несколько плееров на странице не конфликтуют). Space/K — play/pause, ←/→ — перемотка на seekStep, ↑/↓ — громкость, M — mute, F — полный экран, 0–9 — переход на 0–90% длительности, Home/End.
Стримы
*.m3u8(илиtype: 'application/x-mpegurl') → hls.js, который загружается динамически при первом использовании; в Safari используется нативный HLS.- По умолчанию подтягивается light-сборка
hls.js/light(без alt-audio/субтитров/EME/LL — заметно легче полной) — достаточно для обычного VOD m3u8. Если нужна полная сборка, положите её вwindow.Hls(плеер подхватит готовый глобал и не будет грузить свой). - Live-потоки: автоматически определяются (
duration === Infinity), вместо таймкода показывается бейдж LIVE, таймлайн скрывается. - Качества HLS-уровней попадают в меню настроек (с пунктом Auto).
Локализации и вес бандла
Главный вход itube-modern-player включает все 11 локалей (+~4 КБ gzip) — language: 'ru' работает без настройки. Если важен каждый килобайт, есть облегчённый вход без словарей (в ядре остаётся только английский):
import { Player, registerLocale } from 'itube-modern-player/core' // −4 КБ gzip
import { locales } from 'itube-modern-player/locales' // словари отдельным чанком
registerLocale('ru', locales.ru) // регистрируем только нужное
new Player('#mount', { language: 'ru' })registerLocale принимает и собственные словари — любой объект формы PlayerLabels под любым кодом. Незарегистрированный код тихо откатывается к английскому.
| Вход | gzip | Локали |
| --- | --- | --- |
| itube-modern-player | ~23 КБ | все 11 встроены |
| itube-modern-player/core | ~19 КБ | только en, остальное вручную |
| itube-modern-player/locales | ~4 КБ | только словари (отдельный чанк) |
Ленивая загрузка бандла
Точка входа itube-modern-player/lazy (~1.5 КБ) для паттерна «бандл плеера грузим после первого интерактива». Мгновенно рисует постер-заглушку (использует те же CSS-классы — подмена незаметна), а полный чанк плеера подтягивает динамическим импортом:
import { createLazyPlayer } from 'itube-modern-player/lazy'
import 'itube-modern-player/style.css'
const lazy = createLazyPlayer('#mount', {
source: { src: 'stream.m3u8', poster: 'poster.jpg', title: '…' },
// когда качать бандл:
loadOn: 'interaction', // первый pointerdown/keydown/touch/wheel на странице (по умолчанию)
// loadOn: 'placeholder', // любой интерактив В ЗОНЕ заглушки: ховер, тап/нажатие, фокус (мобилки — тоже)
// loadOn: 'visible', // когда заглушка вошла во вьюпорт (IntersectionObserver)
// loadOn: 'immediate' // сразу (но отдельным чанком)
// Клик по заглушке всегда = загрузить и играть. Фоновые триггеры
// ('interaction' / 'placeholder') НИКОГДА не запускают воспроизведение,
// даже с playOnInit: true.
})
lazy.ready.then((player) => { /* полный Player готов */ })
lazy.destroy() // работает на любой стадииКлик по заглушке всегда грузит бандл немедленно и запускает воспроизведение.
Если нужен полный контроль снаружи — он никуда не делся: обычный import('itube-modern-player') по любому вашему триггеру, ядро не делает никаких предположений о моменте своей загрузки.
SSR и LCP (рекомендованный паттерн)
Сам плеер рендерится на клиенте — он целиком конструирует свой DOM через JS (document/HTMLVideoElement), на сервере его «отрисовать» нельзя, как и любой императивный JS-плеер. Это нормально: на сервере у вас и так нет видео, нужен лишь быстрый постер. Поэтому правильный паттерн — постер серверный, плеер ленивый:
Отрендерьте постер на сервере обычным
<img>(неbackground-image— фон не участвует в LCP и грузится позже). Сделайте его LCP-кандидатом:fetchpriority="high", корректныеwidth/height(чтобы не было layout shift), при необходимости<picture>сsrcsetпод мобайл/десктоп.<div id="player" class="my-player-box"> <img src="/poster-1280.jpg" srcset="/poster-480.jpg 480w, /poster-1280.jpg 1280w" sizes="100vw" width="1280" height="720" alt="" fetchpriority="high" decoding="async" class="my-player-poster"> <button class="my-player-play" aria-label="Play"></button> </div>Инициализируйте плеер лениво —
createLazyPlayerподхватит серверную разметку (adopt): если в mount-ноде уже есть содержимое, свой плейсхолдер не создаётся — вешаются только триггеры, а при загрузке бандла содержимое заменяется плеером. CSS плеера для постера не нужен (разметка ваша).import { createLazyPlayer } from 'itube-modern-player/lazy' // #player уже содержит SSR-постер → adopt, ничего не перерисовывается createLazyPlayer('#player', { source: { src: '/stream.m3u8', poster: '/poster-1280.jpg' }, loadOn: 'placeholder' })Nuxt 3: постер — обычная разметка компонента (SSR/SEO-friendly, никакого
<ClientOnly>вокруг него), аcreateLazyPlayer(mountRef.value, …)— вonMounted.import 'itube-modern-player/style.css'можно грузить вместе с ленивым чанком, а не в главном бандле — постеру он не нужен.
Итог: LCP — это ваш серверный <img>-постер (приходит в первом HTML-ответе, приоритизируется браузером), а вес плеера и стрима подключается только когда пользователь реально собрался смотреть. Так делает и тестовый стенд проекта (постер <picture><img> в шаблоне страницы, плеер поверх по интерактиву).
Если задать
source.poster, плеер отрисует свой постер тоже (реальным<img fetchpriority="high">). Он сработает как LCP в чисто клиентских сценариях, но для лучшего LCP всё равно предпочтительнее серверный постер из пункта 1 — он есть в HTML сразу, без ожидания JS.
Сборка и публикация
npm run dev # демо-страница на vite
# /?proxyads — гонит VAST-теги через дев-прокси /__vast
# (для окружений, где рекламные домены режутся сетью/блокировщиком)
npm run typecheck # tsc --noEmit
npm run build # dist/: ESM + CJS + d.ts + style.css
npm publish # prepublishOnly прогонит typecheck + buildЭкспорты пакета:
| Импорт | Что это |
| --- | --- |
| itube-modern-player | ядро (Player, все типы, утилиты) |
| itube-modern-player/vue | Vue 3-компонент ITubePlayer (ядро — динамическим импортом; проп lazy) |
| itube-modern-player/placeholder | изоморфный renderPlaceholder() + posterSrc() (без DOM — можно на сервере) |
| itube-modern-player/style.css | стили (подключить один раз) |
История изменений
Версионирование по SemVer: major.minor.patch.
0.10.0
- Опция
startTime— «продолжить с места остановки»: стартовая позиция в секундах для первого загруженного источника (следующие айтемы плейлиста начинаются с нуля). Работает и до прихода метаданных — хранится отложенным сиком и клампится по реальной длительности; при объявленномsource.durationpre-start бар (прогресс, время, глава) сразу засеян на эту позицию. Снаружи позицию отдаёт существующее событиеtimeupdate({ currentTime, duration }) — пишите её в сторадж и передавайте обратно вstartTimeпри следующем открытии страницы.
0.9.5
- Фикс (iPad): видео запускалось только со второго тапа по play. iPad Safari держит эвристику double-tap-zoom всегда (в отличие от iPhone) и придерживает
clickпервого тапа в ожидании возможного второго — а действия lazy-заглушки живут именно на click: он опаздывал за окно защиты от «свапа посреди тапа» или не приходил вовсе. Три эшелона:touch-action: manipulationна корне плеера и инлайн-заглушки (double-tap-zoom над плеером выключен, click приходит сразу; скраб-поверхности сохраняют свойtouch-action: none), фолбэк ожидания клика поднят 500 → 700 мс, а совсем поздний click падает в постер-кнопку живого плеера в тех же координатах и всё равно запускает. ⚠️ Если заглушка своя (adopted SSR-разметка), добавьтеtouch-action: manipulationна её корень самостоятельно.
0.9.4
- Планшет — микс десктопной и мобильной раскладки. Компактная тач-раскладка (44px кнопки, скрытая громкость, центр-кластер) остаётся, но четыре черты возвращаются к десктопу: меню открываются дропдаунами у кнопки вместо нижних дроверов; под прогресс-баром по центру подписывается текущая сцена; кнопка переключателя типов сцен снова с тайтлом; шестерёнка в контрол-баре, а не плавающим кружком. Технически медиа-условия разделены на «тач» (
MOBILE_MEDIA, телефоны и планшеты) и «телефон» (PHONE_MEDIA, ≤767px) — дровер, плавающий гир, скрытая подпись сцены и icon-only кнопка сцен теперь живут в телефонном блоке; JS-гейты (портал меню,menus: 'external-mobile', исключение гира из коллапса) переведены на телефонный. Инлайн-заглушка разделена так же — паритет с живым плеером сохранён на обоих типах устройств.
0.9.3
- Фикс (десктоп): скрытие контролов при старте перекрывалось микродвижением мыши. Клик по play всегда сопровождается дрожью курсора, и живой ховер-слушатель возвращал оверлей в то же мгновение, когда тот начинал уходить — скрытие фактически не происходило. Теперь на 0.7 с после старта ховер-реведил глохнет (контролы успевают уйти), после чего движение мыши снова показывает их как обычно. Заодно убрана прежняя 700-мс пауза перед скрытием на десктопе — контролы уходят сразу, как на таче (
hideDelayAfterStartдефолт{ touch: 0, mouse: 0 }), а иконка play фиксируется до полного исчезновения кнопок и на мыши.
0.9.2
- Фикс (iOS Safari, lazy): после «дровер → закрыть → play» кнопка постера оставалась висеть над лоадером. Класс, скрывающий её на время старта (
imp-player--starting), ставился только на пути с ожидающим прероллом — а в этом сценарии реклама уже отыграна первым тапом, и на экране одновременно были два конфликтующих состояния: крутящийся лоадер и кнопка play. Теперь класс ставится на любой старт: кнопка гаснет вместе с контролами, кадр постера остаётся фоном до первых кадров, лоадер занимает центр.
0.9.1
- Заглушка и живой плеер снова пиксель-в-пиксель. Расхождения нашлись замером геометрии всех элементов бара в обоих режимах, на 1248px и на 375px: (1) метка типа сцен в живом плеере рисовалась шрифтом браузера по умолчанию (Arial) —
<button>не наследуетfont-family, так что тема--imp-fontдо неё не доходила, и текст был другой ширины; теперь у.imp-btnстоитfont: inherit; (2) инлайн-заглушка наследовала шрифт страницы — ей проброшен тот же стек, что ставит плеер (иstyling.fontFamily, если задан); (3) блок громкости в плеере занимает 48px (кнопка 44 + отступы свёрнутого слайдера), в мок-баре был 44px — всё правее «муте» уезжало на 4px; (4) на мобиле прогресс-бокс плеера 12px против 16px в мок-баре, из-за чего бар был на 4px выше; (5) фикс шва снизу (пункт ниже) был применён только к живому постеру, поэтому заглушка выглядела на пиксель короче. - Фикс: тонкая полоска видео просвечивала под контролами на iPhone. Бокс 16/9 почти всегда имеет дробную высоту, а iOS композитит слой видео и градиент бара с разным округлением — на 3x-экране снизу оставался непокрытый физический пиксель. Градиент бара теперь докрашивает один CSS-пиксель под собой (
box-shadowцветом своего нижнего стопа), постер растянут на тот же пиксель ниже; лишнее срезаетoverflow: hiddenплеера. - Постер держится до ПЕРВЫХ КАДРОВ, а не до события
play: между нажатием и первым кадром элемент видео ещё ничего не рисует, и раньше на этом промежутке мигал чёрный прямоугольник (заметно на мобиле без рекламы). Побочно это вылечило iOS-баг «шестерёнка → закрыл меню → тап по play показывает кнопки перемотки вместо старта»: тап по кнопке постера запускает play изpointerup, Safari отклоняет его (pointerup не медиа-жест) и спасти положение мог только click того же тапа — но постер уже исчезал вместе с кнопкой, в которую этот click летел. Теперь кнопка на месте, click доходит, видео стартует с первого тапа. - Автопауза от скролла оставляет чистый кадр: ни экрана паузы, ни контрол-бара, ни центрального кластера с кнопками перемотки — всё это реакция на осознанное нажатие пользователя, а тут никто ничего не нажимал. Если пауза застала контролы на экране, они убираются (кроме случая, когда открыто меню или панель — UI из-под взаимодействия не выдёргивается). Источник паузы теперь различается явно (
user/offscreen): экран паузы показывается только на паузу зрителя, а возобновление при возврате во вьюпорт отменяет только собственную автопаузу. - Фиксация иконки play/pause распространена на десктоп: глиф держит состояние, в котором его нажали, до полного ухода контролов (на мышином вводе это ~880 мс), поэтому «пауза» больше не проступает на затухающих кнопках.
0.9.0
- Пауза при уходе плеера из вьюпорта и возврат к воспроизведению (новая опция
pauseOffscreen, по умолчанию включена): уехал больше чем наполовину — видео встаёт, вернулся на 70% и больше — играет снова. Пороги разные намеренно (гистерезис): плеер, зависший ровно на границе, иначе дёргался бы между состояниями на каждом пикселе прокрутки. Возобновляется только пауза, которую поставил сам плеер — если видео остановил зритель, никакая прокрутка его не перезапустит; законченное видео (ended) тоже не переигрывается. Picture-in-Picture и фуллскрин не затрагиваются — там уход из вьюпорта штатный. Плеер выше окна измеряется относительно окна, иначе он никогда не набрал бы половину собственной высоты. ОтключаетсяpauseOffscreen: false. - Фикс: с прероллом клик по play надолго «зависал» без реакции. VAST-запрос может идти секунды (у нас ловили ~10), и всё это время контентное видео стоит на паузе и не отдаёт событие
play— на которое был завязан весь стартовый флоу. В плеере оставались видимые контролы в состоянии «play», без лоадера, будто клик не дошёл. Теперь переход начинается в момент вызоваplay(): контролы (и кнопка на постере) уходят СРАЗУ, без обычной паузы вежливости в 700 мс (иначе лоадер, который появляется только после их затухания, пропускал первый хоп VAST-обёртки), лоадер крутится всё время обработки VAST — включая первый редирект — и его больше не гаситcanplayконтентного видео, который прилетает, пока запрос ещё в полёте, а дальше экран забирает реклама — либо, если она не наливается или падает, начинается видео. Кадр постера остаётся фоном, пока не пришли первые кадры. Касается любого режима, не только lazy. - Лоадер и контролы больше не наложены друг на друг: они делят центр плеера, поэтому показ лоадера теперь секвенируется — пока элементы управления на экране он скрыт, а при их уходе появляется строго ПОСЛЕ анимации затухания (длительность берётся из темы,
--imp-transition-ms). Обратный путь мгновенный: контролы вернулись — лоадер сразу убран. Плюс минимальное время показа — один полный оборот спиннера (animation-duration, дефолт 0.8 с): на локальных файлах отдача данных занимает десятки мс, и лоадер раньше мигал парой кадров. Приоритет у запрета наложения: если контролы вернулись посреди этого удержания, лоадер всё равно исчезает сразу. - Иконка play не «дёргается» в паузу во время ухода контролов: глиф фиксируется на время анимации в том состоянии, в котором его нажали, а фактическое состояние применяется, когда контролы уже невидимы. Только для тач-раскладки (там уход начинается сразу); с мышью глиф успевает переключиться задолго до анимации.
- Флоу старта как на референсных тубах: после нажатия play картинка очищается сразу — центр-кластер (play/пауза, перемотка, меню) гаснет первым, нижний бар уходит на 120 мс позже (ступенчатый выход читается как «кадр открылся», а не «всё мигнуло разом»); дальше, пока первый кадр не пришёл, по центру виден лоадер, а с началом воспроизведения он исчезает. Пауза возвращает классический экран: кнопки перемотки, шестерёнка и бар. Коротким остаётся только первый отсчёт — любое следующее раскрытие (движение мыши, тап) снова живёт по
hideDelay. Тайминги:controls.hideDelayAfterStart, дефолт{ touch: 0, mouse: 700 }. На таче скрытие стартует без задержки (сам тап и есть намерение), ступенька бара отключена — кнопки и нижняя зона гаснут вместе за 180 мс затухания; с мышью бар по-прежнему отстаёт от центра на 120 мс, полный уход ~1 с. Можно передать число — одно значение на оба ввода. - Фикс: превью сика пропадали на участках «плохого» VTT. Спрайт-треки генерируются разными скриптами, и дробная часть таймкода может прийти любой длины — например
00:10:00.1000000000(сырой float вместо трёх знаков). Парсер требовал ровно 1–3 цифры, не матчился и отбрасывал такой cue целиком — ровно поэтому превью исчезали на этих отрезках таймлайна. Теперь дробная часть читается с любой точностью (.5,.500,.1000000000→ 0.5/0.5/0.1 с), запятая-разделитель (привычка из SRT) и секунды без ведущего нуля тоже принимаются. Округление до целых секунд не понадобилось — точность кадров сохранена. - Мобильная раскладка теперь и на планшетах: компактный вид включается не только по ширине ≤767px, но и на любом устройстве с тач-вводом как основным (
hover: none+pointer: coarse) — у iPad те же ограничения «толстого пальца», что у телефона. Отсюда бесплатно решается жалоба на планшет: регулировка громкости скрыта (её всё равно не потянуть пальцем;controls.volume: 'always'возвращает), меню открываются дровером снизу, шестерёнка плавающая, кнопки 44px. Условие вынесено в одну константу (MOBILE_MEDIA), чтобы CSS и JS-логика не разъезжались. - Интерфейс больше не скрывается, пока открыто меню или панель таймкодов: авто-таймер прятал контролы прямо во время выбора — не успеть ткнуть в тайм-код. Теперь отсчёт ждёт закрытия и стартует заново после него. (Касается встроенных меню; при
menus: 'external*'дровер рисует приложение, и плеер о нём не знает.) - Подскролл при старте воспроизведения: если в момент запуска плеер виден не целиком (страница проскроллена, верх плеера за экраном, а юзер жмёт play в баре), страница доскроллится на минимально необходимую величину, чтобы плеер попал во вьюпорт целиком; плеер выше вьюпорта выравнивается по верхней кромке. Уважает
prefers-reduced-motion. Отключается опциейscrollIntoView: false. - Фикс: на планшете первый тап иногда только инициализировал плеер. Окно защиты от «свапа заглушки посреди тапа» держалось лишь пока палец на экране, а Safari доставляет click через 50–350 мс после
pointerup(на iPad дольше — эвристики double-tap-zoom плюс смазанный тап). Ядро, приехавшее в этот зазор, снимало заглушку до click — и намерение играть вместе с gesture-разлочкой терялось. Теперь окно живёт от `po
