YouTube Queue Button Restore

サムネイルホバー時に「キューに追加」「後で見る」ボタンを復活させる

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         YouTube Queue Button Restore
// @namespace    https://www.buhoho.net/
// @version      1.3
// @description  サムネイルホバー時に「キューに追加」「後で見る」ボタンを復活させる
// @description:en  Restore "Add to queue" and "Watch later" buttons on YouTube thumbnail hover
// @author       buhoho
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const CONTAINER_CLASS = 'ytqr-overlay';
  const BTN_CLASS = 'ytqr-btn';
  const HIDING_CLASS = 'ytqr-hiding-menu';
  const PROCESSED_ATTR = 'data-ytqr';

  // キューに追加の多言語キーワード(メニューテキスト部分一致用)
  const QUEUE_KEYWORDS = [
    'キューに追加',                    // ja
    'Add to queue',                    // en
    'Añadir a la cola',                // es
    "file d'attente",                  // fr
    'Warteschlange',                   // de
    'Adicionar à fila',                // pt-BR
    '대기열에 추가',                    // ko
    '添加到队列',                       // zh-CN
    '加入佇列',                        // zh-TW
    'Добавить в очередь',              // ru
    'Aggiungi alla coda',              // it
    'Aan wachtrij',                    // nl
    'Dodaj do kolejki',                // pl
    'Sıraya ekle',                     // tr
    'เพิ่มในคิว',                       // th
    'Tambahkan ke antrean',            // id
    'Додати до черги',                 // uk
  ];

  // --- CSS ---
  const style = document.createElement('style');
  style.textContent = `
    yt-thumbnail-view-model,
    ytd-thumbnail {
      position: relative !important;
    }

    .${CONTAINER_CLASS} {
      position: absolute;
      top: 4px;
      right: 4px;
      z-index: 800;
      display: flex;
      flex-direction: column;
      gap: 2px;
      opacity: 0;
      transition: opacity 0.15s ease;
      pointer-events: none;
    }

    yt-thumbnail-view-model:hover .${CONTAINER_CLASS},
    ytd-thumbnail:hover .${CONTAINER_CLASS} {
      opacity: 1;
      pointer-events: auto;
    }

    .${BTN_CLASS} {
      width: 28px;
      height: 28px;
      border-radius: 2px;
      background: rgba(0, 0, 0, 0.7);
      border: none;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0;
      transition: background 0.2s;
    }
    .${BTN_CLASS}:hover {
      background: rgba(0, 0, 0, 0.9);
    }
    .${BTN_CLASS} svg {
      width: 18px;
      height: 18px;
      fill: #fff;
      pointer-events: none;
    }
    .${BTN_CLASS}.ytqr-ok  { background: rgba(30,120,50,0.85); }
    .${BTN_CLASS}.ytqr-err { background: rgba(180,30,30,0.85); }

    /* 三点メニューを画面外に飛ばして不可視にする */
    body.${HIDING_CLASS} tp-yt-iron-dropdown {
      position: fixed !important;
      left: -9999px !important;
      top: -9999px !important;
      opacity: 0 !important;
      pointer-events: none !important;
    }
    body.${HIDING_CLASS} tp-yt-iron-overlay-backdrop {
      display: none !important;
    }
  `;
  document.head.appendChild(style);

  // --- SVG (createElementNS でCSP Trusted Types対策) ---
  function svg(pathD) {
    const ns = 'http://www.w3.org/2000/svg';
    const s = document.createElementNS(ns, 'svg');
    s.setAttribute('viewBox', '0 0 24 24');
    const p = document.createElementNS(ns, 'path');
    p.setAttribute('d', pathD);
    s.appendChild(p);
    return s;
  }

  const ICON_QUEUE = 'M21 3H3v2h18V3zm0 4H3v2h18V7zM3 13h12v-2H3v2zm0 4h12v-2H3v2zm16-4v4h-2v-4h-4v-2h4V7h2v4h4v2h-4z';
  const ICON_WL = 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z';

  // --- ユーティリティ ---

  /** 最寄りの動画アイテムレンダラーを取得 */
  function findVideoItem(el) {
    return el.closest(
      'ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer, yt-lockup-view-model'
    );
  }

  /** 動画アイテムからvideoIdを取得 */
  function getVideoId(videoItem) {
    const link = videoItem.querySelector('a[href*="/watch"]');
    if (!link) return null;
    try {
      return new URL(link.href).searchParams.get('v');
    } catch {
      return null;
    }
  }

  /** 三点メニューボタンを取得 */
  function findMenuBtn(videoItem) {
    return (
      videoItem.querySelector('button[aria-label="その他の操作"]') ||
      videoItem.querySelector('button[aria-label="Action menu"]') ||
      videoItem.querySelector('button[aria-label="More actions"]') ||
      videoItem.querySelector('ytd-menu-renderer #button-shape button') ||
      videoItem.querySelector('ytd-menu-renderer yt-icon-button button')
    );
  }

  /** 要素の出現を待つ */
  function waitFor(selector, timeout = 1500) {
    return new Promise((resolve) => {
      const found = document.querySelector(selector);
      if (found) return resolve(found);

      const obs = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) { obs.disconnect(); resolve(el); }
      });
      obs.observe(document.body, { childList: true, subtree: true, attributes: true });
      setTimeout(() => { obs.disconnect(); resolve(null); }, timeout);
    });
  }

  /** ボタンの成功/失敗フラッシュ */
  function flash(btn, ok) {
    btn.classList.add(ok ? 'ytqr-ok' : 'ytqr-err');
    setTimeout(() => btn.classList.remove('ytqr-ok', 'ytqr-err'), 1200);
  }

  // --- 「後で見る」API直接呼び出し(言語非依存) ---

  /** SAPISIDHASH認証ヘッダーを生成 */
  async function generateSAPISIDHash() {
    const cookies = document.cookie.split('; ');
    let sapisid = null;
    for (const c of cookies) {
      if (c.startsWith('SAPISID=') || c.startsWith('__Secure-3PAPISID=')) {
        sapisid = c.split('=')[1];
        break;
      }
    }
    if (!sapisid) return null;
    const origin = 'https://www.youtube.com';
    const timestamp = Math.floor(Date.now() / 1000);
    const input = `${timestamp} ${sapisid} ${origin}`;
    const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input));
    const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
    return `SAPISIDHASH ${timestamp}_${hash}`;
  }

  /** 「後で見る」に追加(API直接) */
  async function addToWatchLater(videoId) {
    try {
      const apiKey = ytcfg.get('INNERTUBE_API_KEY');
      const context = ytcfg.get('INNERTUBE_CONTEXT');
      const auth = await generateSAPISIDHash();
      if (!auth || !apiKey || !context) return false;

      const res = await fetch('/youtubei/v1/browse/edit_playlist?key=' + apiKey, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': auth,
          'X-Origin': 'https://www.youtube.com',
        },
        credentials: 'include',
        body: JSON.stringify({
          context,
          playlistId: 'WL',
          actions: [{ addedVideoId: videoId, action: 'ACTION_ADD_VIDEO' }],
        }),
      });
      const data = await res.json();
      return data.status === 'STATUS_SUCCEEDED';
    } catch {
      return false;
    }
  }

  // --- 「キューに追加」三点メニュー経由(多言語テキストマッチ) ---

  let busy = false;

  /** 三点メニュー経由でキューに追加 */
  async function addToQueueViaMenu(videoItem) {
    if (busy) return false;
    busy = true;

    const menuBtn = findMenuBtn(videoItem);
    if (!menuBtn) { busy = false; return false; }

    document.body.classList.add(HIDING_CLASS);

    try {
      menuBtn.click();

      const popupSel = [
        'tp-yt-iron-dropdown:not([aria-hidden="true"]) yt-list-item-view-model',
        'tp-yt-iron-dropdown:not([aria-hidden="true"]) ytd-menu-service-item-renderer',
      ].join(',');
      await waitFor(popupSel, 1500);

      const items = document.querySelectorAll(popupSel);
      const target = Array.from(items).find((el) =>
        QUEUE_KEYWORDS.some((kw) => el.textContent.includes(kw))
      );

      if (target) {
        target.click();
        return true;
      }
      document.body.click();
      return false;
    } finally {
      setTimeout(() => {
        document.body.classList.remove(HIDING_CLASS);
        busy = false;
      }, 150);
    }
  }

  // --- Shorts判定 ---
  function isShorts(thumbnail) {
    const link = thumbnail.querySelector('a[href*="/shorts/"]');
    if (link) return true;
    const item = findVideoItem(thumbnail);
    if (!item) return false;
    const anyLink = item.querySelector('a[href*="/shorts/"]');
    return !!anyLink;
  }

  // --- ボタン注入 ---
  function injectButtons(thumbnail) {
    if (thumbnail.hasAttribute(PROCESSED_ATTR)) return;
    thumbnail.setAttribute(PROCESSED_ATTR, '1');

    if (isShorts(thumbnail)) return;

    const container = document.createElement('div');
    container.className = CONTAINER_CLASS;

    const isJa = (document.documentElement.lang || '').startsWith('ja');

    // 後で見る(API直接呼び出し)
    const wlBtn = document.createElement('button');
    wlBtn.className = BTN_CLASS;
    wlBtn.title = isJa ? '後で見る' : 'Watch later';
    wlBtn.appendChild(svg(ICON_WL));
    wlBtn.addEventListener('click', async (e) => {
      e.preventDefault();
      e.stopPropagation();
      const item = findVideoItem(thumbnail);
      if (!item) return;
      const videoId = getVideoId(item);
      if (!videoId) return;
      const ok = await addToWatchLater(videoId);
      flash(wlBtn, ok);
    });

    // キューに追加(メニュー経由)
    const qBtn = document.createElement('button');
    qBtn.className = BTN_CLASS;
    qBtn.title = isJa ? 'キューに追加' : 'Add to queue';
    qBtn.appendChild(svg(ICON_QUEUE));
    qBtn.addEventListener('click', async (e) => {
      e.preventDefault();
      e.stopPropagation();
      const item = findVideoItem(thumbnail);
      if (!item) return;
      const ok = await addToQueueViaMenu(item);
      flash(qBtn, ok);
    });

    container.appendChild(wlBtn);
    container.appendChild(qBtn);
    thumbnail.appendChild(container);
  }

  // --- サムネイル検出 ---
  function processAll() {
    const sel = [
      `yt-thumbnail-view-model:not([${PROCESSED_ATTR}])`,
      `ytd-thumbnail:not([${PROCESSED_ATTR}])`,
    ].join(',');
    document.querySelectorAll(sel).forEach(injectButtons);
  }

  // デバウンス付きMutationObserver
  let timer = null;
  function scheduleProcess() {
    if (timer) return;
    timer = setTimeout(() => { timer = null; processAll(); }, 400);
  }

  const observer = new MutationObserver(scheduleProcess);
  observer.observe(document.body, { childList: true, subtree: true });

  // 初回実行
  processAll();

  // SPA遷移対応
  window.addEventListener('yt-navigate-finish', () => setTimeout(processAll, 600));
})();