Obisidian PublishのようなSPAのサイトをYouTubeIFramePlayerAPI を活用してYouTubeの動画と目次をリンクさせる方法

## 動作はこんな感じ

<iframe width="560" height="315" src="https://www.youtube.com/embed/0mZ9C9P_U0Q" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

### 動作確認用

https://naomina121.com/%E3%83%8A%E3%82%AA%E3%81%AE%E9%9F%B3%E5%A3%B0%E3%83%96%E3%83%AD%E3%82%B0/%E3%83%8A%E3%82%AA%E3%81%A8%E3%81%84%E3%81%86%E7%94%9F%E7%89%A9%E3%82%92%E3%83%8A%E3%82%AA%E3%81%8C%E8%AA%9E%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B

## 参考にさせていただいたサイトはこちら

以前、Obsidianのプロパティをサイトに入れようとしたとき参考にさせていただいたサイトです。

https://minerva.mamansoft.net/%F0%9F%93%97Obsidian%E9%80%86%E5%BC%95%E3%81%8D%E3%83%AC%E3%82%B7%E3%83%94/%F0%9F%93%97Obsidian+Publish%E3%81%AE%E3%82%B5%E3%82%A4%E3%83%88%E3%81%AB%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3(%E3%83%A1%E3%82%BF%E3%83%87%E3%83%BC%E3%82%BF)%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%97%E3%81%9F%E3%81%84

こちらは、メタデータのみですが、今回は実装例として、こちらのコードを組み合わせて、実装します。（必要でなければ適宜不要なコードは、削除してください。）

私の場合はメタデータをサイトに入れたかったためそのまま残しています。

## 実装例

こちらが実装例です。

まず、ノートに入れるためのプロパティ（プラグイン導入でもいけました）を設定しています。

```md
---
tags: ["value1","value2"]
created: 2025-04-08
updated: 2025-04-08
youtubeid: "埋め込みたいYOUTUBEの動画ID 例: 'dQw4w9WgXcQ' など、動画URLの v= の後ろの部分"
---

## 埋め込みたい動画

<div id="originalID" class="origianl-class"></div>

## 動画の説明欄からコピーしたチャプター情報 (タイムスタンプとタイトル)

<div id="chapter-list">
00:00 目次タイトル1
00:51 目次タイトル2
02:17 目次タイトル3
</div>
```
と、このように、Obsidianのノートには、動画の説明欄からコピーしたチャプター情報（タイムスタンプとタイトル）をHTMLの中に貼り付けます。目次生成をしていない場合は、動作がうまくとれないため、YOUTUBEのほうでチャプター情報を生成してからお試しください。

続いては、publish.jsを編集します。

