Всем привет, меня зовут Валерия Рублевская, я 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 - Готовая диаграмма классов после внедрения фичи автоплея

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

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

Комментарии (0)