<canvas>要素の内容をPicture in Pictureで表示する。

この記事は、[ニフティグループ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/nifty) 5日目の記事です。

昨日は @hajimete さんの「[Slackに追加されたカスタム絵文字を通知するBOTを作った](https://qiita.com/hajimete/items/226759d0abd5350fabde)」でした。
毎日のように絵文字が追加されているとこういうBOTもありがたいですね！

# はじめに
Picture in Picture(PiP)は、[&lt;video&gt;要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/video)を常に最前面に表示されるウィンドウ上に表示できる機能です。
本記事では、[&lt;canvas&gt;要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/canvas)に描画された内容をPicture in Pictureで表示したいと思います。

おおまかな流れは以下のとおりです。

1. &lt;canvas&gt;要素に色々描画する。
1. &lt;canvas&gt;要素の内容を&lt;video&gt;要素のメディアソースにする。
1. &lt;video&gt;要素をPicture in Pictureで表示する。


今回は例として現在時刻をPicture in Pictureで表示してみたいと思います。

# (1) &lt;canvas&gt;要素に現在時刻を描画する。
まずは&lt;canvas&gt;要素に現在時刻を描画してみます。

```html:01_canvas.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>現在時刻</title>
    <script type="text/javascript">
        function draw() {
            const canvas = document.querySelector('#canvas');
            const ctx = canvas.getContext('2d');

            // キャンバスをクリアする
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            // 現在時刻を描画
            ctx.font = "48px serif";
            ctx.fillText(getCurrentTimeStr(), 10, 50);
        }

        function getCurrentTimeStr() {
            const date = new Date();
            const hour = String(date.getHours()).padStart(2, '0');
            const min = String(date.getMinutes()).padStart(2, '0');
            const sec = String(date.getSeconds()).padStart(2, '0');
            return `${hour}:${min}:${sec}`;
        }

        // <body>が読み込まれたら呼び出される。
        function init() {
            draw();

            // 1秒以内のズレが生じるが1000ミリ秒ごとに描画
            setInterval(draw, 1000);
        }
    </script>
</head>

<body onload="init();">
    <canvas id="canvas" width="200" height="60"></canvas>
</body>

</html>
```

ブラウザで開くと現在時刻が表示されていること（さらに1秒毎に更新されていること）が確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/914590/a0ad4fd5-7727-d9d3-c187-17f9ed8e6b28.png)

# (2) &lt;canvas&gt;要素の内容を&lt;video&gt;要素のメディアソースにする。
次に&lt;canvas&gt;要素に描画された内容を&lt;video&gt;要素のメディアソースとしてみます。
変更箇所を抜粋したソースコードは以下の通りです。

```html:02_video.htmlの一部
・・・
    <script type="text/javascript">
        ・・・

        function init() {
            ・・・

            const canvas = document.querySelector('#canvas');
            const video = document.querySelector('#video');
            // <canvas>要素の内容を<video>要素のメディアソースに設定する。
            video.srcObject = canvas.captureStream();
            // <video>要素を自動再生するためにmuteにする
            video.muted = true;
            video.play();
        }
    </script>
</head>

<body onload="init();">
    canvas:<br>
    <canvas id="canvas" width="200" height="60"></canvas><br>
    video:<br>
    <video id="video" width="200" height="60"></video>
</body>

</html>
```

