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

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

8997c1bf6fd2c3bbaeed47452cce7fe5.png

Напомню, что 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.. 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, 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 de
    
            

© Habrahabr.ru