Всем привет, меня зовут Валерия Рублевская, я iOS-разработчик на проекте онлайн-кинотеатра KION в МТС Digital. Это третья часть рассказа о фиче Autoplay фильмов и сегодня мы поговорим о нюансах ее реализации на tvOS.
Напомню, что Autoplay – это когда по завершению просмотра одного фильма пользователю предлагается посмотреть другой контент, рекомендованный системой. Подробнее о самой фиче ранее рассказывал мой коллега Алексей Мельников в этой статье на Хабре.
Дисклеймер: некоторые сущности специально были упрощены для простоты восприятия, цель статьи – показать общую структуру и подсветить тонкости реализации.
Следует также отметить, что у нас уже были реализованы кнопки пропуска титров и переключения на следующую серию в сериалах.
Так исторически сложилось, что в KION разные репозитории для iOS и tvOS. Проекты развивались неравномерно и без привязки друг к другу, поэтому сформировалась своя, отличная друг от друга, кодовая база. В этой статье я расскажу только про изменения в tvOS.
Для того, чтобы реализовать фичу, нам нужно было понять, когда начинаются титры. Пользователь вряд ли будет смотреть их полностью. Скорее всего, он выйдет из плеера, а возможно, и вообще из приложения. Этого как раз мы пытаемся избежать.
Но ждать, пока мы разметим весь контент, невозможно. Так у нас появилось два сценария показа следующего фильма. Дизайнеры нарисовали такие макеты:
Кнопки пропуска титров к этому времени у нас уже были. Про фичу пропуска титров ранее на Хабре рассказывали мои коллеги Алексей Мельников и Алексей Охрименко.
На макетах видно, что кнопки Смотреть титры и Следующий фильм для полнометражек такие же, как и для сериалов. А значит, этот функционал можно просто переиспользовать. И первая проблема, с которой я сразу же столкнулась, заглянув в реализацию – это то, что интерфейс взаимодействия с плеером PlayerViewController отвечает абсолютно за все: само проигрывание, отображение контролов (средств управления плеером), кнопки быстрого Пропуска заставки и переключения к следующей серии. Это можно увидеть на диаграмме классов ниже.
В некоторых случаях можно увидеть постер следующего фильма на весь экран, поверх отображено описание фильма, при этом сам плеер – в уменьшенном виде, а контролы скрыты. В таком положении мы можем только управлять кнопками на экране, которые предлагают вернуться к просмотру титров или переключиться на следующий фильм.
Делегат, отвечающий за быстрое переключение между сериями – creditsViewDelegate – должен уметь не просто отобразить нужную кнопку вовремя и переключать на следующую серию. Он должен еще управлять состояниями плеера, отображать детальную информацию о следующем фильме и уметь отличать сериал от фильма. Ведь для сериала мы сохраняем текущую логику и без уменьшения плеера предлагаем переключиться на следующую серию.
Для распределения обязанностей между частями кода я решила использовать контейнер, который будет содержать в себе различные модули, разделенные по зонам ответственности. После анализа логики и обязанностей получилась такая примерная диаграмма, с предварительно составленными методами:
Где:
PlayerViewControllerProtocol – интерфейс для взаимодействия с плеером;
PlayerControlViewControllerProtocol – интерфейс для взаимодействия с контролами (система управления воспроизведением, постановка на паузу, перемотка);
CreditsViewProtocol – интерфейс для взаимодействия с кнопками быстрого доступа (переключение между сериями, пропуск заставки, переключение на следующий фильм).
Итак, введением дополнительной сущности мы получили класс PlayerViewContainerController, который будет управлять взаимодействиями между этими тремя интерфейсами, а также обеспечит масштабируемость. А добавлять дополнительные фичи в будущем станет проще.
Погружаемся глубже в реализацию переключения на следующую серию и пропуска заставки. Для определения необходимости показа этого функционала мы используем массив сущностей MetaChapter, а также дополнительно закрываем функционал фиче-флагом.
При запросе информации о контенте мы получаем и данные о разметке (начало и конец заставки и титров).
Введем новую сущность, которая будет реализовывать интерфейс для работы с автоплеем:
Давайте разберемся, за что же отвечает CreditsViewController и посредством каких методов мы будем взаимодействовать с ним через наш контейнер.
Этот класс должен:
определять по таймкоду, нашлась ли у нас какая-то разметка;
генерировать кнопки переключения (Пропуск заставки, Следующая серия, Следующий фильм);
показывать/скрывать кнопки переключения;
управлять отображением плеера (сворачивать, разворачивать, скрывать);
управлять перемоткой, включением следующего доступного контента;
показывать постер следующего фильма;
показывать детальную информацию о следующем фильме.
Почти все функции относятся непосредственно к отображению и формированию UI-слоя. Какая-то логика присутствует лишь в одном месте, а это значит, что ее можно вынести вовне. Например, в воркер ChapterWorker, который также можно закрыть интерфейсом ChapterWorkingLogic:
Пройдемся по реализации интерфейса, так как это ключевая логика работы нашей фичи:
final class ChapterWorker {
private var chapters: [MetaChapter]?
}
extension ChapterWorker: ChapterWorkingLogic {
// обновление чаптеров, необходимо при переключении с фильма на фильм происходящее непосредственно в самом плеере, так вместо создания нового экземпляра класса, мы обновляем лишь чаптеры
func updateCurrentChapters(chapters: [MetaChapter]?) {
self.chapters = chapters
}
// здесь происходит проверка, входит ли текущий проигрываемый момент времени в один из установленных разметкой временных промежутков
func chapter(currentTime: Double) -> MetaChapter? {
let chapter = chapters?.first(where: {
guard let offTimeString = $0.offTime,
let endOffsetTimeString = $0.endOffsetTime,
let offTime = Int(offTimeString),
let endOffsetTime = Int(endOffsetTimeString),
offTime < (endOffsetTime - 1) else { return false }
return (offTime..<endOffsetTime) ~= Int(floor(currentTime))
})
return chapter
}
//здесь происходит определение типа разметки (заставка, титры, разметки не найдено)
func chapterType(chapter: MetaChapter?) -> AIVChaptersType {
guard let title = chapter?.title else {
return .none
}
return AIVChaptersType(rawValue: title) ?? .none
}
}
Еще одна немаловажная часть – то, как и откуда мы знаем что в конкретный момент времени нужно осуществить проверку на наличие разметки. Для этого в EPlayerView был добавлен следующий метод с таймером, который через заданный интервал осуществляет проверку таймкода на нахождение в разметке:
private func addPeriodicTimeObserver() {
if timeObserverToken == nil {
let interval = CMTime(seconds: EPlayerView.periodicTimeInterval, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObserverToken = avPlayer?.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main) { [weak self] _ in
guard let self = self else {
return
}
let presentationSize = self.avPlayer?.currentItem?.presentationSize
self.presentationSizeDelegate?.updated(presentationSize: presentationSize)
self.playbackDelegate?.updated(presentationSize: presentationSize)
self.updatePlaybackDataIfNeeded(for: self.type, self.avPlayer?.currentItem)
self.notifyAboutAccessLogEntryIfNeeded(self.avPlayer?.currentItem)
}
}
}
Подробнее метод проверки и передачи таймкода описан ниже:
private func updatePlaybackDataIfNeeded(for type: EPlayerViewType, _ currentItem: AVPlayerItem?) {
switch type {
case .vod, .trailer:
guard let currentItem = currentItem else {
return
}
delegate?.update(min: 0)
delegate?.update(max: currentItem.duration.seconds)
delegate?.update(current: currentItem.currentTime().seconds)
let currentTimeInSeconds = floor(currentItem.currentTime().seconds)
if floor(chapterTimerCounter) != currentTimeInSeconds {
chapterTimerCounter = currentTimeInSeconds
playbackDelegate?.updateChaptersWithTime(current: currentTimeInSeconds)
}
default:
guard let currentDate = avPlayer?.currentItem?.currentDate() else {
return
}
if let s = startDate {
self.delegate?.update(start: s)
}
if let e = endDate {
self.delegate?.update(end: e)
}
delegate?.update(current: currentDate)
NotificationCenter.default.post(name: .livePlayerDidUpdateTimeNotification,
object: nil,
userInfo: [EPlayerView.keyFTS: currentDate.timeIntervalSince1970])
}
playbackDelegate?.playerPlaybackStateDidChange(to: playbackState)
}
После такой простой проверки, где chapterTimerCounter – счетчик, который нужен для изменения частоты проверки титров, через наш контейнер мы попадаем в контроллер с кнопками для быстрого перехода, в котором и используем выше созданный ChapterWorker.
На этом вычисляемая часть разметки заканчивается. Далее на основе анализа требований и разделения функционала у нас получился такой интерфейс для взаимодействия контейнера непосредственно с самим модулем автоплея:
Где:
buttonsView – кнопки быстрого доступа, которые используются только для установки правил перемещения фокуса между элементами кнопок и контролов при помощи UIFocusGuide;
updateAutoplayData(...) – метод для обновления разметки контента;
checkCurrentTimeChapter(...) – метод для проверки размечен ли данный временной участок при показанных контролах (если контролы показаны – анимация не нужна);
setCreditsForEndPlayingState() – метод, который вызывается когда контент закончил проигрывание и нужно показать экран автоплея, когда разметки нет или пользователь решил посмотреть титры и досмотрел все до конца;
updateVisibility() – метод для обновления видимости кнопок автоплея;
controlsVisibilityWasChanged(...) – метод, который вызывается когда видимость контролов была изменена (спрятаны или показаны);
menuButtonWasTapped() – метод, который вызывается при нажатии кнопки Меню на пульте;
bringToFront() – метод для возврата view на передний слой.
Но как же общаться модулю автоплея с плеером? Ведь ему тоже нужна возможность управлять его состояниями (скрывать, показывать, уменьшать и закрывать), а еще он должен перематывать время, прятать и показывать контролы. Для этого я использую делегат CreditsViewDelegate.
Здесь предлагаю рассмотреть подробнее для чего нужны эти методы делегата:
constantsForPlayerAndDescriptionPosition – переменная, отвечающая за расположение описания следующего фильма (вычисляем, чтоб было в одну линию с плеером);
skipIntroTo(...) – метод для пропуска заставки до указанного в разметке времени;
nextButtonWasPressed(...) – метод нажатия кнопки Следующий контент (фильм, серия и т.п.), автоматически (анимация закончилась) или нет (пользователь нажал сам);
updatePlayerState(...) – метод для обновления состояния плеера (свернуть, развернуть, скрыть, закрыть);
bringViewToFrontAndUpdateFocusIfNeeded() – метод для обновления фокуса;
showControls() – метод для показа контролов для управления плеером;
hideControls() – метод для скрытия контролов для управления плеером;
hideTabBar() – метод для скрытия таббара с настройками (когда заставка с автоплеем показана на весь экран).
Какие состояния необходимы плееру и для чего они используются?
В разработке наша команда использует AutoLayout, поэтому все манипуляции с размерами я провожу при помощи простого изменения констант у констрейнтов, которые я вычисляю для уменьшенной версии в контейнере и затем передаю в экземпляр класса плеера. Ниже можно посмотреть более наглядно, как каждое из состояний обрабатывается непосредственно в коде:
private func updatePlayerDisplaying(state: VODPlayerState) {
guard self.state != state else {
return
}
self.state = state
switch state {
case .normal:
playerView?.isHidden = false
playerView?.cornersRadius = playerDefaultCornerRadius
resetPlayerConstantsToZero()
case .minimized:
playerView?.isHidden = false
playerView?.cornersRadius = playerMinimizedCornerRadius
if let position = containerDelegate?.constantsForPlayerAndDescriptionPosition {
updatePlayerConstants(to: position)
}
case .hidden:
playerView?.isHidden = true
playerView?.cornersRadius = playerDefaultCornerRadius
case .closed:
closePlayer()
}
UIView.animate(withDuration: 0.5) {
self.view.layoutIfNeeded()
}
}
На этом этапе хотелось бы подвести промежуточный итог, собрать все элементы воедино и взглянуть на получившуюся структуру классов и их взаимодействия между собой.
Так теперь выглядит наш контейнер – посредник между плеером, контролами и автоплеем:
final class VodPlayerViewContainerController: BaseViewController {
private var playerViewControllerProtocol: VodPlayerViewControllerProtocol?
private var controlsViewControllerProtocol: EPlayerControlViewControllerProtocol?
private var creditsViewControllerProtocol: CreditsViewProtocol?
private var isFirstCheck: Bool = true
private var isPlayerDataReloaded: Bool = false
private var bottomControlsLayoutConstraint: NSLayoutConstraint?
private let bottomControlsInsetByDefault: CGFloat = 0
public typealias PlayerAndDescriptionPosition = (bottom: CGFloat, leading: CGFloat, top: CGFloat, trailing: CGFloat)
public lazy var constantsForPlayerAndDescriptionPosition: PlayerAndDescriptionPosition = {
let height = view.frame.size.height
let width = view.frame.size.width
let quarter: CGFloat = 0.25
let minHeight = quarter * height
let minWidth = quarter * width
let bottom: CGFloat = 130
let leading = bottom
let top = height - bottom - minHeight
let trailing = width - leading - minWidth
return (bottom, leading, top, trailing)
}()
override var preferredFocusEnvironments: [UIFocusEnvironment] {
if let controlsView = controlsViewControllerProtocol?.controlsViewController.view,
controlsViewControllerProtocol?.isControlsShown == true {
return [controlsView]
}
if let buttonsView = creditsViewProtocol?.buttonsView {
return [buttonsView]
}
return super.preferredFocusEnvironments
}
// MARK: - Life Cycle
init(type: VodPlayerType, recommendationsDelegate: MovieRecommendationsDelegate?, viewWillDimissClosureAtTime: DoubleClosure?) {
super.init(nibName: nil, bundle: nil)
configurePlayerViewController(type: type, recommendationsDelegate: recommendationsDelegate, viewWillDimissClosureAtTime: viewWillDimissClosureAtTime)
configureCreditsView()
addGesturesToView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Overrides
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
switch press.type {
case .playPause:
playerViewControllerProtocol?.playerViewController.pressesBegan(presses, with: event)
default:
super.pressesBegan(presses, with: event)
}
}
}
// MARK: - Actions
@objc func menuButtonAction() {
if controlsViewControllerProtocol?.isControlsShown == true {
controlsViewControllerProtocol?.hideControls()
} else {
creditsViewControllerProtocol?.menuButtonWasTapped()
}
}
// MARK: - Privates
private func configurePlayerViewController(type: VodPlayerType, recommendationsDelegate: MovieRecommendationsDelegate?, viewWillDimissClosureAtTime: DoubleClosure?) {
playerViewControllerProtocol = VodPlayerViewController.instance(type: type,
recommendationsDelegate: recommendationsDelegate,
viewWillDimissClosureAtTime: viewWillDimissClosureAtTime)
playerViewControllerProtocol?.setContainerDelegate(delegate: self)
if let childController = playerViewControllerProtocol?.playerViewController {
add(child: childController)
}
}
private func configureCreditsView() {
let creditsViewController = CreditsBuilder().makeCreditsModule(delegate: self)
creditsViewControllerProtocol.translatesAutoresizingMaskIntoConstraints = false
view.add(child: creditsViewController)
creditsViewControllerProtocol = creditsViewController
}
private func configureControlsViewControllerIfNeeded() {
guard controlsViewControllerProtocol == nil,
let player = playerViewControllerProtocol?.playerView,
let titleModel = playerViewControllerProtocol?.titleViewModel else {
bottomControlsLayoutConstraint?.constant = bottomControlsInsetByDefault
return
}
controlsViewControllerProtocol = EPlayerControlViewController.instance(view: player, titleModel: titleModel)
controlsViewControllerProtocol?.showContentRating = { [weak self] contentRatingImage in
self?.playerViewControllerProtocol?.configureContentRating(image: contentRatingImage)
}
controlsViewControllerProtocol?.setupPlayerControlsDelegate(delegate: self)
player.delegate = controlsViewControllerProtocol?.controlsViewController
if let childController = controlsViewControllerProtocol?.controlsViewController {
childController.view.translatesAutoresizingMaskIntoConstraints = false
addChild(childController)
view.addSubview(childController.view)
childController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
childController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
childController.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
bottomControlsLayoutConstraint = childController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: bottomControlsInsetByDefault)
bottomControlsLayoutConstraint?.isActive = true
childController.didMove(toParent: self)
}
if let controlsViewControllerProtocol = controlsViewControllerProtocol,
let creditsViewProtocol = creditsViewProtocol {
view.addFocusGuide(from: controlsViewControllerProtocol.controlsViewController.view, to: creditsViewProtocol.buttonsView, direction: .top)
view.addFocusGuide(from: creditsViewProtocol.buttonsView, to: controlsViewControllerProtocol.controlsViewController.view, direction: .bottom)
}
}
private func removeControlsViewController() {
if let childController = controlsViewControllerProtocol?.controlsViewController {
remove(child: childController)
controlsViewControllerProtocol = nil
}
}
private func addGesturesToView() {
view.addTapGesture { [weak self] in
self?.playerViewControllerProtocol?.viewDidTap()
if self?.playerViewControllerProtocol?.isControlsShouldBeShown == true {
self?.showControls()
}
}
let menuRecognizer = view.addMenuButtonTap { [weak self] in
self?.menuButtonAction()
}
menuRecognizer.cancelsTouchesInView = true
}
func updateEPlayerData() {
guard let titleModel = playerViewControllerProtocol?.titleViewModel else {
return
}
controlsViewControllerProtocol?.updateTitle(with: titleModel)
}
}
// MARK: - VodPlayerViewContainerControllerProtocol
extension VodPlayerViewContainerController: VodPlayerViewContainerControllerProtocol {
func updateAndConfigureWith(type: VodPlayerType) {
playerViewControllerProtocol?.updateAndConfigureWith(type: type)
}
}
// MARK: - VodContainerDelegate
extension VodPlayerViewContainerController: VodContainerDelegate {
func updateAutoplayData(chapters: [MetaChapter]?, contentModel: VodPlayerViewModel) {
creditsViewControllerProtocol?.updateAutoplayData(chapters: chapters, contentModel: contentModel)
}
func checkCurrentTimeChapter(time: Double) {
creditsViewControllerProtocol?.checkCurrentTimeChapter(time: time, isControlsShown: controlsViewControllerProtocol?.isControlsShown ?? false, isFirstCheck: isFirstCheck)
isFirstCheck = false
}
func handleEndMoviePlaying() {
creditsViewControllerProtocol?.setCreditsForEndPlayingState()
removeControlsViewController()
}
func onboardingIsShown(isShown: Bool) {
creditsViewControllerProtocol?.updateVisibility(isHidden: isShown)
}
func close() {
dismiss(animated: true)
}
func dismissControls() {
removeControlsViewController()
}
func playingContentDataDidUpdate() {
isPlayerDataReloaded = true
updateEPlayerData()
}
}
// MARK: - PlayerControlsProtocol
extension VodPlayerViewContainerController: PlayerControlsProtocol {
func sliderInProgress(isInProgress: Bool) {
creditsViewControllerProtocol?.updateVisibility(isHidden: isInProgress)
}
func controlsWasShown() {
creditsViewControllerProtocol?.controlsVisibilityWasChanged(isControlsHidden: false)
creditsViewControllerProtocol?.bringToFront()
}
func controlsWasHidden() {
creditsViewControllerProtocol?.controlsVisibilityWasChanged(isControlsHidden: true)
similarInPlayerViewProtocol?.hideSimilarShelfIfNeeded()
}
}
// MARK: - CreditsViewDelegate
extension VodPlayerViewContainerController: CreditsViewDelegate {
func skipIntroTo(time: Double) {
MovieStoriesManager.shared.currentChapterInFilmPlayMode?.wasActivated = true
playerViewControllerProtocol?.playerView?.rewind(time)
}
func nextButtonWasPressed(isAuto: Bool) {
MovieStoriesManager.shared.currentTime = 0
playerViewControllerProtocol?.playerViewModel.playNext(isAuto: isAuto)
}
func updatePlayerState(state: VODPlayerState) {
playerViewControllerProtocol?.updatePlayerDisplaying(state: state)
}
func bringViewToFrontAndUpdateFocusIfNeeded() {
creditsViewControllerProtocol?.bringToFront()
setNeedsFocusUpdate()
updateFocusIfNeeded()
}
func showControls() {
configureControlsViewControllerIfNeeded()
configureSimilarInPlayerViewIfNeeded()
controlsViewControllerProtocol?.showControlsIfNeeded()
creditsViewControllerProtocol?.controlsVisibilityWasChanged(isControlsHidden: false)
isPlayerDataReloaded = false
setNeedsFocusUpdate()
updateFocusIfNeeded()
}
func hideControls() {
dismissControls()
}
func hideTabBar() {
if let tabBarController = presentedViewController as? ExpandableTabBarController {
tabBarController.dismiss()
}
}
}
Новый модуль автоплея было решено написать при помощи новой же архитектуры VIP. В будущем все приложение перейдет на эту архитектуру, а вы сможете почитать о ней подробнее в нашей новой статье. А пока расскажу кратко.
В VIP-архитектуре приложение состоит из множества сцен, и каждая сцена следует циклу VIP. Сцена здесь относится к бизнес-логике. Нет никаких конкретных правил о том, что такое сцена, так как каждый проект уникален, – мы можем иметь столько, сколько захотим для каждого проекта.
Поток данных VIP Architecture – однонаправленный. ViewController получает данные от пользователей и передает их в Interactor в виде запроса. Затем Interactor обрабатывает (например, проверяет данные пользователей с помощью вызова API) и передает данные Presenter в качестве ответа. Presenter обрабатывает (например, делает проверку данных, то есть номер телефона, адрес электронной почты) и передает данные в ViewController.
Вернемся к нашей сцене с автоплеем и кнопками быстрого доступа. Вот как это должно выглядеть на схеме:
А ниже представлен код самой реализации всех классов:
final class CreditsViewController: UIViewController {
weak var delegate: CreditsViewDelegate?
var interactor: CreditsBusinessLogic?
private var isCreditsHidden: Bool = true
private var isControlsHidden: Bool = true
private var headCreditsHideTimer: Timer?
private var topDescriptionStackConstraint: NSLayoutConstraint?
private var bottomDescriptionStackConstraint: NSLayoutConstraint?
private var posterBackgroundImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.isHidden = true
return imageView
}()
private let gradientSublayer: CAGradientLayer = {
let layer = CAGradientLayer()
layer.colors = [
UIColor.clear.cgColor,
UIColor.black.cgColor
]
layer.locations = [0, 0.98]
return layer
}()
private lazy var descriptionStack: NextSimilarContentDescriptionStack = {
let stack = NextSimilarContentDescriptionStack()
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
lazy var buttonsView: CreditsButtonsView = {
let view: CreditsButtonsView = .instanceFromNib()!
view.translatesAutoresizingMaskIntoConstraints = false
view.delegate = self
return view
}()
override var preferredFocusEnvironments: [UIFocusEnvironment] {
[buttonsView]
}
// MARK: - Life Cycle
deinit {
dropHeadCreditsHideTimer()
}
// MARK: - Overrides
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
interactor?.updateGradientFrame(request: CreditsModels.GradientFrameUpdates.Request(frame: posterBackgroundImageView.frame))
}
// MARK: - Privates
private func setupViewsIfNeeded() {
guard posterBackgroundImageView.superview == nil else {
return
}
// постер добавляем в родительский стек, чтоб не было проблем с перемещением фокуса кнопок, так как постер должен находиться позади контроллера плеера
view.superview?.addSubview(posterBackgroundImageView)
posterBackgroundImageView.bindToSuperviewBounds()
posterBackgroundImageView.layer.addSublayer(gradientSublayer)
view.addSubview(buttonsView)
buttonsView.bindToSuperviewBounds()
if let position = delegate?.constantsForPlayerAndDescriptionPosition {
setupDescriptionStackConstraints(position: position)
}
}
private func setupDescriptionStackConstraints(position: VodPlayerViewContainerController.PlayerAndDescriptionPosition) {
view.addSubview(descriptionStack)
topDescriptionStackConstraint = descriptionStack.topAnchor.constraint(equalTo: view.topAnchor, constant: position.top)
topDescriptionStackConstraint?.isActive = true
bottomDescriptionStackConstraint = descriptionStack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -position.bottom - 100)
bottomDescriptionStackConstraint?.isActive = false
descriptionStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -90).isActive = true
descriptionStack.leadingAnchor.constraint(equalTo: view.trailingAnchor, constant: -position.trailing + 25).isActive = true
}
private func updateDescriptionStackPosition(onlyTitle: Bool) {
topDescriptionStackConstraint?.isActive = !onlyTitle
bottomDescriptionStackConstraint?.isActive = onlyTitle
view.layoutIfNeeded()
}
private func updateCreditsView(creditsType: AIVCreditsType?,
nextButtonTitle: AIVCreditsNextButtonTitle?,
toTime: Double?,
animationDuration: Int,
state: NextSimilarContentDescriptionStack.NextSimilarContentDescriptionState,
isBackgroundPosterHidden: Bool,
playerState: VODPlayerState) {
posterBackgroundImageView.isHidden = isBackgroundPosterHidden
view.superview?.sendSubviewToBack(posterBackgroundImageView)
descriptionStack.configure(state: state)
updateDescriptionStackPosition(onlyTitle: state == .shownOnlyTitle)
delegate?.updatePlayerState(state: playerState)
updateButtonsView(creditsType: creditsType,
nextButtonTitle: nextButtonTitle,
toTime: toTime,
animationDuration: animationDuration)
сreditsTypeDidUpdate(creditsType: creditsType)
view.isHidden = isCreditsHidden
}
private func updateButtonsView(creditsType: AIVCreditsType?,
nextButtonTitle: AIVCreditsNextButtonTitle?,
toTime: Double?,
animationDuration: Int) {
buttonsView.configure(nextButtonTitle: nextButtonTitle)
buttonsView.configure(state: creditsType,
endTime: toTime,
animationDuration: animationDuration)
view.bringSubviewToFront(buttonsView)
}
private func сreditsTypeDidUpdate(creditsType: AIVCreditsType?) {
switch creditsType {
case .head where isControlsHidden:
delegate?.bringViewToFrontAndUpdateFocusIfNeeded()
updateFocus()
case .tail, .tailOnlyNextWithAnimation:
delegate?.hideTabBar()
delegate?.bringViewToFrontAndUpdateFocusIfNeeded()
updateFocus()
case .some(.head), .tailOnlyNextWithoutAnimation, .playNext, .none:
break
}
}
private func updateFocus() {
setNeedsFocusUpdate()
updateFocusIfNeeded()
}
private func forceChangeVisibility(isHidden: Bool) {
isCreditsHidden = isHidden
view.isHidden = isCreditsHidden
}
private func dropHeadCreditsHideTimer() {
interactor?.updateTimer(request: CreditsModels.UpdateTimer.Request(shouldTimerStart: false))
}
}
// MARK: - CreditsDisplayLogic
extension CreditsView: CreditsDisplayLogic {
func updateData(viewModel: CreditsModels.UpdateAutoplayData.ViewModel) {
setupViewsIfNeeded()
descriptionStack.configureDescription(info: viewModel.info)
posterBackgroundImageView.loadImage(path: viewModel.path, size: bounds.size)
}
func moveView(viewModel: CreditsModels.MoveView.ViewModel) {
UIView.animate(withDuration: 0.5) {
self.view.transform = viewModel.transform
}
}
func updateGradientFrame(viewModel: CreditsModels.GradientFrameUpdates.ViewModel) {
gradientSublayer.frame = viewModel.frame
}
func bringSubviewToFront(viewModel: CreditsModels.LayoutUpdates.ViewModel) {
view.superview?.bringSubviewToFront(self)
}
func updateCreditsView(viewModel: CreditsModels.SearchCurrentChapter.ViewModel) {
isCreditsHidden = viewModel.isHidden
if viewModel.shouldShowControls {
delegate?.showControls()
}
updateCreditsView(creditsType: viewModel.creditsType,
nextButtonTitle: viewModel.nextButtonTitle,
toTime: viewModel.toTime,
animationDuration: viewModel.animationDuration,
state: viewModel.state,
isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,
playerState: viewModel.playerState)
}
func updateCreditsView(viewModel: CreditsModels.UpdateCreditsType.ViewModel) {
isCreditsHidden = viewModel.isHidden
updateCreditsView(creditsType: viewModel.creditsType,
nextButtonTitle: viewModel.nextButtonTitle,
toTime: viewModel.toTime,
animationDuration: viewModel.animationDuration,
state: viewModel.state,
isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,
playerState: viewModel.playerState)
}
func updateVisibility(viewModel: CreditsModels.UpdateVisibility.ViewModel) {
isCreditsHidden = viewModel.isHidden
updateCreditsView(creditsType: viewModel.creditsType,
nextButtonTitle: viewModel.nextButtonTitle,
toTime: viewModel.toTime,
animationDuration: viewModel.animationDuration,
state: viewModel.state,
isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,
playerState: viewModel.playerState)
}
func controlsVisibilityChanged(viewModel: CreditsModels.ControlsVisibilityWasChanged.ViewModel) {
isControlsHidden = viewModel.isControlsHidden
forceChangeVisibility(isHidden: viewModel.isHidden)
}
func showCredits(viewModel: CreditsModels.UpdateCreditsType.ViewModel) {
isCreditsHidden = viewModel.isHidden
updateCreditsView(creditsType: viewModel.creditsType,
nextButtonTitle: viewModel.nextButtonTitle,
toTime: viewModel.toTime,
animationDuration: viewModel.animationDuration,
state: viewModel.state,
isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,
playerState: viewModel.playerState)
delegate?.hideControls()
}
func playNext(viewModel: CreditsModels.NextButtonDidPress.ViewModel) {
isCreditsHidden = viewModel.isHidden
updateCreditsView(creditsType: viewModel.creditsType,
nextButtonTitle: viewModel.nextButtonTitle,
toTime: viewModel.toTime,
animationDuration: viewModel.animationDuration,
state: viewModel.state,
isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,
playerState: viewModel.playerState)
delegate?.nextButtonWasPressed(isAuto: viewModel.isAuto)
}
func skipIntro(viewModel: CreditsModels.SkipIntro.ViewModel) {
dropHeadCreditsHideTimer()
delegate?.skipIntroTo(time: viewModel.time)
delegate?.hideControls()
}
func startTimer(viewModel: CreditsModels.UpdateTimer.ViewModel) {
headCreditsHideTimer?.invalidate()
headCreditsHideTimer = Timer.scheduledTimer(timeInterval: viewModel.skipIntroTimeInterval,
target: self,
selector: #selector(skipTimerAction),
userInfo: nil,
repeats: true)
forceChangeVisibility(isHidden: viewModel.isHidden)
}
func dropTimer(viewModel: CreditsModels.UpdateTimer.ViewModel) {
headCreditsHideTimer?.invalidate()
headCreditsHideTimer = nil
forceChangeVisibility(isHidden: viewModel.isHidden)
}
func menuButtonWasTapped(viewModel: CreditsModels.MenuButtonTapped.ViewModel) {
interactor?.sendMenuButtonAnalytics(request: CreditsModels.AnalyticsData.Request(buttonType: viewModel.buttonType, isAutomatically: viewModel.isAutomatically))
if viewModel.creditsType != nil {
skipTimerAction()
} else {
updateCreditsView(creditsType: viewModel.creditsType,
nextButtonTitle: viewModel.nextButtonTitle,
toTime: viewModel.toTime,
animationDuration: viewModel.animationDuration,
state: viewModel.state,
isBackgroundPosterHidden: viewModel.isBackgroundPosterHidden,
playerState: viewModel.playerState)
}
}
func sendButtonsAnalytics(viewModel: CreditsModels.AnalyticsData.ViewModel) {
// нужно для завершения цикла (аналитика)
}
}
// MARK: - CreditsDelegate
extension CreditsView: CreditsViewProtocol {
func updateAutoplayData(chapters: [MetaChapter]?, contentModel: VodPlayerViewModel) {
let request = CreditsModels.UpdateAutoplayData.Request(chapters: chapters,
contentModel: contentModel)
interactor?.updateData(request: request)
}
func checkCurrentTimeChapter(time: Double, isControlsShown: Bool, isFirstCheck: Bool) {
let request = CreditsModels.SearchCurrentChapter.Request(currentTime: time,
isControlsShown: isControlsShown,
isFirstCheck: isFirstCheck)
interactor?.findCurrentChapter(request: request)
}
func setCreditsForEndPlayingState() {
interactor?.didContentEnd(request: CreditsModels.UpdateCreditsType.Request())
}
func updateVisibility(isHidden: Bool) {
let request = CreditsModels.UpdateVisibility.Request(shouldBeHidden: isHidden)
interactor?.visibilityShouldBeChanged(request: request)
}
func controlsVisibilityWasChanged(isControlsHidden: Bool) {
let request = CreditsModels.ControlsVisibilityWasChanged.Request(isControlsHidden: isControlsHidden)
interactor?.controlsVisibilityChanged(request: request)
}
func menuButtonWasTapped() {
interactor?.menuButtonWasTapped(request: CreditsModels.MenuButtonTapped.Request())
}
func moveCreditsView(inset: CGFloat) {
let request = CreditsModels.MoveView.Request(inset: inset)
interactor?.moveView(request: request)
}
func bringToFront() {
interactor?.bringSubviewToFront(request: CreditsModels.LayoutUpdates.Request())
}
}
// MARK: - CreditsButtonsViewDelegate
extension CreditsView: CreditsButtonsViewDelegate {
func skipIntroTo(time: Double) {
interactor?.skipIntro(request: CreditsModels.SkipIntro.Request(time: time))
}
func showCredits() {
interactor?.showCredits(request: CreditsModels.UpdateCreditsType.Request())
}
func playNext(isAuto: Bool) {
interactor?.playNext(request: CreditsModels.NextButtonDidPress.Request(isAuto: isAuto))
}
func startSkipIntroTimerIfNeeded() {
interactor?.updateTimer(request: CreditsModels.UpdateTimer.Request(shouldTimerStart: true))
}
@objc func skipTimerAction() {
dropHeadCreditsHideTimer()
}
func sendButtonShowsAnalytics(buttonType: AIVAnalyticsKeys.ButtonTypes?) {
let request = CreditsModels.AnalyticsData.Request(buttonType: buttonType, isAutomatically: nil)
interactor?.sendButtonShowsAnalytics(request: request)
}
func sendButtonWasTappedAnalytics(buttonType: AIVAnalyticsKeys.ButtonTypes?, isAutomatically: Bool) {
let request = CreditsModels.AnalyticsData.Request(buttonType: buttonType, isAutomatically: isAutomatically)
interactor?.sendButtonWasTappedAnalytics(request: request)
}
}
Теперь заглянем в Interactor, здесь у нас преимущественно реализована отсылка аналитики и конечно взаимодействие с Worker поиска разметки:
final class CreditsInteractor {
var presenter: CreditsPresentationLogic?
private var chapterWorker: ChapterWorkingLogic?
private var remoteConfigWorker: AIVRemoteConfigWorkerLogic?
private var analyticsEventForCurrent: Analytics.PlaybackButtonsEvent?
private var analyticsEventForRecommended: Analytics.PlaybackButtonsEvent?
private (set) var animationDuration: Int = 0
init(with chapterWorker: ChapterWorkingLogic, remoteConfigWorker: AIVRemoteConfigWorkerLogic) {
self.chapterWorker = chapterWorker
self.remoteConfigWorker = remoteConfigWorker
}
func creditsContentType(contentModel: VodPlayerViewModel) -> ContentType {
switch contentModel.type {
case .vod:
return .movie
case .serial:
return .serial(serialInfo: VideoDetailViewModel.SerialInfo())
case .trailer, .none:
return .none
}
}
}
// MARK: - CreditsBusinessLogic
extension CreditsInteractor: CreditsBusinessLogic {
func updateGradientFrame(request: CreditsModels.GradientFrameUpdates.Request) {
presenter?.updateGradientFrame(response: CreditsModels.GradientFrameUpdates.Response(frame: request.frame))
}
func moveView(request: CreditsModels.MoveView.Request) {
presenter?.moveView(response: CreditsModels.MoveView.Response(inset: request.inset))
}
func bringSubviewToFront(request: CreditsModels.LayoutUpdates.Request) {
presenter?.bringSubviewToFront(response: CreditsModels.LayoutUpdates.Response())
}
func updateData(request: CreditsModels.UpdateAutoplayData.Request) {
guard let remoteConfigWorker = remoteConfigWorker else {
return
}
analyticsEventForCurrent = request.contentModel.analyticsEventForCurrentContent
analyticsEventForRecommended = request.contentModel.analyticsEventForRecommendedContent
chapterWorker?.updateCurrentChapters(chapters: request.chapters)
let currentContentType = creditsContentType(contentModel: request.contentModel)
animationDuration = remoteConfigWorker.durationForAnimation(currentContentType: currentContentType)
let delegate = request.contentModel.recommendationsDelegate
let isMoviesAutoplayShouldBeShown = remoteConfigWorker.isMoviesAutoplayFunctionalityEnabled && delegate?.firstRecommendedVod != nil
let currentContentTypeResponse = CreditsModels.UpdateAutoplayData.Response(currentContentType: currentContentType,
isMoviesAutoplayShouldBeShown: isMoviesAutoplayShouldBeShown,
info: delegate?.viewModelForDescripton(),
path: delegate?.pathForPoster())
presenter?.updateData(response: currentContentTypeResponse)
}
func findCurrentChapter(request: CreditsModels.SearchCurrentChapter.Request) {
guard let chapterWorker = chapterWorker else {
return
}
let chapter = chapterWorker.chapter(currentTime: request.currentTime)
let response = CreditsModels.SearchCurrentChapter.Response(chapter: chapter,
isControlsShown: request.isControlsShown,
isFirstCheck: request.isFirstCheck,
chapterType: chapterWorker.chapterType(chapter: chapter),
animationDuration: animationDuration)
presenter?.didFindChapter(response: response)
}
func skipIntro(request: CreditsModels.SkipIntro.Request) {
presenter?.skipIntro(response: CreditsModels.SkipIntro.Response(time: request.time))
}
func didContentEnd(request: CreditsModels.UpdateCreditsType.Request) {
let response = CreditsModels.UpdateCreditsType.Response(animationDuration: animationDuration)
presenter?.didContentEnd(response: response)
}
func showCredits(request: CreditsModels.UpdateCreditsType.Request) {
let response = CreditsModels.UpdateCreditsType.Response(animationDuration: animationDuration)
presenter?.showCredits(response: response)
}
func playNext(request: CreditsModels.NextButtonDidPress.Request) {
let response = CreditsModels.NextButtonDidPress.Response(isAuto: request.isAuto,
animationDuration: animationDuration)
presenter?.playNext(response: response)
}
func visibilityShouldBeChanged(request: CreditsModels.UpdateVisibility.Request) {
let response = CreditsModels.UpdateVisibility.Response(shouldBeHidden: request.shouldBeHidden,
animationDuration: animationDuration)
presenter?.visibilityShouldBeChanged(response: response)
}
func controlsVisibilityChanged(request: CreditsModels.ControlsVisibilityWasChanged.Request) {
let response = CreditsModels.ControlsVisibilityWasChanged.Response(isControlsHidden: request.isControlsHidden)
presenter?.controlsVisibilityChanged(response: response)
}
func updateTimer(request: CreditsModels.UpdateTimer.Request) {
presenter?.updateTimer(response: CreditsModels.UpdateTimer.Response(shouldTimerStart: request.shouldTimerStart))
}
func menuButtonWasTapped(request: CreditsModels.MenuButtonTapped.Request) {
presenter?.menuButtonWasTapped(response: CreditsModels.MenuButtonTapped.Response())
}
// MARK: - Analytics
func sendMenuButtonAnalytics(request: CreditsModels.AnalyticsData.Request) {
presenter?.sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response())
if let type = request.buttonType {
AnalyticsManager.shared.sendAutoplayButtonWasTapped(buttonType: type, event: analyticsEventForCurrent, isAutomatically: request.isAutomatically)
}
}
func sendButtonShowsAnalytics(request: CreditsModels.AnalyticsData.Request) {
presenter?.sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response())
var event: Analytics.PlaybackButtonsEvent?
switch request.buttonType {
case .nextMovie:
event = analyticsEventForRecommended
case .skipIntro, .nextEpisode, .some(.showCredits), .closeAutoplay, .none:
event = analyticsEventForCurrent
}
AnalyticsManager.shared.sendAutoplayButtonWasShown(buttonType: request.buttonType, event: event)
}
func sendButtonWasTappedAnalytics(request: CreditsModels.AnalyticsData.Request) {
presenter?.sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response())
AnalyticsManager.shared.sendAutoplayButtonWasTapped(buttonType: request.buttonType, event: analyticsEventForCurrent, isAutomatically: request.isAutomatically)
}
}
Последняя часть – это наш Presenter, который и определяет, как именно должно выглядеть представление на экране пользователя:
final class CreditsPresenter {
private typealias СreditsViewModel = (creditsType: AIVCreditsType?,
nextButtonTitle: AIVCreditsNextButtonTitle?,
toTime: Double?,
animationDuration: Int,
state: NextSimilarContentDescriptionStack.NextSimilarContentDescriptionState,
isBackgroundPosterHidden: Bool,
playerState: VODPlayerState)
weak var view: CreditsDisplayLogic?
private let skipIntroTimeInterval: TimeInterval = 5
private var currentContentType: ContentType?
private var currentChapterID: String?
private var posterPath: String?
private var isMoviesAutoplayShouldBeShown: Bool = true
private var didContentEnd: Bool = false
private var shouldBeHidden: Bool = false
private var isControlsHidden: Bool = true
private var isTimerStarted: Bool = false
private var creditsType: AIVCreditsType?
private lazy var currentCreditsViewModel: CreditsPresenter.СreditsViewModel = defaultChapter()
private var isHidden: Bool {
if shouldBeHidden {
return true
}
if isControlsHidden &&
(creditsType == .tail ||
creditsType == .tailOnlyNextWithAnimation ||
isTimerStarted) {
return false
}
return isControlsHidden || creditsType == nil
}
private func tailCredits(animationDuration: Int, isControlsShown: Bool, isFirstCheck: Bool) -> СreditsViewModel {
switch currentContentType {
case .serial:
// TODO: добавить проверку на флаг с бэка
guard MovieStoriesManager.shared.needsToShowTailCredit else {
return defaultChapter()
}
return tailCreditsModelForSerials(animationDuration: animationDuration, isControlsInFocus: isControlsShown)
case .movie:
guard isMoviesAutoplayShouldBeShown else {
return defaultChapter()
}
return tailCreditsModelForMovies(animationDuration: animationDuration, isControlsInFocus: isControlsShown)
case nil, .some(.none):
return defaultChapter()
}
}
private func headCredits(endTime: Double) -> СreditsViewModel {
creditsType = .head
return СreditsViewModel(creditsType: creditsType,
nextButtonTitle: nil,
toTime: endTime,
animationDuration: 0,
state: .hidden,
isBackgroundPosterHidden: true,
playerState: .normal)
}
private func tailCreditsModelForSerials(animationDuration: Int, isControlsInFocus: Bool) -> СreditsViewModel {
creditsType = isControlsInFocus ? .tailOnlyNextWithoutAnimation : .tail
return СreditsViewModel(creditsType: creditsType,
nextButtonTitle: .nextEpisode,
toTime: nil,
animationDuration: animationDuration,
state: .hidden,
isBackgroundPosterHidden: false,
playerState: .normal)
}
private func tailCreditsModelForMovies(animationDuration: Int, isControlsInFocus: Bool) -> СreditsViewModel {
creditsType = isControlsInFocus ? .tailOnlyNextWithoutAnimation : .tail
return СreditsViewModel(creditsType: creditsType,
nextButtonTitle: .nextMovie,
toTime: nil,
animationDuration: animationDuration,
state: isControlsInFocus ? .shownOnlyTitle : .shownAll,
isBackgroundPosterHidden: isControlsInFocus,
playerState: isControlsInFocus ? .normal : .minimized)
}
private func defaultChapter() -> СreditsViewModel {
creditsType = nil
return СreditsViewModel(creditsType: creditsType,
nextButtonTitle: nil,
toTime: nil,
animationDuration: 0,
state: .hidden,
isBackgroundPosterHidden: true,
playerState: .normal)
}
private func closingPlayerModel() -> СreditsViewModel {
creditsType = nil
return СreditsViewModel(creditsType: creditsType,
nextButtonTitle: nil,
toTime: nil,
animationDuration: 0,
state: .hidden,
isBackgroundPosterHidden: true,
playerState: .closed)
}
private func nextMovieModel(type: AIVCreditsType?, animationDuration: Int) -> СreditsViewModel {
creditsType = type
return СreditsViewModel(creditsType: creditsType,
nextButtonTitle: .nextMovie,
toTime: nil,
animationDuration: animationDuration,
state: .shownAll,
isBackgroundPosterHidden: false,
playerState: .hidden)
}
}
extension CreditsPresenter: CreditsPresentationLogic {
func updateGradientFrame(response: CreditsModels.GradientFrameUpdates.Response) {
view?.updateGradientFrame(viewModel: CreditsModels.GradientFrameUpdates.ViewModel(frame: response.frame))
}
func moveView(response: CreditsModels.MoveView.Response) {
let viewModel = CreditsModels.MoveView.ViewModel(transform: CGAffineTransform(translationX: 0, y: response.inset))
view?.moveView(viewModel: viewModel)
}
func bringSubviewToFront(response: CreditsModels.LayoutUpdates.Response) {
view?.bringSubviewToFront(viewModel: CreditsModels.LayoutUpdates.ViewModel())
}
func updateData(response: CreditsModels.UpdateAutoplayData.Response) {
didContentEnd = false
currentContentType = response.currentContentType
isMoviesAutoplayShouldBeShown = response.isMoviesAutoplayShouldBeShown
let viewModel = CreditsModels.UpdateAutoplayData.ViewModel(info: response.info, path: response.path)
view?.updateData(viewModel: viewModel)
}
func didFindChapter(response: CreditsModels.SearchCurrentChapter.Response) {
guard currentChapterID != response.chapter?.ID else {
return
}
// если воспроизведение контента началось на разметке автоплея - поведение должно быть как при открытых контролах и затем принудительно показываем контролы
let isControlsShown = response.isFirstCheck ? true : response.isControlsShown
currentChapterID = response.chapter?.ID
switch response.chapterType {
case .headCredit:
guard let endOffsetTime = response.chapter?.endOffsetTime?.doubleValue else {
return
}
currentCreditsViewModel = headCredits(endTime: endOffsetTime)
case .tailCredit:
currentCreditsViewModel = tailCredits(animationDuration: response.animationDuration,
isControlsShown: isControlsShown || shouldBeHidden,
isFirstCheck: response.isFirstCheck)
case nil, .some(.movieStorySuperEpisodeChapter), .some(.none):
if let type = creditsType,
AIVCreditsType.tailsChaptersWithAnimation.contains(type) {
// если текущая разметка с анимацией, даем анимации доиграть до конца, чаптеры не обнуляем
return
}
currentCreditsViewModel = defaultChapter()
}
let shouldShowControls = response.isFirstCheck && creditsType == .tailOnlyNextWithoutAnimation
let viewModel = CreditsModels.SearchCurrentChapter.ViewModel(creditsType: currentCreditsViewModel.creditsType,
nextButtonTitle: currentCreditsViewModel.nextButtonTitle,
toTime: currentCreditsViewModel.toTime,
animationDuration: currentCreditsViewModel.animationDuration,
state: currentCreditsViewModel.state,
isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,
playerState: currentCreditsViewModel.playerState,
isHidden: isHidden,
shouldShowControls: shouldShowControls)
view?.updateCreditsView(viewModel: viewModel)
}
func skipIntro(response: CreditsModels.SkipIntro.Response) {
view?.skipIntro(viewModel: CreditsModels.SkipIntro.ViewModel(time: response.time))
}
func didContentEnd(response: CreditsModels.UpdateCreditsType.Response) {
didContentEnd = true
if isMoviesAutoplayShouldBeShown {
creditsType = shouldBeHidden ? .tailOnlyNextWithoutAnimation : .tailOnlyNextWithAnimation
currentCreditsViewModel = nextMovieModel(type: creditsType, animationDuration: response.animationDuration)
} else {
// если автоплей выключен, не закрываем плеер, пока полка открыта
if shouldBeHidden {
return
}
currentCreditsViewModel = closingPlayerModel()
}
let viewModel = CreditsModels.UpdateCreditsType.ViewModel(creditsType: currentCreditsViewModel.creditsType,
nextButtonTitle: currentCreditsViewModel.nextButtonTitle,
toTime: currentCreditsViewModel.toTime,
animationDuration: currentCreditsViewModel.animationDuration,
state: currentCreditsViewModel.state,
isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,
playerState: currentCreditsViewModel.playerState,
isHidden: isHidden)
view?.updateCreditsView(viewModel: viewModel)
}
func showCredits(response: CreditsModels.UpdateCreditsType.Response) {
let creditsViewModel = tailCredits(animationDuration: 0, isControlsShown: true, isFirstCheck: false)
currentCreditsViewModel = creditsViewModel
let viewModel = CreditsModels.UpdateCreditsType.ViewModel(creditsType: currentCreditsViewModel.creditsType,
nextButtonTitle: currentCreditsViewModel.nextButtonTitle,
toTime: currentCreditsViewModel.toTime,
animationDuration: currentCreditsViewModel.animationDuration,
state: currentCreditsViewModel.state,
isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,
playerState: currentCreditsViewModel.playerState,
isHidden: isHidden)
view?.showCredits(viewModel: viewModel)
}
func playNext(response: CreditsModels.NextButtonDidPress.Response) {
let creditsViewModel = defaultChapter()
currentCreditsViewModel = creditsViewModel
let viewModel = CreditsModels.NextButtonDidPress.ViewModel(isAuto: response.isAuto,
creditsType: currentCreditsViewModel.creditsType,
nextButtonTitle: currentCreditsViewModel.nextButtonTitle,
toTime: currentCreditsViewModel.toTime,
animationDuration: currentCreditsViewModel.animationDuration,
state: currentCreditsViewModel.state,
isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,
playerState: currentCreditsViewModel.playerState,
isHidden: isHidden)
view?.playNext(viewModel: viewModel)
}
func visibilityShouldBeChanged(response: CreditsModels.UpdateVisibility.Response) {
shouldBeHidden = response.shouldBeHidden
// когда полка закрылась и контент доиграл до конца - нужно обновить экран автоплея
if !shouldBeHidden, didContentEnd {
switch creditsType {
case .tailOnlyNextWithoutAnimation:
// если есть автоплей по окончанию контента, по закрытии полки похожих возобновляем анимацию со счетчиком на кнопке автоплея
currentCreditsViewModel = nextMovieModel(type: .tailOnlyNextWithAnimation, animationDuration: response.animationDuration)
case .tailOnlyNextWithAnimation, .tail, .playNext, .head, .none:
// если автоплей выключен по окончанию контента, по закрытии полки похожих закрываем плеер
if !isMoviesAutoplayShouldBeShown {
currentCreditsViewModel = closingPlayerModel()
}
}
}
let viewModel = CreditsModels.UpdateVisibility.ViewModel(creditsType: currentCreditsViewModel.creditsType,
nextButtonTitle: currentCreditsViewModel.nextButtonTitle,
toTime: currentCreditsViewModel.toTime,
animationDuration: currentCreditsViewModel.animationDuration,
state: currentCreditsViewModel.state,
isBackgroundPosterHidden: currentCreditsViewModel.isBackgroundPosterHidden,
playerState: currentCreditsViewModel.playerState,
isHidden: isHidden)
view?.updateVisibility(viewModel: viewModel)
}
func controlsVisibilityChanged(response: CreditsModels.ControlsVisibilityWasChanged.Response) {
isControlsHidden = response.isControlsHidden
let viewModel = CreditsModels.ControlsVisibilityWasChanged.ViewModel(isControlsHidden: isControlsHidden,
isHidden: isHidden)
view?.controlsVisibilityChanged(viewModel: viewModel)
}
func updateTimer(response: CreditsModels.UpdateTimer.Response) {
isTimerStarted = response.shouldTimerStart
let viewModel = CreditsModels.UpdateTimer.ViewModel(skipIntroTimeInterval: skipIntroTimeInterval, isHidden: isHidden)
if isTimerStarted {
view?.startTimer(viewModel: viewModel)
} else {
view?.dropTimer(viewModel: viewModel)
}
}
func menuButtonWasTapped(response: CreditsModels.MenuButtonTapped.Response) {
var buttonType: AIVAnalyticsKeys.ButtonTypes?
var isAutomatically: Bool?
switch creditsType {
case .tail, .tailOnlyNextWithAnimation:
buttonType = .closeAutoplay
isAutomatically = false
case .head, .playNext, .none, .some(.tailOnlyNextWithoutAnimation):
buttonType = nil
isAutomatically = nil
}
var currentViewModel: СreditsViewModel
if isTimerStarted {
currentViewModel = defaultChapter()
} else {
currentViewModel = closingPlayerModel()
}
let viewModel = CreditsModels.MenuButtonTapped.ViewModel(buttonType: buttonType,
isAutomatically: isAutomatically,
creditsType: currentViewModel.creditsType,
nextButtonTitle: currentViewModel.nextButtonTitle,
toTime: currentViewModel.toTime,
animationDuration: currentViewModel.animationDuration,
state: currentViewModel.state,
isBackgroundPosterHidden: currentViewModel.isBackgroundPosterHidden,
playerState: currentViewModel.playerState)
view?.menuButtonWasTapped(viewModel: viewModel)
}
func sendButtonsAnalytics(response: CreditsModels.AnalyticsData.Response) {
view?.sendButtonsAnalytics(viewModel: CreditsModels.AnalyticsData.ViewModel())
}
}
Ну и напоследок посмотрим на Builder, единую точку входа для экрана с кнопками быстрого переключения:
protocol CreditsBuildingLogic: AnyObject {
func makeCreditsModule(delegate: CreditsViewDelegate?) -> CreditsViewController
}
final class CreditsBuilder: CreditsBuildingLogic {
func makeCreditsModule(delegate: CreditsViewDelegate?) -> CreditsViewController {
let view = CreditsViewController()
let presenter = CreditsPresenter()
let interactor = CreditsInteractor(with: ChapterWorker(), remoteConfigWorker: AIVRemoteConfigWorker())
interactor.presenter = presenter
presenter.view = view
view.delegate = delegate
view.interactor = interactor
return view
}
}
Здесь предлагаю взглянуть на готовую диаграмму классов, что у нас получилась.
Так с помощью последовательного подхода и разделения ответственности (как между модулями, так и внутри сцен) была реализована эта функциональность. Теперь пользователь может наслаждаться не только возможностью переключения серий внутри сериала, но и не тянуться за пультом, чтоб посмотреть похожий фильм сразу после окончания нынешнего.
Надеюсь, вам эта статья стала для вас интересным и познавательным опытом! Если у вас есть вопросы или замечания – жду вас в комментариях. Спасибо за внимание!