YouTube Queue Button Restore

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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));
})();