サムネイルホバー時に「キューに追加」「後で見る」ボタンを復活させる
// ==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));
})();