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

Напомню, что Autoplay – это когда по завершению просмотра одного фильма пользователю предлагается посмотреть другой контент, рекомендованный системой. Подробнее о самой фиче ранее рассказывал мой коллега Алексей Мельников в этой статье на Хабре.
Дисклеймер: некоторые сущности специально были упрощены для простоты восприятия, цель статьи – показать общую структуру и подсветить тонкости реализации.
Следует также отметить, что у нас уже были реализованы кнопки пропуска титров и переключения на следующую серию в сериалах.
Так исторически сложилось, что в KION разные репозитории для iOS и tvOS. Проекты развивались неравномерно и без привязки друг к другу, поэтому сформировалась своя, отличная друг от друга, кодовая база. В этой статье я расскажу только про изменения в tvOS.
Для того, чтобы реализовать фичу, нам нужно было понять, когда начинаются титры. Пользователь вряд ли будет смотреть их полностью. Скорее всего, он выйдет из плеера, а возможно, и вообще из приложения. Этого как раз мы пытаемся избежать.
Но ждать, пока мы разметим весь контент, невозможно. Так у нас появилось два сценария показа следующего фильма. Дизайнеры нарисовали такие макеты:


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

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

Где:
PlayerViewControllerProtocol – интерфейс для взаимодействия с плеером;
PlayerControlViewControllerProtocol – интерфейс для взаимодействия с контролами (система управления воспроизведением, постановка на паузу, перемотка);
CreditsViewProtocol – интерфейс для взаимодействия с кнопками быстрого доступа (переключение между сериями, пропуск заставки, переключение на следующий фильм).
Итак, введением дополнительной сущности мы получили класс PlayerViewContainerController, который будет управлять взаимодействиями между этими тремя интерфейсами, а также обеспечит масштабируемость. А добавлять дополнительные фичи в будущем станет проще.
Погружаемся глубже в реализацию переключения на следующую серию и пропуска заставки. Для определения необходимости показа этого функционала мы используем массив сущностей MetaChapter, а также дополнительно закрываем функционал фиче-флагом.
При запросе информации о контенте мы получаем и данные о разметке (начало и конец ��аставки и титров).

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

Давайте разберемся, за что же отвечает CreditsViewController и посредством каких методов мы будем взаимодействовать с ним через наш контейнер.
Этот класс должен:
определять по таймкоду, нашлась ли у нас какая-то разметка;
генерировать кнопки переключения (Пропуск заставки, Следующая серия, Следующий фильм);
показывать/скрывать кнопки переключения;
управлять отображением плеера (сворачивать, разворачивать, скрывать);
управлять перемоткой, включением следующего доступного контента;
показывать постер следующего фильма;
показывать детальную информацию о следующем фильме.
Почти все функции относятся непосредственно к отображению и формированию UI-слоя. Какая-то логика присутствует лишь в одном месте, а это значит, что ее можно вынести вовне. Например, в воркер ChapterWorker, который также можно закрыть интерфейсом ChapterWorkingLogic:

Пройдемся по реализации интерфейса, так как это ключевая логика работы нашей фичи:
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.
На этом вычисляемая часть разметки заканчивается. Далее на основе анализа требований и разделения функционала у нас получился такой интерфейс для взаимодействия контейнера непосредственно с самим модулем автоплея:

Где:
buttonsView – кнопки быстрого доступа, которые используются только для установки правил перемещения фокуса между элементами кнопок и контролов при помощи UIFocusGuide;
updateAutoplayData(...) – метод для обновления разметки контента;
checkCurrentTimeChapter(...) – метод для проверки размечен ли данный временной участок при показанных контролах (если контролы показаны – анимация не нужна);
setCreditsForEndPlayingState() – метод, который вызывается когда контент закончил проигрывание и нужно показать экран автоплея, когда разметки нет или пользователь решил посмотреть титры и досмотрел все до конца;
updateVisibility() – метод для обновления видимости кнопок автоплея;
controlsVisibilityWasChanged(...) – метод, который вызывается когда видимость контролов была изменена (спрятаны или показаны);
menuButtonWasTapped() – метод, который вызывается при нажатии кнопки Меню на пульте;
bringToFront() – метод для возврата view на передний слой.
Но как же общаться модулю автоплея с плеером? Ведь ему тоже нужна возможность управлять его состояниями (скрывать, показывать, уменьшат�� и закрывать), а еще он должен перематывать время, прятать и показывать контролы. Для этого я использую делегат CreditsViewDelegate.

Здесь предлагаю рассмотреть подробнее для чего нужны эти методы делегата:
constantsForPlayerAndDescriptionPosition – переменная, отвечающая за расположение описания следующего фильма (вычисляем, чтоб было в одну линию с плеером);
skipIntroTo(...) – метод для пропуска заставки до указанного в разметке времени;
nextButtonWasPressed(...) – метод нажатия кнопки Следующий контент (фильм, серия и т.п.), автоматически (анимация закончилась) или нет (пользователь нажал сам);
updatePlayerState(...) – метод для обновления состояния плеера (свернуть, развернуть, скрыть, закрыть);
bringViewToFrontAndUpdateFocusIfNeeded() – метод для обновления фокуса;
showControls() – метод для показа контролов для управления плеером;
hideControls() – метод для скрытия контролов для управления плеером;
hideTabBar() – метод для скрытия таббара с настройками (когда заставка с автоплеем показана на весь экран).
Какие состояния необходимы плееру и для чего они используются?

В разработке наша команда использует 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() } }
На этом этапе хотелось бы подвести промежуточный итог, собрать все элементы воедино и взглянуть на получившуюся структуру классов и их взаимодействия между собой.

Так теперь выглядит наш контейнер – посредник между плеером, контролами и автоплеем:
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. Сцена здесь относится к бизнес-логике. Нет никаких конкретных правил о том, что такое сцена, так как каждый проект уникален, – мы можем иметь столько, сколько захотим для каждого проекта.

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

А ниже представлен код самой реализации всех классов:
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 } }
Здесь предлагаю взглянуть на готовую диаграмму классов, что у нас получилась.

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