[iOS] Vimeoの動画をネイティブUIに埋め込みたい

2021.09.16

こんにちは。きんくまです。

今回はVimeoの動画をネイティブUIに埋め込みたいです。

Vimeoって?

動画共有サービスです。個人的なイメージは、アートとかファッション、映画のメイキングとかおしゃれ系動画サイトという感じですね。

動画一覧

Vimeo | About Vimeo

できたもの

Github
cm-tsmaeda / EmbedVimeoIntoNativeView

ネイティブUIの中にVimeoの動画を埋め込みたかったです。

プログラムの流れ

iOSのSDKが欲しかったです。調べてみたところ、存在はするのですがdeprecated扱いで、今後はなくなるとのこと。
なので、ネイティブでのUI埋め込みはあきらめて、部分的にWebViewを使って埋め込むことにしました。

Libraries and SDKs

プログラムの流れ

アプリ側
1. 動画情報をAPIから取得
2. 1から幅と高さが得られるので、WebViewの高さを設定
3. WebViewにクエリをつけてURLを読み込む

Web側
1. URLのクエリから動画idを取得
2. 動画情報をAPIから取得
3. 2から埋め込み用のhtmlが得られるので、それを使ってVimeo用のPlayerを埋め込み

今回は、アプリ側とWeb側を用意しました。
Web側は、htmlを作成して、Webサーバーにホスティングします。
実はWeb側を用意しなくても、htmlの文字列さえあればWebViewで同じことをすることは可能です。
では、どうしてWebサーバーにホスティングするかというと、セキュリティで制限かけられている動画も読み込ませたいためです。

動画のプライバシー設定を変更

制限のかかったドメインに、読み込み用のhtmlをホスティングすることでセキュリティの制限を回避します。

oEmbed API

Vimeoでは埋め込み動画情報を取得するAPIが用意されています。

Working with oEmbed: Embedding Videos

リクエスト例

https://vimeo.com/api/oembed.json?responsive=true&url=https%3A%2F%2Fvimeo.com%2F601252574

レスポンス例

{
  "type": "video",
  "version": "1.0",
  "provider_name": "Vimeo",
  "provider_url": "https://vimeo.com/",
  "title": "Vogue - History of American Fashion",
  "author_name": "Andrew B. Myers",
  "author_url": "https://vimeo.com/andrewbmyers",
  "is_plus": "1",
  "account_type": "plus",
  "html": "<div style=\"padding:56.25% 0 0 0;position:relative;\"><iframe src=\"https://player.vimeo.com/video/601252574?h=bd62b143a3&amp;app_id=122963\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen style=\"position:absolute;top:0;left:0;width:100%;height:100%;\" title=\"Vogue - History of American Fashion\"></iframe></div><script src=\"https://player.vimeo.com/api/player.js\"></script>",
  "width": 426,
  "height": 240,
  "duration": 530,
  "description": "A look at American fashion in the 20th century, with its evolution from simple, European inspired design to the creative work of new and BIPOC designers in the 21st century.",
  "thumbnail_url": "https://i.vimeocdn.com/video/1237215280_295x166",
  "thumbnail_width": 295,
  "thumbnail_height": 166,
  "thumbnail_url_with_play_button": "https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F1237215280_295x166&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png",
  "upload_date": "2021-09-09 15:00:15",
  "video_id": 601252574,
  "uri": "/videos/601252574"
}

これで動画の幅と高さがわかります。
またhtmlというプロパティは、埋め込み用のhtml文字列が入っていますので、これをそのままhtmlに追加すればよさそうです。

アプリ側

Storyboadはこんな感じにUILabel, UIImageView, WKWebViewをUIScrollViewに並べました。

VimeoAPI.swift

struct VimeoGetVideoInfoRequest {
    let baseUrlStr: String = "https://vimeo.com/api/oembed.json?responsive=true&url="
    let videoId: String
    
    var url: URL? {
        let videoUrl = "https://vimeo.com/\(videoId)"
        guard let encoded = videoUrl.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else {
            return nil
        }
        return URL(string: baseUrlStr + encoded)
    }
}

/// 動画情報
struct VimeoVideoInfo: Codable {
    
    let videoId: Int
    let width: Int
    let height: Int
    
    enum CodingKeys: String, CodingKey {
        case videoId = "video_id"
        case width
        case height
    }
}

enum VimeoError: Error {
    case fetch
    case other(Error)
    
