Как мы улучшали функциональность онлайн-кинотеатра на tvOS

от автора

Всем привет, меня зовут Валерия Рублевская, я iOS-разработчик на проекте онлайн-кинотеатра KION в МТС Digital. Это третья часть рассказа о фиче Autoplay фильмов и сегодня мы поговорим о нюансах ее реализации на tvOS.

Напомню, что Autoplay – это когда по завершению просмотра одного фильма пользователю предлагается посмотреть другой контент, рекомендованный системой. Подробнее о самой фиче ранее рассказывал мой коллега Алексей Мельников в этой статье на Хабре.

Дисклеймер: некоторые сущности специально были упрощены для простоты восприятия, цель статьи – показать общую структуру и подсветить тонкости реализации.

Следует также отметить, что у нас уже были реализованы кнопки пропуска титров и переключения на следующую серию в сериалах.

Так исторически сложилось, что в KION разные репозитории для iOS и tvOS. Проекты развивались неравномерно и без привязки друг к другу, поэтому сформировалась своя, отличная друг от друга, кодовая база. В этой статье я расскажу только про изменения в tvOS. 

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

Но ждать, пока мы разметим весь контент, невозможно. Так у нас появилось два сценария показа следующего фильма. Дизайнеры нарисовали такие макеты:

Рисунок 1 - Автоплей следующего фильма, когда была найдена разметка титров
Рисунок 1 — Автоплей следующего фильма, когда была найдена разметка титров
Рисунок 2 - После нажатия кнопки Смотреть титры
Рисунок 2 — После нажатия кнопки Смотреть титры

Кнопки пропуска титров к этому времени у нас уже были. Про фичу пропуска титров ранее на Хабре рассказывали мои коллеги Алексей Мельников и Алексей Охрименко. 

На макетах видно, что кнопки Смотреть титры и Следующий фильм для полнометражек такие же, как и для сериалов. А значит, этот функционал можно просто переиспользовать. И первая проблема, с которой я сразу же столкнулась, заглянув в реализацию – это то, что интерфейс взаимодействия с плеером PlayerViewController отвечает абсолютно за все: само проигрывание, отображение контролов (средств управления плеером), кнопки быстрого Пропуска заставки и переключения к следующей серии. Это можно увидеть на диаграмме классов ниже.

Рисунок 3 - Изначальная диаграмма классов в плеере
Рисунок 3 — Изначальная диаграмма классов в плеере

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

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

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

Рисунок 4 - Добавление прослойки контейнера с протоколами
Рисунок 4 — Добавление прослойки контейнера с протоколами

Где:

  • PlayerViewControllerProtocol – интерфейс для взаимодействия с плеером;

  • PlayerControlViewControllerProtocol – интерфейс для взаимодействия с контролами (система управления воспроизведением, постановка на паузу, перемотка);

  • CreditsViewProtocol – интерфейс для взаимодействия с кнопками быстрого доступа (переключение между сериями, пропуск заставки, переключение на следующий фильм).

Итак, введением дополнительной сущности мы получили класс PlayerViewContainerController, который будет управлять взаимодействиями между этими тремя интерфейсами, а также обеспечит масштабируемость. А добавлять дополнительные фичи в будущем станет проще.

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

При запросе информации о контенте мы получаем и данные о разметке (начало и конец заставки и титров).

Рисунок 5 - Структура с разметкой
Рисунок 5 — Структура с разметкой

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

Рисунок 6 - Предварительный интерфейс автоплея
Рисунок 6 — Предварительный интерфейс автоплея

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

Этот класс должен:

  • определять по таймкоду, нашлась ли у нас какая-то разметка;

  • генерировать кнопки переключения (Пропуск заставки, Следующая серия, Следующий фильм);

  • показывать/скрывать кнопки переключения;

  • управлять отображением плеера (сворачивать, разворачивать, скрывать);

  • управлять перемоткой, включением следующего доступного контента;

  • показывать постер следующего фильма;

  • показывать детальную информацию о следующем фильме.

Почти все функции относятся непосредственно к отображению и формированию UI-слоя. Какая-то логика присутствует лишь в одном месте, а это значит, что ее можно вынести вовне. Например, в воркер ChapterWorker, который также можно закрыть интерфейсом ChapterWorkingLogic:

Рисунок 7 - Логика поиска и нахождения разметки для кнопок автоплея
Рисунок 7 — Логика поиска и нахождения разметки для кнопок автоплея

Пройдемся по реализации интерфейса, так как это ключевая логика работы нашей фичи:

final class ChapterWorker {     private var chapters: [MetaChapter]? }  extension ChapterWorker: ChapterWorkingLogic {  // обновление чаптеров, необходимо при переключении с фильма на фильм происходящее непосредственно в самом плеере, так вместо создания нового экземпляра класса, мы обновляем лишь чаптеры     func updateCurrentChapters(chapters: [MetaChapter]?) {         self.chapters = chapters     }  // здесь происходит проверка, входит ли текущий проигрываемый момент времени в один из установленных разметкой временных промежутков     func chapter(currentTime: Double) -> MetaChapter? {         let chapter = chapters?.first(where: {             guard let offTimeString = $0.offTime,                   let endOffsetTimeString = $0.endOffsetTime,                   let offTime = Int(offTimeString),                   let endOffsetTime = Int(endOffsetTimeString),                   offTime < (endOffsetTime - 1) else { return false }             return (offTime..<endOffsetTime) ~= Int(floor(currentTime))         })         return chapter     }  //здесь происходит определение типа разметки (заставка, титры, разметки не найдено)     func chapterType(chapter: MetaChapter?) -> AIVChaptersType {         guard let title = chapter?.title else {             return .none         }         return AIVChaptersType(rawValue: title) ?? .none     } }

Еще одна немаловажная часть – то, как и откуда мы знаем что в конкретный момент времени нужно осуществить проверку на наличие разметки. Для этого в EPlayerView был добавлен следующий метод с таймером, который через заданный интервал осуществляет проверку таймкода на нахождение в разметке:

private func addPeriodicTimeObserver() {     if timeObserverToken == nil {         let interval = CMTime(seconds: EPlayerView.periodicTimeInterval, preferredTimescale: CMTimeScale(NSEC_PER_SEC))         timeObserverToken = avPlayer?.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main) { [weak self] _ in             guard let self = self else {                 return             }             let presentationSize = self.avPlayer?.currentItem?.presentationSize             self.presentationSizeDelegate?.updated(presentationSize: presentationSize)             self.playbackDelegate?.updated(presentationSize: presentationSize)             self.updatePlaybackDataIfNeeded(for: self.type, self.avPlayer?.currentItem)             self.notifyAboutAccessLogEntryIfNeeded(self.avPlayer?.currentItem)         }     } }

Подробнее метод проверки и передачи таймкода описан ниже:

