Reactでニコニコ動画の埋め込みプレイヤーを使う

# 概要
ニコニコ動画の埋め込みプレイヤーってのはこういう記事中とかで動画を流せるやつです。
![スクリーンショット 2023-09-06 21.48.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2603863/cd4431a7-8a47-50b4-6633-440e51b5c78e.png)

埋め込みコードは各動画ページからコピーでき、以下のような形式になっています。
```html
<script type="application/javascript" src="https://embed.nicovideo.jp/watch/sm41951611/script?w=640&h=360"></script>
<noscript>
    <a href="https://www.nicovideo.jp/watch/sm41951611">一千光年</a>
</noscript>
```
普通のHTMLであればこれをコピペするだけで良いのですが、Reactはscriptタグを使えないため、そのままコピペしても動かないのでなんとかします。

ついでに[react-youtube](https://github.com/tjallingt/react-youtube)みたいにidから表示できるように切り出しておきましょう。

# 方法
思いついたのは2つです。

## 1. appendChildでscriptタグを足す
scriptタグを直で書けないだけなので、useEffectでワンクッションおいてscriptタグを作り、appendChildで追加します。
```tsx
import { useEffect, useRef } from "react";

type Props = {
  id: string,
  width: number,
  height: number,
};

export default function NicovideoPlayer (props: Props) {
  const { id, width, height } = props;

  const divRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const divElm = divRef.current;
    if (divElm === null) return;

    const script = document.createElement('script');
    script.type = 'text/javascript';
    script.src = `https://embed.nicovideo.jp/watch/${id}/script?w=${width}&h=${height}`;

    divElm.appendChild(script);

    return () => {
      while (divElm.firstChild) {
        divElm.removeChild(divElm.firstChild);
      }
    };
  }, [height, id, width]);

  return (
    <div ref={divRef} />
  );
}

```
scriptタグが複数追加されないよう、クリーンアップ関数でdivの中身を空にしましょう。

## 2. scriptの中身を直で書く

https://embed.nicovideo.jp/watch/sm41951611/script?w=640&h=360
埋め込みにはこの形式のURLを使うようですが、URL先のJavaScriptを見てみるとそこまで長くない上にクエリとして渡したid, height, width以外に変数が使われていなさそうなことがわかります。これならこっちで実装できそうです。

読み込み時にscriptタグの中にiframeを設置しているようなので、iframeを返すコンポーネントを作ります。

```tsx
import { CSSProperties, useEffect, useLayoutEffect, useRef, useState } from "react";

type Props = {
  id: string,
  width: number,
  height: number,
  style?: CSSProperties,
};

export default function NicovideoPlayer (props: Props) {
  const { id, width, height, style = {} } = props;

  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [screenWidth, setScreenWidth] = useState<CSSProperties['width']>();
  const [screenHeight, setScreenHeight] = useState<CSSProperties['height']>();
  const [isLandscape, setIsLandScape] = useState<boolean>(false);
  const [isFullScreen, setIsFullScreen] = useState<boolean>(false);

  const src = `https://embed.nicovideo.jp/watch/${id}?persistence=1&oldScript=1&referer=&from=0&allowProgrammaticFullScreen=1`;

  const styleFullScreen: CSSProperties = isFullScreen ? {
    top: 0,
    left: isLandscape ? 0 : '100%',
    position: 'fixed',
    width: screenWidth,
    height: screenHeight,
    zIndex: 2147483647,
    maxWidth: 'none',
    transformOrigin: '0% 0%',
    transform: isLandscape ? 'none' : 'rotate(90deg)',
    WebkitTransformOrigin: '0% 0%',
    WebkitTransform: isLandscape ? 'none' : 'rotate(90deg)',
  } : {};

  const margedStyle = {
    border: 'none',
    maxWidth: '100%',
    ...style,
    ...styleFullScreen,
  };

  useEffect(() => {
    const onMessage = (event: MessageEvent<any>) => {
      if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return;
      if (event.data.eventName === 'enterProgrammaticFullScreen') {
        setIsFullScreen(true);
      } else if (event.data.eventName === 'exitProgrammaticFullScreen') {
        setIsFullScreen(false);
      }
    };

    window.addEventListener('message', onMessage);

    return () => {
      window.removeEventListener('message', onMessage);
    };
  }, []);

  useLayoutEffect(() => {
    if (!isFullScreen) return;

    const initialScrollX = window.scrollX;
    const initialScrollY = window.scrollY;
    let timer: NodeJS.Timeout;
    let ended = false;

    const pollingResize = () => {
      if (ended) return;

      const isLandscape = window.innerWidth >= window.innerHeight;
      const windowWidth = `${isLandscape ? window.innerWidth : window.innerHeight}px`;
      const windowHeight = `${isLandscape ? window.innerHeight : window.innerWidth}px`;

      setIsLandScape(isLandscape);
      setScreenWidth(windowWidth);
      setScreenHeight(windowHeight);
      timer = setTimeout(startPollingResize, 200);
    }

    const startPollingResize = () => {
      if (window.requestAnimationFrame) {
        window.requestAnimationFrame(pollingResize);
      } else {
        pollingResize();
      }
    }

    startPollingResize();

    return () => {
      clearTimeout(timer);
      ended = true;
      window.scrollTo(initialScrollX, initialScrollY);
    };
  }, [isFullScreen]);

  useEffect(() => {
    if (!isFullScreen) return;
    window.scrollTo(0, 0);
  }, [screenWidth, screenHeight, isFullScreen]);

  return (
    <iframe
      ref={iframeRef}
      src={src}
      width={width}
      height={height}
      style={margedStyle}
      allowFullScreen
      allow="autoplay"
    />
  );
}
```

元のコードにはiframeの追加以外にスマホでのフルスクリーン表示に関する処理があり、せっかくなのでそこも含めてReactらしく書き直してみました。中身の処理内容はそのままなはずです。

1つ目の方法に比べてiframeもReactで書けるのでstyleを楽に当てれたり、細かいところを自分でいじれるので小回りは効きますが、手前で実装する以上当然ニコニコ側の仕様変更には弱くなります。

お好みの方をお使いください。

# おまけ
## next/scriptを使う（使えない）
実はNext.jsにはscriptタグを擬似的に使える機能があります。
```jsx
import Script from 'next/script'

<Script type="application/javascript" src="https://embed.nicovideo.jp/watch/sm41951611/script?w=640&h=360"></Script>
```
あまりにそのまんまなのでコードは端折りますが、importして差し替えるだけです。

わぁ簡単、と見せかけてブラウザ上では実際のscriptタグがbodyの下に配置されてしまうため、scriptタグの中にiframeを置く今回の埋め込みではプレイヤーがページの最下部に配置されてしまい使えませんでした。