Rodando vídeos do YouTube com o YouTube-Player-iOS-Helper

Rodando vídeos do YouTube com o YouTube-Player-iOS-Helper

O YouTube é um dos mais antigos e populares serviços de streaming de vídeo existente na web. Se você pretende rodar um vídeo do YouTube no iOS saiba que não é possível faze-lo diretamente em um componente como o AVPlayer. Para tocar o vídeo no iOS teremos que utilizar um UIWebView para mostrar o player do YouTube desenvolvido em HTML e Javascript.

Para facilitar a interação entre as funções disponíveis em JavaScript e o Swift iremos utilizar o pod YouTube-Player-iOS-Helper. Este pod é mantido pelo próprio YouTube e pode ser encontrado no GitHub. Existe um guia na documentação da API do YouTube sobre este pod.

Para o nosso projeto iremos precisar de uma chave da API do Google. Abra o Google Developers Console e crie um novo projeto iOS Youtube. Com o projeto selecionado clique no menu lateral Credenciais e depois em Criar Credenciais. Na lista de tipo de credenciais selecione Chave de API.

google-developers-console-new-project
google-developers-console-credenciais
google-developers-console-criar-credenciais
google-developers-console-chave-de-api-criada

Para aumentar a segurança da chave você pode restringi-la a um determinado site, IP ou bundle de aplicativo.

google-developers-console-restricao-de-chave

Instalando o YouTube-Player-iOS-Helper

Para adicionar a biblioteca ao projeto utilizaremos o CocoaPods. Você pode encontrar mais informações sobre a utilização do CocoaPods neste artigo.

Adicione a linha abaixo no Podfile para adicionar a biblioteca.

pod "youtube-ios-player-helper"

Para adicionar o player no Storyboard basta inserir um UIView, inserir as constraints de layout e alterar a classe para YTPlayerView.

A classe YTPlayerView possui os seguintes métodos:

  • load(withVideoId:)
  • load(withPlaylistId:)
  • load(withVideoId:playerVars:)
  • load(withPlaylistId:playerVars:)
  • load(withPlayerParams:)
  • playVideo()
  • pauseVideo()
  • stopVideo()
  • seek(toSeconds:allowSeekAhead:)
  • cueVideo(byId:startSeconds:suggestedQuality:)
  • cueVideo(byId:startSeconds:endSeconds:suggestedQuality:)
  • loadVideo(byId:startSeconds:suggestedQuality:)
  • loadVideo(byId:startSeconds:endSeconds:suggestedQuality:)
  • cueVideo(byURL:startSeconds:suggestedQuality:)
  • cueVideo(byURL:startSeconds:endSeconds:suggestedQuality:)
  • loadVideo(byURL:startSeconds:suggestedQuality:)
  • loadVideo(byURL:startSeconds:endSeconds:suggestedQuality:)
  • cuePlaylist(byVideos:index:startSeconds:suggestedQuality:)
  • cuePlaylist(byPlaylistId:index:startSeconds:suggestedQuality:)
  • loadPlaylist(byPlaylistId:index:startSeconds:suggestedQuality:)
  • loadPlaylist(byVideos:index:startSeconds:suggestedQuality:)
  • nextVideo()
  • previousVideo()
  • playVideo(at:)
  • playbackRate()
  • setPlaybackRate(suggestedRate:)
  • availablePlaybackRates()
  • setLoop(loop:)
  • setShuffle(shuffle:)
  • videoLoadedFraction()
  • playerState()
  • currentTime()
  • playbackQuality()
  • setPlaybackQuality(suggestedQuality:)
  • availableQualityLevels()
  • duration()
  • videoUrl()
  • videoEmbedCode()
  • playlist()
  • playlistIndex()
  • removeWebView()

O delegate YTPlayerViewDelegate possui os seguintes métodos:

  • playerViewDidBecomeReady(_:)
  • playerView(_:didChangeTo:)
  • playerView(_:didChangeTo:)
  • playerView(_:receivedError:)
  • playerView(_:didPlayTime:)
  • playerViewPreferredWebViewBackgroundColor(_:)
  • playerViewPreferredInitialLoading(_:)

Caso queria carregar um novo vídeo em um mesmo playerView, utilize os métodos cueVideo ou cuePlaylist. Eles não recarregam o iframe dentro do WebView o que dá uma melhor performance no carregamento do vídeo.

Codificando o aplicativo

O nosso aplicativo irá listar os 200 videos mais populares do Brasil. Para isto iremos utilizar a API de listagem de vídeos e iremos adicionar o parâmetro chart com o valor mostPopular. A documentação pode ser encontrada aqui.

Criando o Model

A resposta do vídeo vem em várias partes, e em nosso projeto iremos implementar as partes snippet (Contém detalhes básicos sobre o vídeo, como seu título, sua descrição e sua categoria.), contentDetails (Contém informações sobre o conteúdo do vídeo, incluindo sua duração e proporção.) e statistics (Contém estatísticas sobre o vídeo.). A listagem completa da estrutura do vídeo pode ser encontrada aqui.

Vamos criar um novo arquivo chamado VideoList.swift. Nele iremos definir uma struct VideoList como cabeçalho da resposta da API.