private func updatePlaybackDataIfNeeded(for type: EPlayerViewType, _ currentItem: AVPlayerItem?) {     switch type {     case .vod, .trailer:         guard let currentItem = currentItem else {             return         }         delegate?.update(min: 0)         delegate?.update(max: currentItem.duration.seconds)         delegate?.update(current: currentItem.currentTime().seconds)         let currentTimeInSeconds = floor(currentItem.currentTime().seconds)         if floor(chapterTimerCounter) != currentTimeInSeconds {             chapterTimerCounter = currentTimeInSeconds             playbackDelegate?.updateChaptersWithTime(current: currentTimeInSeconds)         }     default:         guard let currentDate = avPlayer?.currentItem?.currentDate() else {             return         }         if let s = startDate {             self.delegate?.update(start: s)         }         if let e = endDate {             self.delegate?.update(end: e)         }         delegate?.update(current: currentDate)         NotificationCenter.default.post(name: .livePlayerDidUpdateTimeNotification,                                         object: nil,                                         userInfo: [EPlayerView.keyFTS: currentDate.timeIntervalSince1970])     }     playbackDelegate?.playerPlaybackStateDidChange(to: playbackState) }

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

На этом вычисляемая часть разметки заканчивается. Далее на основе анализа требований и разделения функционала у нас получился такой интерфейс для взаимодействия контейнера непосредственно с самим модулем автоплея:

Рисунок 8 - Реализация протокола автоплея
Рисунок 8 — Реализация протокола автоплея

Где:

  • buttonsView – кнопки быстрого доступа, которые используются только для установки правил перемещения фокуса между элементами кнопок и контролов при помощи UIFocusGuide; 

  • updateAutoplayData(…) – метод для обновления разметки контента; 

  • checkCurrentTimeChapter(…) – метод для проверки размечен ли данный временной участок при показанных контролах (если контролы показаны – анимация не нужна); 

  • setCreditsForEndPlayingState() – метод, который вызывается когда контент закончил проигрывание и нужно показать экран автоплея, когда разметки нет или пользователь решил посмотреть титры и досмотрел все до конца; 

  • updateVisibility() – метод для обновления видимости кнопок автоплея; 

  • controlsVisibilityWasChanged(…) – метод, который вызывается когда видимость контролов была изменена (спрятаны или показаны); 

  • menuButtonWasTapped() – метод, который вызывается при нажатии кнопки Меню на пульте; 

  • bringToFront() – метод для возврата view на передний слой.

Но как же общаться модулю автоплея с плеером? Ведь ему тоже нужна возможность управлять его состояниями (скрывать, показывать, уменьшать и закрывать), а еще он должен перематывать время, прятать и показывать контролы. Для этого я использую делегат CreditsViewDelegate.

Рисунок 9 - Делегат для взаимодействия с плеером через контейнер
Рисунок 9 — Делегат для взаимодействия с плеером через контейнер

Здесь предлагаю рассмотреть подробнее для чего нужны эти методы делегата:

  • constantsForPlayerAndDescriptionPosition – переменная, отвечающая за расположение описания следующего фильма (вычисляем, чтоб было в одну линию с плеером);

  • skipIntroTo(…) – метод для пропуска заставки до указанного в разметке времени;

  • nextButtonWasPressed(…) – метод нажатия кнопки Следующий контент (фильм, серия и т.п.), автоматически (анимация закончилась) или нет (пользователь нажал сам);

  • updatePlayerState(…) – метод для обновления состояния плеера (свернуть, развернуть, скрыть, закрыть);

  • bringViewToFrontAndUpdateFocusIfNeeded() – метод для обновления фокуса;

  • showControls() – метод для показа контролов для управления плеером;

  • hideControls() – метод для скрытия контролов для управления плеером;

  • hideTabBar() – метод для скрытия таббара с настройками (когда заставка с автоплеем показана на весь экран).

Какие состояния необходимы плееру и для чего они используются?

Рисунок 10 - Перечень состояний представления плеера
Рисунок 10 — Перечень состояний представления плеера

В разработке наша команда использует AutoLayout, поэтому все манипуляции с размерами я провожу при помощи простого изменения констант у констрейнтов, которые я вычисляю для уменьшенной версии в контейнере и затем передаю в экземпляр класса плеера. Ниже можно посмотреть более наглядно, как каждое из состояний обрабатывается непосредственно в коде:

private func updatePlayerDisplaying(state: VODPlayerState) {     guard self.state != state else {         return     }     self.state = state     switch state {     case .normal:         playerView?.isHidden = false         playerView?.cornersRadius = playerDefaultCornerRadius resetPlayerConstantsToZero()     case .minimized:         playerView?.isHidden = false         playerView?.cornersRadius = playerMinimizedCornerRadius         if let position = containerDelegate?.constantsForPlayerAndDescriptionPosition { updatePlayerConstants(to: position)         }     case .hidden:         playerView?.isHidden = true         playerView?.cornersRadius = playerDefaultCornerRadius     case .closed:         closePlayer()     }     UIView.animate(withDuration: 0.5) {         self.view.layoutIfNeeded()     } }

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

Рисунок 11 - Диаграмма классов промежуточного этапа разработки автоплея
Рисунок 11 — Диаграмма классов промежуточного этапа разработки автоплея

Так теперь выглядит наш контейнер – посредник между плеером, контролами и автоплеем:

