見出し画像

Document Picture-in-Picture でUIをお手軽ポップアウト

こんにちは!
株式会社jig.jpでウェブフロントエンド開発をしている M です。
この記事では Document Picture-in-Picture API について紹介します。

ピクチャー・イン・ピクチャー (PiP) といえば動画を最前面に小窓表示する機能で、動画を見ながら他の作業をしたい時に便利ですよね。

ウェブページで PiP で表示できるのはこれまで <video> 要素の動画コンテンツだけでしたが、Document Picture-in-Picture API を使うと、任意の HTML 要素を PiP で表示できます。

Document Picture-in-Picture API は 2024 年 10 月現在 Chrome、Edge、Opera のみ対応しています。

使い方

documentPictureInPicture.requestWindow() で PiP ウィンドウを開き、開いたウィンドウの document に要素を挿入して使います。

// PiP ウィンドウを開くにはユーザー操作が必要
buttonElement.addEventListener('click', async () => {
  // PiP ウィンドウを開く
  const pipWindow = await documentPictureInPicture.requestWindow();
  // PiP ウィンドウに要素を挿入
  pipWindow.document.body.append(contentElement);
});

サンプル

Document Picture-in-Picture を体験できるサンプルを作りました。
iframe 内からは PiP を開くことができないので、「Open Preview in new tab」で開いてください。

「Open Preview in new tab」

コントロールの「ポップアウト」ボタンを押すと、中の要素が PiP window に移動します。
PiP ウィンドウに移動した要素は同じインスタンスなので、引き続き入力を受け付けることができます。

プレビューの要素も同様に PiP 表示したあとも、引き続き元ウィンドウから操作できることが確認できます。

サンプルコードの解説

ボタンをクリックした時

まず、ボタンをクリックした時に openInPIP() という関数に PiP で表示したい要素を渡しています。

  popoutControls.addEventListener('click', () => {
    openInPIP(controls);
  });

  popoutPreview.addEventListener('click', () => {
    openInPIP(preview);
  });

ユーザー操作以外で PiP を開こうとした場合は次のエラーで reject されます。

Uncaught (in promise) NotAllowedError: Failed to execute 'requestWindow' on 'DocumentPictureInPicture': Document PiP requires user activation

PiP ウィンドウの表示

openInPIP() の中身を見ていくと、最初に documentPictureInPicture.requestWindow() で PiP ウィンドウを開いています。
width と height で初期サイズを設定できます。

  // Picture in Picture ウィンドウを開く
  const pipWindow = await documentPictureInPicture.requestWindow({
    width: width ?? target.clientWidth,
    height: height ?? target.clientHeight,
  });

スタイルシートをコピーする

PiP ウィンドウの中身はまっさらな Document なので、元ウィンドウで適用されているスタイルシートを維持するには、それを引き継ぐ必要があります。

初期の仕様では copyStyleSheets というオプションがありましたが、現在は廃止されているのでスタイルシートをコピーする処理を書きます。

参考: ピクチャー イン ピクチャー ウィンドウにスタイルシートをコピーする

  // スタイルシートをコピー
  [...document.styleSheets].forEach((styleSheet) => {
    try {
      const cssRules = [...styleSheet.cssRules]
        .map((rule) => rule.cssText)
        .join('');
      const style = document.createElement('style');

      style.textContent = cssRules;
      pipWindow.document.head.appendChild(style);
    } catch (e) {
      const link = document.createElement('link');

      link.rel = 'stylesheet';
      link.type = styleSheet.type;
      link.media = styleSheet.media;
      link.href = styleSheet.href;
      pipWindow.document.head.appendChild(link);
    }
  });

PiP で表示したい要素を移動させる

append で表示したい要素を移動させます。

  // PiP ウィンドウに要素を移動
  pipWindow.document.body.append(target);

PiP を閉じた時に元の位置に戻す

PiP ウィンドウに移動させた要素は閉じた時に自動で戻ってくれないので、元の位置に戻す必要がります。

目印となる要素を代わりに置くと、元の位置に戻しやすくなります。
今回は「Picture in Picture で表示中」というテキストを表示したかったので span 要素にしましたが、何も表示したくない場合はコメント要素をマーカーとして使うといいと思います。

要素を移動させる前に target.before(marker) で要素の手前にマーカー (span 要素) を置き、PiP ウィンドウが閉じられた時に marker.after(target) でマーカーの位置に要素を戻しています。

  // 元の位置に戻すためのマーカーを設置
  const marker = document.createElement('span');
  marker.textContent = 'Picture in Picture で表示中';
  target.before(marker);

  // PiP ウィンドウに要素を移動
  pipWindow.document.body.append(target);

  pipWindow.addEventListener('pagehide', () => {
    // PiP ウィンドウを閉じた時にマーカーの場所に要素を復元
    if (document.contains(marker)) {
      marker.after(target);
      marker.remove();
    }
  });

以上がサンプルの解説になります。

JS フレームワークと組み合わせて気づいたこと

PiPに移動しても要素のインスタンスは保持されるので、JS フレームワークと組み合わせても使えそうだなと思い、Angular アプリケーション内で要素を PiP してみました。

基本的に問題なく動きましたが、上記のサンプルコードのままでは一つ問題があり、Angular はコンポーネントをマウントするタイミングで head にスタイルシートを追加するので、それを PiP 側に随時コピーする必要がありました。

以下は head に追加されたスタイルシートを随時コピーするサンプルです。

async function openInPIP(target, width, height) {
  // Picture in Picture ウィンドウを開く
  const pipWindow = await documentPictureInPicture.requestWindow({
    width: width ?? target.clientWidth,
    height: height ?? target.clientHeight,
  });
  ...
  // head を監視して style 要素が追加されたら PiP のウィンドウにコピーする
  const mo = new MutationObserver((mutation) => {
    mutation.forEach(({ addedNodes }) => {
      if (addedNodes) {
        addedNodes.forEach((node) => {
          if (node.nodeName === 'STYLE') {
            pipWindow.document.head.append(node.cloneNode(true));
          }
        });
      }
    });
  });
  mo.observe(document.head, { childList: true });
  ...
  pipWindow.addEventListener('pagehide', () => {
    ...
    // MutationObserver を停止
    mo.disconnect();
  });
}

おわりに

UI をポップアウトする手段としては window.open() で開くサブウィンドウと似ていますが、サブウィンドウと比べて Document Picture-in-Picture は別途ページを作る必要がなく、現在のページの UI をシームレスに切り出せることが魅力かなと思います。
常に最前面に表示されるのもポイントですね。

主なユースケースとしては、独自の動画プレーヤーやビデオ会議などが挙げられていますが、他にもいろんな使い道が考えられそうです。

みなさんもぜひ PiP してみてください。

この記事が気に入ったらサポートをしてみませんか?