import Foundation

public struct VideoList {
  public var etag: String
  public var nextPageToken: String?
  public var totalResults: Int
  public var resultsPerPage: Int
  public var items: [VideoListItem]
}

A nossa estrutura contém algumas propriedades mas irei destacar duas delas. Iremos chamar a API trazendo a informação de 25 vídeos por vez ao invés de trazer todos os 200. Com isto iremos paginar o resultado e a propriedade nextPageToken irá armazenar o token para obtermos a próxima página. Observe que ela é opcional pois na última página ela será nula. A outra propriedade é a items que irá armazenar a estrutura com os dados dos vídeos que iremos criar a seguir.

Em todos os meus projetos sempre crio um arquivo com algumas extensões e tipos de dados para facilitar a codificação. Vamos criar o arquivo Utils.swift e adicionar o código abaixo.

import UIKit
import Foundation

public typealias JSON = [String: Any]

public enum SerializationError: Error {
  case missing(String, JSON)
  case invalid(String, Any)
}

Para ajudar na decodificação do json criei o typealias JSON que é um “apelido” para [String: Any]. Ele facilita na hora de digitar e visualizar o código. Também criei o enum SerializationError que iremos retornar sempre que encontrarmos problemas na decodificação do json. Com ele é fácil identificar os erros da decodificação pois ele retorna a tag que esperávamos encontrar e o pedaço do json onde ela deveria estar.

extension VideoList {
  init(with json: JSON) throws {
    guard let etag = json["etag"] as? String else { throw SerializationError.missing("etag", json) }
    guard let pageInfo = json["pageInfo"] as? JSON else { throw SerializationError.missing("pageInfo", json) }
    guard let totalResults = pageInfo["totalResults"] as? Int else { throw SerializationError.missing("totalResults", pageInfo) }
    guard let resultsPerPage = pageInfo["resultsPerPage"] as? Int else { throw SerializationError.missing("resultsPerPage", pageInfo) }
    guard let items = json["items"] as? [JSON] else { throw SerializationError.missing("items", json) }

    self.etag = etag
    self.totalResults = totalResults
    self.resultsPerPage = resultsPerPage
    self.items = [VideoListItem]()

    if let nextPageToken = json["nextPageToken"] as? String {
      self.nextPageToken = nextPageToken
    }

    for item in items {
      do {
        let videoListItem = try VideoListItem(with: item)

        self.items.append(videoListItem)
      }
    }
  }
}

Voltando ao VideoList.swift criamos um initializer que recebe um JSON e gera uma exceção caso não consiga inicializar o JSON.

Vamos criar agora a estrutura do video.

public struct VideoListItem {
  public var kind: String
  public var etag: String
  public var id: String
  public var contentDetails: VideoListItemDetail
  public var statistics: VideoListItemStatistics
  public var snippet: VideoListSnippet
}

Na struct VideoListItem podemos destacar a propriedade id que é o identificador do vídeo necessário para a sua visualização.

extension VideoListItem {
  init(with json: JSON) throws {
    guard let kind = json["kind"] as? String else { throw SerializationError.missing("kind", json) }
    guard let etag = json["etag"] as? String else { throw SerializationError.missing("etag", json) }
    guard let id = json["id"] as? String else { throw SerializationError.missing("id", json) }
    guard let contentDetails = json["contentDetails"] as? JSON else { throw SerializationError.missing("contentDetails", json) }
    guard let statistics = json["statistics"] as? JSON else { throw SerializationError.missing("statistics", json) }
    guard let snippet = json["snippet"] as? JSON else { throw SerializationError.missing("snippet", json) }

    self.kind = kind
    self.etag = etag
    self.id = id

    do {
      self.contentDetails = try VideoListItemDetail(with: contentDetails)
      self.statistics = try VideoListItemStatistics(with: statistics)
      self.snippet = try VideoListSnippet(with: snippet)
    }
  }
}

Criamos um initializer para a struct que recebe um JSON e gera um exceção caso encontre algum problema na decodificação do json.

public struct VideoListItemDetail {
  public var duration: Int //seconds
  public var dimension: String
  public var definition: String
  public var caption: Bool
  public var licensedContent: Bool
  public var projection: String
}

Na struct VideoListItemDetail podemos destacar a propriedade duration que é a duração do vídeo em segundos.

extension VideoListItemDetail {
  init(with json: JSON) throws {
    guard let duration = json["duration"] as? String else { throw SerializationError.missing("duration", json) }
    guard let dimension = json["dimension"] as? String else { throw SerializationError.missing("dimension", json) }
    guard let definition = json["definition"] as? String else { throw SerializationError.missing("definition", json) }
    guard let caption = json["caption"] as? String else { throw SerializationError.missing("caption", json) }
    guard let licensedContent = json["licensedContent"] as? Bool else { throw SerializationError.missing("licensedContent", json) }
    guard let projection = json["projection"] as? String else { throw SerializationError.missing("projection", json) }

    self.dimension = dimension
    self.definition = definition
    self.licensedContent = licensedContent
    self.projection = projection
    self.duration = 0

    if let captionBool = Bool(caption) {
      self.caption = captionBool
    } else {
      throw SerializationError.invalid("caption", json)
    }

    do {
      self.duration = getDuration(from: duration)
    }
  }
}

