Воспроизведение - это основное предложение Tubi, и мы постоянно стремимся обеспечить восхитительное воспроизведение. Веб-плеер Tubi поддерживает более десятка платформ OTT, и наша небольшая команда интерфейсных инженеров принимает вызов поддержки воспроизведения на любом устройстве. Мы многому научились при переходе от одной платформы OTT к множеству, что привело к созданию захватывающего проекта, сфокусированного на воспроизведении и быстром развитии.

В это время роста был итеративно сконструирован специальный модуль веб-плеера, чтобы помочь нам быстро предоставлять качественные функции. Эта статья подробно расскажет об этом проекте, и, надеюсь, вы сможете извлечь из него некоторую ценную информацию.

Области для улучшения

«Простая формулировка проблемы гораздо важнее, чем ее решение».
- Альберт Эйнштейн

В последнее время Tubi переживает замечательный рост, что создает проблемы, поскольку наши платформы OTT в основном используют единую кодовую базу. Расширяясь для поддержки такого количества платформ, мы столкнулись с трудностями при разрешении логики, зависящей от платформы, на все большем количестве устройств. Наши решения, которые хорошо работали для 1–3 платформ воспроизведения, не масштабировались так же хорошо, как мы развернули до 10–15 платформ. Мы поняли, что пришло время пересмотреть архитектуру плеера, и приступили к разработке плеера следующего поколения. Мы стремились улучшить как пользователей, так и опыт разработки.

Мы начали с изучения нашего старого плеера и обнаружили, что основные проблемы этого плеера заключаются в следующем:

Смешано с бизнес-логикой.

Вначале было нормально объединить проигрыватель и приложение, потому что сложность нашей кодовой базы была небольшой, но со временем мы позволили некоторому бизнес-коду и аналитическому коду сбить ее фокус. Расширение нашего модуля Player для поддержки все большего числа платформ привело к появлению длинных условных операторов. Выбор дизайна, достаточный для SDK для одного или двух игроков, стал препятствием для новых разработчиков, добавляющих функциональные возможности проигрывателя. Различных проблем, связанных с каждой платформой, стало слишком много, чтобы поддерживать их.

Отсутствие спецификаций интерфейса плеера, особенно методов и событий.

У нас был набор методов и событий проигрывателя, генерируемых в определенные моменты во время воспроизведения, и наши компоненты пользовательского интерфейса могли подписываться на эти события и реагировать на них. Однако документация не всегда была достаточно понятной для новых разработчиков. Что означало «готовое» мероприятие? Какие методы требовались для каждого адаптера плеера? Как переходят состояния игрока? Мы столкнулись с множеством подобных вопросов, на которые не было однозначных ответов.

Низкое качество кода.

Нам не хватало четкой архитектуры. В основе нашего модуля проигрывателя лежали специальные функциональные возможности продукта или техники, что привело к плохому дизайну кода. Кроме того, для модуля Player было всего несколько тестов. В большинстве случаев нам требовалось ручное тестирование в модуле Player, которое не было ни масштабируемым, ни надежным.

Определение целей

Мы определили несколько целей, которые позволят решить три проблемы, упомянутые выше.

  • Создайте специальный модуль проигрывателя, ориентированный исключительно на воспроизведение.
    Мы решили создать пакет, в котором наше приложение могло бы взаимодействовать только со своим общедоступным интерфейсом. Кроме того, модуль Player должен полностью сосредоточиться на логике воспроизведения. Делай одно дело и делай это хорошо.
  • Предоставьте простой, легкий в использовании, не зависящий от платформы интерфейс.
    Поскольку мы используем тяжелые функции воспроизведения, нам нужно было создать интерфейс, который был бы хорошо разработан, богат и прост в использовании. Кроме того, нам нужно было поддерживать множество веб-платформ и OTT-платформ. Интерфейс должен быть независимым от платформы, чтобы разработчики могли свободно вызывать его и получать ожидаемое поведение.
  • Создавайте иерархическую, четко определенную архитектуру.
    Модуль проигрывателя - это непростой проект. Нам нужно было поддерживать разные SDK плеера на разных платформах OTT. Нам также нужно было поддерживать различные протоколы субтитров, воспроизведение рекламы и такие операции, как перемотка вперед. Было сложно разумно разделить разные уровни, но как только мы сможем создать звуковую архитектуру, добавление функциональности и постоянное улучшение модуля игрока станет намного проще.
  • Напишите надежный, хорошо протестированный код.
    В дополнение к модульным тестам с высоким охватом нам нужно было также рассмотреть интеграционные тесты и тесты E2E, чтобы гарантировать, что наш проигрыватель работает должным образом. Даже с неподдерживаемыми медиаисточниками нужно как-то аккуратно обращаться.
  • Напишите четкую документацию.
    Наша команда разработчиков сильно выросла. Подробная и понятная документация будет иметь первостепенное значение, чтобы уменьшить наше разочарование и повысить как эффективность, так и счастье. Но нам нужно было найти хороший подход, чтобы побудить всех легко писать документацию по коду.

