2020/11版:超強引にTypeScript+hls.jsでChromeでもvideoタグでHLS再生できるページ作る

8 min read読了の目安(約7800字

超強引なのでオススメしないやり方ですが、できました

TL;DR

Google Chrome では HTTP Live Streaming ファイルを video タグで再生できない

いろいろ調べてると2015〜2017年頃の記事で「Chromeでは再生できない」とあり
まぁ順次対応されてるんだろ…… とタカを括って2020/11時点で見てみたら対応されてませんでした。
えぇ……(困惑)

テスト再生は Apple Developer の Examples | HTTP Live Streaming のサイトで
確認できるのですが、以下のような状態です


Google Chrome(ver86.0.4240.198 Mac版)


Mac safari(バージョン14.0 (15610.1.28.1.9, 15610))


えぇ……(困惑)
なので、対応が必要です。ま、まぁFireFoxも再生できないみたいだし多少はね?
Youtube は Blob URL を使ってサーバーからストリーミングを流しているようですし
ちゃんとストリーミング受信の処理を各々用意してねってことなんでしょうか。知らんけど。
しょうがないので hls.js を導入して再生できるようにしましょう。

hls.js は ESModule 対応版が無い(2020/11時点)

ここが一番困りました。
hls.js の README.md にもISSUEにもあるんですが
npm から持ってきて自前で配信するなら webpack 使ってね! とのことみたいです。

  • video-dev / hls.js | GitHub
    • "To build our distro bundle and serve our development environment we use Webpack."
    • 「バンドルを構築して開発環境にサービスを提供するには、Webpackを使用します。」
    • https://github.com/video-dev/hls.js
  • Provide an ES Modules build for modern browsers/tools #2910 | video-dev / hls.js | GitHub
  • How to import Hls.js from another file #2911 | video-dev / hls.js | GitHub

そんなぁ。
ということでいろいろ試しまして、以下の方針で行くことにしました。かなり強引です。

  1. hls.js の型定義ファイルだけを導入
  2. TypeScript で import して書く
  3. トランスパイルする
  4. トランスパイル後に import 文のあたりは置換で消す

正直、一度作ってしまえばあまり触るようなところでもないので
こんな方法で TypeScript で実装できるようにしなくても
webpack なしで hls.js を導入するなら
hls.js を使う部分だけ JavaScript で書いちゃった方が良いですね。
全くオススメできないですが、今回はやってみてできたので記事に書いちゃおうと思いまして……。

hls.js の型定義だけを導入する

hls.js は型定義ファイルが DefinitelyTyped で提供されています。

yarn add @types/hls.js --dev

hls.js で動画を再生する

まぁこんな感じです。
hls.js の解説がすごくわかりやすいので読めばわかります。
hls.js demo のサイトのソースコードを読むのも早いです。
./src/ts/video.ts

import Hls from "hls.js";

const videoSourceUrl = (document.getElementById("video_source") as HTMLSpanElement).innerText;
const vidoElement = document.getElementById("video") as HTMLMediaElement;
if (Hls.isSupported()) {
  const hls = new Hls();
  hls.loadSource(videoSourceUrl);
  hls.attachMedia(vidoElement);
  hls.on(Hls.Events.MEDIA_ATTACHED, function () {
    console.log("video and hls.js are now bound together !");
    vidoElement.style.display = "block";
    vidoElement.muted = true;
    vidoElement.play();

    hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
      console.log("manifest loaded, found " + data.levels.length + " quality level");
    });
  });
} if (vidoElement.canPlayType('application/vnd.apple.mpegurl')) {
  vidoElement.src = videoSourceUrl;
  vidoElement.addEventListener('canplay',function() {
    vidoElement.style.display = "block";
    vidoElement.play();
  });
}

再生側のhtmlはこんな感じにします。hls.js は CDN から持ってくるようにします。
source URL は非表示DOMから持ってくることにしました。

<!DOCTYPE html>
<html>
  <head>
    <title>video player test</title>
    <link href="./css/global.css" rel="stylesheet" type="text/css">
    <link href="./css/tailwindcss.css" rel="stylesheet" type="text/css">
    <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
    <script type="module" src="./js/video.js"></script>
    <style>[hidden] { display: none !important; }</style>
  </head>
  <body>
    <h1>video player</h1>
    <video id="video" controls width="640" height="360" style="display: none;"></video>

    <div hidden>
      <span hidden id="video_source">https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8"</span>
    </div>
  </body>
</html>

トランスパイル後に replace でimport 文のあたりは置換で消す

こんなことをします。わぁお強引。
トランスパイル後に import を抜いて、コードを改変するから sourcemap も消すってことですね。

package.json

  "scripts": {
    "replacehls": "replace 'import Hls from \"hls.js\";' '' ./public/js/video.js",
    "replacemap": "replace '//# sourceMappingURL=video.js.map' '' ./public/js/video.js",
 },

で、こうします。

yarn ttsc
yarn replacehls
yarn replacemap

こんな JavaScript になります。
vidoElement.style.display = "block"; してるのは再生の準備が完了するまで
要素を非表示にしてるからです。上記HTML側で style="display: none;" してあります。
./public/js/video.js

const videoSourceUrl = document.getElementById("video_source").innerText;
const vidoElement = document.getElementById("video");
if (Hls.isSupported()) {
    const hls = new Hls();
    hls.loadSource(videoSourceUrl);
    hls.attachMedia(vidoElement);
    hls.on(Hls.Events.MEDIA_ATTACHED, function () {
        console.log("video and hls.js are now bound together !");
        vidoElement.style.display = "block";
        vidoElement.muted = true;
        vidoElement.play();
        hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
            console.log("manifest loaded, found " + data.levels.length + " quality level");
        });
    });
}
if (vidoElement.canPlayType('application/vnd.apple.mpegurl')) {
    vidoElement.src = videoSourceUrl;
    vidoElement.addEventListener('canplay', function () {
        vidoElement.style.display = "block";
        vidoElement.play();
    });
}

できた

できました。

今回のリポジトリはここの一部にしてあります。

https://github.com/JUNKI555/yarn_run_practice04

なんで上記実行画面でミュートになってるの?

前述しましたが、
Google Chrome はユーザーのアクションなしには動画を再生できない様になっているので
自動再生させるなら video.muted = true が必要です。
再生ボタンを出して上げる方が親切かなとは思いました。
Youtubeやニコニコ動画は自動再生するけどどうなってるんだろう……。

なんで webpack 使わないの?

私もそう思います。

なんで非表示要素に source url 埋め込んでるの?

Ruby の ERB でレスポンスするときに埋め込んで置ければ
JavaScritp 側でURLを取得するのが楽だからです。
ページ表示してから fetch API で取得してきても良いんですけどね。

その他の参考サイト