Como nas outras structs criamos um initializer que recebe um JSON e retorna uma exceção caso haja algum erro. Note que pra propriedade caption verificamos se ela existe e se ela tem um valor Bool válido.

extension VideoListItemDetail {
  fileprivate mutating func getDuration(from duration: String) -> Int {
    var minuteString = ""
    var secondString = ""
    var temp = ""
    var result = 0

    for char in duration.characters {
      if char == "M" {
        minuteString = temp
        temp = ""
      }  else if char == "S" {
        secondString = temp
      } else if char.isNumeric {
        temp = temp + String(char)
      }
    }

    if let minute = Int(minuteString) {
      result = minute * 60
    }

    if let second = Int(secondString) {
      result = result + second
    }

    return result
  }
}

A duração do vídeo vem em no formato ISO 8601 que iremos transformar em segundos com o método getDuration(from:). Veja o que diz a documentação da API sobre o campo:

A duração do vídeo. O valor da tag é uma duração ISO 8601 no formato PT#M#S, onde as letras PT indicam que o valor especifica um período e M e S se referem à duração em minutos em segundos, respectivamente. Os caracteres # que precedem as letras M e S são inteiros que especificam o número de minutos (ou segundos) do vídeo. Por exemplo, um valor PT15M51S indica que o vídeo tem 15 minutos e 51 segundos de duração.

extension Character {
  var isNumeric: Bool {
    let s = String(self).unicodeScalars
    let uni = s[s.startIndex]

    let digits = NSCharacterSet.decimalDigits as NSCharacterSet

    return digits.longCharacterIsMember(uni.value)
  }
}

No método utilizamos uma extensão criada no arquivo Utils.swift.

public struct VideoListItemStatistics {
  public var viewCount: Int
  public var likeCount: Int
  public var dislikeCount: Int
  public var favoriteCount: Int
  public var commentCount: Int
}

Na struct VideoListItemStatistics temos as quantidades de visualizações e comentários assim como a quantidade de likes, dislikes e favorites.

extension VideoListItemStatistics {
  init(with json: JSON) throws {
    if let commentCount = json["commentCount"] as? String, let commentCountInt = Int(commentCount) {
      self.commentCount = commentCountInt
    } else {
      self.commentCount = 0
    }

    if let likeCount = json["likeCount"] as? String, let likeCountInt = Int(likeCount) {
      self.likeCount = likeCountInt
    } else {
      self.likeCount = 0
    }

    if let dislikeCount = json["dislikeCount"] as? String, let dislikeCountInt = Int(dislikeCount) {
      self.dislikeCount = dislikeCountInt
    } else {
      self.dislikeCount = 0
    }

    if let favoriteCount = json["favoriteCount"] as? String, let favoriteCountInt = Int(favoriteCount) {
      self.favoriteCount = favoriteCountInt
    } else {
      self.favoriteCount = 0
    }

    if let viewCount = json["viewCount"] as? String, let viewCountInt = Int(viewCount) {
      self.viewCount = viewCountInt
    } else {
      self.viewCount = 0
    }
  }
}

Como nenhuma das propriedades acima é obrigatória, atribuímos zero as que forem nulas.

public struct VideoListSnippet {
  public var publishedAt: Date
  public var channelId: String
  public var title: String
  public var description: String
  public var thumbnail: String
  public var channelTitle: String
  public var categoryId: Int
  public var defaultAudioLanguage: String?
}

A struct VideoListSnippet tem as principais propriedades que iremos utilizar na apresentação do vídeo tais como título, data da publicação, descrição e imagem de exibição.

extension VideoListSnippet {
  init(with json: JSON) throws {
    guard let publishedAt = json["publishedAt"] as? String else { throw SerializationError.missing("publishedAt", json) }
    guard let channelId = json["channelId"] as? String else { throw SerializationError.missing("channelId", json) }
    guard let title = json["title"] as? String else { throw SerializationError.missing("title", json) }
    guard let description = json["description"] as? String else { throw SerializationError.missing("description", json) }
    guard let thumbnails = json["thumbnails"] as? JSON else { throw SerializationError.missing("thumbnails", json) }
    guard let channelTitle = json["channelTitle"] as? String else { throw SerializationError.missing("channelTitle", json) }
    guard let categoryId = json["categoryId"] as? String else { throw SerializationError.missing("categoryId", json) }

    guard let publishedDate = dateFormatter.date(from: publishedAt) else { throw SerializationError.invalid("publishedAt", publishedAt) }
    guard let categoryIdInt = Int(categoryId) else { throw SerializationError.invalid("categoryId", categoryId) }

    self.publishedAt = publishedDate
    self.channelId = channelId
    self.title = title
    self.description = description
    self.channelTitle = channelTitle
    self.categoryId = categoryIdInt

    if let standard = thumbnails["standard"] as? JSON {
      guard let url = standard["url"] as? String else { throw SerializationError.missing("url", standard) }
      self.thumbnail = url
    } else if let high = thumbnails["high"] as? JSON {
      guard let url = high["url"] as? String else { throw SerializationError.missing("url", high) }
      self.thumbnail = url
    } else if let medium = thumbnails["medium"] as? JSON {
      guard let url = medium["url"] as? String else { throw SerializationError.missing("url", medium) }
      self.thumbnail = url
    } else if let defaultThumbnail = thumbnails["default"] as? JSON {
      guard let url = defaultThumbnail["url"] as? String else { throw SerializationError.missing("url", defaultThumbnail) }
      self.thumbnail = url
    } else {
      throw SerializationError.invalid("thumbnails", thumbnails)
    }

    if let defaultAudioLanguage = json["defaultAudioLanguage"] as? String {
      self.defaultAudioLanguage = defaultAudioLanguage
    }
  }
}