Структура кода

Мы увидели два способа сделать специальный модуль игрока. Первый - создать новый репозиторий, опубликовать проигрыватель как частный пакет, а затем импортировать его в основную базу кода. В настоящее время мы используем этот подход в нашем проекте UIKit. Основная проблема, с которой мы столкнулись при использовании этого подхода, - это управление зависимостью. При разработке UIKit с нашей основной кодовой базой нам нужно было настроить (и не забыть удалить) ссылки пряжи, в то время как обычно (в других проектах) нам нужно было только установить пряжу.

Поскольку воспроизведение сильно зависит от требований уровня приложения, лучше держать их ближе. Поэтому мы выбрали другой подход: используем наше основное репо как монорепозиторий и внедрили Lerna для управления пакетами. Наша структура папок в конечном итоге выглядела так:

├── lerna.json
├── package.json
├── packages
│   └── player
└── src

Добавление машинописного текста для надежности

В нашей команде фронтендов мы как можно больше применяли TypeScript. Благодаря таким функциям, как проверка статического типа и автозаполнение, написание новых модулей на TypeScript придало нам дополнительную уверенность в нашем коде.

TypeScript дал нам легкую победу при создании нашего модуля Player. Мы определили интерфейсы, которые будут использоваться во всем модуле Player, чтобы не было путаницы в отношении того, какие аргументы и где ожидаются, и какой должна быть схема каждого аргумента. Кроме того, будет устранена любая двусмысленность в методах проигрывателя. Например, будущим разработчикам не нужно будет задаваться вопросом, возвращает ли `seek` обещание с новой целевой позицией. Эта информация теперь будет доступна в определении метода.

Мы также адаптировали TypeDoc для автоматического создания документации на основе комментариев в наших файлах TypeScript. Это упростит разработчикам понимание и использование нашего модуля Player.

Изменение архитектуры

После нескольких итераций постепенно обрисовывалась новая архитектура:

Мы решили разделить модуль игрока на четыре уровня: адаптер, проигрыватель, действие и редуктор снизу вверх. Каждый слой ориентирован на решение конкретных задач.

Слой адаптера

На разных платформах необходимо использовать разные SDK для видеопроигрывателя, например WebMAF на PS4 / PS3, AVPlay на телевизоре Samsung, Hls.js в Интернете и видеопроигрыватель HTML5 на FireTV. Уровень адаптера используется для обработки всех специфичных для платформы деталей и предоставления согласованного интерфейса для верхних уровней. Таким образом, приложение полагается только на этот единственный интерфейс, что позволяет нам легко и надежно разрабатывать функции для разных платформ. Это секрет того, как мы можем использовать одну базу кода для всех платформ OTT.

Мы использовали TypeScript, чтобы ограничить все адаптеры одним и тем же интерфейсом. В следующем примере демонстрируется интерфейс адаптера и то, как два адаптера реализуют его через разные SDK проигрывателя:

Адаптеры могут определять свойства или методы, выходящие за рамки набора, требуемого интерфейсом адаптера. Каждый адаптер имеет уникальное поведение и часто требует сопоставления некоторых свойств или методов (все они будут частными, благодаря Typescript).

Слой игрока

Существует некоторая общая логика для всех адаптеров плеера, такая как настройка, установка заголовков по умолчанию и т. Д. Крайне важно, чтобы этот уровень был простым для взаимодействия с верхними уровнями, мы хотели, чтобы каждая платформа использовала этот уровень одинаково. Верхние слои должны знать и вызывать Player.

В нашем слое Player мы определили набор методов для управления воспроизведением. Эти методы включали метод настройки для принятия настраиваемых параметров и основные элементы управления плеером, такие как воспроизведение, пауза и поиск. Тяжелая работа каждого из этих методов будет возложена на уровень адаптера. Вот пример нашего простого слоя Player:

Класс Player также является прокси-отправителем событий, позволяющим нашему приложению подписываться на базовые события адаптера во время воспроизведения. Строка «Адаптер» внизу дает нам возможность поддерживать любой тип SDK для воспроизведения, от HLS.js до AVPlayer от Samsung и до WebMAF SDK для Playstation.

Слой действия / редуктора

В Tubi мы везде используем React, а Redux помогает нам управлять потоком данных. Обрабатывая логику воспроизведения, мы обнаружили, что нам нужно получить доступ к большому количеству данных проигрывателя во всем приложении. Мы решили использовать возможности Redux для управления состоянием игрока. Не углубляясь в Redux, вы можете рассматривать «действие» как API для управления проигрывателем, а «редуктор» - как состояние проигрывателя, доступное веб-приложению. Действия могут быть отправлены из любого места в веб-приложении, а редуктор будет выводить единый источник состояния игрока истины, который может быть прочитан любым компонентом в нашем веб-приложении.

