Всем привет. Меня зовут Ярослав Карманников, я разработчик команды SmartTV/Web в онлайн-кинотеатре KION МТС Digital. Это четвертая часть сериала, посвященного фиче Autoplay в нашем кинотеатре. Сегодня обсудим нюансы реализации фичи на платформе SmartTV: я расскажу о том, как мы внедряли автоплей, с какими трудностями столкнулись и как их решали.
Если вы пропустили предыдущие серии – два слова о том, что это за фича такая.
С помощью Autoplay мы предлагаем зрителю онлайн-кинотеатра фильмы, похожие на тот, что он только что посмотрел. Это система рекомендаций, главная цель которой – предложить зрителю наиболее подходящий для него контент и тем самым удержать его у экрана как можно дольше.
Пролог
Начну свой рассказ парой слов о том, как устроены наши JS-приложения. Для Web- и SmartTV-версий мы применяем Angular. Логика этих приложений схожа, поэтому мы используем NX-монорепозиторий и выносим общее в библиотеки, чтобы не дублировать код и облегчить его использование.
Если разбирать по компонентам – плеер выглядит так:
компонент
movie-player
– живет в самом приложении, отвечает за общение с нашим бэкендом, за передачу данных внутрь библиотеки плеера и отображение ui из этой библиотеки;библиотека
player-lib
, которую можно разделить на 2 «подбиблиотеки»:
- lib-player-ui
– отвечает за контролы и видео элемент. В ней находится компонент player-ui-controls;
- lib-player-core
– конфигурация плеера для Tizen и WebOS.
Далее по ходу рассказа эти компоненты понадобятся для понимания работы кода, поэтому верхнеуровневый компонент я буду называть movie-player
, входной lib-player-ui
обзовем player-ui
, а компонент с контролами – player-controls
.
Макеты и описание
Переходим к самой фиче. Макет экрана с переключением фильмов выглядит так:
Плеер сворачивается в маленькое окошко, в котором продолжают идти титры. На весь экран появляется постер следующего фильма с информацией о нем, две кнопки и крестик.
Кнопка Следующий фильм появляется в фокусе и с таймером, по истечении которого запустится этот самый фильм. Если с нее смещается фокус – таймер останавливается и при повторном фокусе на эту кнопку уже не запускается.
Кнопка Смотреть титры появляется в том случае, если фильм еще не закончился, и мы запустили автоплей по разметке титров. По нажатию на нее плеер разворачивается, и показывается одна кнопка Следующий фильм без фокуса.
Так должна выглядеть анимация сворачивания и разворачивания:
Спойлер: не все устройства с SmartTV способны на такое:)
Что насчет кода?
Каждую секунду компонент player-ui
обрабатывает событие timeupdate
и проверяет, попали ли мы в разметку титров.
case ListenerEnums.timeupdate: {
// проверяем попали ли в разметку и если да,
// то оповещаем об этом верхний movie-player компонент через output
this.setFilmAutoplayMode.emit({mode: true});
break;
}
В кейсе, когда мы вызываем автоплей по окончанию фильма, у нас есть специальный ended
-обработчик.
case ListenerEnums.ended: {
// оповещаем верхний movie-player компонент об
// окончании фильма через тот же output
this.setFilmAutoplayMode.emit({mode: true});
break;
}
Далее в верхнем movie-player
компоненте мы вызываем функцию, которая выставляет значения для двух переменных, управляющих состоянием автоплея. Расскажу о них подробнее
1) playerInPlayerOverlayData
– модель данных для экрана с постером следующего фильма и информацией о нем. Эта модель описывается следующим интерфейсом:
export interface NextMovieOverlayData {
imgUrl: string;
score: number;
produceYear: string;
duration: string;
produceCountries: string[];
genres: string[];
}
2) isPlayerInPlayerMode
– boolean-переменная, которая определяет свернут ли плеер или развернут.
Сама функция, где эти значения выставляется выглядит так:
setNextMovieOverlayData(event: boolean, fromChapter: boolean): void {
if (!this.playerInPlayerOverlayData) {
const movie = this.nextRelatedMovie;
this.playerInPlayerOverlayData = event
? {
imgUrl: movie.imgUrl,
score: movie.score,
produceYear: movie.produceYear,
duration: movie.duration,
produceCountries: movie.produceCountries,
genres: movie.genres
}
: null;
this.isPlayerInPlayerMode = true;
} else {
this.isPlayerInPlayerMode = event;
if (!event) {
this.playerInPlayerOverlayData = null;
}
}
}
Далее значения этих двух переменных и некоторые вспомогательные данные для автоплея передаются внутрь библиотеки player-ui через инпуты.
<lib-player-ui>
[isPlayerInPlayerMode]="isPlayerInPlayerMode
[playerInPlayerOverlayData]="playerInPlayerOverlayData"
/* 3 впомогательные переменные */
[isFilmAutoplayEnabled]="isFilmAutoplayEnabled"
[isMoviePlaying]="isMoviePlaying"
[nextVODCountdownStart]="nextVODCountDownTime"
</lib-player-ui>
Разберем подробнее три вспомогательные переменные.
а) isFilmAutoplayEnabled
– определяет включена ли наша фича автоплей в конфиге. Как конфиг мы используем Firebase и, если что-то непредвиденное случается на клиенте, мы отключаем фичу через него.
б) isMoviePlaying
– определяет фильм ли сейчас играет. Нужно для того, чтобы отключить автоплей на всем остальном контенте, кроме фильмов.
в) nextVODCountdownStart
– определяет количество секунд на таймере переключения на следующий фильм. Значение мы берем также из конфига Firebase.
Затем в player-ui навешиваем на корневой элемент с видео и контролами класс player-in-player-mode
, который сворачивает плеер в маленькое окно. На одном уровне с ним делаем div с бэкграундом автоплея.
Компонент player-ui
выглядит так:
<div [ngClass]="
{'player-in-player-mode':isPlayerInPlayerMode && playerInPlayerOverlayData}"
class="player-wrapper">
<div #htmlVideoContainer> /* видео элемент */ </div>
<div *ngIf="playerInPlayerOverlayData && playerInPlayerImageLoaded"
class="player-in-player-wrapper">
<div #playerInPlayerCloseButton
/* обработчик крескика */
(click)="handleClosePlayerInPlayerButton()">
<svg svgIcon="delete_close"></svg>
</div>
<div [class.hidden]="!isPlayerInPlayerMode">
<div class="player-in-player-description">
<h1 [innerHTML]="nextVodName"></h1>
<div class="player-in-player-description-meta">
<span *ngIf="playerInPlayerOverlayData.score">
<svg svgIcon="star_full"></svg>
{{ playerInPlayerOverlayData.score }}
</span>
<span *ngIf="playerInPlayerOverlayData.produceYear">
{{ playerInPlayerOverlayData.produceYear }}
</span>
<span *ngIf="playerInPlayerOverlayData.duration">
{{ playerInPlayerOverlayData.duration }}
</span>
<span *ngIf="playerInPlayerOverlayData.produceCountries">
{{ playerInPlayerOverlayData.produceCountries }}
</span>
<span *ngIf="playerInPlayerOverlayData.genres">
{{ playerInPlayerOverlayData.genres }}
</span>
</div>
</div>
<div class="player-in-player-buttons-container">
<watch-credits-button
// обработчик кнопки “Смотреть титры”
(click)="watchTailCredits()"
(focusin)="stopNextVODCountdown()"
*ngIf="isShowVideoContainer"
class="player-in-player-watch-credits-button">
</watch-credits-button>
<next-episode-button
// обработчик кнопки “Следующий фильм
(click)="handleNextVodButton()"
[currentCountdownValue]="nextVODCountdown"
[disabled]="!nextVODCountdown && nextVODCountdown !== 0"
nextVodText="Следующий фильм"
class="player-in-player-next-vod-button">
</next-episode-button
</div>
</div>
</div>
</div>
На все три кнопки навешаны свои собственные обработчики, потому что по крестику мы должны выходить из плеера, по кнопке Смотреть титры мы должны возвращаться в проигрывание нынешнего фильма, по кнопке Следующий фильм мы запускаем следующий фильм.
После этого player-ui завершает автоплей. Разберу пример завершения по клику на кнопку Следующий фильм.
handleNextVodButton() {
// стираем данные текущего автоплея и даем команду
// movie-player компоненту запустить следующий фильм
this.setFilmAutoplayMode.emit({mode: false});
this.watchNextVod.emit();
}
Проблемы и их решение
Камнем преткновения для реализации этой фичи на SmartTV стала производительность устройств со SmartTV :)
Тут позволю себе небольшое лирическое отступление.
В виду своей специфики, в Smart TV хорошо оптимизировано проигрывание видео. Но с другими моментами, например, перерисовкой интерфейса приложения или отрисовкой анимации, все сложнее. Иными словами, производительность в Smart TV — не самое главное, поэтому традиционно она сравнима с производительностью хорошего смартфона на Android.
Поэтому в задачах, которые требует большой производительности (как наша с вами), возникают трудности.
Разработчики не всегда видят их, потому что мы запускаем наше приложение в основном в браузере. С точки зрения разработки приложение для SmartTV – это обычное веб-приложение. Как я писал выше, у нас Angular. Запускаем приложение мы (как это у нас, ангулярщиков, заведено) через ng serve (в нашем случае с NX nx serve), открываем Chrome последней версии на нашем уютном производительном макбуке, набираем localhost:4200 в адресной строке, и радуемся жизни. В самих же смартах для приложений нам предоставляют Chromium, версия которого зависит от года выпуска телевизора. Также по сравнению с макбуками, у устройств на SmartTV небольшой объем оперативной памяти.
Сделать точную эмуляцию телевизора на компьютере невозможно. Поэтому чтобы увидеть баг, нужно прогонять сборки непосредственно на телевизоре. Тут возникает еще одна проблема – чтобы покрыть полностью все уязвимости, нужно посмотреть как работает фича на огромном количестве телевизоров.
В идеале, для распределенной команды как у нас, в офисе должна быть специальная комната с 50+ моделями смартов и с функцией просмотра и установки сборки удаленно.
Производители телевизоров пытаются работать с производительностью и улучшают ее на новых моделях. На моем личном телевизоре LG 2020 года с WebOS 5 из среднего ценового сегмента изложенных ниже проблем не возникало. Но мы должны поддерживать и телевизоры, например, 2017 года выпуска с операционками Tizen и WebOS 3+ из нижнего ценового сегмента. С такими моделями куда сложнее работать. Поэтому некоторые вещи, которые хорошо работают на новых моделях, мы отключаем на старых, дабы наше приложение выглядело и работало хорошо.
Первое что «отстрелило» на ряде моделей – это постер на экране автоплея.
Некоторые телевизоры не могли быстро прорисовать картинку во весь экран сразу после воспроизведения видео.
Здесь проблему решили достаточно просто:
Запрос на получение рекомендованного фильма перенесли на начало проигрывания, а не на момент показа автоплея;
imgUrl
вынесли из модели данных автоплея;Саму картинку вынесли в отдельный template, который имел visibility: hidden, пока автоплей не активен, и передавали этот template в дочерний компонент.
Прорисовка постера после этих манипуляций стала работать, как швейцарские часы, причем даже на самых старых моделях.
Ниже приведу код добавленный в movie-player
.
1) Template для постера:
<ng-template #preloadedImageTemplate>
<img
(error)="handlePreloadedImageTemplateLoad()"
(load)="handlePreloadedImageTemplateLoad()"
*ngIf="nextRelatedMovie"
[src]="nextRelatedMovie.backgroundUrl"
[style.hidden]="!playerInPlayerImageLoaded"
alt=""
class="next-related-movie-image-preload"
/>
</ng-template>
2) Новый инпут, через который мы передаем данные внутрь player-ui
:
<lib-player-ui>
[isPlayerInPlayerMode]="isPlayerInPlayerMode"
[playerInPlayerOverlayData]="playerInPlayerOverlayData"
/* 3 впомогательные переменные */
[isFilmAutoplayEnabled]="isFilmAutoplayEnabled"
[isMoviePlaying]="isMoviePlaying"
[nextVODCountdownStart]="nextVODCountDownTime"
/* передаем template с изображением */
[preloadedImageTemplateRef]="preloadedImageTemplate"
</lib-player-ui>
3) В player-ui
мы прорисовываем этот template:
/* контейнер с видео элементом и контролами плеера */
<ng-container *ngTemplateOutlet="preloadedImageTemplateRef"></ng-container>
/* контейнер с автоплеем */
Далее – проблема посерьезнее, переход плеера из маленького окошка в обычное состояние и наоборот, на некоторых моделях телевизоров тормозило видео.
Как видно на этой гифке, разворачивание длилось примерно 3 секунды, при установленном transition: transform 0.5s
;
Как это лечить?
Первое, что пришло в голову – отключить анимацию именно на том телевизоре, где это было замечено. Решение нежизнеспособно – выяснилось, что добрая половина старых телевизоров грешит этим.
Далее мы начали исследование. На наших телевизорах мы провели некоторые замеры. Также попросили QA-инженеров сделать тоже самое. Нас интересовали следующие переменные: год выпуска телевизора, версия операционной системы, общее количество оперативки у телевизора (проверяли посредством значения jsHeapSizeLimit).
Протестировав 4 телевизора, мы заметили, что количество оперативной памяти никак не влияет на этот баг. Казалось, что именно оперативная память – тот индикатор, на который можно забиться при отключении анимации. Но, помимо оперативной памяти, есть еще несколько элементов – процессор, разный у всех телевизоров, и версия ОС.
Если дорогие читатели знают, как можно выразить индекс производительности телевизора, чтобы он максимально близко к идеалу характеризовал мощность, – прошу написать об этом в комментариях.
Но также было замечено, что даже самый простенький телевизор с версией ОС Tizen и WebOS 5+ справляется с этой анимацией на отлично, а устройства с 5- версиями, даже из среднего+ сегмента не справляются.
После теста 4 телевизоров мы перестали измерять объем оперативной памяти, так как стало ясно, что через этот параметр проблему не решить.
Поэтому было решено отключить анимацию, ориентируясь на версию ОС.
Ниже приведу код как определяли версию и что именно отключили.
1) Добавили новую переменную isAutoplayAnimationOff
:
if (this.platform === PlatformEnum.webos) {
const majorVer = parseInt(this.platformService.device.platformVersion[0], 10);
this.isAutoplayAnimationOff = majorVer < 5;
}
if (this.platform === PlatformEnum.tizen) {
const majorVer = parseInt(this.platformService.device.platformVersion, 10);
this.isAutoplayAnimationOff = majorVer < 5;
}
2) Добавили новый инпут в player-ui
:
<lib-player-ui>
[isPlayerInPlayerMode]="isPlayerInPlayerMode"
[playerInPlayerOverlayData]="playerInPlayerOverlayData"
/* 3 впомогательные переменные */
[isFilmAutoplayEnabled]="isFilmAutoplayEnabled"
[isMoviePlaying]="isMoviePlaying"
[nextVODCountdownStart]="nextVODCountDownTime"
/* передаем template с изображением */
[preloadedImageTemplateRef]="preloadedImageTemplate"
/* передаем переменную для отключения анимации */
[isAutoplayAnimationOff]="isAutoplayAnimationOff"
</lib-player-ui>
3) Отключили свойство transition
на контейнере с видео элементом и контролами.
<div [ngClass]="{
'player-in-player-mode': isPlayerInPlayerMode && playerInPlayerOverlayData,
'transition-none': isAutoplayAnimationOff
}" class="player-wrapper">
Эпилог
В итоге получилось хорошо внедрить эту фичу на всех моделях телевизоров, на которых есть приложение KION.
В дальнейшем мы собираемся внедрить эту фичу в сериалах и, помимо автоплея, сделать в плеере специальный экран показывающий все рекомендации к текущему контенту.
Если у вас есть свое мнение по этому вопросу, либо есть чем поделиться по теме разработки под SmartTV – оставляйте комментарии под статьей, будет интересно почитать :)
Мои коллеги с различных платформ написали свои статьи по этой фиче, и если вы по каким-то своим убеждениям против Javascript, то советую также ознакомиться с их трудами:
Про саму фичу Autoplay в онлайн-кинотеатре
Про нюансы реализации фичи на tvOS
А еще мы рассказывали на Хабре про другую фичу KION, реализованную с помощью искусственного интеллекта – пропуск титров.
Подробно про саму фичу
Про проблемы и их решения с помощью Computed Properties в Angular
mdss
В месте анимации, где вы применяете свойство
transition: transform 0.5s
Можно попробовать еще указать дополнительно свойство
will-change: transform
Что тригернет выделение памяти в гпу и заранее проведет оптимизацию анимации