No initializer podemos destacar a obtenção da imagem de exibição onde tentamos obter a imagem em diversas resoluções tendo como prioridade standard, high, medium e default. Caso nenhuma delas seja encontrada é gerada uma exeção.

public var dateFormatter: DateFormatter {
  let dateFormatter = DateFormatter()
  dateFormatter.locale = Locale(identifier: "en_US_POSIX")
  dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'zzz'Z'"
  dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)

  return dateFormatter
}

Também temos mais um código do arquivo Utils.swift onde criamos a propriedade calculada dateFormatter. Como a criação do objeto da classe DateFormatter é uma tarefa dispendiosa criamos esta propriedade uma vez e a reutilizamos várias vezes.

Agora que o nosso modelo está pronto precisamos de uma struct para buscar as informações do vídeo na API do YouTube. Vamos criar a struct Youtube.

import UIKit
import Foundation

public struct Youtube {
  fileprivate enum Addresses: String {
    case youtubeAPI = "https://www.googleapis.com/youtube/"
    case apiVersion = "v3/"
    case videos = "videos"

    static public func getVideosV3() -> String {
      return self.youtubeAPI.rawValue + self.apiVersion.rawValue + self.videos.rawValue
    }
  }

  fileprivate var apiKey: String
  fileprivate var part: String
  fileprivate var chart: String
  fileprivate var regionCode: String
  fileprivate var maxResults: Int

  fileprivate var dataTask: URLSessionDataTask?

  public init() {
    self.apiKey = ""
    self.part = "contentDetails,statistics,snippet"
    self.chart = "mostPopular"
    self.regionCode = "BR"
    self.maxResults = 25
  }

  public mutating func set(apiKey: String) {
    self.apiKey = apiKey
  }
}

Começaremos analisando o enum Addresses que contém as partes que compõem o endereço da API. Gosto de utilizar desta forma porque depois podemos fazer alteraçoes como mudar a versão da API ou acrescentar um outro recurso da API como channels, comments ou playlists por exemplo. O método getVideosV3() retorna a string completa.

Como propriedade temos part, chart, regionCode e maxResults como parâmetros da API. No inicializador da struct definimos os valores para estes parâmetros sendo "contentDetails,statistics,snippet" como as informações do vídeo que iremos requisitar. "mostPopular" como parâmetro da propriedade chart para trazer os vídeos mais populares como vimos anteriormente. "BR" como a região Brasil. 25 como a quantidade de vídeos que iremos buscar por consulta.

Por último temos o método set(apiKey:) que atribui a nossa API_Key.

public mutating func getTrendingVideosBrasil(nextPage: String?, callback: @escaping VideoListCallback) {
  dataTask?.cancel()

  var urlComponents = URLComponents(string: Addresses.getVideosV3())!
  var queryItems = [URLQueryItem]()

  queryItems.append(URLQueryItem(name: "part", value: part))
  queryItems.append(URLQueryItem(name: "chart", value: chart))
  queryItems.append(URLQueryItem(name: "maxResults", value: String(maxResults)))
  queryItems.append(URLQueryItem(name: "regionCode", value: regionCode))

  if let nextPageToken = nextPage {
    queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
  }

  queryItems.append(URLQueryItem(name: "key", value: apiKey))

  urlComponents.queryItems = queryItems

  dataTask = URLSession.shared.dataTask(with: urlComponents.url!) { (data, response, error) in
    DispatchQueue.main.async {
      UIApplication.shared.isNetworkActivityIndicatorVisible = false
    }

    if let error = error {
      callback(VideoListResult.error(error: YoutubeError.Error(msg: "\(error)")))
    } else if let data = data {
      if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? JSON {
        if let httpStatus = response as? HTTPURLResponse, httpStatus.statusCode != 200 {
          callback(VideoListResult.error(error: YoutubeError.Error(msg: "\(json)")))
        } else {
          do {
            let videoList = try VideoList(with: json!)

            callback(VideoListResult.success(videoList: videoList))
          } catch SerializationError.missing(let property, let data) {
            callback(VideoListResult.error(error: YoutubeError.Format(msg: "Missing property \(property).\n Data: \(data)")))
          } catch SerializationError.invalid(let property, let data) {
            callback(VideoListResult.error(error: YoutubeError.Format(msg: "Invalid data for property \(property).\n Data: \(data)")))
          } catch {
            callback(VideoListResult.error(error: YoutubeError.Error(msg: String(data: data, encoding: .utf8)!)))
          }
        }
      } else {
        callback(VideoListResult.error(error: YoutubeError.Format(msg: String(data: data, encoding: .utf8)!)))
      }
    }
  }

  UIApplication.shared.isNetworkActivityIndicatorVisible = true

  dataTask?.resume()
}