ここでポイントなのが、以下の部分です。
[HTMLCanvasElement.captureStream()](https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/captureStream) で&lt;canvas&gt;要素からリアルタイムにキャプチャした動画を取得し、それを&lt;video&gt;要素のメディアソース([srcObject](https://developer.mozilla.org/ja/docs/Web/API/HTMLMediaElement/srcObject))に設定しています。
こうすることで、&lt;canvas&gt;要素で描画された内容が&lt;video&gt;要素に描画されます。

```javascript
const canvas = document.querySelector('#canvas');
const video = document.querySelector('#video');
// <canvas>要素の内容を<video>要素のメディアソースに設定する。
video.srcObject = canvas.captureStream();
```

また、&lt;video&gt;要素を自動再生するために&lt;video&gt;要素のミュートをONにしています。

```javascript
video.muted = true;
video.play();
```

> Chrome's autoplay policies are simple:
> 
> - Muted autoplay is always allowed.
> 
> 引用：[Autoplay policy in Chrome \- Chrome Developers](https://developer.chrome.com/blog/autoplay/#new-behaviors)

ブラウザで開くと&lt;canvas&gt;要素（上）、&lt;video&gt;要素（下）に現在時刻が表示されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/914590/a0beed29-532b-aa15-62e5-d8ab313002c9.png)
下の現在時刻を右クリックすると&lt;video&gt;要素特有のメニューが表示されるかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/914590/3c13964f-8794-975f-5379-d6a0a78805c1.png)

<details><summary>現時点での全体のソースコード</summary><div>

```html:02_video.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>現在時刻</title>
    <script type="text/javascript">
        function draw() {
            const canvas = document.querySelector('#canvas');
            const ctx = canvas.getContext('2d');

            // キャンバスをクリアする
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            // 現在時刻を描画
            ctx.font = "48px serif";
            ctx.fillText(getCurrentTimeStr(), 10, 50);
        }

        function getCurrentTimeStr() {
            const date = new Date();
            const hour = String(date.getHours()).padStart(2, '0');
            const min = String(date.getMinutes()).padStart(2, '0');
            const sec = String(date.getSeconds()).padStart(2, '0');
            return `${hour}:${min}:${sec}`;
        }

        // <body>が読み込まれたら呼び出される。
        function init() {
            draw();

            // 1秒以内のズレが生じるが1000ミリ秒ごとに描画
            setInterval(draw, 1000);

            const canvas = document.querySelector('#canvas');
            const video = document.querySelector('#video');
            // <canvas>要素の内容を<video>要素のメディアソースに設定する。
            video.srcObject = canvas.captureStream();
            // <video>要素を自動再生するためにmuteにする
            video.muted = true;
            video.play();
        }
    </script>
</head>

<body onload="init();">
    canvas:<br>
    <canvas id="canvas" width="200" height="60"></canvas><br>
    video:<br>
    <video id="video" width="200" height="60"></video>
</body>

</html>
```
</div></details>

# (3) Picture in Pictureで表示する。
次に、&lt;video&gt;要素の動画（&lt;canvas&gt;要素の内容）をPicture in Pictureで表示するボタンを作ってみます。
（&lt;video&gt;要素の右クリックメニューからでも出来なくはないですが…）
変更箇所を抜粋したソースコードは以下の通りです。

```html:03_pip.htmlの一部
・・・
    <script type="text/javascript">
        ・・・

        function init() {
            ・・・・

            const pip = document.querySelector('#button-pip');
            pip.addEventListener('click', function () {
                video.requestPictureInPicture();
            });
        }
    </script>
</head>

<body onload="init();">
    ・・・
    <br>
    <input type="button" id="button-pip" value="enter picture in picture">
</body>
```

[HTMLVideoElement.requestPictureInPicture()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture) で&lt;video&gt;要素の動画をPicture in Pictureで表示するようにしています。

ブラウザで開いて「enter picture in picture」ボタンを押すと、Picture in Pictureのウィンドウは表示されますが時刻が表示されていません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/914590/02398ed1-d735-36f4-8e3a-e345163fddef.png)
これはただ、Picture in Pictureのウィンドウの背景色と現在時刻を表示するテキストの文字色が同じなだけなので、&lt;canvas&gt;要素の背景を白色に塗りつぶしましょう。

```html:03_pip_fix.htmlの一部
・・・
    <script type="text/javascript">
        function draw() {
            const canvas = document.querySelector('#canvas');
            const ctx = canvas.getContext('2d');

            // キャンバスをクリアする
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            // 背景を白色にする
            ctx.fillStyle = "rgb(255, 255, 255)";
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            // 現在時刻を描画
            ctx.fillStyle = "rgb(0, 0, 0)";
            ctx.font = "48px serif";
            ctx.fillText(getCurrentTimeStr(), 10, 50);
        }

        ・・・
    </script>
・・・
```

再度ブラウザで開いて「enter picture in picture」ボタンを押すと、Picture in Pictureのウィンドウに現在時刻が表示されていることが確認できるかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/914590/059e0add-5786-2793-72ad-c0e6d191f917.png)
&lt;canvas&gt;要素の内容をPicture in Pictureで表示できました！

<details><summary>完成品のソースコード</summary><div>

```html:03_pip_fix.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>現在時刻</title>
    <script type="text/javascript">
        function draw() {
            const canvas = document.querySelector('#canvas');
            const ctx = canvas.getContext('2d');

            // キャンバスをクリアする
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            // 背景を白色にする
            ctx.fillStyle = "rgb(255, 255, 255)";
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            // 現在時刻を描画
            ctx.fillStyle = "rgb(0, 0, 0)";
            ctx.font = "48px serif";
            ctx.fillText(getCurrentTimeStr(), 10, 50);
        }

        function getCurrentTimeStr() {
            const date = new Date();
            const hour = String(date.getHours()).padStart(2, '0');
            const min = String(date.getMinutes()).padStart(2, '0');
            const sec = String(date.getSeconds()).padStart(2, '0');
            return `${hour}:${min}:${sec}`;
        }

        // <body>が読み込まれたら呼び出される。
        function init() {
            draw();

            // 1秒以内のズレが生じるが1000ミリ秒ごとに描画
            setInterval(draw, 1000);

            const canvas = document.querySelector('#canvas');
            const video = document.querySelector('#video');
            // <canvas>要素の内容を<video>要素のメディアソースに設定する。
            video.srcObject = canvas.captureStream();
            // <video>要素を自動再生するためにmuteにする
            video.muted = true;
            video.play();

            const pip = document.querySelector('#button-pip');
            pip.addEventListener('click', function () {
                video.requestPictureInPicture();
            });
        }
    </script>
</head>

<body onload="init();">
    canvas:<br>
    <canvas id="canvas" width="200" height="60"></canvas><br>
    video:<br>
    <video id="video" width="200" height="60"></video>
    <br>
    <input type="button" id="button-pip" value="enter picture in picture">
</body>

</html>
```
</div></details>


