npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 минуты (копипаст)

Три готовых рецепта. Скопируй подходящий целиком, подставь свои 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-player
import { 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-player

hls.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, midRolltimer — секундой срабатывания) или 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
}

Три правила, чтобы не отстрелить ногу:

  1. Payload — снимок на момент нажатия. Колбэки живые (замыкания на плеер), но active/current/value не обновляются сами. После select/toggle либо закрывайте дровер (обычный UX), либо обновите свою копию модели — по событиям плеера ratechange, qualitychange, subtitlechange, scenetypechange, chapterchange.
  2. Не дублируйте работу колбэков. select у sceneTypes уже делает setSceneGroup + seek + play — не вызывайте player.seek() сами.
  3. 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-плеер. Это нормально: на сервере у вас и так нет видео, нужен лишь быстрый постер. Поэтому правильный паттерн — постер серверный, плеер ленивый:

  1. Отрендерьте постер на сервере обычным <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>
  2. Инициализируйте плеер лениво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.duration pre-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