No método getTrendingVideosBrasil(nextPage:callback:) é onde realizamos a chamada da API. Começamos criando uma URL com a ajuda do enum Addresses. Em seguida montamos o resto da url adicionando os parâmetros da chamada. Para acrescentar os parâmetros utilizamos a variável queryItems do tipo [URLQueryItem]. Com a struct URLQueryItem podemos criar os parâmetros com o initializer init(name:value:). Utilizando esta struct não precisamos nos preocupar com a formatação dos caracteres das propriedades pois eles já virão no formato correto.

Temos o parâmetro pageToken da API que informamos quando for a requisição de uma nova página. Como o parâmetro nextPage do método é opcional não iremos utiliza-lo na primeira e na última requisição.

Para realizar a requisição utilizamos o URLSession padrão do iOS verificando se o error é nulo, se o data é um json válido e a response possui o código 200. Depois chamamos o initializer da classe VideoList passando o json. Como o initializer gera exceção fazemos a chamada em um do catch.

Caso o objeto seja gerado com sucesso realizamos a chamada do callback callback(VideoListResult.success(videoList: videoList)). Caso tenha algum erro realizamos a chamada do callback passando o erro.

public enum VideoListResult {
  case success(videoList: VideoList)
  case error(error: Error)
}

public enum YoutubeError: Error {
  case Authenticate(msg: String)
  case Format(msg: String)
  case Empty(msg: String)
  case Error(msg: String)
}

public typealias VideoListCallback = (VideoListResult) -> Void

Como em outras partes do código criamos outros alias e enums no Utils.swift para facilitar a codificação. O enum VideoListResult contém os possíveis resultados da chamada da API. Pode ser um objeto VideoList em caso de sucesso ou um YouTubeError em caso de erro.
Como calback temos o typealias VideoListCallback que corresponde a uma closure que recebe um VideoListResult e não tem retorno.

Apresentando os vídeos

O aplicativo consiste de duas telas com tableView. Na primeira tela iremos listar os vídeos mostrando a imagem do vídeo, o título e as estatísticas (like, dislike, view, comment e favorite). No segunda tela exibiremos o vídeo no lugar da imagem e acrescentaremos a descrição, a data da postagem e a duração. Caso o usuário altere a posição do celular para paisagem o player do vídeo ocupará a tela inteira.

layout01 layout02

import UIKit

class YoutubeTableViewController: UITableViewController {
  fileprivate enum Cells {
    static let youtubeCell = "YoutubeCell"
  }

  fileprivate enum Segues {
    static let showDetailSegue = "showDetailSegue"
  }

  // MARK: - Properties
  override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .portrait
  }

  public var youtube: Youtube!

  fileprivate var videoList: VideoList?
  fileprivate var timer: Timer!

  // MARK: - LifeCycle Methods
  override func viewDidLoad() {
    super.viewDidLoad()

    title = "Youtube Trending Brasil"

    getVideos()
  }

  // MARK: - Navigation
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == Segues.showDetailSegue {
      let videoListItem = sender as! VideoListItem

      let youtubeVideoTableViewController = segue.destination as! YoutubeVideoTableViewController
      youtubeVideoTableViewController.videoListItem = videoListItem
    }
  }

  // MARK: - Private Methods
  fileprivate func getVideos(nextPage: String? = nil) {
    youtube.getTrendingVideosBrasil(nextPage: nextPage) { response in
      switch response as VideoListResult {
        case .success(let videoList):
          DispatchQueue.main.sync {
            if let _ = nextPage {
              self.videoList!.items.append(contentsOf: videoList.items)
              self.videoList!.etag = videoList.etag
              self.videoList!.nextPageToken = videoList.nextPageToken
              self.videoList!.totalResults = videoList.totalResults
              self.videoList!.resultsPerPage = videoList.resultsPerPage
            } else {
              self.videoList = videoList
            }

            self.tableView.reloadData()
          }
        case .error(let error):
          print(error)
      }
    }
  }
}

Vamos destacar alguns códigos mostrados acima. Primeiramente temos a propriedade supportedInterfaceOrientations onde limitamos a exibição do controller para a posição retrato. A propriedade videoList será onde armazenaremos os vídeos retornados pela API do YouTube. A propriedade timer será utilizada para exibir o detalhe do vídeo que estiver mais ao centro da tela caso o aplicativo fique 3 segundos sem interação. No método prepare(for:sender:) iremos passar os dados do vídeo para a tela de detalhe.

O método getVideos(nextPage:) é o responsável por buscar a lista de vídeos. Ele é chamado no método viewDidLoad() com o parâmetro nextPage igual a nil. Nele utilizamos a nossa struct Youtube com a chamada youtube.getTrendingVideosBrasil(nextPage:). Caso seja retornado a lista de vídeos, verificamos se é uma nova página para atualizarmos a propriedade videoList acrescentando os novos vídeos, ou se é a primeira chamada onde iremos atribuir o resultado diretamente à propriedade.