    static func map(_ error: Error) -> VimeoError {
        if let vimeoError = error as? VimeoError {
            return vimeoError
        }
        return .other(error)
    }
}

ViewController.swift

import UIKit
import WebKit
import Combine

class ViewController: UIViewController {

    var subscriptions: Set<AnyCancellable> = []
    
    @IBOutlet weak var contentView: UIView!
    @IBOutlet weak var contentViewHeightConstraint: NSLayoutConstraint!
    @IBOutlet weak var largeImageView: UIImageView!
    @IBOutlet weak var webViewHeightConstraint: NSLayoutConstraint!
    @IBOutlet weak var webView: WKWebView!
    var videoInfo: VimeoVideoInfo?
    
    let videoId: String = "601252574"
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        contentViewHeightConstraint.constant = 1200
        webView.isHidden = true
        
        fetchVimeoVideoInfo(videoId: videoId)
    }
    
    func setupWebView() {
        guard let videoInfo = videoInfo else { return }
        let screenW = UIScreen.main.bounds.width
        let webViewHeight: CGFloat = (screenW - 30 * 2) * CGFloat(videoInfo.height) / CGFloat(videoInfo.width)
        
        webViewHeightConstraint.constant = webViewHeight
        
        guard let url = URL(string: "http://localhost:8090/?id=\(videoInfo.videoId)") else { return }
        let req = URLRequest(url: url)
        webView.navigationDelegate = self
        webView.load(req)
    }
    
    func fetchVimeoVideoInfo(videoId: String) {
        createVimeoGetVideoInfoRequest(videoId: videoId)
            .receive(on: DispatchQueue.global(qos: .userInitiated))
            .sink(receiveCompletion: { [weak self] completion in
                guard let self = self else { return }
                switch completion {
                case .finished:
                    DispatchQueue.main.async {
                        self.setupWebView()
                    }
                case .failure(let error):
                    print("Error: \(error.localizedDescription)")
                }
            }, receiveValue: { [weak self] videoInfo in
                self?.videoInfo = videoInfo
            }).store(in: &subscriptions)
    }
    
    func createVimeoGetVideoInfoRequest(videoId: String) -> AnyPublisher<VimeoVideoInfo, VimeoError> {
        let api = VimeoGetVideoInfoRequest(videoId: videoId)
        guard let url = api.url else {
            fatalError("cannot make url")
        }
        let req = URLRequest(url: url)
        return URLSession.shared.dataTaskPublisher(for: req)
            .tryMap { (data, response) -> Data in
                guard let httpRes = response as? HTTPURLResponse,
                      200 ..< 300 ~= httpRes.statusCode  else {
                    throw VimeoError.fetch
                }
                return data
            }
            .decode(type: VimeoVideoInfo.self, decoder: JSONDecoder())
            .mapError{ VimeoError.map($0) }
            .eraseToAnyPublisher()
    }
}

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        if webView.isHidden {
            webView.isHidden = false
        }
    }
}

WebViewでページを読み込むときに、動画のidをクエリにつけています。これをWeb側で読み取ります。

http://localhost:8090/?id={video_id}

Web側

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vimeoサンプル</title>
<link rel="stylesheet" href="vimeo.css">
</head>
<body>
<script src="vimeo.js"></script>
</body>
</html>

vimeo.js

function getParameterByName(name) {
    const match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
    return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
}

function fetchVideoInfo(id) {
    const videoUrl = "https://vimeo.com/" + id;
    const encoded = encodeURIComponent(videoUrl);
    const fetchUrl = "https://vimeo.com/api/oembed.json?responsive=true&url=" + encoded;
    fetch(fetchUrl)
        .then(function(response) {
            if (!response.ok) {
                throw new Error("HTTP Error!");
            }
            return response.json();
        })
        .then(function(response) {
            //console.log(response);
            embedVideo(response.html);
        });
}

function embedVideo(htmlStr) {
    const body = document.getElementsByTagName('body')[0];
    body.insertAdjacentHTML('afterbegin', htmlStr);
    //console.log('embed', htmlStr);
}

const videoId = getParameterByName('id');
fetchVideoInfo(videoId);

余談。感想

一番わからなかったところは、どうやって動画情報を取得するかでした。
今回使ったoEmbedの方ではない、APIも用意されていて、はじめはそれを調べていました。

そちらの方はアプリの登録が必要だったり、回数制限があったりして困ったのですが、oEmbedの方が見つかりました。oEmbedの方は特に制限もなかったのでよかったです。

ではでは。