Давайте сначала посмотрим, как мы использовали редуктор для хранения почти всех необходимых данных, которые затем очень легко использовались бы компонентами нашего приложения:

В нашем новом подходе действия (из redux) обрабатывают две основные области: подписку на события игрока (и обновление состояния redux) и элементы управления плеером. В качестве примера потока действий проигрывателя см. Ниже, как мы прослушиваем событие «play» и соответственно обновляем состояние.

В этих действиях также обрабатываются некоторые взаимосвязанные обновления. Например, мы сбрасываем позицию буферизации при изменении типа контента и обновляем флаг HD при изменении уровня визуального качества. Таким образом, логика обновления состояния проигрывателя изолирована на уровне действий, а не на уровне проигрывателя или уровне адаптера, что ожидается от архитектуры с четкими обязанностями для каждой части.

Вторая область, в которой используется redux, - это действия по управлению игроком на основе обещаний:

В приведенном выше примере действие «seek» предоставляет высокоуровневый интерфейс, обертывая низкоуровневый метод проигрывателя и событие. Он возвращает обещание с привязкой ко времени, которое разрешается, когда операция поиска завершается успешно. Эта инкапсуляция значительно повышает эффективность вызовов приложений. Мы представим это подробно в следующем разделе.

Интеграция в приложение

В нашем решении для проигрывателя новый модуль Player предоставляет единый источник истинного состояния игрока, набор высокоуровневых действий и множество полезных событий. Давайте посмотрим, как мы можем использовать его в нашем приложении.

В этом примере WebPlayerOverlay отображает индикатор выполнения, считывая текущую позицию воспроизведения, продолжительность, позицию буферизации и тип контента из состояния проигрывателя redux. Всякий раз, когда пользователь ищет новую позицию, WebPlayerOverlay просто отправляет действие «seek» и отправляет аналитический отчет, как только действие разрешается. Внутренне модуль Player обновит состояние игрока, и новая позиция в состоянии игрока вызовет повторный рендеринг. Пользовательский интерфейс почти сразу синхронизируется с нашим обновленным состоянием игрока. Потрясающие!

Разделение пользовательского интерфейса игрока

При изоляции модуля проигрывателя для строгой обработки воспроизведения возникли некоторые вопросы о состояниях пользовательского интерфейса проигрывателя. Принадлежит ли буферизация к состоянию игрока? А как насчет поиска? Как мы можем представить эти общие состояния воспроизведения, не добавляя проблем пользовательского интерфейса в модуль Player?

Рассмотрев состояние «поиска», мы решили создать второе, отдельное состояние для PlayerUI на уровне веб-приложения. Пока playerUI «ищет», модуль Player «приостановлен». Это важное различие, которое помогло нам организовать наш код. Вкратце, важно пояснить, что «поиск», как мы его определяем, также называется быстрой перемоткой вперед или назад, это НЕ время между операцией «поиска» и последующей операцией «воспроизведения».

Действие «поиска» предназначено для того, чтобы пользователь нашел правильную позицию воспроизведения в заголовке. Мы решили, что это не главное в воспроизведении, поэтому, пока пользователь ищет, состояние redux нашего модуля Player будет сообщать о воспроизведении как «приостановлено». Пользовательский интерфейс в этом состоянии должен помочь пользователю найти правильную позицию воспроизведения, а игрок должен ждать в «приостановленном» состоянии, пока не будет достигнута подходящая целевая позиция. Сохранение этой бизнес-логики вне модуля проигрывателя соответствует нашей цели - «специальный модуль проигрывателя, ориентированный исключительно на воспроизведение».

С другой стороны, буферизация неотделима от состояния воспроизведения. Теперь мы можем использовать наши события проигрывателя, чтобы позволить нашему слою веб-приложения подписаться на события буферизации.

Извлечение логики поиска из нашего модуля Player в состояние PlayerUI было полезным упражнением и хорошим напоминанием о том, что разные веб-приложения могут иметь разные потребности в плеере. У нас нет функции перемотки вперед или назад на нашем веб-сайте, поэтому мы не хотели объединять правила, касающиеся скорости поиска и обновления целевой позиции пользователя в общем модуле Player. В Интернете достаточно прокрутки индикатора выполнения, чтобы пользователь мог найти свою позицию. У OTT-приложения разные потребности, и наш специализированный модуль Player помог нам эффективно решить эти проблемы.

Жду с нетерпением

Переход на новый модуль проигрывателя, несомненно, упростит разработку и, следовательно, должен упростить дальнейшее улучшение нашего воспроизведения. Наличие изолированных адаптеров означает, что мы можем точно настроить параметры воспроизведения для конкретной платформы без риска регресса в других местах. Мы надеемся развернуть больше функций, таких как поддержка протокола потоковой передачи DASH, DRM, настраиваемых расширений источников мультимедиа, а также некоторых внутренних инструментов, таких как конечный автомат, тесты E2E с различными мультимедийными файлами и т. Д., Для поддержки более плавного воспроизведения даже на больше платформ.