OBS: Em um aplicativo comercial iríamos adicionar os vídeos sem recarregar o tableView.

// MARK: -
extension YoutubeTableViewController {
  // MARK: - UITableViewDataSource
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return videoList?.items.count ?? 0
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: Cells.youtubeCell, for: indexPath) as! YoutubeTableViewCell
    let videoListItem = videoList!.items[indexPath.row]

    cell.setupCell(with: videoListItem)

    return cell
  }
}

No protocolo UITableViewDataSource implementamos o método tableView(_:numberOfRowsInSection:) para retornar a quantidade de vídeos. No método tableView(_:cellForRowAt:) criamos uma célula do tipo YoutubeTableViewCell que iremos definir a seguir. Utilizamos o método setupCell(with:) para preencher a célula com a informação do vídeo.

import UIKit
import Foundation

class YoutubeTableViewCell: UITableViewCell {
  @IBOutlet weak var videoImageView: UIImageView!
  @IBOutlet weak var titleLabel: UILabel!
  @IBOutlet weak var likeCountLabel: UILabel!
  @IBOutlet weak var dislikeCountLabel: UILabel!
  @IBOutlet weak var favoriteCountLabel: UILabel!
  @IBOutlet weak var viewCountLabel: UILabel!
  @IBOutlet weak var commentCountLabel: UILabel!

  fileprivate var videoListItem: VideoListItem!
  fileprivate var dataTask: URLSessionDataTask?

  override func awakeFromNib() {
    super.awakeFromNib()

    clearCell()
  }

  override func prepareForReuse() {
    clearCell()
  }

  public func setupCell(with videoListItem: VideoListItem) {
    self.videoListItem = videoListItem

    titleLabel.text = videoListItem.snippet.title
    likeCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.likeCount))
    dislikeCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.dislikeCount))
    favoriteCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.favoriteCount))
    viewCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.viewCount))
    commentCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.commentCount))

    getPhoto()
  }

  private func clearCell() {
    videoImageView.image = nil
    titleLabel.text = ""
    likeCountLabel.text = "0"
    dislikeCountLabel.text = "0"
    favoriteCountLabel.text = "0"
    viewCountLabel.text = "0"
    commentCountLabel.text = "0"
  }
}

O método setupCell(with:) utiliza o objeto numberFormatter para formatar a quantidade dos dados estatísticos e chama o método getPhoto() para buscar a imagem do vídeo.

public var numberFormatter: NumberFormatter {
  let numberFormatter = NumberFormatter()
  numberFormatter.locale = Locale.autoupdatingCurrent
  numberFormatter.numberStyle = .decimal
  numberFormatter.alwaysShowsDecimalSeparator = false
  numberFormatter.maximumFractionDigits = 0

  return numberFormatter
}

O objeto numberFormatter foi definido no fonte Utils.swift.

extension YoutubeTableViewCell {
  fileprivate func getPhoto() {
    let tempPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
    let photoPath = tempPath.appendingPathComponent("t-\(videoListItem.id).jpg")

    if FileManager.default.fileExists(atPath: photoPath.path) {
      if let photo = UIImage(contentsOfFile: photoPath.path) {
        self.videoImageView.image = photo
      }
    } else {
      downloadPhoto()
    }
  }

  fileprivate func downloadPhoto() {
    if let url = URL(string: videoListItem.snippet.thumbnail) {
      dataTask?.cancel()

      dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, response, error) in
        if let weakSelf = self {
          if let error = error {
            print(error)
          } else if let data = data, let httpStatus = response as? HTTPURLResponse, httpStatus.statusCode == 200 {
            if let image = UIImage(data: data) {
              let _ = image.saveToFileAsJPEG(withName: "t-\(weakSelf.videoListItem.id)")

              DispatchQueue.main.sync {
                weakSelf.videoImageView.image = image
              }
            }
          }
        }
      }

      dataTask?.resume()
    }
  }
}

A método getPhoto() realiza um cache das imagens dos vídeos armazenando-as no diretório de cache do aplicativo. O iOS irá apagar as imagens do diretório caso precise de mais espaço e estas imagens não ocuparão espaço de backup do usuário. Caso a imagem do vídeo não tenha sido baixada ainda o método downloadPhoto() é chamado para baixar a imagem. Novamente utilizamos o URLSession para realizar o download da imagem e armazená-la no diretório de cache.

Importante notar o uso de weak self para permitir que a célula seja desalocada caso o usuário role o vídeo para fora da tela antes do download ser concluído.

extension UIImage {
  func saveToFileAsJPEG(withName name: String) -> String? {
    if let data = UIImageJPEGRepresentation(self, 1.0) {
      let filename = getCacheDirectory().appendingPathComponent("\(name).jpg")

      do {
        try data.write(to: filename)

        return filename.absoluteString
      } catch {
        return nil
      }
    } else {
      return nil
    }
  }

  private func getCacheDirectory() -> URL {
    return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
  }
}

Os métodos saveToFileAsJPEG(withName:) e getCacheDirectory() foram definidos no Utils.swift.