# 注意：自動でPicture in Pictureを表示することはできない。
ページを開いた瞬間にPicture in Pictureで自動表示するということは出来ません。

```html:04_pip_error.html
・・・
    <script type="text/javascript">
        ・・・

        function init() {
            ・・・

            // エラー：自動でPicture in Pictureを実行することはできない。
            video.addEventListener('loadeddata', function () {
                video.requestPictureInPicture();
            })
        }
    </script>
・・・
```

ブラウザで開いても、Picture in Pictureで自動表示されていないことが確認できるかと思います。
Chrome開発者ツールでコンソールを確認すると以下のようなエラーが出ています。
> Uncaught (in promise) DOMException: Failed to execute 'requestPictureInPicture' on 'HTMLVideoElement': Must be handling a user gesture if there isn't already an element in Picture-in-Picture.

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/914590/d978c52e-e5d3-fb04-9f4b-ab22fc4d7140.png)


ユーザージェスチャー経由でないとPicture in Pictureで表示することが出来ないそうです。

> The good news is that a Picture-in-Picture Web API specification is being drafted as we speak. This spec aims to allow websites to initiate and control this behavior by exposing the following set of properties to the API:
> 
> - Notify the website when a video enters and leaves Picture-in-Picture mode.
> - **Allow the website to trigger Picture-in-Picture on a video element via a user gesture.**
> - Allow the website to exit Picture-in-Picture.
> - Allow the website to check if Picture-in-Picture can be triggered.
> 
> 引用：[Picture\-in\-Picture \(PiP\)  \|  Web  \|  Google Developers](https://developers.google.com/web/updates/2017/09/picture-in-picture)

Picture in Pictureを表示することができるユーザージェスチャーの一覧は以下のとおりです。
> An activation triggering input event is any event whose isTrusted attribute is true and whose type is one of:
> 
> - keydown, provided the key is neither the Esc key nor a shortcut key reserved by the user agent.
> - mousedown.
> - pointerdown, provided the event's pointerType is "mouse".
> - pointerup, provided the event's pointerType is not "mouse".
> - touchend.
> 
> 引用：[HTML Standard](https://html.spec.whatwg.org/multipage/interaction.html#user-activation-processing-model)

参考：[javascript \- Why video\.requestPictureInPicture\(\) works only once? \- Stack Overflow](https://stackoverflow.com/questions/56252108/why-video-requestpictureinpicture-works-only-once/56252704#56252704)
参考：[HTML Standard (WebArchive 2019/05/21 10:47:15)](https://web.archive.org/web/20190521104715/https://html.spec.whatwg.org/multipage/interaction.html#triggered-by-user-activation)

# おわりに
今回は、&lt;canvas&gt;要素に描画された現在時刻をPicture in Pictureで表示しました。
&lt;canvas&gt;要素に描けるものであれば、[カウントダウンタイマーといったもの](https://blog.agney.dev/pomodoro-on-pip/)もPicture in Pictureで表示することができたりします。
みなさんも色々試してみてください！

明日の担当は@kasayu さんです。
よろしくおねがいします！

# 参考サイト
- [&lt;video&gt;: 動画埋め込み要素 \- HTML: HyperText Markup Language \| MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/video)
- [&lt;canvas&gt;: グラフィックキャンバス要素 \- HTML: HyperText Markup Language \| MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/canvas)
- [HTMLCanvasElement\.captureStream\(\) \- Web API \| MDN](https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/captureStream)
- [HTMLMedia​Element​\.src​Object \- Web API \| MDN](https://developer.mozilla.org/ja/docs/Web/API/HTMLMediaElement/srcObject)
- [Autoplay policy in Chrome \- Chrome Developers](https://developer.chrome.com/blog/autoplay/#new-behaviors)
- [HTMLVideoElement\.requestPictureInPicture\(\) \- Web APIs \| MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture)
- [Picture\-in\-Picture \(PiP\)  \|  Web  \|  Google Developers](https://developers.google.com/web/updates/2017/09/picture-in-picture)
- [HTML Standard #6.4.2 Processing model](https://html.spec.whatwg.org/multipage/interaction.html#user-activation-processing-model)
- [javascript \- Why video\.requestPictureInPicture\(\) works only once? \- Stack Overflow](https://stackoverflow.com/questions/56252108/why-video-requestpictureinpicture-works-only-once/56252704#56252704)
- [HTML Standard (WebArchive 2019/05/21 10:47:15)](https://web.archive.org/web/20190521104715/https://html.spec.whatwg.org/multipage/interaction.html#triggered-by-user-activation)
- [Let's make a Picture in Picture Countdown timer on the Web \| The Mindless](https://blog.agney.dev/pomodoro-on-pip/)