Subterranean Flower

JavaScriptで任意のHTML要素をPicture-in-Pictureする

Author
古都こと
ふよんとえんよぷよぐやま!!!

みなさんはPicture-in-Picture(PiP)という機能を使ったことがありますか。PiPは動画コンテンツなどを浮遊する小窓に表示する機能です。小窓はウィンドウの外側を自由に移動できます。

pip-chrome

デスクトップが賑やかになりがちなPCでもPiPは大活躍なのですが、特にスマートフォンにおいては数少ない「ウィンドウ」機能になります。Androidはもちろん、iOS14も対応したことで話題になりました。

これによってスマホ一台あれば、ソシャゲの公式生放送を見ながらソシャゲのイベントを周回する地獄のような行為が可能になりました。

利用者という視点から見ると非常に便利なのですが、開発者から見ると動画しか表示できないのはなかなか使い所が難しくなります。そこで、この機能を使って好きな情報を表示できないか実験してみました。

PiP機能の対応環境

  • Chrome 70
  • Firefox 71(制限付き)
  • macOS/iPad Safari 13.1
  • iPhone Safari 14

PiP自体はどの環境も対応しています。iOS自体は13.1から対応していたのですがiPadのみ対応で、iPhoneにも機能が解放されたのはiOS14になります。

今回の目標と対象外環境

今回は好きなHTML要素をPiPしてみたいと思います。曲芸飛行みたいなことするのでちゃんと動かない、対象外の環境が2つあります。

ひとつはFirefoxです。PiP自体には対応しているのですが制限付きで、JavaScript側から操作できません。なので今回は対象外となります。

もうひとつはiOS Safariです。今回使う機能がバグってます。数時間格闘したんですが真っ黒な画面しか出ませんでした。2018年からずっと報告されてるんですが特に動きはないようです。

実現手順

PiPが動画にしか対応していない部分は変わりません。なのでHTMLを動画にしてやれば任意の要素をPiPできるわけですね!

このような動作イメージです:

pip-demo

次の手順で実現します:

  1. HTMLを画像に落とし込む
  2. 画像化したHTMLをcanvasに書き込む
  3. canvasから動画ストリームを取得する
  4. video要素を作成してcanvasの動画ストリームを再生させる
  5. そのvideo要素をPiPする

理屈の上ではできそうですね。それではやっていきましょう。

HTMLをcanvasに描画する

次のようなHTMLがあるとします:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Picture in Picture</title>
    <script src="main.js" defer></script>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <div id="pip-source">
      <img src="https://source.unsplash.com/9UUoGaaHtNE">
      <span class="message">Hellooooooooooooo</span>
    </div>
    <button id="pip-button">PiP them</button>
  </body>
</html>

CSSはこうです:

#pip-source {
  background-color: white;
  text-align: center;
  width: 320px;
  padding: 1em;
}

#pip-source img {
  display: inline-block;
  object-position: center center;
  object-fit: contain;
  width: 100%;
}

:picture-in-picture {
  display: none;
}

pip-source

このとき #pip-source の要素をPiPの対象にしたいとします。これを画像化すればよいわけですね。

画像化にはsvgを使います。svgには foreignObject という仕組みがあり、その中の描画をブラウザに任せる、ということができます。一方でsvgはブラウザ上では画像として扱うことができ、ブラウザの描画結果を画像として扱うことができるようになります。

svgを組み立てるところまで一気に行きますね。

const pipSource = document.querySelector('#pip-source');
const pipButton = document.querySelector('#pip-button');
pipButton.style.opacity = 0.3;

const setup = async() => {
  // 描画する要素のサイズを取得したい
  // 画像の読み込みが終わるまで待つ
  const waitingImgList = [...pipSource.querySelectorAll('img')];
  await Promise.all(waitingImgList.map((el) => new Promise((resolve) => {
    el.addEventListener('load', () => resolve());
  })));

  // 描画する要素のサイズを取得する
  // 注意:重い処理なので頻繁に呼び出さないように
  const { width, height } = pipSource.getBoundingClientRect();

  // svgタグを作る
  // SVGはHTMLではないので作り方がちょっと違う
  // 決められたNS(Namespace)を指定して作る
  const ns = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(ns, 'svg');
  svg.setAttribute('width', width);
  svg.setAttribute('height', height);

  // foreignObjectを作る
  // foreignObjectはその中身の描画を
  // SVG自身ではなく外側(ここではブラウザ)に任せる
  const foreignObject = document.createElementNS(ns, 'foreignObject');
  foreignObject.setAttribute('width', width);
  foreignObject.setAttribute('height', height);

  // 表示したい要素のコピーを作る
  // xmlとしてのNSを追加
  const html = pipSource.cloneNode(true);
  html.style.display = 'content';

  // 画像をすべてDataURLに置き換える
  const imgList = [...html.querySelectorAll('img')];
  await Promise.all(imgList.map(async (el) => {
    const data = await fetch(el.src).then((res) => res.blob());
    const reader = new FileReader();
    const url = await new Promise((resolve) => {
      reader.onload = () => resolve(reader.result);
      reader.readAsDataURL(data);
    });
    el.src = url;
    return el.decode();
  }));

  // 描画に必要そうなものを作っておく
  const style = document.createElement('style');
  const css = await fetch('style.css').then((res) => res.text());
  style.textContent = css;

  // 組み立てる
  html.appendChild(style);
  foreignObject.appendChild(html);
  svg.appendChild(foreignObject);
}