// MARK: -
extension YoutubeTableViewController {
  // MARK: - UITableViewDelegate
  override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    // Does an animation
    cell.layer.transform = CATransform3DMakeScale(0.1, 0.1, 1)

    UIView.animate(withDuration: 0.5, animations: {
      cell.layer.transform = CATransform3DMakeScale(1.05, 1.05, 1)
    }, completion: { finished in
      UIView.animate(withDuration: 0.2, animations: {
        cell.layer.transform = CATransform3DMakeScale(1, 1, 1)
      })
    })

    // Load more data
    if videoList!.items.count - indexPath.row == 5 && videoList!.items.count < videoList!.totalResults {
      getVideos(nextPage: videoList!.nextPageToken)
    }

    // Plays the video in middle of screen after 3 seconds
    timer?.invalidate()
    timer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(self.showVideo), userInfo: nil, repeats: false)
  }

  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    timer.invalidate()

    let videoListItem = videoList!.items[indexPath.row]

    performSegue(withIdentifier: Segues.showDetailSegue, sender: videoListItem)
  }

  func showVideo() {
    let center = tableView.convert(self.view.center, to: tableView) + tableView.contentOffset

    if let indexPath = tableView.indexPathForRow(at: center) { // Get the indexPath of the cell in middle of screen
      timer.invalidate()

      let videoListItem = videoList!.items[indexPath.row]

      performSegue(withIdentifier: Segues.showDetailSegue, sender: videoListItem)
    }
  }
}

Voltando a classe YoutubeTableViewController temos a implementação do protocolo UITableViewDelegate. No método tableView(_:willDisplay:forRowAt:) realizamos três tarefas.

A primeira é uma animação da célula assim que ela aparece na tela. Utilizamos o método animation(withDuration:animations:) para alterar o layer.transform da célula e criar a animação.

A segunda tarefa é carregar a próxima página de vídeos quando o usuário chegar perto do fim da lista atual. Verificamos se o usuário chegou nos últimos cinco registros da lista e se a lista possui mais páginas. Caso a condição seja verdadeira chamamos o método getVideos(nextPage:) passando o token da próxima página como parâmetro.

A terceira tarefa é iniciar o timer para que ele acione o método showVideo() quando o tableView ficar três segundos sem iteração. O método showVideo() calcula qual célula está no centro da tela e navega até o detalhe do vídeo da célula.

public func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
  return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}

Adicionei o fonte acima no Utils.swift para facilitar o cálculo do offset da tableView.

import UIKit
import youtube_ios_player_helper

class YoutubeVideoTableViewController: UITableViewController {
  //MARK: - Outlets
  @IBOutlet weak var playerView: YTPlayerView!
  @IBOutlet weak var titleLabel: UILabel!
  @IBOutlet weak var publishedAtLabel: UILabel!
  @IBOutlet weak var durationLabel: UILabel!
  @IBOutlet weak var likeCountLabel: UILabel!
  @IBOutlet weak var dislikeCountLabel: UILabel!
  @IBOutlet weak var favoriteCountLabel: UILabel!
  @IBOutlet weak var viewCountLabel: UILabel!
  @IBOutlet weak var commentCountLabel: UILabel!
  @IBOutlet weak var descriptionLabel: UILabel!

  // MARK: - Properties
  override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .allButUpsideDown
  }

  public var videoListItem: VideoListItem!

  // MARK: - Lifecycle Methods
  override func viewDidLoad() {
    super.viewDidLoad()

    title = videoListItem.snippet.title

    playerView.delegate = self

    loadData()
  }

  // MARK: - Private Methods
  private func loadData() {
    titleLabel.text = videoListItem.snippet.title
    likeCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.likeCount))
    dislikeCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.dislikeCount))
    favoriteCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.favoriteCount))
    viewCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.viewCount))
    commentCountLabel.text = numberFormatter.string(from: NSNumber(value: videoListItem.statistics.commentCount))
    publishedAtLabel.text = DateFormatter.localizedString(from: videoListItem.snippet.publishedAt, dateStyle: .short, timeStyle: .short)

    // Get video duration
    let hour = Int(videoListItem.contentDetails.duration / 3_600)
    let minute = Int(videoListItem.contentDetails.duration / 60) - Int(hour * 60)
    let second = Int(videoListItem.contentDetails.duration - Int(hour * 3_600) - Int(minute * 60))

    if hour > 0 {
      durationLabel.text = "\(hour < 10 ? "0" : "")\(hour):\(minute < 10 ? "0" : "")\(minute):\(second  0 {
      durationLabel.text = "\(minute < 10 ? "0" : "")\(minute):\(second < 10 ? "0" : "")\(second)"
    } else {
      durationLabel.text = "\(second < 10 ? "0" : "")\(second)"
    }

    // Get formatted video description
    if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) {
      let links = detector.matches(in: videoListItem.snippet.description, options: .reportCompletion, range: NSMakeRange(0, videoListItem.snippet.description.characters.count))

      let attributedDescription = NSMutableAttributedString(string: videoListItem.snippet.description)

      for link in links {
         attributedDescription.setAsLink(textToFind: link.url!.absoluteString, linkURL: link.url!.absoluteString)
      }

      descriptionLabel.attributedText = attributedDescription
    }

    loadVideo()
  }
}

