【kintone/プラグイン】IFramePlayerAPIでこっそり動画を再生するやつ作ってみた【dev活】

## 1. はじめに
### 経緯：
先日、サイボウズさん主催のカスタマイズ勉強会の後、懇親会にお邪魔してきました！
その懇親会の中で、「こんなプラグインがあったらいいな」という案をグループごとに発表して提案力を競うレクリエーションがあったんです。

今回は、そこで発表したプラグインをせっかくだから実現してみようという物です。

前の記事でも勉強会について少し触れましたが、あれ実はこの記事をやりたいがための前準備です😆

多分kintoneじゃなくても応用できる内容だと思います！

<details><summary>詳細</summary>
<font color="#808080">

このテーマに関して「わぁなんてアバウトな要求なんだ」と思ったのは私だけじゃないはず……🤔  
とはいえ懇親会という事だったので、大喜利の一種と解釈して真面目に取り組みました。

全ての社会人が抱える悩み、それは「どうやってサボるか」だと思うんです。  
やる気の出ないときに、無理に取り組んで作業は進みますか？  
息抜きとは、仕事をする上で最も重要な時間だといっても過言ではありません。  
しかし、皆が仕事をしている中、堂々と休憩するのは罪悪感を覚える人も多いでしょう。

そこで、私たちのグループは**こっそり動画をみるプラグイン**を提案しました。

特定の箇所にカーソルを合わせている間だけ動画が再生され、上司が来たらカーソルを移動させるだけで動画を隠すことができる優れものです。

残念ながら受賞はできませんでしたが、笑ってくれた人がいたので実質勝ちですね!!!  
自社業務に限らないプラグインという視点は今まで持ってなかったので、新鮮で楽しかったです😁

</font>
</details>


## 2. 作るもの
### 概要：
kintoneの画面上でこっそり動画をみるプラグイン

### 機能：
1. 設定画面で以下の情報を保存、編集できる
    - 動画ID/プレイリストID（文字列）
    - 再生リスト（チェックボックス）
    - プライバシーモード（チェックボックス）
    - 再生できる人のログイン名（文字列）
1. デスクトップでやること
    - kintone内の画面上部のヘッダーメニューに動画再生ボタンをつくる
    - ボタンをホバーすると画面の左下に動画のポップアップが表示される
    - 再生ボタンを初回クリックで動画再生
    - 再生ボタンのクリック二回目以降はミュートのon/offを切り替える
    - 再生中にカーソルをボタンから外すと動画がストップし、ポップアップが非表示になる
    - カーソルをボタンに合わせるとポップアップが表示され再生される
    - 一覧画面、詳細画面に対応

※今回モバイルはスコープ外


## 3.  事前準備
### 開発環境
- windows
- Vscode
- kintone（kintone管理者権限必須）

