0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【備忘録】ReactでPicture-in-Picture(PiP)を実装してみた話

Posted at

1. はじめに

個人開発したポモドーロタイマー(WEBアプリ)があるのですが、使ってる内に

「タイマーを常に見たい…」
「別タブ開いても小窓で動いてほしい…」

みたいな願望湧いてきたんで、実装してみることにしました。
この記事では、自分がまた忘れたときのために実装方法を書き残しておきまーす。

2. Picture-in-Picture(PiP)とは?

よく YouTube で動画が小窓で残るアレです。
ただ今回のはそれより進化してて、任意のHTML(Reactコンポーネント)を表示できるというもの。

Chrome 116 以降で追加された
「Document Picture-in-Picture API」
を使うと、好きなUIをそのまま前面ウィンドウに出せるみたいです。

✅ 対応ブラウザ

ブラウザ 対応状況
Chrome / Edge ✅ OK(v116以上)
Safari / Firefox ❌ 未対応(そのうち来るかも)

間違ってたらごめん

✅ ざっくり流れ

ざっくりいうと、こうです👇

  1. window.documentPictureInPicture が使えるか確認
  2. requestWindow() でミニウィンドウを作る
  3. Reactコンポーネントをそこにマウント
  4. 閉じられたらunmount()して掃除

3. 使用技術

  • フレームワーク: React
  • ビルドツール: Vite
  • 言語: TypeScript

4. 実装コード

/src/hooks/useDocumentPiP.tsx

React で扱いやすいように、まずフックを作ります。
PiPのウィンドウを開いたり閉じたり、Reactの世界から操作できるように。

import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { createRoot, Root } from 'react-dom/client';

// 型宣言(Chrome系のみ対応)
declare global {
  interface Window {
    documentPictureInPicture?: {
      requestWindow: (options?: { width?: number; height?: number }) => Promise<Window>;
    };
  }
}

// 元のページのCSSをPiPにも反映する関数
const copyStyles = (source: Document, target: Document) => {
  Array.from(source.styleSheets).forEach((styleSheet) => {
    try {
      // 通常のスタイルを全部コピー
      const rules = styleSheet.cssRules;
      const style = target.createElement('style');
      style.textContent = Array.from(rules)
        .map((rule) => rule.cssText)
        .join('\n');
      target.head.appendChild(style);
    } catch (error) {
      // ⚠️ クロスオリジンCSSは例外が出るので、linkタグで再挿入
      const ownerNode = styleSheet.ownerNode as HTMLLinkElement | null;
      if (ownerNode?.tagName === 'LINK' && ownerNode.href) {
        const link = target.createElement('link');
        link.rel = 'stylesheet';
        link.href = ownerNode.href;
        target.head.appendChild(link);
      }
    }
  });
};

