動画を見ながらコンテンツを読みたい〜Picture-in-Pictureで実現してみた〜
はじめに
サイトの上部にチュートリアル的な動画があり、その下に詳細のドキュメントがあるので、動画を見ながらドキュメントを読むのが煩わしいと感じたことはありませんか?
わざわざ同じページを複数のタブで開いて、動画を見る用とドキュメントを読む用とで使い分けたことはありませんか?
この不便さを解消するのがDocument Picture-in-Picture APIです。
本記事では実装例を提示しながらAPIの使用方法を紹介していきます。
Document Picture-in-Picture APIとは
Document Picture-in-Picture APIは、ブラウザ上でウェブコンテンツを小さなフローティングウィンドウとして表示できる機能を提供するAPIです。
このフローティングウィンドウは、他のタブやアプリケーションを操作している間も画面上に表示され続け、ユーザーがアプリケーションを操作しながら閲覧することを可能にします。
次の画像は、Document Picture-in-Picture APIを利用した実際の例です。
引用: https://developer.chrome.com/blog/the-future-of-picture-in-picture?hl=ja
Picture-in-Picture APIとの違い
実は、Picture-in-Picture APIというWeb APIも存在します。こちらはHTMLのvideo要素のみをフローティングウィンドウに表示するためのAPIとなります。
名前が似ているため混同しやすいですが、もともとWeb APIとしてPicture-in-Picture APIが提供されていたものを、video要素以外の要素でも使用可能にしたAPIがDocument Picture-in-Picture APIとなります
ブラウザのサポート状況
それぞれサポート状況を見てみましょう。
Document Picture-in-Picture API
2024年12月現在、本APIが使用できる主要ブラウザは、Chrome, Edgeです。Safari, FireFoxはサポートされていません。
Picture-in-Picture API
2024年12月現在、本APIが使用できる主要ブラウザは、Chrome, Edge, Safariです。FireFoxはサポートされていません。
実装例
今回は、Document Picture-in-Picture APIを使用して、動画をPicture-in-Picture上で閲覧できるアプリを作成していきます。
実装環境
- next: 15.0.3
- react: 18.3.1
Picture-in-Pictureさせたい要素の準備
まずはPiPさせる動画を表示するコンポーネントを作成します。
適当なmp4ファイルを用意してvideoタグで視聴できるようにして、PiPを開閉するためのbuttonタグを実装します。
export function Component() {
return (
<div>
<video controls width={800}>
<source src="/sample.mp4" />
</video>
<button type="button">Toggle Picture-in-Picture</button>
</div>
);
}
Picture-in-Pictureを開く
PiPを開くにはwindow.documentPictureInPicture.requestWindowメソッドを使用します。このメソッドは引数にオプションを受け取り、PromiseでラップされたWindowを返します。
返り値のWindowは後ほどの処理で必要になるのでrefに代入することとします。
これでbuttonをクリックするとPiPが表示されると思います。
export function Component() {
// Picture-in-Picture の参照を保持
const pipWindowRef = useRef<Window | null>(null);
async function onClick() {
// Picture-in-Picture を開く
pipWindowRef.current = await window.documentPictureInPicture.requestWindow({
// ウィンドウのサイズを指定
width: 400,
height: 240,
// [タブに戻る] ボタンを非表示にする
disallowReturnToOpener: true,
// デフォルトの位置とサイズで開く
preferInitialWindowPlacement: true,
});
}
return (
<div>
<video controls width={800}>
<source src="/sample.mp4" />
</video>
<button type="button" onClick={onClick}>Toggle Picture-in-Picture</button>
</div>
);
}
Picture-in-Pictureに動画を表示する
PiPは表示されても中身は真っ白で何も表示されていないと思うので、ここに動画を表示させます。
DOM操作を用いてvideo要素を先程取得したPiPのWindowに移動します。
これでPiPを開くと動画が表示されるようになりました。
export function Component() {
// 動画プレーヤーの参照を保持
const videoRef = useRef<HTMLVideoElement | null>(null);
const pipWindowRef = useRef<Window | null>(null);
async function onClick() {
pipWindowRef.current = await window.documentPictureInPicture.requestWindow({
width: 400,
height: 240,
disallowReturnToOpener: true,
preferInitialWindowPlacement: true,
});
if (!pipWindowRef.current || !videoRef.current) return;
// Picture-in-Picture に動画プレーヤーを移動
pipWindowRef.current.document.body.append(videoRef.current);
}
return (
<div>
<video controls width={800} ref={videoRef}>
<source src="/sample.mp4" />
</video>
<button type="button" onClick={onClick}>
Toggle Picture-in-Picture
</button>
</div>
);
}
Picture-in-Pictureを閉じる
続いては、PiPが開いてる状態でbuttonをクリックしたら閉じる実装をします。
closeメソッドを使用します。
これでbuttonをクリックしたら閉じれるようになりました。
export function Component() {
const videoRef = useRef<HTMLVideoElement | null>(null);
const pipWindowRef = useRef<Window | null>(null);
async function onClick() {
// Picture-in-Picture が立ち上がっていれば閉じる
if (pipWindowRef.current) {
pipWindowRef.current.close();
pipWindowRef.current = null;
return;
}
pipWindowRef.current = await window.documentPictureInPicture.requestWindow({
width: 400,
height: 240,
disallowReturnToOpener: true,
preferInitialWindowPlacement: true,
});
if (!pipWindowRef.current || !videoRef.current) return;
pipWindowRef.current.document.body.append(videoRef.current);
}
return (
<div>
<video controls width={800} ref={videoRef}>
<source src="/sample.mp4" />
</video>
<button type="button" onClick={onClick}>
Toggle Picture-in-Picture
</button>
</div>
);
}
Picture-in-Pictureさせた要素を元に戻す
上記の実装だと閉じた後、video要素自体が消えたままになってしまいます。
PiPにあるvideo要素を元の表示位置に戻す必要がありますが、これもDOM操作で行います。
video要素の親要素にもrefを付与して、その要素にprependすることで元に戻します。
これでbuttonのクリックではなく、PiPを直接閉じた場合もvideo要素が元に戻ると思います。
export function Component() {
// 動画プレーヤーの親要素の参照を保持(動画プレーヤーを Picture-in-Picture から戻すために使用)
const videoWrapperRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const pipWindowRef = useRef<Window | null>(null);
async function onClick() {
if (pipWindowRef.current) {
pipWindowRef.current.close();
pipWindowRef.current = null;
return;
}
pipWindowRef.current = await window.documentPictureInPicture.requestWindow({
width: 400,
height: 240,
disallowReturnToOpener: true,
preferInitialWindowPlacement: true,
});
if (!pipWindowRef.current || !videoRef.current) return;
pipWindowRef.current.document.body.append(videoRef.current);
// Picture-in-Picture が閉じられたら、動画プレーヤーを元の位置に戻す
pipWindowRef.current.addEventListener('pagehide', () => {
if (!videoWrapperRef.current || !videoRef.current) return;
videoWrapperRef.current.prepend(videoRef.current);
});
}
return (
<div ref={videoWrapperRef}>
<video controls width={800} ref={videoRef}>
<source src="/sample.mp4" />
</video>
<button type="button" onClick={onClick}>
Toggle Picture-in-Picture
</button>
</div>
);
}
完成
これまでの実装で、動画をPiPで閲覧できるようになりました!
デモアプリでは実際のアプリケーションに近づけて、動画を閲覧しながら読めるコンテンツを用意したサイトになってます。
- デモアプリ:https://dm-picture-in-picture.vercel.app/
- リポジトリ:https://github.com/hakushun/dm_picture-in-picture
注意点
スタイル
上記で説明した実装例だとCSSは指定してませんでしたが、実際のアプリケーションには指定されていると思います。DOM操作だけではスタイルは維持できないので、PiPウィンドウにスタイルをコピーする処理が必要になります。
Picture-in-pictureへの切り替え
当初、IntersectionObserverかonScrollイベントで動画が画面上から見えなくなったら、PiPへの切り替えを行うと思ってました。
しかし、その実装をすると下記のようなエラーとなりました。
このエラーは、APIがユーザーの明確な操作を要求するため、スクロールや画面外検知のような間接的な操作では許可されないことを示しています。
つまり、クリックやキー操作といったユーザーから明確なアクションを持って切り替える必要があるようです。
みなさんもお使いかもしれないGoogle Meetだと、会議中にタブを切り替えると自動でPiPに切り替わると思いますが、これはマイクやカメラの使用が許可されているから可能になるそうです。
おわりに
いかがでしたでしょうか。
video要素以外も別ウィンドウに切り出せることによって使い方の幅は一気に広がりました。
このAPIをうまく使うことでユーザーにより良い体験を届けられないか考えていきたいと思います。
最後まで読んでいただきありがとうございました!
Discussion