これ自体は簡単なのですがひとつ注意点があります。それはsvg内の画像やcssは外部ファイルであってはいけないということです。svgを直接HTMLの中に記述するならそれも動くのですが、画像として扱う場合はsvgの中で全て完結する必要があります。今回はスクリプトは使っていませんが、スクリプトを使用するならそれもインライン化が必要です。

これでDOMとしてのsvgが出来上がりました。img要素に放り込めば画像として取得できるようになるのですが、残念ながらimg要素はDOMを受け付けません。なのでこれをData URLに変換します。文字列化してくっつけるだけです。

const setup = async() => {
  //
  // ↑ここに先ほどまでの処理がある
  //

  // svgを文字列化してURL化する
  const svgStr = new XMLSerializer().serializeToString(svg);
  const svgUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);

  // svgを画像として読み込む
  const img = new Image(width, height);
  img.src = svgUrl;
  await img.decode();
}

あとはこれをcanvasに転写します

const setup = async() => {
  //
  // ↑ここに先ほどまでの処理がある
  //

  // canvasを作る
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = width;
  canvas.height = height;

  (function render() {
    ctx.clearRect(0, 0, width, height);
    ctx.drawImage(img, 0, 0, img.width, img.height);
    requestAnimationFrame(() => render());
  })();
}

canvasに対して drawImage するだけですね。ここで requestAnimationFrame している理由なんですが、drawImageは静止画の転写になるのでこうしないとsvgにCSSアニメーションなどが入っていた場合に反映できないというのと、あとはなぜかこうしないとSafariが描画してくれなかったからですね。

とりあえずcanvasまではできました。

canvasをvideoに流し込む

次に先ほど作ったcanvasを動画としてvideoに流し込みます。なんだか難しそうに聞こえますが、標準機能だけでできます。iOS Safariはこの機能がバグってて実現できないんですけどね……。

const setup = async() => {
  //
  // ↑ここに先ほどまでの処理がある
  //

  // canvasを動画として取得
  const stream = canvas.captureStream(60);

  // canvasを表示するだけのvideo要素を作る
  const video = document.createElement('video');
  video.autoplay = true;
  video.muted = true;
  video.playsInline = true;
  video.width = width;
  video.height = height;
  video.srcObject = stream;

  await new Promise((resolve) => {
    video.ontimeupdate = () => {
      resolve();
    }
    video.play();
  });

  return video;
}

canvasに captureStream というメソッドが生えてますね。これは指定したフレームレートで動画として書き出してくれる機能です。今回は60fpsです。引数を省略するとcanvas更新時のみ動画も更新され、0に指定すると手動で引っ張ってこないと更新されなくなります。

また、video要素を勝手に再生するときはmutedとplaysInlineが必須です。ユーザジェスチャーがあればよいのですが、JavaScript側で先行して再生したい時などはこれらをtrueにしておきましょう。

あとはPromiseで見苦しいことをやっているのですが、こうしないとmacOSのSafariで動きませんでした。macOSのSafariではplay直後に一度suspendしたあとにバッファ溜まってから再生再開という挙動になっていたので、timeupdateを待つと確実だったというわけです。

動画をPiPにする

あとはPiPにするだけです。

//
// ↑このあたりにさっきの関数がある
//

(async function main() {
  const video = await setup();
  pipButton.style.opacity = 1;

  video.onenterpictureinpicture = () => {
    video.style.display = 'none';
  }

  video.onleavepictureinpicture = () => {
    video.remove();
  }

  pipButton.addEventListener('click', (event) => {
    document.body.appendChild(video);
    video.play();
    video.requestPictureInPicture();
  });
})();

PiPにするにはvideo要素の requestPictureInPicture というメソッドを呼び出すだけなのですが、呼び出すにはユーザジェスチャー(クリックイベント等)必須なので注意してください。

video要素をbodyに追加しているのはSafari対策です。一瞬だけでも表示させないとPiPが真っ黒になったので……。このあたりに関してはSafariのほうが挙動的に正しそうな気もしますけどね。