// React Hooks 版 PiP制御
export const useDocumentPiP = () => {
  const pipWindowRef = useRef<Window | null>(null);
  const pipRootRef = useRef<Root | null>(null);
  const [isOpen, setIsOpen] = useState(false);

  const isSupported = useMemo(
    () => typeof window !== 'undefined' && 'documentPictureInPicture' in window,
    []
  );

  // クリーンアップ処理
  const cleanup = useCallback(() => {
    if (pipRootRef.current) {
      pipRootRef.current.unmount();
      pipRootRef.current = null;
    }
    pipWindowRef.current = null;
    setIsOpen(false);
  }, []);

  // PiPウィンドウを開く
  const openPiP = useCallback(
    async (content: ReactNode, options?: { width?: number; height?: number }) => {
      if (!isSupported || !window.documentPictureInPicture) {
        throw new Error('Document Picture-in-Picture は未対応です');
      }

      cleanup();

      // 小ウィンドウ作成
      const pipWindow = await window.documentPictureInPicture.requestWindow(options);
      pipWindowRef.current = pipWindow;

      // bodyを初期化
      pipWindow.document.body.innerHTML = '';
      pipWindow.document.body.style.margin = '0';
      pipWindow.document.body.style.display = 'flex';
      pipWindow.document.body.style.alignItems = 'center';
      pipWindow.document.body.style.justifyContent = 'center';

      // 元ページのCSSをコピー
      copyStyles(document, pipWindow.document);

      // Reactのコンテナを作ってレンダリング
      const container = pipWindow.document.createElement('div');
      container.style.width = '100%';
      container.style.height = '100%';
      pipWindow.document.body.appendChild(container);

      const root = createRoot(container);
      root.render(content);
      pipRootRef.current = root;
      setIsOpen(true);

      // 小ウィンドウが閉じられたら掃除
      const handleClose = () => cleanup();
      pipWindow.addEventListener('pagehide', handleClose, { once: true });
      pipWindow.addEventListener('beforeunload', handleClose, { once: true });
    },
    [cleanup, isSupported]
  );

  // 手動で閉じる関数
  const closePiP = useCallback(() => {
    pipWindowRef.current?.close();
    cleanup();
  }, [cleanup]);

  return { isSupported, isOpen, openPiP, closePiP };
};

/src/components/PictureInPictureButton.tsx

UI 側です。
PiPがサポートされてないブラウザでは、ボタンをまるごと非表示にしてます。

import { useCallback, useState } from 'react';
import { MdPictureInPictureAlt } from 'react-icons/md';
import { Timer } from './Timer';
import { useDocumentPiP } from '../hooks/useDocumentPiP';

export const PictureInPictureButton = () => {
  const { isSupported, isOpen, openPiP, closePiP } = useDocumentPiP();
  const [isPending, setIsPending] = useState(false);

  const handleClick = useCallback(async () => {
    if (isPending) return;

    if (isOpen) {
      closePiP(); // すでに開いてたら閉じる
      return;
    }

    try {
      setIsPending(true);
      await openPiP(
        <div className="bg-base-100 text-base-content w-full h-full flex flex-col items-center justify-center gap-4 p-4">
          {/* ここに小窓で表示したい内容 */}
          <Timer />
          <span className="text-sm font-medium">Picture in Picture</span>
        </div>,
        { width: 360, height: 220 } // ウィンドウサイズ指定
      );
    } catch (error) {
      console.error('PiP 起動に失敗しました', error);
    } finally {
      setIsPending(false);
    }
  }, [closePiP, isOpen, isPending, openPiP]);

  // 対応してるブラウザでだけ表示
  return (
    isSupported && (
      <button
        type="button"
        className="btn btn-outline btn-sm"
        onClick={handleClick}
        disabled={isPending}
        title="Picture-in-Picture ウィンドウを開く"
      >
        <MdPictureInPictureAlt size={16} className="mr-1" />
        {isOpen ? 'PiP解除' : 'PiP表示'}
      </button>
    )
  );
};

5. よくあるハマりどころ

症状 原因 対策
CSS が反映されない クロスオリジンCSSの例外 <link>タグで再挿入して回避
真っ白な画面になる <body> が生成される前に描画している await の順番を確認 or React 18 の createRoot で解決
2つ以上開けない API仕様で1つまで isOpen 状態で制御

☕ まとめ(+ちょっと宣伝)

今回、React で PiP を触ってみて思ったのは、
「思ってたより全然簡単!」ってこと。

Chrome 116 以降ならかなり安定して動作するので、
タイマー・ミニUI・チャット などにめっちゃ応用できます。

自分の開発しているアプリ Pomodoro Flow にもこの機能を組み込んでます🍅
「ポモドーロタイマーを常に前面に出したい」って人は、ぜひ触ってみてください!

👇 参考リンク

おまけ

最近、寒暖差激しくて、ガッツリ風邪ひきました、、、
めっちゃ萎える。

もしこの記事が役に立ったら、
「いいね」 押してくれたら励みになります 🙌

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?