iOS でも Document Picture-in-Picture したい!
どうも、uzimaru です。
Document Picture-in-Picture API という API がありますが、iOS などのモバイルブラウザでは動かないという制約があります。
そこで今回は、iOS でも動作する代替アプローチを思いついたので、その方法をまとめようと思います。
実際に動作するサンプルはこちらです(iOS Safari で試してみてください):
Document Picture-in-Picture とは
Document Picture-in-Picture API は、動画だけでなく任意の HTML コンテンツを Picture-in-Picture ウィンドウに表示できる比較的新しい API です。
通常の PiP が <video> 要素に限定されるのに対し、Document PiP では React コンポーネントなどの任意の DOM を小窓に表示できるため、カスタム UI を持った PiP ウィンドウを作成できます。
// Document PiP の基本的な使い方
const pipWindow = await documentPictureInPicture.requestWindow({
width: 400,
height: 300,
});
// 任意の DOM を表示できる
pipWindow.document.body.append(yourElement);
iOS での制約
しかし、この便利な Document PiP API は iOS Safari ではサポートされていません(2025年10月時点)。
iOS で利用できるのは従来の <video> 要素を使った PiP のみです。これでは動画しか表示できず、カスタム UI を持った PiP ウィンドウは作れません。
代替アプローチの概要
そこで、以下のような変換フローを使って iOS でも Document PiP 相当の機能を実現することにしました。
JSX → SVG → 画像 → Canvas → Video → PiP
具体的には、satori を使って JSX から SVG を生成し、それを画像に変換して Canvas に描画、Canvas のストリームを Video 要素のソースにすることで、<video> ベースの PiP API でも任意のコンテンツを表示できるようにします。
実装の全体像
この方法を選んだ理由は、以下の制約を満たす必要があったためです。
- iOS Safari で動作すること
- React コンポーネントを使って UI を書きたい
- できるだけシンプルな実装で実現すること
Document PiP が使えない以上、iOS で利用可能な <video> 要素の PiP API を使うしかありません。しかし、通常の方法では動画コンテンツしか表示できません。
そこで、「任意のコンテンツを動画に変換する」というアプローチを取ることにしました。
なぜ satori なのか
HTML から画像を生成するライブラリはいくつかありますが、多くの場合 React コンポーネントを一度 HTML に変換してから描画する必要があります。
一方、satori は JSX を直接扱えるため、React コンポーネントを DOM に変換する手間が省けます。
また、以前に Cloudflare Workers で OGP 画像生成 をした際にも satori を使った経験があり、ブラウザ環境でも動作することを確認していたので、今回も採用しました。
主な利点は以下の通りです。
- ブラウザ環境で動作する(Headless ブラウザ不要)
- React の JSX を直接扱える(DOM への変換が不要)
- SVG 経由なので高品質な出力が得られる
- 軽量で依存関係が少ない
変換フローの詳細
それぞれのステップで以下の処理を行います。
- satori で JSX を SVG に変換: React コンポーネントを SVG 文字列として出力
- SVG から画像を生成: Data URL を使って Image オブジェクトを作成
- Canvas に描画: Image を Canvas に描画
- Canvas から MediaStream を取得:
captureStream()で動画ストリームを取得 - Video 要素に設定: MediaStream を Video のソースに設定
- PiP で表示: Video 要素の
requestPictureInPicture()を呼び出し
この一連の流れにより、iOS Safari でも任意の UI を PiP で表示できるようになります。
実装の詳細
実際の実装をステップごとに見ていきます。全体のコードは機能ごとにモジュール化しています。
1. satori で JSX を SVG に変換
まず、satori をインストールします。
npm install satori
satori を使って React 要素を SVG に変換する関数を作成します。
// satoriRenderer.ts
import satori, { type SatoriOptions } from 'satori'
export interface SatoriRendererOptions {
width: number
height: number
fonts?: SatoriOptions['fonts']
}
export const renderToSVG = async (
element: React.ReactNode,
options: SatoriRendererOptions,
): Promise<string> => {
const { width, height, fonts } = options
// フォントが指定されていない場合はデフォルトをロード
const fontsToUse = fonts || (await loadDefaultFonts())
const svg = await satori(element, {
width,
height,
fonts: fontsToUse,
})
return svg
}
フォントは必須なので、デフォルトフォントをロードする仕組みも用意します。
// fonts.ts
import type { SatoriOptions } from 'satori'
let cachedFonts: SatoriOptions['fonts'] | null = null
export const loadDefaultFonts = async (): Promise<SatoriOptions['fonts']> => {
if (cachedFonts) {
return cachedFonts
}
// バンドルしたフォントファイルを読み込む
const fontUrl = new URL('../assets/fonts/NotoSansJP-Regular.otf', import.meta.url)
const response = await fetch(fontUrl)
const data = await response.arrayBuffer()
cachedFonts = [
{
name: 'Noto Sans JP',
data,
weight: 400,
style: 'normal',
},
]
return cachedFonts
}
今回はフォントファイルをプロジェクトに含めることで、動的な取得よりも安定性を重視しました。
2. SVG から画像を生成
SVG 文字列を Image 要素に変換する関数を作成します。
// satoriRenderer.ts
export const svgToImage = async (svg: string): Promise<HTMLImageElement> => {
const img = new Image()
const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(svgBlob)
try {
img.src = url
await img.decode() // 画像のデコードを待つ
return img
} finally {
URL.revokeObjectURL(url) // メモリリーク防止
}
}
img.decode() を使うことで、画像が完全にロードされるまで待機できます。
3. Canvas に画像を描画
Image を Canvas に描画する関数と、一連の処理をまとめた関数を作成します。
// satoriRenderer.ts
export const drawImageToCanvas = (
image: HTMLImageElement,
canvas: HTMLCanvasElement,
): void => {
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Failed to get 2D context from canvas')
}
ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
}
export interface RenderToCanvasOptions extends SatoriRendererOptions {
element: React.ReactNode
canvas: HTMLCanvasElement
}
// JSX から Canvas への一連の処理をまとめた関数
export const renderToCanvas = async (
options: RenderToCanvasOptions,
): Promise<void> => {
const { element, canvas, ...satoriOptions } = options
// Canvas のサイズを設定
canvas.width = satoriOptions.width
canvas.height = satoriOptions.height
// JSX → SVG → Image → Canvas
const svg = await renderToSVG(element, satoriOptions)
const image = await svgToImage(svg)
drawImageToCanvas(image, canvas)
}
4. React Hooks で PiP を実装
Canvas と Video の管理、PiP の制御を React Hooks でまとめます。
// hooks.ts
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { SatoriOptions } from 'satori'
import { renderToCanvas } from './satoriRenderer'
export interface UsePinPOptions {
element: ReactNode
width?: number
height?: number
fonts?: SatoriOptions['fonts']
onEnter?: () => void
onLeave?: () => void
}
export const usePinP = ({
element,
width = 640,
height = 480,
fonts,
onEnter,
onLeave,
}: UsePinPOptions) => {
const videoRef = useRef<HTMLVideoElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const [isPiPActive, setIsPiPActive] = useState(false)
const [isSupported] = useState(
() => 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled,
)
// Video と Canvas を初期化
useEffect(() => {
if (!videoRef.current) {
const video = document.createElement('video')
video.muted = true
video.playsInline = true
video.autoplay = false
// 画面上には表示しないが DOM には追加する必要がある
video.style.position = 'absolute'
video.style.left = '0'
video.style.top = '0'
video.style.width = '1px' // 最小サイズにする
video.style.height = '1px' // 最小サイズにする
video.style.opacity = '0'
video.style.pointerEvents = 'none'
video.preload = 'auto' // 事前ロードして準備しておく
document.body.appendChild(video)
videoRef.current = video
}
if (!canvasRef.current) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.style.display = 'none'
document.body.appendChild(canvas)
canvasRef.current = canvas
}
// Canvas から MediaStream を取得
const canvas = canvasRef.current
const video = videoRef.current
if (canvas && video && !streamRef.current) {
const stream = canvas.captureStream()
streamRef.current = stream
video.srcObject = stream
video.load()
}
return () => {
// クリーンアップ
if (videoRef.current) {
document.body.removeChild(videoRef.current)
videoRef.current = null
}
if (canvasRef.current) {
document.body.removeChild(canvasRef.current)
canvasRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
}
}
}, [width, height])
// element が変更されたら Canvas を再描画
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let cancelled = false
const render = async () => {
if (cancelled) return
try {
await renderToCanvas({
element,
canvas,
width,
height,
fonts,
})
} catch (error) {
console.error('Failed to render to canvas:', error)
}
}
render()
return () => {
cancelled = true
}
}, [element, width, height, fonts])
// PiP イベントリスナー
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleEnterPiP = () => {
setIsPiPActive(true)
onEnter?.()
}
const handleLeavePiP = () => {
setIsPiPActive(false)
onLeave?.()
}
video.addEventListener('enterpictureinpicture', handleEnterPiP)
video.addEventListener('leavepictureinpicture', handleLeavePiP)
return () => {
video.removeEventListener('enterpictureinpicture', handleEnterPiP)
video.removeEventListener('leavepictureinpicture', handleLeavePiP)
}
}, [onEnter, onLeave])
const enter = useCallback(async () => {
if (!isSupported) {
throw new Error('Picture-in-Picture is not supported')
}
const video = videoRef.current
if (!video) {
throw new Error('Video element not initialized')
}
// iOS では play() と requestPictureInPicture() を同期的に呼ぶ必要がある
// await を使うとユーザージェスチャーのコンテキストが失われる
video.play().catch((error) => {
console.error('Failed to play video:', error)
})
video.requestPictureInPicture().catch((error) => {
console.error('Failed to enter PiP:', error)
throw error
})
}, [isSupported])
const exit = useCallback(async () => {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
}
}, [])
const toggle = useCallback(async () => {
if (isPiPActive) {
exit()
}
enter()
}, [isPiPActive, enter, exit])
return {
isSupported,
active: isPiPActive,
toggle,
enter,
exit,
}
}
使い方
実装した Hooks は以下のように使用できます。
import { usePinP } from './pip'
function App() {
const { toggle, active, isSupported } = usePinP({
element: (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1a1a1a',
color: 'white',
fontSize: 60,
}}>
Hello, PiP!
</div>
),
width: 640,
height: 480,
})
return (
<button onClick={toggle} disabled={!isSupported}>
{active ? 'Close PiP' : 'Open PiP'}
</button>
)
}
ハマったポイント・注意点
実装する中でいくつかハマったポイントがあったので、共有します。
iOS Safari での制約
iOS Safari で PiP を動作させるには、いくつかの重要な制約があります。
1. ユーザージェスチャーのコンテキストを失わない
iOS では play() と requestPictureInPicture() をユーザーインタラクション(クリックなど)の同期的なコンテキスト内で呼び出す必要があります。
// ❌ これは iOS で動かない
const enter = async () => {
await video.play() // await するとユーザージェスチャーのコンテキストが失われる
await video.requestPictureInPicture()
}
// ✅ これなら動く
const enter = () => {
video.play().catch(error => console.error(error))
video.requestPictureInPicture().catch(error => console.error(error))
}
await を使うと非同期処理によってユーザージェスチャーのコンテキストが失われ、PiP の起動に失敗します。
2. Video 要素を DOM に追加する必要がある
Video 要素は画面上に表示する必要はありませんが、DOM に追加されている必要があります。そのため、1px × 1px の最小サイズにして、opacity: 0 で非表示にしています。
video.style.width = '1px'
video.style.height = '1px'
video.style.opacity = '0'
完全に display: none にすると PiP が動作しないため、この方法を取っています。
また、left: -9999px のように画面外に配置する方法も試しましたが、これも PiP が起動しませんでした。Video 要素は描画領域内に存在する必要があるようです。
まとめ
iOS Safari で Document Picture-in-Picture API が使えない問題に対して、satori を使った代替実装を紹介しました。
この方法のメリット
- iOS Safari で動作する: 従来の Video PiP API を使うため、iOS でも問題なく動作します
- React コンポーネントを使える: satori のおかげで JSX をそのまま使えます
- ブラウザ環境で完結: Headless ブラウザなどのサーバー側の処理が不要です
- 高品質な描画: SVG 経由なので、拡大しても綺麗に表示されます
この方法のデメリット
- インタラクションができない: 実態は video 要素なので、ボタンなどの UI 操作はできません
- リアルタイム更新のコスト: 内容を更新するたびに JSX → SVG → Image → Canvas の変換が必要です
- satori の制約: CSS のサブセットしか使えず、すべての React コンポーネントが描画できるわけではありません。また、
divにはdisplayが必須などの制約もあります。 - フォントの準備が必要: フォントデータを事前に用意するか、動的に取得する必要があります
今後の改善案
現状の実装でも基本的な用途には十分ですが、以下のような改善が考えられます。
Document PiP との併用
環境によって使い分けることで、より良い UX を提供できます。
const useAdaptivePiP = () => {
const hasDocumentPiP = 'documentPictureInPicture' in window
if (hasDocumentPiP) {
// Document PiP を使う(インタラクション可能)
return useDocumentPiP()
} else {
// Canvas ベースの PiP を使う(iOS 対応)
return usePinP()
}
}
この方法で、デスクトップでは Document PiP、iOS では Canvas ベースの PiP という使い分けができます。
今回の実装は、iOS で PiP を使いたいという要望から思いつきで試してみたら意外とうまく動いたので記事にしました。
プロダクション環境でどこまで安定的に使えるかは検証が必要ですが、特定のユースケース(情報表示や通知など)では十分実用的だと思います。
将来的に iOS Safari でも Document Picture-in-Picture API が正式にサポートされることを楽しみに待ちましょう!
Discussion