A tela de detalhes do vídeo é controlada pela classe YoutubeVideoTableViewController. Nela podemos destacar a propriedade supportedInterfaceOrientations que permite que a tela seja exibida em modo retrato e modo paisagem. A propriedade videoListItem é os dados do vídeo recebido pela tela principal. A propriedade playerView da classe YTPlayerView é o nosso player do YouTube. Atribuimos o delegate ao nosso controller para acessarmos alguns métodos que serão vistos posteriormente.

No método loadData() buscamos as informações do vídeo igual fizemos na tela principal. Destaque para a duração do vídeo onde transformamos a duração de segundos para o formato hh:MM:ss. Na descrição adicionamos um destaque aos links dentro da descrição do vídeo.

extension NSMutableAttributedString {
  public func setAsLink(textToFind: String, linkURL: String) {
    let foundRange = self.mutableString.range(of: textToFind)

    if foundRange.location != NSNotFound {
      self.addAttribute(NSLinkAttributeName, value: linkURL, range: foundRange)
    }
  }
}

O método setAsLink(textToFind:linkURL:) está definido no Utils.swift.

// MARK: -
extension YoutubeVideoTableViewController {
  // MARK: -
  public func loadVideo() {
    let playerVars = ["playsinline": 1]
    playerView.load(withVideoId: videoListItem.id, playerVars: playerVars)
  }

  public func playVideo() {
    playerView.playVideo()
  }
}

Para auxiliar na manipulação dos vídeos criamos dois métodos. O método loadVideo() onde carregamos o vídeo no player. Aproveitamos o método para inserir o parâmetro playsinline igual 1 para rodar o vídeo sem maximizar o player. Você pode encontrar a lista com os outros parâmetros do player aqui.

O outro método foi o playVideo() onde inicializamos a execução do vídeo.

// MARK: -
extension YoutubeVideoTableViewController: YTPlayerViewDelegate {
  //MARK: - YTPlayerViewDelegate
  func playerViewDidBecomeReady(_ playerView: YTPlayerView) {
    playVideo()
  }

  func playerViewPreferredInitialLoading(_ playerView: YTPlayerView) -> UIView? {
    let tempPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
    let photoPath = tempPath.appendingPathComponent("t-\(videoListItem.id).jpg")

    if FileManager.default.fileExists(atPath: photoPath.path) {
      if let photo = UIImage(contentsOfFile: photoPath.path) {
        return UIImageView(image: photo)
      }
    }

    return nil
  }
}

No delegate YTPlayerViewDelegate implementamos dois métodos. O método playerViewDidBecomeReady(_:) é chamado quando o iframe do player for carregado no UIWebView. Nele iniciamos a execução do vídeo.

OBS: Poderíamos iniciar o vídeo de forma automática passando a propriedade autoplay igual a 1 junto com a propriedade playsinline para o método load(withVideoId:playerVars:).

No método playerViewPreferredInitialLoading(_:) carregamos a imagem do diretório de cache para mostrar enquanto o player é inicializado.

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  private let apiKey = "AIzaSyCHr59bM5wVrLizbdOqwOVCUWWVspHzjls"

  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    var youtube = Youtube()
    youtube.set(apiKey: apiKey)

    let youtubeTableViewController = window?.rootViewController?.contentViewController as! YoutubeTableViewController
    youtubeTableViewController.youtube = youtube

    return true
  }
}

Para finalizar temos a inicialização do objeto youtube, a definição da chave da API e a passagem do objeto youtube para a view principal no AppDelegate.

Obs: Esta key já foi excluída. Você deve substituí-la pela key que você gerou.

extension UINavigationController {
  open override var shouldAutorotate: Bool {
    return true
  }

  open override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return visibleViewController?.supportedInterfaceOrientations ?? .allButUpsideDown
  }
}

extension UIViewController {
  var contentViewController: UIViewController   {
    if let navcon = self as? UINavigationController     {
      return navcon.visibleViewController!
    } else {
      return self
    }
  }
}

Outras extensões do Utils.swift que utilizamos no projeto.

Conclusão

O nosso aplicativo ficou com o seguinte visual.

screen01 screen02 screen03 screen04

Animação do tableView
Execução do video em modo retrato
Execução do video em modo paisagem

A biblioteca YouTube-Player-iOS-Helper é um grande facilitador para a exibição de vídeos do YouTube pois permite uma integração com as funções do javascript de maneira transparente permitindo que nos preocupemos com a regra de negócio do aplicativo ao invés de ficar construindo wrappers para interação com javascript. Como a biblioteca é mantida pelo próprio YouTube temos a segurança de ter um fonte que estará atualizado com as alterações do player.

Espero que tenham gostado do artigo. Qualquer dúvida, crítica ou sugestão de novos assuntos deixe um comentário abaixo. Caso queira entrar em contato pode me chamar no twitter @mateusfsilva. Se gostou compartilhe.

Publicidade

Deixe um comentário

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair /  Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair /  Alterar )

Conectando a %s