```publish.js
// ============================================
// 参考サイトサイト様からコピーしたコード（参考サイト：https://minerva.mamansoft.net/%F0%9F%93%97Obsidian%E9%80%86%E5%BC%95%E3%81%8D%E3%83%AC%E3%82%B7%E3%83%94/%F0%9F%93%97Obsidian+Publish%E3%81%AE%E3%82%B5%E3%82%A4%E3%83%88%E3%81%AB%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3(%E3%83%A1%E3%82%BF%E3%83%87%E3%83%BC%E3%82%BF)%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%97%E3%81%9F%E3%81%84）
// ============================================

// ============================================
// グローバル変数
// ============================================

let player; // YouTubeプレーヤーオブジェクト
let id; //　既存のメタ情報用
let youtubeID; 

function insertMetaDates() {
    const frontmatter = app.site.cache.cache[app.currentFilepath].frontmatter;
    if (!frontmatter) {
      clearInterval(id);
      return;
    }

    const created = frontmatter["created"]?.replaceAll("-", "/");
    const updated = frontmatter["updated"]?.replaceAll("-", "/");
    if (!created && !updated) {
      clearInterval(id);
      return;
    }

    const tags = frontmatter["tags"];
    if (!tags) {
      clearInterval(id);
      return;
    }

    const frontmatterEl = document.querySelector(".frontmatter");
    if (!frontmatterEl) {
      return;
    }

    const tagElms = tags
      .map(
        (tag) => `
      <a href="#${tag}" class="tag" target="_blank" rel="noopener">#${tag}</a>
      `
      )
      .join("");
    frontmatterEl.insertAdjacentHTML(
      "afterend",
      `
  <div class="properties">
        ${tagElms}
      <div class="created">作成:${created}</div>
    <div class="updated">更新:${updated}</div>
  </div>
  `
    );

    clearInterval(id);
  }

// ============================================
// YOUTUBEAPI
// ============================================

let tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
let firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

// ============================================
// API準備完了時の処理
// ============================================

function onYouTubeIframeAPIReady() {

  const frontmatter = app.site.cache.cache[app.currentFilepath].frontmatter;
  if (!frontmatter) {
    clearInterval(youtubeID);
    return;
  }

  const ytID = frontmatter["youtubeid"];

  if(!ytID){
    clearInterval(youtubeID);
    return;
  }

  const frontmatterEl = document.getElementById("originalID");
  if(!frontmatterEl){
    return;
  }

  frontmatterEl.insertAdjacentHTML(
    "afterbegin",
    `
  <div id="player" data-id="${ytID}"></div>
  `);

    const ytVideo = document.getElementById("player");

    // player要素やdata-id属性が存在するか確認
    if (!ytVideo) {
        clearInterval(youtubeID);
        console.error("Error: Element with id 'player' not found.");
        return;
    }
    const videoId = ytVideo.getAttribute('data-id');
    if (!videoId) {
        clearInterval(youtubeID);
        console.error("Error: data-id attribute not found on element 'player'.");
        return;
    }

    try {
        player = new YT.Player('player', {
            height: '360',
            width: '640',
            videoId: videoId,
            events: {
                // 'onReady'イベントハンドラを追加
                'onReady': onPlayerReady,
            }
        });
        clearInterval(youtubeID);
    } catch (error) {
        clearInterval(youtubeID);
        console.error("Error creating YT.Player:", error); // エラーハンドリング
    }
}

// ============================================
// プレーヤー準備完了時の処理
// ============================================
// YT.Playerの準備ができたら呼び出される関数
function onPlayerReady(event) {
    // プレーヤーが準備できてからクリックイベントを設定
    setupChapterClickEvents();
}

// ============================================
// チャプターリスト関連
// ============================================

// 時間文字列 (HH:MM:SS or MM:SS) を秒に変換する関数
function timeToSeconds(timeString) {
  const parts = timeString.split(':').map(Number);
  let seconds = 0;
  if (parts.length === 3) { // HH:MM:SS
      seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
  } else if (parts.length === 2) { // MM:SS
      seconds = parts[0] * 60 + parts[1];
  }
  return seconds;
}

// チャプターリストのクリックイベントを設定する関数
function setupChapterClickEvents() {
    console.log("Setting up chapter click events."); // ログ追加
    const chapterListElement = document.getElementById('chapter-list');
    let text = chapterListElement.innerHTML;

    if (!chapterListElement) {
        clearInterval(youtubeID);
        console.warn("Element with id 'chapter-list' not found.");
        return;
    }

    const lines = text.split('\n').filter(line => line.trim() !== ''); // テキストを行ごとに分割
    const chapters = [];

    // 正規表現でタイムスタンプとタイトルを抽出
    const regex = /^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/;

    lines.forEach(line => {
        const match = line.trim().match(regex);
        if (match) {
            const time = match[1]; // 時間文字列 (例: "1:15")
            const title = match[2]; // タイトル (例: "トピックA")
            const seconds = timeToSeconds(time); // 秒に変換 (例: 75)
            chapters.push({ time, title, seconds });
        }
    });

    if (chapters.length === 0) {
        chapterListElement.innerHTML = '<p>チャプター情報が見つかりませんでした。</p>';
        return;
    }

    // 目次リストのHTMLを生成
    const ul = document.createElement('ul');
    chapters.forEach(chapter => {
        const li = document.createElement('li');
        li.textContent = `${chapter.time} ${chapter.title}`;
        li.setAttribute('data-seconds', chapter.seconds); // 秒数をdata属性として保持

        // 新しいイベントリスナーを追加
        li.addEventListener('click', handleChapterClick);
        ul.appendChild(li);
    });
    chapterListElement.innerHTML = ''; // 古い内容をクリア
    chapterListElement.appendChild(ul); // 生成したリストを追加
}

// クリックイベントのハンドラ関数
function handleChapterClick(event) {
    const chapter = event.currentTarget; // クリックされたli要素
    const seconds = chapter.getAttribute("data-seconds");

    if (seconds === null) {
        console.warn("Clicked item is missing data-seconds attribute:", chapter.textContent);
        return;
    }

    console.log(`Chapter clicked: ${chapter.textContent.trim()}, seeking to ${seconds}s`); // ログ追加

    // playerオブジェクトとseekToメソッドの存在を確認
    if (player && typeof player.seekTo === 'function') {
        player.seekTo(seconds, true); // 指定秒数に移動し、再生を開始
    } else {
        console.error("Player is not ready or seekTo function is unavailable when clicking chapter.");
    }
}

const onChangeDOM = (mutationsList, observer) => {
  for (let mutation of mutationsList) {
    if (
      mutation.type === "childList" &&
      mutation.addedNodes[0]?.className === "page-header"
    ) {
      clearInterval(youtubeID);
      clearInterval(id);
      youtubeID = setInterval(onYouTubeIframeAPIReady, 50);
      id = setInterval(insertMetaDates, 50);
    }
  }
};

// 監視するセレクターを指定
const targetNode = document.querySelector(
  ".markdown-preview-sizer.markdown-preview-section"
);


// MutationObserverによって監視する。
const observer = new MutationObserver(onChangeDOM);
observer.observe(targetNode, { childList: true, subtree: true });
youtubeID = setInterval(onYouTubeIframeAPIReady, 50);
id = setInterval(insertMetaDates, 50);
```

publish.jsでやっていることは、`MutationObserver`で監視セレクターに変更があった場合、挙動を確認しつつ、SPA内でも動的に動作できるように処理されています。

目次は、JSで組み込みしなくてもいいのですが、面倒なので、そのままコードに組み込みました。

以上が実装例でした。