### 使うもの
- [kintoneCLIツール create-plugin](https://cybozu.dev/ja/kintone/sdk/development-environment/create-plugin/)
- [Font Awesome CDNライブラリ 5.6.3](https://cybozu.dev/ja/id/5eac8b3387b5abbfa1d8df21/#fontawesome)
- [YouTube IFrame Player API](https://developers.google.com/youtube/iframe_api_reference?hl=ja)


### 準備

<details><summary>プラグインのフォルダ構成</summary>

 IFramePlayerAPIを扱いやすいように、処理をyouTubePlayer.jsにまとめます。

```プラグインのフォルダ構成.

sample_plugin_project
    |   .eslintrc.js
    |   package-lock.json
    |   package.json
    |   private.ppk
    |   
    +---dist
    |       plugin.zip
    |       
    +---node_modules
    |                   
    +---scripts
    |       npm-start.js
    |       
    \---src
        |   manifest.json
        |   
        +---css
        |       51-modern-default.css
        |       config.css
        |       desktop.css
        |       
        +---html
        |       config.html
        |       
        +---image
        |       icon.png
        |       
        \---js
                config.js
                desktop.js
                youTubePlayer.js
```
</details>



<details><summary>manifest.json</summary>

アイコンにFontAwesomeライブラリを使用するので、jsとcssにライブラリCDNのパスを追加
youTubePlayer.jsも追加

```manifest.json

{
  "$schema": "https://raw.githubusercontent.com/kintone/js-sdk/%40kintone/plugin-manifest-validator%4010.2.0/packages/plugin-manifest-validator/manifest-schema.json",
  "manifest_version": 1,
  "version": 1,
  "type": "APP",
  "desktop": {
    "js": [
      "https://js.cybozu.com/font-awesome/v6.6.0/js/all.min.js",
      "js/YouTubePlayer.js",
      "js/desktop.js"
    ],
    "css": [
      "https://js.cybozu.com/font-awesome/v6.6.0/css/fontawesome.min.css",
      "css/51-modern-default.css",
      "css/desktop.css"
    ]
  },
  "icon": "image/icon.png",
  "config": {
    "html": "html/config.html",
    "js": [
      "js/config.js"
    ],
    "css": [
      "css/51-modern-default.css",
      "css/config.css"
    ],
    "required_params": [
    ]
  },
  "name": {
    "en": "watchYoutubeOnKintone",
    "ja": "YouTubeこっそり見る君"
  },
  "description": {
    "en": "watchYoutubeOnKintone",
    "ja": "YouTubeこっそり見る君"
  }
}

```

</details>


:::note info

　create-pluginの設定は[前の記事](https://qiita.com/hirado-ns/items/e5184a11c9f045c4bbea)を参考にしてください。
:::



## 3. 動画操作の処理をクラスにまとめる

YT.Playerクラスをそのまま使おうと思ったんですが、混乱してきたので別ファイルに纏めました
生成AIのClaudeくんに解説してもらいつつですが😂

```youTubePlayer.js
/** YouTubeプレーヤーをカプセル化し、制御するためのクラス */
class YouTubePlayer {
  constructor(elementId, videoIdOrPlaylistId, options = {}) {
    this.player = null;
    this.elementId = elementId; 
    this.videoId = null;
    this.playlistId = null;
    this.options = {
      width: options.width || 640,
      height: options.height || 360,
      autoplay: options.autoplay || false,
      mute: options.mute || false,
      controls: options.controls ?? true,
      privacyMode: options.privacyMode || false,
      playlist: options.playlist || false,
      ...options
    };

    //プレイリストと単体の動画で処理が違うから分けとく
    if (options.playlist) {
      this.playlistId = videoIdOrPlaylistId;
    } else {
      this.videoId = videoIdOrPlaylistId;
    }

    // 自動再生が有効で、明示的にミュートが指定されていない場合、自動的にミュートを有効にする
    if (this.options.autoplay && !options.hasOwnProperty('mute')) {
      console.warn('自動再生が有効です。自動再生できるように音声をミュートします。');
      this.options.mute = true;
    }

    //クラスインスタンスのthisであることを明示しとく
    this.onReady = this.onReady.bind(this);
    this.onStateChange = this.onStateChange.bind(this);

    //プレイヤーを読み込む
    this.loadYouTubeAPI();
  }

  /**
   * YouTube IFrame API をロードする
   */
  loadYouTubeAPI() {
    if (!window.YT) {
      //公式曰く、このコードは、IFrame Player API コードを非同期的にロードします。らしい
      const tag = document.createElement('script');
      tag.src = 'https://www.youtube.com/iframe_api';
      const firstScriptTag = document.getElementsByTagName('script')[0];
      firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
      window.onYouTubeIframeAPIReady = this.onYouTubeIframeAPIReady.bind(this);
    } else {
      this.onYouTubeIframeAPIReady();
    }
  }

  /**
   * YouTube IFrame API の準備が完了したときに呼び出されるメソッド
   */
  onYouTubeIframeAPIReady() {
    const playerVars = {
      autoplay: this.options.autoplay ? 1 : 0,
      mute: this.options.mute ? 1 : 0,
      controls: this.options.controls ? 1 : 0,
    };

    if (this.playlistId) {
      playerVars.listType = 'playlist';
      playerVars.list = this.playlistId;
    }

    //プライバシーモードはホスト名が違うので切り替え可能にしておく
    const host = this.options.privacyMode ? 'https://www.youtube-nocookie.com' : 'https://www.youtube.com';

    //公式曰く、API コードのダウンロード後<iframe> (および YouTube プレーヤー) を作成します。らしい
    this.player = new YT.Player(this.elementId, {
      height: this.options.height,
      width: this.options.width,
      videoId: this.videoId,
      playerVars: playerVars,
      host: host,
      //イベントリスト
      events: {
        'onReady': this.onReady,
        'onStateChange': this.onStateChange
      }
    });
  }

  /**
   * プレーヤーの準備が完了したときに呼び出されるメソッド
   * @param {Object} event - YouTubeプレーヤーのイベントオブジェクト
   */
  onReady(event) {
    console.log('YouTubePlayerの準備が出来ました');
    if (this.options.onReady) {
      this.options.onReady(this);
    }
  }
  
  /** 動画を再生する */
  play() {
    if (this.player) {
      this.player.playVideo();
    }
  }

  /** 動画を一時停止する */
  pause() {
    if (this.player) {
      this.player.pauseVideo();
    }
  }

  /** 動画を停止する */
  stop() {
    if (this.player) {
      this.player.stopVideo();
    }
  }

  /** プレーヤーをミュートにする */
  mute() {
    if (this.player) {
      this.player.mute();
    }
  }
  
  /** プレーヤーのミュートを解除する */
  unMute() {
    if (this.player) {
      this.player.unMute();
    }
  }
  
  /** プレーヤーにフォーカスを当てる */
  focus() {
    if (this.player && this.player.getIframe) {
      //埋め込まれたiframeのDOMノード取得
      const iframe = this.player.getIframe();
      if (iframe) {
        iframe.focus();
        iframe.contentWindow.focus();
      }
    }
  }

}

```

<details><summary>YouTubePlayerクラスの使い方</summary>

```sample.js
// 使用例 (プレイリスト、プライバシーモード有効)
const playlistPlayer = new YouTubePlayer('playlist-container', 'PLAYLIST_ID', {
  width: 800,
  height: 450,
  autoplay: true,
  playlist: true,
  privacyMode: true, 
});

 /** YouTubePlayerのコンストラクタ
  * @param {string} elementId - プレーヤーを埋め込む要素のID
  * @param {string} videoIdOrPlaylistId - 再生する動画またはプレイリストのID
  * @param {Object} options - プレーヤーのオプション
  * @param {number} [options.width=640] - プレーヤーの幅
  * @param {number} [options.height=360] - プレーヤーの高さ
  * @param {boolean} [options.autoplay=false] - 自動再生を有効にするかどうか
  * @param {boolean} [options.mute=false] - ミュートにするかどうか
  * @param {boolean} [options.controls=true] - コントロールを表示するかどうか
  * @param {boolean} [options.privacyMode=false] - プライバシー強化モードを有効にするかどうか
  * @param {boolean} [options.playlist=false] - プレイリストモードを有効にするかどうか
  */

```

</details>



## 4. 設定画面の実装
### HTMLでフォームをつくる：
```cinfig.html
<section class="settings">
  <h2 class="settings-heading">プラグインの設定</h2>
  <p class="kintoneplugin-desc">プラグインの説明</p>
  <form class="js-submit-settings">
    <p class="kintoneplugin-row">
      <label for="video-url">
        動画URL:
        <input type="text" class="js-text-message kintoneplugin-input-text" id="video-url">
      </label>
    </p>
    <p class="kintoneplugin-row">
    <div class="kintoneplugin-input-checkbox">
      <span class="kintoneplugin-input-checkbox-item">
        <input type="checkbox" name="playlist-checkbox" value="0" id="playlist-checkbox">
        <label for="playlist-checkbox">プレイリスト</label>
      </span>
    </div>
    </p>
    <p class="kintoneplugin-row">
    <div class="kintoneplugin-input-checkbox">
      <span class="kintoneplugin-input-checkbox-item">
        <input type="checkbox" name="privacyMode-checkbox" value="0" id="privacyMode-checkbox" checked="">
        <label for="privacyMode-checkbox">プライバシーモード</label>
      </span>
    </div>
    </p>
    <p class="kintoneplugin-row">
      <label for="login-name">
        ログイン名:
        <input type="text" class="js-text-message kintoneplugin-input-text" id="login-name">
      </label>
    </p>
    <p class="kintoneplugin-row">
      <button type="button" class="js-cancel-button kintoneplugin-button-dialog-cancel">キャンセル</button>
      <button class="kintoneplugin-button-dialog-ok">保存する</button>
    </p>
  </form>
</section>
```


### JSで設定を保存する機能を実装する：

```config.js
(function (PLUGIN_ID) {
  //create-pluginで作ったら"use strict"が無いので忘れず書くべし
  "use strict"; 

  //入力フォームのDOM要素を取得する
  const submitButtonEl = document.querySelector('.js-submit-settings');
  const cancelButtonEl = document.querySelector('.js-cancel-button');
  const videoUrl = document.querySelector('#video-url');
  const playlist = document.querySelector('#playlist-checkbox');
  const privacyMode = document.querySelector('#privacyMode-checkbox');
  const loginName = document.querySelector('#login-name');
  if (!(submitButtonEl && cancelButtonEl && videoUrl && playlist && privacyMode && loginName )) {
    throw new Error('必要な要素が存在しません');
  }

  //現在の設定を入力フォームに反映させる
  const options = kintone.plugin.app.getConfig(pluginId)?.options;
  if (!options) {
    window.alert('プラグインの設定が読み込めませんでした');
    window.location.href = '../../' + kintone.app.getId() + '/plugin/';
  }
  const config = JSON.parse(options);
  videoUrl.value = config.videoUrl ?? '';
  playlist.checked = config.playlist ?? false;
  privacyMode.checked = config.privacyMode ?? true;
  loginName.value = config.loginName ?? '';

  //保存ボタンをクリックしたら設定を保存する
  submitButtonEl.addEventListener('submit', (e) => {
    e.preventDefault();
    const configParms={
      videoUrl: videoUrl.value,
      playlist: playlist.checked,
      privacyMode: privacyMode.checked,
      loginName: loginName.value
    };
    const configParmString = JSON.stringify(configParms);
    kintone.plugin.app.setConfig({ options: configParmString }, () => {
      alert('プラグインの設定を保存しました。 アプリを更新してください!');
      window.location.href = '../../flow?app=' + kintone.app.getId();
    });
  });
  
  //キャンセルボタンをクリックしたらプラグインの一覧画面に戻る
  cancelButtonEl.addEventListener('click', () => {
    window.location.href = '../../' + kintone.app.getId() + '/plugin/';
  });
  
})(kintone.$PLUGIN_ID);


```
<details><summary>画面</summary>

![スクリーンショット 2024-09-23 225542.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/956154/9c8bee8a-473b-6c4f-785c-c135d27e9d39.png)

</details>


## 5. カスタマイズ本体の実装

```desktop.js
(function (PLUGIN_ID) {
  //create-pluginで作ったら"use strict"が無いので忘れず書くべし
  'use strict';

  // 設定とグローバル変数
  /** YouTubePlayerクラスのインスタンス */
  const config = getPluginConfig(PLUGIN_ID);
  let player;
  /** 動画のミュート状態 */
  let muted;
  /** 動画の再生状態（noPlay/play/pause） */
  let playStatus;

  /** プラグインを初期化する */
  function initializePlugin() {
    kintone.events.on(['app.record.index.show', 'app.record.detail.show'], handlePageShow);
  }

  /** プラグイン設定を取得する
  * @param {string} pluginId - プラグインのID
  * @returns {Object} プラグインの設定オブジェクト
  * @throws {Error} 設定が読み込めない場合
  */
  function getPluginConfig(pluginId) {
    const options = kintone.plugin.app.getConfig(pluginId)?.options;
    if (!options) {
      throw new Error('プラグインの設定が読み込めませんでした。');
    }
    const config = JSON.parse(options);
    return {
      videoUrlCode: config.videoUrl ?? '',
      playlist: config.playlist ?? false,
      privacyMode: config.privacyMode ?? true,
      loginName: config.loginName ?? true
    };
  }

  /** ページ表示時のハンドラ
   * @param {Object} event - Kintoneのイベントオブジェクト
   */
  function handlePageShow(event) {
    const spaceEl = getHeaderSpaceElement(event);
    if (spaceEl === null) {
      throw new Error('The header element is unavailable on this page.');
    }

    //プレイヤー機能の初期化
    if (player) {
      player.destroy();
      player = null;
      muted = null;
      playStatus = null;

    }
    const button = document.querySelector('.icon-outer');
    if (button) {
      console.log('button remove')
      button.remove();
    }
    const youtubeEle = document.querySelector('#video-outer');
    if (youtubeEle) {
      console.log('video remove');
      youtubeEle.remove();
    }

    //ログインユーザー名が一致していたらプレイヤー機能を呼び出す
    if (kintone.getLoginUser().code === config.loginName) {
      setYoutubePlayer(spaceEl);
    }
  }


  /** ヘッダー要素を取得する
   * @param {Object} event - Kintoneのイベントオブジェクト
   * @returns {HTMLElement|null} ヘッダー要素
   */
  function getHeaderSpaceElement(event) {
    return event.type === 'app.record.index.show'
      ? kintone.app.getHeaderMenuSpaceElement()
      : kintone.app.record.getHeaderMenuSpaceElement();
  }

  /** YouTubeポップアップを生成する
   * @returns {HTMLElement} YouTubeポップアップ要素
   */
  function createYoutubeModal() {
    const videoOuterEl = document.createElement('div');
    videoOuterEl.id = 'video-outer';
    videoOuterEl.className = 'hide-ele';
    return videoOuterEl;
  }

  /** 再生ボタン要素を生成する
  * @returns {HTMLElement} 再生ボタン要素
  */
  function createPlayButton() {
    const outer = document.createElement('div');
    outer.classList.add('icon-outer');
    //ここで全パターンのアイコンを事前に非表示で用意しておく
    outer.innerHTML = `
      <div id="play" class='hide-ele'><i class="fa-solid fa-circle-play"></i></div>
      <div id="pause" class='hide-ele'><i class="fa-solid fa-circle-pause"></i></div>
      <div id="volume-high" class='hide-ele'><i class="fa-solid fa-volume-high"></i></div>
      <div id="volume-xmark" class='hide-ele'><i class="fa-solid fa-volume-xmark"></i></div>
    `;
    return outer
  }

  /** 動画プレーヤーを設置する
   * @param {HTMLElement} spaceEl - プレーヤーを設置する要素
   */
  function setYoutubePlayer(spaceEl) {
    //必要な要素を設置
    const videoOuterEl = createYoutubeModal();
    const videoEl = createVideoElement();
    const playbackEl = createPlayButton();
    const fragment = document.createDocumentFragment();
    videoOuterEl.appendChild(videoEl);
    fragment.appendChild(videoOuterEl);
    fragment.appendChild(playbackEl);
    spaceEl.appendChild(fragment);

    //ステータスを更新
    playStatus = 'noPlay';

    //アイコンの初期化
    updateIcon();

    //動画プレイヤーを設置
    initializeYouTubePlayer(videoEl.id);
    
    //イベントリスナーを設置
    setupEventListeners(playbackEl, videoOuterEl);
  }

  /** 動画要素を生成する
   * @returns {HTMLElement} 動画要素
   */
  function createVideoElement() {
    const videoEl = document.createElement('div');
    videoEl.id = 'youtube-player';
    return videoEl;
  }

  /** YouTubeプレーヤーを初期化する
   * @param {string} elementId - プレーヤーを設置する要素のID
   */
  function initializeYouTubePlayer(elementId) {
    player = new YouTubePlayer(elementId, config.videoUrlCode, {
      width: 560,
      height: 315,
      autoplay: false,
      mute: false,
      controls: false,
      playlist: config.playlist,
      privacyMode: config.privacyMode,
      autoFocus: true
    });
  }

  /** アイコンを更新する */
  function updateIcon() {
    const playIcon = document.querySelector("#play");
    const pauseIcon = document.querySelector("#pause");
    const volumeHighIcon = document.querySelector("#volume-high");
    const volumeXmarkIcon = document.querySelector("#volume-xmark");
    if (!(playIcon && pauseIcon && volumeHighIcon && volumeXmarkIcon)) {
      throw new Error('The header element is unavailable on this page.');
    }

    // 一旦すべてのアイコンを非表示にする
    [playIcon, pauseIcon, volumeHighIcon, volumeXmarkIcon].forEach(icon => {
      icon.className = "hide-ele"
    });

    //ステータスに応じたアイコンを表示させる
    if (playStatus === 'noPlay') {
      playIcon.className = "show-ele";
      return
    }

    if (playStatus === "play") {
      if (muted) {
        volumeXmarkIcon.className = "show-ele";
      } else {
        volumeHighIcon.className = "show-ele";
      }
    } else if (playStatus === "stop") {
      pauseIcon.className = "show-ele";
    } else {
      playIcon.className = "show-ele";
    }

  }


  /** イベントリスナーを設定する
   * @param {HTMLElement} playbackEl - 再生ボタン要素
   * @param {HTMLElement} videoOuterEl - 動画外側要素
   */
  function setupEventListeners(playbackEl, videoOuterEl) {

    //ミュート機能
    const clickMute = () => {
      player.mute();
      muted = true;
      updateIcon();
      playbackEl.onclick = clickUnMute;
    };

    //ミュート解除機能
    const clickUnMute = () => {
      player.unMute();
      muted = false;
      updateIcon();
      playbackEl.onclick = clickMute;
    };

    //再生機能
    playbackEl.onclick = () => {
      player.play();
      playStatus = 'play';
      clickUnMute();
    };

    //こっそり再生機能
    playbackEl.addEventListener('mouseenter', () => {
      videoOuterEl.classList.remove('animation_slide_out', 'hide-ele');
      videoOuterEl.classList.add('animation_slide_in', 'show-ele');
      player.focus();
      if (playStatus !== 'noPlay') {
        player.play();
        playStatus = 'play';
        updateIcon();
      }
    });

    //一時停止機能機能
    playbackEl.addEventListener('mouseleave', () => {
      player.pause();
      playStatus = playStatus === 'noPlay' ? 'noPlay' : 'stop';
      updateIcon();
      videoOuterEl.classList.remove('animation_slide_in');
      videoOuterEl.classList.add('animation_slide_out');
    });
  }

  // プラグインの初期化を呼び出す
  initializePlugin();

})(kintone.$PLUGIN_ID);

```
## 6.動作確認
画質アレだけどこんな感じで動きます。
![無題の動画-‐-Clipchampで作成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/956154/e3c04502-fba8-4232-2bde-ce6d91bb6637.gif)


## 7. 難しかったところ

- 1クリックで動画が再生できない問題：
    - 再生前にiframe.contentWindow.focus();を呼び出したら解決した
    - iframeのwindowにフォーカスしないとダメだった
- 動画自動再生ポリシー：
    - ブラウザの問題で自動再生させたいときは音声をミュートにしないと再生されない
    - 今回は急に再生したらびっくりすると思って自動再生にしなかった
- FontAwesomeの仕様の把握：
    - アイコン自体は\<i>タグで設置する
    - \<i>タグのクラスを変更してもアイコンは切り替わらない
    - \<i>タグ生成後に表示/非表示をJSで切り替えて解決した
- 再生状態の取得：
    - 始めは動画の再生状態をIFramePlayerAPIで取得しようとしていた
    - うまくいかなかったので再生状態をグローバル変数に入れて管理することにした
    - これを元にアイコンを切り替える事が可能になった

## 8. 課題
- WindowやIFramePlayerAPIのイベントまわりの理解度が低い
- plyerのステータス取得がなんか遅い
   - 今回はグローバル変数で無理やり何とかした  
   - IFramePlayerAPIが扱えるイベントを使って何とかしたい


## 9. 最後に
### 感想
はーー楽しかった。
ネタで作ったけど、動画を再生する機能自体は使い方次第で実用化できそう。
ナレッジベース的なアプリに、動画ID用のフィールド準備して、レコード詳細画面に動画を埋め込む、的な。
容量食わず動画仕込めるし、ファイルをDLせずに再生できるのはとても良い。
kintone認定試験の復習アプリと組み合わせても良いかも？？

### 今後
- Viteつかってみたい
- GitHub actionsと組み合わせてみたい
- 外部サーバーとの通信処理やりたい

## 10. 使用ツール
https://cybozu.dev/ja/kintone/sdk/development-environment/create-plugin/

https://fontawesome.com/icons

https://developers.google.com/youtube/iframe_api_reference?hl=ja

https://claude.ai/

## 11. 参考文献
https://cybozu.dev/ja/kintone/docs/js-api/

https://cybozu.dev/ja/id/5eac8b3387b5abbfa1d8df21/#fontawesome

https://coosy.co.jp/blog/youtube-cutomize/