実行

あとはブラウザで開いて動作確認です。fetchとか使ってるのでファイルから開くのではなく適当にサーバ立ててください。よくわからなかったら適当に python3 -m http.server とか叩くとサーバ起動します。

pip-demo

ボタンを押せばHTML要素がPiPになるはずです!

その他

この方法ではアニメーションを含むgifやwebpを扱えません。常に最初のフレームが表示されます。アニメーション画像や動画を使いたい場合は、自力でフレームをバラバラにして次々に描画する必要があると思います。一方でCSSアニメーションは大丈夫です。

また、あくまで要素全体をコピーしているだけなので、ユーザのインタラクション等は再現できません。あくまである程度静的なコンテンツを表示するためのトリックです。

プログラム全体

HTML

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Picture in Picture</title>
    <script src="main.js" defer></script>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <div id="pip-source">
      <img src="https://source.unsplash.com/9UUoGaaHtNE">
      <span class="message">Hellooooooooooooo</span>
    </div>
    <button id="pip-button">PiP them</button>
  </body>
</html>

CSS

#pip-source {
  background-color: white;
  text-align: center;
  width: 320px;
  padding: 1em;
}

#pip-source img {
  display: inline-block;
  object-position: center center;
  object-fit: contain;
  width: 100%;
}

:picture-in-picture {
  display: none;
}

JavaScript

const pipSource = document.querySelector('#pip-source');
const pipButton = document.querySelector('#pip-button');
pipButton.style.opacity = 0.3;

const setup = async() => {
  // 描画する要素のサイズを取得したい
  // 画像の読み込みが終わるまで待つ
  const waitingImgList = [...pipSource.querySelectorAll('img')];
  await Promise.all(waitingImgList.map((el) => new Promise((resolve) => {
    el.addEventListener('load', () => resolve());
  })));

  // 描画する要素のサイズを取得する
  // 注意:重い処理なので頻繁に呼び出さないように
  const { width, height } = pipSource.getBoundingClientRect();

  // svgタグを作る
  // SVGはHTMLではないので作り方がちょっと違う
  // 決められたNS(Namespace)を指定して作る
  const ns = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(ns, 'svg');
  svg.setAttribute('width', width);
  svg.setAttribute('height', height);

  // foreignObjectを作る
  // foreignObjectはその中身の描画を
  // SVG自身ではなく外側(ここではブラウザ)に任せる
  const foreignObject = document.createElementNS(ns, 'foreignObject');
  foreignObject.setAttribute('width', width);
  foreignObject.setAttribute('height', height);

  // 表示したい要素のコピーを作る
  // xmlとしてのNSを追加
  const html = pipSource.cloneNode(true);
  html.style.display = 'content';

  // 画像をすべてDataURLに置き換える
  const imgList = [...html.querySelectorAll('img')];
  await Promise.all(imgList.map(async (el) => {
    const data = await fetch(el.src).then((res) => res.blob());
    const reader = new FileReader();
    const url = await new Promise((resolve) => {
      reader.onload = () => resolve(reader.result);
      reader.readAsDataURL(data);
    });
    el.src = url;
    return el.decode();
  }));

  // 描画に必要そうなものを作っておく
  const style = document.createElement('style');
  const css = await fetch('style.css').then((res) => res.text());
  style.textContent = css;

  // 組み立てる
  html.appendChild(style);
  foreignObject.appendChild(html);
  svg.appendChild(foreignObject);

  // svgを文字列化してURL化する
  const svgStr = new XMLSerializer().serializeToString(svg);
  const svgUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);

  // svgを画像として読み込む
  const img = new Image(width, height);
  img.src = svgUrl;
  await img.decode();

  // canvasを作る
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = width;
  canvas.height = height;

  (function render() {
    ctx.clearRect(0, 0, width, height);
    ctx.drawImage(img, 0, 0, img.width, img.height);
    requestAnimationFrame(() => render());
  })();

  // canvasを動画として取得
  const stream = canvas.captureStream(60);

  // canvasを表示するだけのvideo要素を作る
  const video = document.createElement('video');
  video.autoplay = true;
  video.muted = true;
  video.playsInline = true;
  video.width = width;
  video.height = height;
  video.srcObject = stream;

  await new Promise((resolve) => {
    video.ontimeupdate = () => {
      resolve();
    }
    video.play();
  });

  return video;
};

(async function main() {
  const video = await setup();
  pipButton.style.opacity = 1;

  video.onenterpictureinpicture = () => {
    video.style.display = 'none';
  }

  video.onleavepictureinpicture = () => {
    video.remove();
  }

  pipButton.addEventListener('click', (event) => {
    document.body.appendChild(video);
    video.play();
    video.requestPictureInPicture();
  });
})();