// ==UserScript==
// @name YouTube 影片儲存按鈕強制顯示
// @namespace https://github.com/downwarjers/WebTweaks
// @version 2.4.0
// @description 強制在 YouTube 影片操作列顯示「儲存」(加入播放清單)按鈕。當視窗縮放導致按鈕被收入「...」選單時,自動複製並生成一個獨立的按鈕置於操作列上。
// @author downwarjers
// @license MIT
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant GM_addStyle
// @run-at document-idle
// @downloadURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/youtube-save-button-fixer/youtube-save-button-fixer.user.js
// @updateURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/youtube-save-button-fixer/youtube-save-button-fixer.user.js
// ==/UserScript==
(function () {
'use strict';
const MY_BTN_LABEL = '儲存';
const CONTAINER_ID = 'my-save-dock';
let compressionFailCount = 0; // 記錄被壓縮失敗的次數
let stopFixing = false; // 是否停止強制顯示按鈕
const MAX_FAILURES = 3; // 最大容許失敗次數
const NATIVE_BTN_SELECTORS = [
'button[aria-label="儲存至播放清單"]',
'button[aria-label="Save to playlist"]',
];
// === CSS ===
GM_addStyle(`
/* 獨立特區樣式
這個容器位於 #actions-inner 的左邊,不受內部 Flex 影響
*/
#${CONTAINER_ID} {
display: flex;
align-items: center;
flex-shrink: 0 !important; /* 絕對不准壓縮 */
margin-right: 8px; /* 跟右邊的按讚按鈕保持距離 */
}
/* 隱形點擊遮罩 */
body.yt-proxy-clicking ytd-popup-container {
opacity: 0 !important;
pointer-events: none !important;
}
`);
async function executeInvisibleClick(threeDotButton) {
document.body.classList.add('yt-proxy-clicking');
try {
threeDotButton.click();
const saveItem = await waitForItem(MY_BTN_LABEL);
if (saveItem) {
saveItem.click();
} else {
threeDotButton.click();
}
} catch (e) {
console.error('[YouTube Save Fix] Error:', e);
} finally {
setTimeout(() => {
document.body.classList.remove('yt-proxy-clicking');
}, 200);
}
}
function waitForItem(text) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(() => {
attempts++;
const items = document.querySelectorAll(
'ytd-menu-service-item-renderer, tp-yt-paper-item, yt-formatted-string',
);
for (let item of items) {
if (item.innerText && item.innerText.trim() === text) {
clearInterval(timer);
resolve(
item.closest('ytd-menu-service-item-renderer') ||
item.closest('tp-yt-paper-item') ||
item,
);
return;
}
}
if (attempts > 50) {
clearInterval(timer);
resolve(null);
}
}, 20);
});
}
// === 建立按鈕 DOM (複製 Share 按鈕樣式) ===
function createDockButton(menuRenderer) {
const shareBtn =
menuRenderer.querySelector('button[aria-label="分享"]') ||
menuRenderer.querySelector('button[aria-label="Share"]') ||
menuRenderer.querySelector('button');
if (!shareBtn) {
return null;
}
const threeDotButtonShape = menuRenderer.querySelector('yt-button-shape#button-shape button');
if (!threeDotButtonShape) {
return null;
}
const clonedBtn = shareBtn.cloneNode(true);
clonedBtn.id = '';
clonedBtn.removeAttribute('title');
clonedBtn.setAttribute('aria-label', MY_BTN_LABEL);
clonedBtn.style.cssText = '';
// 樣式標準化:確保是膠囊樣式
clonedBtn.classList.remove('yt-spec-button-shape-next--icon-button');
clonedBtn.classList.remove('yt-spec-button-shape-next--segmented-start');
clonedBtn.classList.remove('yt-spec-button-shape-next--segmented-end');
clonedBtn.classList.add('yt-spec-button-shape-next--tonal');
clonedBtn.classList.add('yt-spec-button-shape-next--icon-leading');
clonedBtn.classList.add('yt-spec-button-shape-next--size-m');
// Icon
let iconContainer = clonedBtn.querySelector('.yt-spec-button-shape-next__icon');
if (iconContainer) {
iconContainer.innerHTML = `
`;
}
// Text
let textContainer = clonedBtn.querySelector('.yt-spec-button-shape-next__button-text-content');
if (!textContainer) {
textContainer = document.createElement('div');
textContainer.className = 'yt-spec-button-shape-next__button-text-content';
clonedBtn.appendChild(textContainer);
}
textContainer.innerHTML = `${MY_BTN_LABEL}`;
clonedBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
executeInvisibleClick(threeDotButtonShape);
};
return clonedBtn;
}
// === 主邏輯 ===
function checkAndToggle() {
if (stopFixing) {
const myDock = document.getElementById(CONTAINER_ID);
if (myDock) {
myDock.style.display = 'none';
}
return;
}
// 1. 找到大容器 #actions (包含 actions-inner 和 menu)
const actionsContainer = document.querySelector('#actions');
const actionsInner = document.querySelector('#actions-inner');
if (!actionsContainer || !actionsInner) {
return;
}
const menuRenderer = actionsInner.querySelector('ytd-menu-renderer');
if (!menuRenderer) {
return;
}
// 2. 判斷原生按鈕狀態
let isNativeVisible = false;
let nativeBtn = null;
for (const selector of NATIVE_BTN_SELECTORS) {
const found = menuRenderer.querySelector(selector);
if (found) {
nativeBtn = found;
break;
}
}
if (nativeBtn) {
const flexibleContainer = nativeBtn.closest('#flexible-item-buttons');
if (flexibleContainer) {
const rect = flexibleContainer.getBoundingClientRect();
if (rect.width > 2 && window.getComputedStyle(flexibleContainer).display !== 'none') {
isNativeVisible = true;
}
} else if (nativeBtn.offsetParent !== null) {
isNativeVisible = true;
}
}
// 3. 處理Dock
let myDock = document.getElementById(CONTAINER_ID);
if (!myDock) {
const btn = createDockButton(menuRenderer);
if (btn) {
myDock = document.createElement('div');
myDock.id = CONTAINER_ID;
myDock.appendChild(btn);
actionsContainer.insertBefore(myDock, actionsInner);
}
}
if (myDock && myDock.style.display === 'flex') {
if (myDock.offsetWidth < 10) {
compressionFailCount++;
console.warn(`[YouTube Save Fix] 按鈕被壓縮 (${compressionFailCount}/${MAX_FAILURES})`);
if (compressionFailCount >= MAX_FAILURES) {
stopFixing = true;
console.error('[YouTube Save Fix] 空間不足,停止強制顯示以免閃爍。');
myDock.style.display = 'none';
return;
}
} else {
compressionFailCount = 0;
}
}
// 4. 切換顯示
if (myDock) {
if (isNativeVisible) {
myDock.style.display = 'none';
} else {
myDock.style.display = 'flex';
}
}
}
// === 監聽器 ===
let resizeObserver = null;
let mutationObserver = null;
function attachObservers() {
const actionsContainer = document.querySelector('#actions');
if (!actionsContainer) {
return;
}
if (resizeObserver) {
resizeObserver.disconnect();
}
if (mutationObserver) {
mutationObserver.disconnect();
}
let resizeTimeout;
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(checkAndToggle, 100);
});
resizeObserver.observe(actionsContainer);
mutationObserver = new MutationObserver((mutations) => {
const isSelfMutation = mutations.some((m) => {
return (
m.target.id === CONTAINER_ID ||
(m.addedNodes.length > 0 && m.addedNodes[0].id === CONTAINER_ID)
);
});
if (!isSelfMutation) {
checkAndToggle();
}
});
const actionsInner = document.querySelector('#actions-inner');
if (actionsInner) {
mutationObserver.observe(actionsInner, { childList: true, subtree: true });
}
checkAndToggle();
}
const globalObserver = new MutationObserver(() => {
if (document.querySelector('#actions')) {
attachObservers();
}
});
globalObserver.observe(document.body, { childList: true, subtree: true });
setTimeout(attachObservers, 1500);
window.addEventListener('yt-navigate-finish', () => {
stopFixing = false;
compressionFailCount = 0;
setTimeout(attachObservers, 1500);
});
})();