final class VodPlayerViewContainerController: BaseViewController {     private var playerViewControllerProtocol: VodPlayerViewControllerProtocol?     private var controlsViewControllerProtocol: EPlayerControlViewControllerProtocol?     private var creditsViewControllerProtocol: CreditsViewProtocol?      private var isFirstCheck: Bool = true     private var isPlayerDataReloaded: Bool = false      private var bottomControlsLayoutConstraint: NSLayoutConstraint?     private let bottomControlsInsetByDefault: CGFloat = 0      public typealias PlayerAndDescriptionPosition = (bottom: CGFloat, leading: CGFloat, top: CGFloat, trailing: CGFloat)      public lazy var constantsForPlayerAndDescriptionPosition: PlayerAndDescriptionPosition = {         let height = view.frame.size.height         let width = view.frame.size.width          let quarter: CGFloat = 0.25         let minHeight = quarter * height         let minWidth = quarter * width          let bottom: CGFloat = 130         let leading = bottom         let top = height - bottom - minHeight         let trailing = width - leading - minWidth          return (bottom, leading, top, trailing)     }()      override var preferredFocusEnvironments: [UIFocusEnvironment] {         if let controlsView = controlsViewControllerProtocol?.controlsViewController.view,             controlsViewControllerProtocol?.isControlsShown == true {             return [controlsView]         }         if let buttonsView = creditsViewProtocol?.buttonsView {             return [buttonsView]         }         return super.preferredFocusEnvironments     }      // MARK: - Life Cycle      init(type: VodPlayerType, recommendationsDelegate: MovieRecommendationsDelegate?, viewWillDimissClosureAtTime: DoubleClosure?) {         super.init(nibName: nil, bundle: nil)         configurePlayerViewController(type: type, recommendationsDelegate: recommendationsDelegate, viewWillDimissClosureAtTime: viewWillDimissClosureAtTime)         configureCreditsView()         addGesturesToView()     }      required init?(coder: NSCoder) {         fatalError("init(coder:) has not been implemented")     }      // MARK: - Overrides      override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {         for press in presses {             switch press.type {             case .playPause:                 playerViewControllerProtocol?.playerViewController.pressesBegan(presses, with: event)             default:                 super.pressesBegan(presses, with: event)             }         }     }      // MARK: - Actions      @objc func menuButtonAction() {         if controlsViewControllerProtocol?.isControlsShown == true {             controlsViewControllerProtocol?.hideControls()         } else {             creditsViewControllerProtocol?.menuButtonWasTapped()         }     }      // MARK: - Privates      private func configurePlayerViewController(type: VodPlayerType, recommendationsDelegate: MovieRecommendationsDelegate?, viewWillDimissClosureAtTime: DoubleClosure?) {         playerViewControllerProtocol = VodPlayerViewController.instance(type: type,                                                                         recommendationsDelegate: recommendationsDelegate,                                                                         viewWillDimissClosureAtTime: viewWillDimissClosureAtTime)          playerViewControllerProtocol?.setContainerDelegate(delegate: self)         if let childController = playerViewControllerProtocol?.playerViewController {             add(child: childController)         }     }      private func configureCreditsView() {         let creditsViewController = CreditsBuilder().makeCreditsModule(delegate: self)         creditsViewControllerProtocol.translatesAutoresizingMaskIntoConstraints = false         view.add(child: creditsViewController)          creditsViewControllerProtocol = creditsViewController     }      private func configureControlsViewControllerIfNeeded() {         guard controlsViewControllerProtocol == nil,               let player = playerViewControllerProtocol?.playerView,               let titleModel = playerViewControllerProtocol?.titleViewModel else {                   bottomControlsLayoutConstraint?.constant = bottomControlsInsetByDefault             return         }         controlsViewControllerProtocol = EPlayerControlViewController.instance(view: player, titleModel: titleModel)          controlsViewControllerProtocol?.showContentRating = { [weak self] contentRatingImage in             self?.playerViewControllerProtocol?.configureContentRating(image: contentRatingImage)         }         controlsViewControllerProtocol?.setupPlayerControlsDelegate(delegate: self)         player.delegate = controlsViewControllerProtocol?.controlsViewController          if let childController = controlsViewControllerProtocol?.controlsViewController {             childController.view.translatesAutoresizingMaskIntoConstraints = false             addChild(childController)             view.addSubview(childController.view)             childController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true             childController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true             childController.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true             bottomControlsLayoutConstraint = childController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: bottomControlsInsetByDefault)             bottomControlsLayoutConstraint?.isActive = true             childController.didMove(toParent: self)         }          if let controlsViewControllerProtocol = controlsViewControllerProtocol,            let creditsViewProtocol = creditsViewProtocol {             view.addFocusGuide(from: controlsViewControllerProtocol.controlsViewController.view, to: creditsViewProtocol.buttonsView, direction: .top)             view.addFocusGuide(from: creditsViewProtocol.buttonsView, to: controlsViewControllerProtocol.controlsViewController.view, direction: .bottom)         }     }      private func removeControlsViewController() {         if let childController = controlsViewControllerProtocol?.controlsViewController {             remove(child: childController)             controlsViewControllerProtocol = nil         }     }      private func addGesturesToView() {         view.addTapGesture { [weak self] in             self?.playerViewControllerProtocol?.viewDidTap()             if self?.playerViewControllerProtocol?.isControlsShouldBeShown == true {                 self?.showControls()             }         }          let menuRecognizer = view.addMenuButtonTap { [weak self] in             self?.menuButtonAction()         }         menuRecognizer.cancelsTouchesInView = true     }      func updateEPlayerData() {         guard let titleModel = playerViewControllerProtocol?.titleViewModel else {             return         }         controlsViewControllerProtocol?.updateTitle(with: titleModel)     } }  // MARK: - VodPlayerViewContainerControllerProtocol extension VodPlayerViewContainerController: VodPlayerViewContainerControllerProtocol {     func updateAndConfigureWith(type: VodPlayerType) {         playerViewControllerProtocol?.updateAndConfigureWith(type: type)     } }  // MARK: - VodContainerDelegate extension VodPlayerViewContainerController: VodContainerDelegate {     func updateAutoplayData(chapters: [MetaChapter]?, contentModel: VodPlayerViewModel) {         creditsViewControllerProtocol?.updateAutoplayData(chapters: chapters, contentModel: contentModel)     }      func checkCurrentTimeChapter(time: Double) {         creditsViewControllerProtocol?.checkCurrentTimeChapter(time: time, isControlsShown: controlsViewControllerProtocol?.isControlsShown ?? false, isFirstCheck: isFirstCheck)         isFirstCheck = false     }      func handleEndMoviePlaying() {         creditsViewControllerProtocol?.setCreditsForEndPlayingState()         removeControlsViewController()     }      func onboardingIsShown(isShown: Bool) {         creditsViewControllerProtocol?.updateVisibility(isHidden: isShown)     }      func close() {         dismiss(animated: true)     }      func dismissControls() {         removeControlsViewController()     }      func playingContentDataDidUpdate() {         isPlayerDataReloaded = true         updateEPlayerData()     } }  // MARK: - PlayerControlsProtocol extension VodPlayerViewContainerController: PlayerControlsProtocol {     func sliderInProgress(isInProgress: Bool) {         creditsViewControllerProtocol?.updateVisibility(isHidden: isInProgress)     }      func controlsWasShown() {         creditsViewControllerProtocol?.controlsVisibilityWasChanged(isControlsHidden: false)         creditsViewControllerProtocol?.bringToFront()     }      func controlsWasHidden() {         creditsViewControllerProtocol?.controlsVisibilityWasChanged(isControlsHidden: true)         similarInPlayerViewProtocol?.hideSimilarShelfIfNeeded()     } }  // MARK: - CreditsViewDelegate extension VodPlayerViewContainerController: CreditsViewDelegate {     func skipIntroTo(time: Double) {         MovieStoriesManager.shared.currentChapterInFilmPlayMode?.wasActivated = true         playerViewControllerProtocol?.playerView?.rewind(time)     }      func nextButtonWasPressed(isAuto: Bool) {         MovieStoriesManager.shared.currentTime = 0         playerViewControllerProtocol?.playerViewModel.playNext(isAuto: isAuto)     }      func updatePlayerState(state: VODPlayerState) {         playerViewControllerProtocol?.updatePlayerDisplaying(state: state)     }      func bringViewToFrontAndUpdateFocusIfNeeded() {         creditsViewControllerProtocol?.bringToFront()         setNeedsFocusUpdate()         updateFocusIfNeeded()     }      func showControls() {         configureControlsViewControllerIfNeeded()         configureSimilarInPlayerViewIfNeeded()         controlsViewControllerProtocol?.showControlsIfNeeded()         creditsViewControllerProtocol?.controlsVisibilityWasChanged(isControlsHidden: false)         isPlayerDataReloaded = false         setNeedsFocusUpdate()         updateFocusIfNeeded()     }      func hideControls() {         dismissControls()     }      func hideTabBar() {         if let tabBarController = presentedViewController as? ExpandableTabBarController {             tabBarController.dismiss()         }     } }

Новый модуль автоплея было решено написать при помощи новой же архитектуры VIP. В будущем все приложение перейдет на эту архитектуру, а вы сможете почитать о ней подробнее в нашей новой статье. А пока расскажу кратко.

В VIP-архитектуре приложение состоит из множества сцен, и каждая сцена следует циклу VIP. Сцена здесь относится к бизнес-логике. Нет никаких конкретных правил о том, что такое сцена, так как каждый проект уникален, – мы можем иметь столько, сколько захотим для каждого проекта.

Рисунок 12 -  Схема работы VIP-цикла
Рисунок 12 — Схема работы VIP-цикла

Поток данных VIP Architecture – однонаправленный. ViewController получает данные от пользователей и передает их в Interactor в виде запроса. Затем Interactor обрабатывает (например, проверяет данные пользователей с помощью вызова API) и передает данные Presenter в качестве ответа. Presenter обрабатывает (например, делает проверку данных, то есть номер телефона, адрес электронной почты) и передает данные в ViewController.

Вернемся к нашей сцене с автоплеем и кнопками быстрого доступа. Вот как это должно выглядеть на схеме:

Рисунок 13 -  VIP-цикл сцены автоплея
Рисунок 13 — VIP-цикл сцены автоплея

А ниже представлен код самой реализации всех классов:

final class CreditsViewController: UIViewController {     weak var delegate: CreditsViewDelegate?     var interactor: CreditsBusinessLogic?      private var isCreditsHidden: Bool = true     private var isControlsHidden: Bool = true     private var headCreditsHideTimer: Timer?     private var topDescriptionStackConstraint: NSLayoutConstraint?     private var bottomDescriptionStackConstraint: NSLayoutConstraint?      private var posterBackgroundImageView: UIImageView = {         let imageView = UIImageView()         imageView.translatesAutoresizingMaskIntoConstraints = false         imageView.contentMode = .scaleAspectFit         imageView.isHidden = true         return imageView     }()      private let gradientSublayer: CAGradientLayer = {         let layer = CAGradientLayer()         layer.colors = [             UIColor.clear.cgColor,             UIColor.black.cgColor         ]         layer.locations = [0, 0.98]         return layer     }()      private lazy var descriptionStack: NextSimilarContentDescriptionStack = {         let stack = NextSimilarContentDescriptionStack()         stack.translatesAutoresizingMaskIntoConstraints = false         return stack     }()      lazy var buttonsView: CreditsButtonsView = {         let view: CreditsButtonsView = .instanceFromNib()!         view.translatesAutoresizingMaskIntoConstraints = false         view.delegate = self         return view     }()      override var preferredFocusEnvironments: [UIFocusEnvironment] {         [buttonsView]     }      // MARK: - Life Cycle      deinit {         dropHeadCreditsHideTimer()     }      // MARK: - Overrides      override func viewDidLayoutSubviews() {         super.viewDidLayoutSubviews()         interactor?.updateGradientFrame(request: CreditsModels.GradientFrameUpdates.Request(frame: posterBackgroundImageView.frame))     }      // MARK: - Privates      private func setupViewsIfNeeded() {         guard posterBackgroundImageView.superview == nil else {             return         }         // постер добавляем в родительский стек, чтоб не было проблем с перемещением фокуса кнопок, так как постер должен находиться позади контроллера плеера         view.superview?.addSubview(posterBackgroundImageView)         posterBackgroundImageView.bindToSuperviewBounds()         posterBackgroundImageView.layer.addSublayer(gradientSublayer)         view.addSubview(buttonsView)         buttonsView.bindToSuperviewBounds()         if let position = delegate?.constantsForPlayerAndDescriptionPosition {             setupDescriptionStackConstraints(position: position)         }     }      private func setupDescriptionStackConstraints(position: VodPlayerViewContainerController.PlayerAndDescriptionPosition) {         view.addSubview(descriptionStack)         topDescriptionStackConstraint = descriptionStack.topAnchor.constraint(equalTo: view.topAnchor, constant: position.top)         topDescriptionStackConstraint?.isActive = true         bottomDescriptionStackConstraint = descriptionStack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -position.bottom - 100)         bottomDescriptionStackConstraint?.isActive = false         descriptionStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -90).isActive = true         descriptionStack.leadingAnchor.constraint(equalTo: view.trailingAnchor, constant: -position.trailing + 25).isActive = true     }      private func updateDescriptionStackPosition(onlyTitle: Bool) {         topDescriptionStackConstraint?.isActive = !onlyTitle         bottomDescriptionStackConstraint?.isActive = onlyTitle         view.layoutIfNeeded()     }      private func updateCreditsView(creditsType: AIVCreditsType?,                                    nextButtonTitle: AIVCreditsNextButtonTitle?,                                    toTime: Double?,                                    animationDuration: Int,                                    state: NextSimilarContentDescriptionStack.NextSimilarContentDescriptionState,                                    isBackgroundPosterHidden: Bool,                                    playerState: VODPlayerState) {         posterBackgroundImageView.isHidden = isBackgroundPosterHidden         view.superview?.sendSubviewToBack(posterBackgroundImageView)          descriptionStack.configure(state: state)         updateDescriptionStackPosition(onlyTitle: state == .shownOnlyTitle)          delegate?.updatePlayerState(state: playerState)          updateButtonsView(creditsType: creditsType,                           nextButtonTitle: nextButtonTitle,                           toTime: toTime,                           animationDuration: animationDuration)          сreditsTypeDidUpdate(creditsType: creditsType)          view.isHidden = isCreditsHidden     }      private func updateButtonsView(creditsType: AIVCreditsType?,                                    nextButtonTitle: AIVCreditsNextButtonTitle?,                                    toTime: Double?,                                    animationDuration: Int) {         buttonsView.configure(nextButtonTitle: nextButtonTitle)         buttonsView.configure(state: creditsType,                               endTime: toTime,                               animationDuration: animationDuration)         view.bringSubviewToFront(buttonsView)     }      private func сreditsTypeDidUpdate(creditsType: AIVCreditsType?) {         switch creditsType {         case .head where isControlsHidden:             delegate?.bringViewToFrontAndUpdateFocusIfNeeded()             updateFocus()         case .tail, .tailOnlyNextWithAnimation:             delegate?.hideTabBar()             delegate?.bringViewToFrontAndUpdateFocusIfNeeded()             updateFocus()         case .some(.head), .tailOnlyNextWithoutAnimation, .playNext, .none:             break         }     }      private func updateFocus() {         setNeedsFocusUpdate()         updateFocusIfNeeded()     }      private func forceChangeVisibility(isHidden: Bool) {         isCreditsHidden = isHidden         view.isHidden = isCreditsHidden     }      private func dropHeadCreditsHideTimer() {         interactor?.updateTimer(request: CreditsModels.UpdateTimer.Request(shouldTimerStart: false))     } }  // MARK: - CreditsDisplayLogic  extension CreditsView: CreditsDisplayLogic {     func updateData(viewModel: CreditsModels.UpdateAutoplayData.ViewModel) {         setupViewsIfNeeded()         descriptionStack.configureDescription(info: viewModel.info)         posterBackgroundImageView.loadImage(path: viewModel.path, size: bounds.size)     }      func moveView(viewModel: CreditsModels.MoveView.ViewModel) {         UIView.animate(withDuration: 0.5) {             self.view.transform = viewModel.transform         }     }      func updateGradientFrame(viewModel: CreditsModels.GradientFrameUpdates.ViewModel) {         gradientSublayer.frame = viewModel.frame     }      func bringSubviewToFront(viewModel: CreditsModels.LayoutUpdates.ViewModel) {         view.superview?.bringSubviewToFront(self)     }      func updateCreditsView(viewModel: CreditsModels.SearchCurrentChapter.ViewModel) {         isCreditsHidden = viewModel.isHidden         if viewModel.shouldShowControls {             delegate?.showControls()         }         updateCreditsView(creditsType: viewModel.creditsType,                           nextButtonTitle: viewModel.nextButtonTitle,                           toTime: viewModel.toTime,                           animationDuration: viewModel.animationDuration,                           state: viewModel.state,                           isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,                           playerState: viewModel.playerState)     }      func updateCreditsView(viewModel: CreditsModels.UpdateCreditsType.ViewModel) {         isCreditsHidden = viewModel.isHidden         updateCreditsView(creditsType: viewModel.creditsType,                           nextButtonTitle: viewModel.nextButtonTitle,                           toTime: viewModel.toTime,                           animationDuration: viewModel.animationDuration,                           state: viewModel.state,                           isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,                           playerState: viewModel.playerState)     }      func updateVisibility(viewModel: CreditsModels.UpdateVisibility.ViewModel) {         isCreditsHidden = viewModel.isHidden         updateCreditsView(creditsType: viewModel.creditsType,                           nextButtonTitle: viewModel.nextButtonTitle,                           toTime: viewModel.toTime,                           animationDuration: viewModel.animationDuration,                           state: viewModel.state,                           isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,                           playerState: viewModel.playerState)     }      func controlsVisibilityChanged(viewModel: CreditsModels.ControlsVisibilityWasChanged.ViewModel) {         isControlsHidden = viewModel.isControlsHidden         forceChangeVisibility(isHidden: viewModel.isHidden)     }      func showCredits(viewModel: CreditsModels.UpdateCreditsType.ViewModel) {         isCreditsHidden = viewModel.isHidden         updateCreditsView(creditsType: viewModel.creditsType,                           nextButtonTitle: viewModel.nextButtonTitle,                           toTime: viewModel.toTime,                           animationDuration: viewModel.animationDuration,                           state: viewModel.state,                           isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,                           playerState: viewModel.playerState)         delegate?.hideControls()     }      func playNext(viewModel: CreditsModels.NextButtonDidPress.ViewModel) {         isCreditsHidden = viewModel.isHidden         updateCreditsView(creditsType: viewModel.creditsType,                           nextButtonTitle: viewModel.nextButtonTitle,                           toTime: viewModel.toTime,                           animationDuration: viewModel.animationDuration,                           state: viewModel.state,                           isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,                           playerState: viewModel.playerState)         delegate?.nextButtonWasPressed(isAuto: viewModel.isAuto)     }      func skipIntro(viewModel: CreditsModels.SkipIntro.ViewModel) {         dropHeadCreditsHideTimer()         delegate?.skipIntroTo(time: viewModel.time)         delegate?.hideControls()     }      func startTimer(viewModel: CreditsModels.UpdateTimer.ViewModel) {         headCreditsHideTimer?.invalidate()         headCreditsHideTimer = Timer.scheduledTimer(timeInterval: viewModel.skipIntroTimeInterval,                                                     target: self,                                                     selector: #selector(skipTimerAction),                                                     userInfo: nil,                                                     repeats: true)         forceChangeVisibility(isHidden: viewModel.isHidden)     }      func dropTimer(viewModel: CreditsModels.UpdateTimer.ViewModel) {         headCreditsHideTimer?.invalidate()         headCreditsHideTimer = nil         forceChangeVisibility(isHidden: viewModel.isHidden)     }      func menuButtonWasTapped(viewModel: CreditsModels.MenuButtonTapped.ViewModel) {         interactor?.sendMenuButtonAnalytics(request: CreditsModels.AnalyticsData.Request(buttonType: viewModel.buttonType, isAutomatically: viewModel.isAutomatically))          if viewModel.creditsType != nil {             skipTimerAction()         } else {             updateCreditsView(creditsType: viewModel.creditsType,                               nextButtonTitle: viewModel.nextButtonTitle,                               toTime: viewModel.toTime,                               animationDuration: viewModel.animationDuration,                               state: viewModel.state,                               isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,                               playerState: viewModel.playerState)         }     }      func sendButtonsAnalytics(viewModel: CreditsModels.AnalyticsData.ViewModel) {         // нужно для завершения цикла (аналитика)     } }  // MARK: - CreditsDelegate  extension CreditsView: CreditsViewProtocol {     func updateAutoplayData(chapters: [MetaChapter]?, contentModel: VodPlayerViewModel) {         let request = CreditsModels.UpdateAutoplayData.Request(chapters: chapters,                                                                  contentModel: contentModel)         interactor?.updateData(request: request)     }      func checkCurrentTimeChapter(time: Double, isControlsShown: Bool, isFirstCheck: Bool) {         let request = CreditsModels.SearchCurrentChapter.Request(currentTime: time,                                                                  isControlsShown: isControlsShown,                                                                  isFirstCheck: isFirstCheck)         interactor?.findCurrentChapter(request: request)     }      func setCreditsForEndPlayingState() {         interactor?.didContentEnd(request: CreditsModels.UpdateCreditsType.Request())     }      func updateVisibility(isHidden: Bool) {         let request = CreditsModels.UpdateVisibility.Request(shouldBeHidden: isHidden)         interactor?.visibilityShouldBeChanged(request: request)     }      func controlsVisibilityWasChanged(isControlsHidden: Bool) {         let request = CreditsModels.ControlsVisibilityWasChanged.Request(isControlsHidden: isControlsHidden)         interactor?.controlsVisibilityChanged(request: request)     }      func menuButtonWasTapped() {         interactor?.menuButtonWasTapped(request: CreditsModels.MenuButtonTapped.Request())     }      func moveCreditsView(inset: CGFloat) {         let request = CreditsModels.MoveView.Request(inset: inset)         interactor?.moveView(request: request)     }      func bringToFront() {         interactor?.bringSubviewToFront(request: CreditsModels.LayoutUpdates.Request())     } }  // MARK: - CreditsButtonsViewDelegate  extension CreditsView: CreditsButtonsViewDelegate {     func skipIntroTo(time: Double) {         interactor?.skipIntro(request: CreditsModels.SkipIntro.Request(time: time))     }      func showCredits() {         interactor?.showCredits(request: CreditsModels.UpdateCreditsType.Request())     }      func playNext(isAuto: Bool) {         interactor?.playNext(request: CreditsModels.NextButtonDidPress.Request(isAuto: isAuto))     }      func startSkipIntroTimerIfNeeded() {         interactor?.updateTimer(request: CreditsModels.UpdateTimer.Request(shouldTimerStart: true))     }      @objc func skipTimerAction() {         dropHeadCreditsHideTimer()     }      func sendButtonShowsAnalytics(buttonType: AIVAnalyticsKeys.ButtonTypes?) {         let request = CreditsModels.AnalyticsData.Request(buttonType: buttonType, isAutomatically: nil)         interactor?.sendButtonShowsAnalytics(request: request)     }      func sendButtonWasTappedAnalytics(buttonType: AIVAnalyticsKeys.ButtonTypes?, isAutomatically: Bool) {         let request = CreditsModels.AnalyticsData.Request(buttonType: buttonType, isAutomatically: isAutomatically)         interactor?.sendButtonWasTappedAnalytics(request: request)     } }

Теперь заглянем в Interactor, здесь у нас преимущественно реализована отсылка аналитики и конечно взаимодействие с Worker поиска разметки:

final class CreditsInteractor {     var presenter: CreditsPresentationLogic?     private var chapterWorker: ChapterWorkingLogic?     private var remoteConfigWorker: AIVRemoteConfigWorkerLogic?     private var analyticsEventForCurrent: Analytics.PlaybackButtonsEvent?     private var analyticsEventForRecommended: Analytics.PlaybackButtonsEvent?     private (set) var animationDuration: Int = 0      init(with chapterWorker: ChapterWorkingLogic, remoteConfigWorker: AIVRemoteConfigWorkerLogic) {         self.chapterWorker = chapterWorker         self.remoteConfigWorker = remoteConfigWorker     }      func creditsContentType(contentModel: VodPlayerViewModel) -> ContentType {         switch contentModel.type {         case .vod:             return .movie         case .serial:             return .serial(serialInfo: VideoDetailViewModel.SerialInfo())         case .trailer, .none:             return .none         }     } }  // MARK: - CreditsBusinessLogic extension CreditsInteractor: CreditsBusinessLogic {     func updateGradientFrame(request: CreditsModels.GradientFrameUpdates.Request) {         presenter?.updateGradientFrame(response: CreditsModels.GradientFrameUpdates.Response(frame: request.frame))     }      func moveView(request: CreditsModels.MoveView.Request) {         presenter?.moveView(response: CreditsModels.MoveView.Response(inset: request.inset))     }      func bringSubviewToFront(request: CreditsModels.LayoutUpdates.Request) {         presenter?.bringSubviewToFront(response: CreditsModels.LayoutUpdates.Response())     }      func updateData(request: CreditsModels.UpdateAutoplayData.Request) {         guard let remoteConfigWorker = remoteConfigWorker else {             return         }         analyticsEventForCurrent = request.contentModel.analyticsEventForCurrentContent         analyticsEventForRecommended = request.contentModel.analyticsEventForRecommendedContent         chapterWorker?.updateCurrentChapters(chapters: request.chapters)          let currentContentType = creditsContentType(contentModel: request.contentModel)         animationDuration = remoteConfigWorker.durationForAnimation(currentContentType: currentContentType)          let delegate = request.contentModel.recommendationsDelegate         let isMoviesAutoplayShouldBeShown = remoteConfigWorker.isMoviesAutoplayFunctionalityEnabled && delegate?.firstRecommendedVod != nil          let currentContentTypeResponse = CreditsModels.UpdateAutoplayData.Response(currentContentType: currentContentType,                                                                                      isMoviesAutoplayShouldBeShown: isMoviesAutoplayShouldBeShown,                                                                                      info: delegate?.viewModelForDescripton(),                                                                                      path: delegate?.pathForPoster())         presenter?.updateData(response: currentContentTypeResponse)     }      func findCurrentChapter(request: CreditsModels.SearchCurrentChapter.Request) {         guard let chapterWorker = chapterWorker else {             return         }         let chapter = chapterWorker.chapter(currentTime: request.currentTime)         let response = CreditsModels.SearchCurrentChapter.Response(chapter: chapter,                                                                    isControlsShown: request.isControlsShown,                                                                    isFirstCheck: request.isFirstCheck,                                                                    chapterType: chapterWorker.chapterType(chapter: chapter),                                                                    animationDuration: animationDuration)         presenter?.didFindChapter(response: response)     }      func skipIntro(request: CreditsModels.SkipIntro.Request) {         presenter?.skipIntro(response: CreditsModels.SkipIntro.Response(time: request.time))     }      func didContentEnd(request: CreditsModels.UpdateCreditsType.Request) {         let response = CreditsModels.UpdateCreditsType.Response(animationDuration: animationDuration)         presenter?.didContentEnd(response: response)     }      func showCredits(request: CreditsModels.UpdateCreditsType.Request) {         let response = CreditsModels.UpdateCreditsType.Response(animationDuration: animationDuration)         presenter?.showCredits(response: response)     }      func playNext(request: CreditsModels.NextButtonDidPress.Request) {         let response = CreditsModels.NextButtonDidPress.Response(isAuto: request.isAuto,                                                                  animationDuration: animationDuration)         presenter?.playNext(response: response)     }      func visibilityShouldBeChanged(request: CreditsModels.UpdateVisibility.Request) {         let response = CreditsModels.UpdateVisibility.Response(shouldBeHidden: request.shouldBeHidden,                                                                animationDuration: animationDuration)         presenter?.visibilityShouldBeChanged(response: response)     }      func controlsVisibilityChanged(request: CreditsModels.ControlsVisibilityWasChanged.Request) {         let response = CreditsModels.ControlsVisibilityWasChanged.Response(isControlsHidden: request.isControlsHidden)         presenter?.controlsVisibilityChanged(response: response)     }      func updateTimer(request: CreditsModels.UpdateTimer.Request) {         presenter?.updateTimer(response: CreditsModels.UpdateTimer.Response(shouldTimerStart: request.shouldTimerStart))     }      func menuButtonWasTapped(request: CreditsModels.MenuButtonTapped.Request) {         presenter?.menuButtonWasTapped(response: CreditsModels.MenuButtonTapped.Response())     }      // MARK: - Analytics      func sendMenuButtonAnalytics(request: CreditsModels.AnalyticsData.Request) {         presenter?.sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response())          if let type = request.buttonType {             AnalyticsManager.shared.sendAutoplayButtonWasTapped(buttonType: type, event: analyticsEventForCurrent, isAutomatically: request.isAutomatically)         }     }      func sendButtonShowsAnalytics(request: CreditsModels.AnalyticsData.Request) {         presenter?.sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response())          var event: Analytics.PlaybackButtonsEvent?         switch request.buttonType {         case .nextMovie:             event = analyticsEventForRecommended         case .skipIntro, .nextEpisode, .some(.showCredits), .closeAutoplay, .none:             event = analyticsEventForCurrent         }         AnalyticsManager.shared.sendAutoplayButtonWasShown(buttonType: request.buttonType, event: event)     }      func sendButtonWasTappedAnalytics(request: CreditsModels.AnalyticsData.Request) {         presenter?.sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response())          AnalyticsManager.shared.sendAutoplayButtonWasTapped(buttonType: request.buttonType, event: analyticsEventForCurrent, isAutomatically: request.isAutomatically)     } }

Последняя часть – это наш Presenter, который и определяет, как именно должно выглядеть представление на экране пользователя:

final class CreditsPresenter {     private typealias СreditsViewModel = (creditsType: AIVCreditsType?,                                           nextButtonTitle: AIVCreditsNextButtonTitle?,                                           toTime: Double?,                                           animationDuration: Int,                                           state: NextSimilarContentDescriptionStack.NextSimilarContentDescriptionState,                                           isBackgroundPosterHidden: Bool,                                           playerState: VODPlayerState)      weak var view: CreditsDisplayLogic?      private let skipIntroTimeInterval: TimeInterval = 5     private var currentContentType: ContentType?     private var currentChapterID: String?     private var posterPath: String?     private var isMoviesAutoplayShouldBeShown: Bool = true     private var didContentEnd: Bool = false     private var shouldBeHidden: Bool = false     private var isControlsHidden: Bool = true     private var isTimerStarted: Bool = false     private var creditsType: AIVCreditsType?     private lazy var currentCreditsViewModel: CreditsPresenter.СreditsViewModel = defaultChapter()      private var isHidden: Bool {         if shouldBeHidden {             return true         }         if isControlsHidden &&             (creditsType == .tail ||              creditsType == .tailOnlyNextWithAnimation ||              isTimerStarted) {             return false         }         return isControlsHidden || creditsType == nil     }      private func tailCredits(animationDuration: Int, isControlsShown: Bool, isFirstCheck: Bool) -> СreditsViewModel {         switch currentContentType {         case .serial:             // TODO: добавить проверку на флаг с бэка             guard MovieStoriesManager.shared.needsToShowTailCredit else {                 return defaultChapter()             }             return tailCreditsModelForSerials(animationDuration: animationDuration, isControlsInFocus: isControlsShown)         case .movie:             guard isMoviesAutoplayShouldBeShown else {                 return defaultChapter()             }             return tailCreditsModelForMovies(animationDuration: animationDuration, isControlsInFocus: isControlsShown)         case nil, .some(.none):             return defaultChapter()         }     }      private func headCredits(endTime: Double) -> СreditsViewModel {         creditsType = .head         return СreditsViewModel(creditsType: creditsType,                                 nextButtonTitle: nil,                                 toTime: endTime,                                 animationDuration: 0,                                 state: .hidden,                                 isBackgroundPosterHidden: true,                                 playerState: .normal)     }      private func tailCreditsModelForSerials(animationDuration: Int, isControlsInFocus: Bool) -> СreditsViewModel {         creditsType = isControlsInFocus ? .tailOnlyNextWithoutAnimation : .tail         return СreditsViewModel(creditsType: creditsType,                                 nextButtonTitle: .nextEpisode,                                 toTime: nil,                                 animationDuration: animationDuration,                                 state: .hidden,                                 isBackgroundPosterHidden: false,                                 playerState: .normal)     }      private func tailCreditsModelForMovies(animationDuration: Int, isControlsInFocus: Bool) -> СreditsViewModel {         creditsType = isControlsInFocus ? .tailOnlyNextWithoutAnimation : .tail         return СreditsViewModel(creditsType: creditsType,                                 nextButtonTitle: .nextMovie,                                 toTime: nil,                                 animationDuration: animationDuration,                                 state: isControlsInFocus ? .shownOnlyTitle : .shownAll,                                 isBackgroundPosterHidden: isControlsInFocus,                                 playerState: isControlsInFocus ? .normal : .minimized)     }      private func defaultChapter() -> СreditsViewModel {         creditsType = nil         return СreditsViewModel(creditsType: creditsType,                                 nextButtonTitle: nil,                                 toTime: nil,                                 animationDuration: 0,                                 state: .hidden,                                 isBackgroundPosterHidden: true,                                 playerState: .normal)     }      private func closingPlayerModel() -> СreditsViewModel {         creditsType = nil         return СreditsViewModel(creditsType: creditsType,                                 nextButtonTitle: nil,                                 toTime: nil,                                 animationDuration: 0,                                 state: .hidden,                                 isBackgroundPosterHidden: true,                                 playerState: .closed)     }      private func nextMovieModel(type: AIVCreditsType?, animationDuration: Int) -> СreditsViewModel {         creditsType = type         return СreditsViewModel(creditsType: creditsType,                                 nextButtonTitle: .nextMovie,                                 toTime: nil,                                 animationDuration: animationDuration,                                 state: .shownAll,                                 isBackgroundPosterHidden: false,                                 playerState: .hidden)     } }  extension CreditsPresenter: CreditsPresentationLogic {     func updateGradientFrame(response: CreditsModels.GradientFrameUpdates.Response) {         view?.updateGradientFrame(viewModel: CreditsModels.GradientFrameUpdates.ViewModel(frame: response.frame))     }      func moveView(response: CreditsModels.MoveView.Response) {         let viewModel = CreditsModels.MoveView.ViewModel(transform: CGAffineTransform(translationX: 0, y: response.inset))         view?.moveView(viewModel: viewModel)     }      func bringSubviewToFront(response: CreditsModels.LayoutUpdates.Response) {         view?.bringSubviewToFront(viewModel: CreditsModels.LayoutUpdates.ViewModel())     }      func updateData(response: CreditsModels.UpdateAutoplayData.Response) {         didContentEnd = false         currentContentType = response.currentContentType         isMoviesAutoplayShouldBeShown = response.isMoviesAutoplayShouldBeShown          let viewModel = CreditsModels.UpdateAutoplayData.ViewModel(info: response.info, path: response.path)         view?.updateData(viewModel: viewModel)     }      func didFindChapter(response: CreditsModels.SearchCurrentChapter.Response) {         guard currentChapterID != response.chapter?.ID else {             return         }          // если воспроизведение контента началось на разметке автоплея - поведение должно быть как при открытых контролах и затем принудительно показываем контролы         let isControlsShown = response.isFirstCheck ? true : response.isControlsShown          currentChapterID = response.chapter?.ID         switch response.chapterType {         case .headCredit:             guard let endOffsetTime = response.chapter?.endOffsetTime?.doubleValue else {                 return             }             currentCreditsViewModel = headCredits(endTime: endOffsetTime)         case .tailCredit:             currentCreditsViewModel = tailCredits(animationDuration: response.animationDuration,                                                   isControlsShown: isControlsShown || shouldBeHidden,                                                   isFirstCheck: response.isFirstCheck)         case nil, .some(.movieStorySuperEpisodeChapter), .some(.none):             if let type = creditsType,                AIVCreditsType.tailsChaptersWithAnimation.contains(type) {                 // если текущая разметка с анимацией, даем анимации доиграть до конца, чаптеры не обнуляем                 return             }             currentCreditsViewModel = defaultChapter()         }          let shouldShowControls = response.isFirstCheck && creditsType == .tailOnlyNextWithoutAnimation         let viewModel = CreditsModels.SearchCurrentChapter.ViewModel(creditsType: currentCreditsViewModel.creditsType,                                                                      nextButtonTitle: currentCreditsViewModel.nextButtonTitle,                                                                      toTime: currentCreditsViewModel.toTime,                                                                      animationDuration: currentCreditsViewModel.animationDuration,                                                                      state: currentCreditsViewModel.state,                                                                      isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,                                                                      playerState: currentCreditsViewModel.playerState,                                                                      isHidden: isHidden,                                                                      shouldShowControls: shouldShowControls)         view?.updateCreditsView(viewModel: viewModel)     }      func skipIntro(response: CreditsModels.SkipIntro.Response) {         view?.skipIntro(viewModel: CreditsModels.SkipIntro.ViewModel(time: response.time))     }      func didContentEnd(response: CreditsModels.UpdateCreditsType.Response) {         didContentEnd = true         if isMoviesAutoplayShouldBeShown {             creditsType = shouldBeHidden ? .tailOnlyNextWithoutAnimation : .tailOnlyNextWithAnimation             currentCreditsViewModel = nextMovieModel(type: creditsType, animationDuration: response.animationDuration)         } else {             // если автоплей выключен, не закрываем плеер, пока полка открыта             if shouldBeHidden {                 return             }             currentCreditsViewModel = closingPlayerModel()         }          let viewModel = CreditsModels.UpdateCreditsType.ViewModel(creditsType: currentCreditsViewModel.creditsType,                                                                   nextButtonTitle: currentCreditsViewModel.nextButtonTitle,                                                                   toTime: currentCreditsViewModel.toTime,                                                                   animationDuration: currentCreditsViewModel.animationDuration,                                                                   state: currentCreditsViewModel.state,                                                                   isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,                                                                   playerState: currentCreditsViewModel.playerState,                                                                   isHidden: isHidden)         view?.updateCreditsView(viewModel: viewModel)     }      func showCredits(response: CreditsModels.UpdateCreditsType.Response) {         let creditsViewModel = tailCredits(animationDuration: 0, isControlsShown: true, isFirstCheck: false)         currentCreditsViewModel = creditsViewModel         let viewModel = CreditsModels.UpdateCreditsType.ViewModel(creditsType: currentCreditsViewModel.creditsType,                                                                   nextButtonTitle: currentCreditsViewModel.nextButtonTitle,                                                                   toTime: currentCreditsViewModel.toTime,                                                                   animationDuration: currentCreditsViewModel.animationDuration,                                                                   state: currentCreditsViewModel.state,                                                                   isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,                                                                   playerState: currentCreditsViewModel.playerState,                                                                   isHidden: isHidden)         view?.showCredits(viewModel: viewModel)     }      func playNext(response: CreditsModels.NextButtonDidPress.Response) {         let creditsViewModel = defaultChapter()         currentCreditsViewModel = creditsViewModel         let viewModel = CreditsModels.NextButtonDidPress.ViewModel(isAuto: response.isAuto,                                                                    creditsType: currentCreditsViewModel.creditsType,                                                                    nextButtonTitle: currentCreditsViewModel.nextButtonTitle,                                                                    toTime: currentCreditsViewModel.toTime,                                                                    animationDuration: currentCreditsViewModel.animationDuration,                                                                    state: currentCreditsViewModel.state,                                                                    isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,                                                                    playerState: currentCreditsViewModel.playerState,                                                                    isHidden: isHidden)         view?.playNext(viewModel: viewModel)     }      func visibilityShouldBeChanged(response: CreditsModels.UpdateVisibility.Response) {         shouldBeHidden = response.shouldBeHidden         // когда полка закрылась и контент доиграл до конца - нужно обновить экран автоплея         if !shouldBeHidden, didContentEnd {             switch creditsType {             case .tailOnlyNextWithoutAnimation:                 // если есть автоплей по окончанию контента, по закрытии полки похожих возобновляем анимацию со счетчиком на кнопке автоплея                 currentCreditsViewModel = nextMovieModel(type: .tailOnlyNextWithAnimation, animationDuration: response.animationDuration)             case .tailOnlyNextWithAnimation, .tail, .playNext, .head, .none:                 // если автоплей выключен по окончанию контента, по закрытии полки похожих закрываем плеер                 if !isMoviesAutoplayShouldBeShown {                     currentCreditsViewModel = closingPlayerModel()                 }             }         }         let viewModel = CreditsModels.UpdateVisibility.ViewModel(creditsType: currentCreditsViewModel.creditsType,                                                                  nextButtonTitle: currentCreditsViewModel.nextButtonTitle,                                                                  toTime: currentCreditsViewModel.toTime,                                                                  animationDuration: currentCreditsViewModel.animationDuration,                                                                  state: currentCreditsViewModel.state,                                                                  isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,                                                                  playerState: currentCreditsViewModel.playerState,                                                                  isHidden: isHidden)         view?.updateVisibility(viewModel: viewModel)     }      func controlsVisibilityChanged(response: CreditsModels.ControlsVisibilityWasChanged.Response) {         isControlsHidden = response.isControlsHidden         let viewModel = CreditsModels.ControlsVisibilityWasChanged.ViewModel(isControlsHidden: isControlsHidden,                                                                              isHidden: isHidden)         view?.controlsVisibilityChanged(viewModel: viewModel)     }      func updateTimer(response: CreditsModels.UpdateTimer.Response) {         isTimerStarted = response.shouldTimerStart         let viewModel = CreditsModels.UpdateTimer.ViewModel(skipIntroTimeInterval: skipIntroTimeInterval, isHidden: isHidden)         if isTimerStarted {             view?.startTimer(viewModel: viewModel)         } else {             view?.dropTimer(viewModel: viewModel)         }     }      func menuButtonWasTapped(response: CreditsModels.MenuButtonTapped.Response) {         var buttonType: AIVAnalyticsKeys.ButtonTypes?         var isAutomatically: Bool?          switch creditsType {         case .tail, .tailOnlyNextWithAnimation:             buttonType = .closeAutoplay             isAutomatically = false         case .head, .playNext, .none, .some(.tailOnlyNextWithoutAnimation):             buttonType = nil             isAutomatically = nil         }          var currentViewModel: СreditsViewModel         if isTimerStarted {             currentViewModel = defaultChapter()         } else {             currentViewModel = closingPlayerModel()         }          let viewModel = CreditsModels.MenuButtonTapped.ViewModel(buttonType: buttonType,                                                                  isAutomatically: isAutomatically,                                                                  creditsType: currentViewModel.creditsType,                                                                  nextButtonTitle: currentViewModel.nextButtonTitle,                                                                  toTime: currentViewModel.toTime,                                                                  animationDuration: currentViewModel.animationDuration,                                                                  state: currentViewModel.state,                                                                  isBackgroundPosterHidden: currentViewModel.isBackgroundPosterHidden,                                                                  playerState: currentViewModel.playerState)         view?.menuButtonWasTapped(viewModel: viewModel)     }      func sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response) {         view?.sendButtonsAnalytics(viewModel: CreditsModels.AnalyticsData.ViewModel())     } }

Ну и напоследок посмотрим на Builder, единую точку входа для экрана с кнопками быстрого переключения:

protocol CreditsBuildingLogic: AnyObject {     func makeCreditsModule(delegate: CreditsViewDelegate?) -> CreditsViewController }  final class CreditsBuilder: CreditsBuildingLogic {     func makeCreditsModule(delegate: CreditsViewDelegate?) -> CreditsViewController {         let view = CreditsViewController()         let presenter = CreditsPresenter()         let interactor = CreditsInteractor(with: ChapterWorker(), remoteConfigWorker: AIVRemoteConfigWorker())          interactor.presenter = presenter         presenter.view = view          view.delegate = delegate         view.interactor = interactor          return view     } }

Здесь предлагаю взглянуть на готовую диаграмму классов, что у нас получилась.

Рисунок 14 -  Готовая диаграмма классов после внедрения фичи автоплея
Рисунок 14 — Готовая диаграмма классов после внедрения фичи автоплея

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

Надеюсь, вам эта статья стала для вас интересным и познавательным опытом! Если у вас есть вопросы или замечания – жду вас в комментариях. Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/articles/692074/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *