Add delete, add, or play buttons to the following pages: History, Playlists, and Channel pages. However, YouTube constantly makes pointless UI changes instead of focusing on actual work. On the History page, locating Shorts buttons is difficult; you must switch to the Shorts filter to find them.
// ==UserScript==
// @name Youtube Button Come Back
// @namespace http://tampermonkey.net/
// @version 5.3
// @description Add delete, add, or play buttons to the following pages: History, Playlists, and Channel pages. However, YouTube constantly makes pointless UI changes instead of focusing on actual work. On the History page, locating Shorts buttons is difficult; you must switch to the Shorts filter to find them.
// @description:zh-TW 在以下頁面添加刪除或添加、播放按鈕:歷史記錄、播放清單、頻道頁面。但Youtube不做正經事,成天亂調純搞事。歷史頁面shorts較難抓取按鈕,需切換至shorts類型
// @author AI
// @match https://www.youtube.com/*
// @require https://update.greasyfork.org/scripts/419640/1775486/onElementReady.js
// @grant none
// @license MIT
// ==/UserScript==
// = CONFIG: 全局設定 / Global Settings =
const CONFIG = {
NAVIGATION_DELAY: 200, // = 頁面導航後延遲執行 (ms) / Delay after navigation (ms)
PLAY_ALL_ENABLED: true, // = 啟用「播放全部」功能 / Enable Play All feature
FLOAT_BUTTONS_ENABLED: true, // = 啟用歷史記錄懸浮按鈕 / Enable history float buttons
PLAYLIST_FLOAT_ENABLED: true // = 啟用播放清單懸浮按鈕 / Enable playlist float buttons
}
// = CONFIG: 播放全部功能設定 / Play All Feature Settings =
const PLAY_CONFIG = {
ENABLED_PATHS: [{ type: 'regex', value: '^/@[^/]+/(videos|shorts|streams)$' }], // = 啟用的頁面路徑規則 / Enabled page path patterns
BTN_TEXT: { all: 'Play all', popular: 'Play popular', oldest: 'Play oldest' }, // = 按鈕顯示文字 / Button labels
BTN_SPACING: '0.5em' // = 按鈕間距 / Button spacing
}
// = CONFIG: 懸浮按鈕通用設定 / Float Button Common Settings =
const BTN_CONFIG = {
FLOAT_PATHS: [ // = 顯示歷史懸浮按鈕的頁面路徑 / Paths for history float buttons
{ type: 'exact', value: '/' },
{ type: 'startsWith', value: '/feed/subscriptions' },
{ type: 'startsWith', value: '/feed/history' }
],
PLAYLIST_PATHS: [{ type: 'startsWith', value: '/playlist?list=' }], // = 播放清單頁面路徑 / Playlist page path
SIZE: 36, // = 按鈕尺寸 (px) / Button size in pixels
SPACING: 4, // = 按鈕間距 (px) / Button spacing in pixels
BG_OPACITY: 0.9, // = 背景透明度 / Background opacity
FLOAT_LEFT_MARGIN: 170, // = 歷史頁按鈕水平位移 (px) / Horizontal offset for history buttons
FLOAT_TOP_MARGIN: 5, // = 歷史頁按鈕垂直位移 (px) / Vertical offset for history buttons
PLAYLIST_LEFT_MARGIN: 160, // = 播放清單按鈕左邊距 (px) / Left margin for playlist buttons
MENU_WAIT_TIMEOUT: 3000, // = 選單載入等待超時 (ms) - 已延長以適應 Shorts / Menu load timeout in ms - extended for Shorts
MENU_CHECK_INTERVAL: 80, // = 選單檢查間隔 (ms) - 已放緩以減少負擔 / Menu check interval in ms - slowed to reduce load
MENU_RETRY_DELAY: 150, // = 選單按鈕查找重試延遲 (ms) - 新增 / Menu button retry delay in ms - new
MENU_MAX_RETRIES: 3, // = 選單按鈕查找最大重試次數 - 新增 / Max retries for menu button lookup - new
TRANSITION_SPEED: '0.15s', // = CSS 過渡動畫速度 / CSS transition duration
BATCH_SIZE: 15, // = 批量處理元素數量 / Batch processing size
BATCH_DELAY: 20, // = 批次間延遲 (ms) / Delay between batches in ms
SUBSEQUENT_DELAY: 100 // = 後續單元素處理延遲 (ms) / Delay for subsequent single element processing
}
// = 常量:SVG 圖示路徑定義 / Constants: SVG Icon Path Definitions =
const ICON_PATHS = {
WATCH_LATER: 'M12 1C5.925 1 1 5.925 1 12s4.925 11 11 11 11-4.925 11-11S18.075 1 12 1Zm0 2a9 9 0 110 18.001A9 9 0 0112 3Zm0 3a1 1 0 00-1 1v5.565l.485.292 3.33 2a1 1 0 001.03-1.714L13 11.435V7a1 1 0 00-1-1Z', // = 稍後觀看圖示 / Watch Later icon
ADD_TO_PLAYLIST: 'M2 2.864v6.277a.5.5 0 00.748.434L9 6.002 2.748 2.43A.5.5 0 002 2.864ZM21 5h-9a1 1 0 100 2h9a1 1 0 100-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Z', // = 加入播放佇列圖示 / Add to Queue icon
SAVE_TO_PLAYLIST: 'M19 2H5a2 2 0 00-2 2v16.887c0 1.266 1.382 2.048 2.469 1.399L12 18.366l6.531 3.919c1.087.652 2.469-.131 2.469-1.397V4a2 2 0 00-2-2ZM5 20.233V4h14v16.233l-6.485-3.89-.515-.309-.515.309L5 20.233Z', // = 儲存至播放清單圖示 / Save to Playlist icon
REMOVE: 'M19 3h-4V2a1 1 0 00-1-1h-4a1 1 0 00-1 1v1H5a2 2 0 00-2 2h18a2 2 0 00-2-2ZM6 19V7H4v12a4 4 0 004 4h8a4 4 0 004-4V7h-2v12a2 2 0 01-2 2H8a2 2 0 01-2-2Zm4-11a1 1 0 00-1 1v8a1 1 0 102 0V9a1 1 0 00-1-1Zm4 0a1 1 0 00-1 1v8a1 1 0 002 0V9a1 1 0 00-1-1Z' // = 移除/刪除圖示 / Remove icon
}
// = 常量:樣式 ID 與自訂屬性定義 / Constants: Style IDs and Custom Attribute Definitions =
const STYLE_IDS = { PLAY: 'yt-play-all-style', CUSTOM_BTN: 'yt-custom-btn-style' } // = 動態注入的 <style> 標籤 ID / IDs for dynamically injected <style> tags
const ATTRS = { PLAY_PAGE_INIT: 'data-play-all-init', PLAY_ELEM_ADDED: 'data-play-all-added', BTN_PROCESSED: 'data-btn-added' } // = 用於標記元素處理狀態的 data 屬性 / Data attributes for tracking element processing state
const CLASSES = { PLAY_CONTAINER: 'ytpa-container', PLAY_BTN: 'ytpa-btn', BTN_CONTAINER: 'ytcb-container', BTN: 'yt-custom-btn' } // = 腳本生成的 CSS 類別名稱 / CSS class names generated by the script
// = 常量:DOM 選擇器定義 (MENU_BTN 已修正) / Constants: DOM Selector Definitions (MENU_BTN fixed) =
const SELECTORS = {
PLAY_TARGET: 'ytd-rich-item-renderer, ytd-playlist-video-renderer, ytd-video-renderer', // = 播放全部功能監聽的目標元素 / Target elements for Play All feature
FLOAT_TARGET: 'yt-lockup-view-model', // = 歷史記錄一般影片卡片選擇器 / Selector for history regular video cards
HISTORY_SHORTS: 'ytd-video-renderer[lockup="true"]', // = 歷史記錄 Shorts 卡片選擇器 (需帶 lockup 屬性) / Selector for history Shorts cards (requires lockup attribute)
MENU_BTN: 'ytd-menu-renderer yt-icon-button#button.dropdown-trigger>button.style-scope.yt-icon-button,ytd-menu-renderer button,div.ytLockupMetadataViewModelMenuButton button', // = 三點選單按鈕選擇器 (已修正:優先匹配 Shorts 內層真實 button 元素,避免觸發 yt-icon-button 自帶導航) / Three-dot menu button selector (fixed: prioritize inner real button for Shorts to avoid yt-icon-button navigation)
PLAYLIST_VIDEO: 'ytd-playlist-video-renderer', // = 播放清單內影片項目選擇器 / Selector for playlist video items
PLAYLIST_MENU_BTN: 'ytd-menu-renderer button, .yt-icon-button', // = 播放清單內選單按鈕選擇器 / Menu button selector within playlists
MENU_ITEM_BTN: 'ytd-menu-service-item-renderer tp-yt-paper-item,ytd-menu-navigation-item-renderer tp-yt-paper-item,yt-list-item-view-model button.ytButtonOrAnchorHost.ytButtonOrAnchorButton.yt-list-item-view-model__button-or-anchor, button.ytButtonOrAnchorHost.ytButtonOrAnchorButton.ytListItemViewModelButtonOrAnchor' // = 選單內可點擊項目按鈕選擇器 / Clickable menu item button selector
}
// = 運行狀態管理物件 / Runtime State Management Object =
const state = {
playActive: false, // = 播放全部功能是否已激活 / Play All feature activation status
btnActive: false, // = 歷史懸浮按鈕是否已激活 / History float buttons activation status
playlistActive: false, // = 播放清單懸浮按鈕是否已激活 / Playlist float buttons activation status
processedCount: 0, // = 已處理的歷史項目計數 / Count of processed history items
playlistCount: 0, // = 已處理的播放清單項目計數 / Count of processed playlist items
timer: null, // = 歷史按鈕批量處理計時器 / Timer for history button batch processing
playlistTimer: null // = 播放清單按鈕批量處理計時器 / Timer for playlist button batch processing
}
// = 工具函數:安全綁定事件監聽 / Utility: Safe Event Listener Binding =
const safeOn = (el, evt, fn) => el?.addEventListener(evt, fn) // = 若元素存在則添加事件監聽器 / Add event listener if element exists
// = 工具函數:路徑規則匹配 / Utility: Path Pattern Matching =
function matchPathRules(path, rules) { // = 檢查當前路徑是否符合任一規則 / Check if current path matches any rule
return rules.some(rule => {
if (rule.type === 'startsWith') return path.startsWith(rule.value) // = 前綴匹配 / Prefix match
if (rule.type === 'includes') return path.includes(rule.value) // = 包含匹配 / Contains match
if (rule.type === 'regex') return new RegExp(rule.value).test(path) // = 正則表達式匹配 / Regex match
if (rule.type === 'exact') return path === rule.value || path === rule.value + '/' // = 精確匹配 / Exact match
return false
})
}
// = 頁面判斷:是否為播放全部目標頁面 / Page Check: Is Play All Target Page =
function isPlayTargetPage() { // = 根據 CONFIG 與 PLAY_CONFIG 判斷是否啟用播放全部功能 / Determine if Play All feature should be enabled based on config
if (!CONFIG.PLAY_ALL_ENABLED) return false
return matchPathRules(location.pathname, PLAY_CONFIG.ENABLED_PATHS)
}
// = 頁面判斷:是否為歷史懸浮按鈕目標頁面 / Page Check: Is History Float Button Target Page =
function isBtnTargetPage() { // = 根據 CONFIG 與 BTN_CONFIG 判斷是否啟用歷史懸浮按鈕 / Determine if history float buttons should be enabled
if (!CONFIG.FLOAT_BUTTONS_ENABLED) return false
return matchPathRules(location.pathname, BTN_CONFIG.FLOAT_PATHS)
}
// = 頁面判斷:是否為播放清單懸浮按鈕目標頁面 / Page Check: Is Playlist Float Button Target Page =
function isPlaylistTargetPage() { // = 根據 CONFIG 與 BTN_CONFIG 判斷是否啟用播放清單懸浮按鈕 / Determine if playlist float buttons should be enabled
if (!CONFIG.PLAYLIST_FLOAT_ENABLED) return false
return matchPathRules(location.pathname + location.search, BTN_CONFIG.PLAYLIST_PATHS)
}
// = 工具函數:創建 SVG 圖示元素 / Utility: Create SVG Icon Element =
function createSVGIcon(path) { // = 根據 path 字符串生成 SVG <path> 元素 / Generate SVG <path> element from path string
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
svg.setAttribute('fill', 'currentColor')
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path')
pathEl.setAttribute('d', path)
svg.appendChild(pathEl)
return svg
}
// = 工具函數:延遲執行 / Utility: Delay Execution =
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) // = 返回 Promise 的延遲函數 / Delay function returning Promise
// ============ 播放全部功能模組 / Play All Feature Module ============
// = 清理:移除播放全部相關元素與狀態 / Cleanup: Remove Play All Elements and Reset State =
function playCleanup() { // = 重置 playActive 狀態並移除所有動態生成的按鈕與容器 / Reset playActive state and remove all dynamically generated buttons and containers
state.playActive = false
document.querySelectorAll(`.${CLASSES.PLAY_BTN}`).forEach(btn => btn.remove())
document.querySelectorAll(`.${CLASSES.PLAY_CONTAINER}`).forEach(container => container.remove())
}
// = 樣式注入:播放全部按鈕樣式 / Style Injection: Play All Button Styles =
function playAddStyles() { // = 動態插入 <style> 標籤,定義按鈕容器與懸浮效果 / Dynamically inject <style> tag defining button container and hover effects
if (document.getElementById(STYLE_IDS.PLAY)) return
const style = document.createElement('style')
style.id = STYLE_IDS.PLAY
style.textContent = `
.${CLASSES.PLAY_BTN} { background: #000000 !important; color: #ffffff !important; transition: background-color 0.15s ease, color 0.15s ease !important; }
.${CLASSES.PLAY_BTN}:hover { background: #ffffff !important; color: #000000 !important; }
.${CLASSES.PLAY_CONTAINER} { display: flex !important; flex-wrap: wrap !important; align-items: center !important; margin: 8px 0 !important; }
`
document.head.appendChild(style)
}
// = 創建:播放全部功能按鈕元素 / Create: Play All Feature Button Element =
function playCreateBtn(text, href) { // = 生成帶有樣式的 <a> 按鈕,支援點擊跳轉與行動版相容 / Generate styled <a> button with click handling and mobile compatibility
const a = document.createElement('a')
a.className = CLASSES.PLAY_BTN
a.href = href
a.textContent = text
a.role = 'button'
a.style.cssText = `display:inline-flex;align-items:center;justify-content:center;padding:0 0.8em;height:32px;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;margin-left:${PLAY_CONFIG.BTN_SPACING};user-select:none;`
safeOn(a, 'click', e => { if (location.host === 'm.youtube.com') { e.preventDefault(); location.href = a.href } })
return a
}
// = 非同步:獲取頻道 ID / Async: Fetch Channel ID =
async function playGetChannelId() { // = 透過頁面內容或 URL 提取 channelId,用於生成播放清單連結 / Extract channelId from page content or URL for generating playlist links
let id = ''
const extract = html => { const m = /var ytInitialData.+?[ "']channelId[ "']:[ "']?(UC[\w-]+)/.exec(html); return m?.[1] || '' }
try {
const link = document.querySelector('#content ytd-rich-item-renderer a')?.href
if (link) { const res = await fetch(link); const html = await res.text(); id = extract(html) }
} catch (_) { }
if (!id) { const m = location.href.match(/youtube\.com\/channel\/(UC[\w-]+)/); if (m) id = m[1] }
return id.replace('UC', '')
}
// = 插入:播放全部按鈕至頁面 / Insert: Play All Buttons into Page =
function playInsertButtons(channelId) { // = 根據當前頁面類型 (videos/shorts/streams) 插入三個播放按鈕 / Insert three play buttons based on current page type (videos/shorts/streams)
const isVideos = location.pathname.endsWith('/videos'), isShorts = location.pathname.endsWith('/shorts')
const lists = isVideos ? ['UULF', 'UULP'] : isShorts ? ['UUSH', 'UUPS'] : ['UULV', 'UUPV']
const [allList, popList] = lists
let container = document.querySelector('ytd-feed-filter-chip-bar-renderer iron-selector#chips, ytm-feed-filter-chip-bar-renderer .chip-bar-contents, chip-bar-view-model.ytChipBarViewModelHost')
if (!container) {
const grid = document.querySelector('ytd-rich-grid-renderer, ytm-rich-grid-renderer, div.ytChipBarViewModelChipWrapper')
grid?.insertAdjacentHTML('afterbegin', `<div class="${CLASSES.PLAY_CONTAINER}"></div>`)
container = grid?.querySelector(`.${CLASSES.PLAY_CONTAINER}`)
}
if (!container) return
container.querySelectorAll(`.${CLASSES.PLAY_BTN}`).forEach(b => b.remove())
const base = `/playlist?list=`
const btns = [
playCreateBtn(PLAY_CONFIG.BTN_TEXT.all, `${base}${allList}${channelId}&playnext=1&sort=1`),
playCreateBtn(PLAY_CONFIG.BTN_TEXT.popular, `${base}${popList}${channelId}&playnext=1`),
playCreateBtn(PLAY_CONFIG.BTN_TEXT.oldest, `${base}${allList}${channelId}&playnext=1&sort=2`)
]
btns.forEach(b => container.appendChild(b))
}
// = 激活:播放全部功能主流程 / Activate: Play All Feature Main Flow =
async function playActivate() { // = 初始化樣式、監聽目標元素、提取頻道 ID 並插入按鈕 / Initialize styles, observe target elements, extract channel ID, and insert buttons
if (state.playActive || !CONFIG.PLAY_ALL_ENABLED) return
state.playActive = true
playAddStyles()
if (document.body.hasAttribute(ATTRS.PLAY_PAGE_INIT)) {
onElementReady(SELECTORS.PLAY_TARGET, { once: false }, async (el) => {
if (!el.hasAttribute(ATTRS.PLAY_ELEM_ADDED)) {
const channelId = await playGetChannelId()
if (channelId) playInsertButtons(channelId)
el.setAttribute(ATTRS.PLAY_ELEM_ADDED, 'true')
}
})
return
}
document.body.setAttribute(ATTRS.PLAY_PAGE_INIT, 'true')
onElementReady(SELECTORS.PLAY_TARGET, { once: false }, async (el) => {
if (!el.hasAttribute(ATTRS.PLAY_ELEM_ADDED)) {
const channelId = await playGetChannelId()
if (channelId) playInsertButtons(channelId)
el.setAttribute(ATTRS.PLAY_ELEM_ADDED, 'true')
}
})
const hasTarget = document.querySelector(SELECTORS.PLAY_TARGET)
if (hasTarget) { const channelId = await playGetChannelId(); if (channelId) playInsertButtons(channelId) }
}
// ============ 懸浮按鈕通用模組 / Float Button Common Module ============
// = 清理:移除所有懸浮按鈕相關元素與狀態 / Cleanup: Remove All Float Button Elements and Reset State =
function btnCleanup() { // = 重置 btnActive/playlistActive 狀態、清除計時器、移除按鈕容器與處理標記 / Reset activation states, clear timers, remove button containers and processed markers
state.btnActive = false
state.playlistActive = false
if (state.timer) { clearTimeout(state.timer); state.timer = null }
if (state.playlistTimer) { clearTimeout(state.playlistTimer); state.playlistTimer = null }
document.querySelectorAll(`.${CLASSES.BTN_CONTAINER}`).forEach(c => c.remove())
document.querySelectorAll(`[${ATTRS.BTN_PROCESSED}]`).forEach(el => el.removeAttribute(ATTRS.BTN_PROCESSED))
state.processedCount = 0
state.playlistCount = 0
}
// = 樣式注入:懸浮按鈕通用樣式 / Style Injection: Float Button Common Styles =
function btnAddStyles() { // = 動態插入 <style> 標籤,定義按鈕容器定位、懸浮顯示邏輯與按鈕樣式 / Dynamically inject <style> tag defining container positioning, hover display logic, and button styles
if (document.getElementById(STYLE_IDS.CUSTOM_BTN)) return
const style = document.createElement('style')
style.id = STYLE_IDS.CUSTOM_BTN
const t = BTN_CONFIG.TRANSITION_SPEED
const s = BTN_CONFIG.SIZE
const o = BTN_CONFIG.BG_OPACITY
style.textContent = `
.${CLASSES.BTN_CONTAINER} {
position: absolute !important;
top: ${BTN_CONFIG.FLOAT_TOP_MARGIN}px !important;
left: ${BTN_CONFIG.FLOAT_LEFT_MARGIN}px !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: ${BTN_CONFIG.SPACING}px !important;
z-index: 10000 !important;
pointer-events: none !important;
opacity: 0 !important;
visibility: hidden !important;
transition: opacity ${t} ease, visibility ${t} ease !important;
}
${SELECTORS.FLOAT_TARGET}:hover .${CLASSES.BTN_CONTAINER},
${SELECTORS.HISTORY_SHORTS}:hover .${CLASSES.BTN_CONTAINER},
${SELECTORS.PLAYLIST_VIDEO}:hover .${CLASSES.BTN_CONTAINER} {
opacity: 1 !important;
visibility: visible !important;
}
.${CLASSES.BTN} {
width: ${s}px !important;
height: ${s}px !important;
border-radius: 50% !important;
border: none !important;
background: rgba(28, 28, 28, ${o}) !important;
color: #fff !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: background-color ${t}, transform ${t} !important;
pointer-events: auto !important;
padding: 0 !important;
flex-shrink: 0 !important;
}
.${CLASSES.BTN}:hover {
background: rgba(255, 255, 255, 0.25) !important;
transform: scale(1.15) !important;
}
.${CLASSES.BTN} svg {
width: 20px !important;
height: 20px !important;
fill: currentColor !important;
}
${SELECTORS.FLOAT_TARGET},
${SELECTORS.HISTORY_SHORTS},
${SELECTORS.PLAYLIST_VIDEO} {
overflow: visible !important;
position: relative !important;
}
`
document.head.appendChild(style)
}
// = 核心邏輯:點擊選單內指定圖示項目 / Core Logic: Click Menu Item by Icon Path =
async function clickMenuByIcon(menuButton, targetIconPath, itemSelector, isHistoryStyle = false) { // = 打開選單後輪詢查找匹配 icon path 的項目並點擊 / Open menu then poll for item matching icon path and click it
menuButton.click()
const startTime = Date.now()
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const popupContainer = document.querySelector('ytd-popup-container')
if (!popupContainer) {
if (Date.now() - startTime > BTN_CONFIG.MENU_WAIT_TIMEOUT) {
clearInterval(checkInterval)
resolve(false)
return
}
return
}
const menuItems = popupContainer.querySelectorAll('ytd-menu-service-item-renderer, ytd-menu-navigation-item-renderer, yt-list-item-view-model')
for (const item of menuItems) {
const svg = item.querySelector('svg path')
if (svg && svg.getAttribute('d') === targetIconPath) {
clearInterval(checkInterval)
let actionBtn = null
if (item.tagName.toLowerCase().includes('menu-service-item') || item.tagName.toLowerCase().includes('menu-navigation-item')) {
actionBtn = item.querySelector('tp-yt-paper-item')
} else {
actionBtn = item.querySelector(SELECTORS.MENU_ITEM_BTN) || item
}
if (actionBtn) {
actionBtn.click()
setTimeout(() => { popupContainer.click() }, 50)
resolve(true)
return
}
}
}
if (Date.now() - startTime > BTN_CONFIG.MENU_WAIT_TIMEOUT) {
clearInterval(checkInterval)
resolve(false)
}
}, BTN_CONFIG.MENU_CHECK_INTERVAL)
})
}
// = 工具函數:查找三點選單按鈕 (已修正 Shorts 導航問題) / Utility: Find Three-Dot Menu Button (Shorts navigation fixed) =
async function findMenuButton(row, isShorts = false) { // = 嘗試多種選擇器定位影片卡片內的選單觸發按鈕,已修正 Shorts 選擇器避免點擊到帶導航的 yt-icon-button / Try multiple selectors to locate menu trigger button, fixed Shorts selector to avoid clicking navigation-triggering yt-icon-button
if (isShorts) await delay(BTN_CONFIG.MENU_RETRY_DELAY)
for (let attempt = 0; attempt <= BTN_CONFIG.MENU_MAX_RETRIES; attempt++) {
// = 優先:Shorts 專用 - 直接選擇內層真實 button 元素,避開外層會觸發導航的 yt-icon-button / Priority: Shorts specific - select inner real button to avoid outer yt-icon-button that triggers navigation =
let btn = row.querySelector('ytd-menu-renderer yt-icon-button#button.dropdown-trigger>button.style-scope.yt-icon-button')
if (btn) return btn
// = 次選:用戶提供的通用選擇器 / Fallback: User-provided generic selector =
btn = row.querySelector(SELECTORS.MENU_BTN)
if (btn) return btn
// = 備援:其他常見選單按鈕結構 / Backup: Other common menu button structures =
btn = row.querySelector('ytd-menu-renderer button, .yt-lockup-metadata-view-model__menu-button button, .yt-spec-button-shape-next--icon-button')
if (btn) return btn
if (attempt < BTN_CONFIG.MENU_MAX_RETRIES) {
await delay(BTN_CONFIG.MENU_RETRY_DELAY)
}
}
return null
}
// = 創建:統一風格的懸浮按鈕 / Create: Uniform Style Float Button =
function createBtn(icon, title, onClick) { // = 生成帶有 SVG 圖示、title 提示與點擊事件的圓形按鈕 / Generate circular button with SVG icon, title tooltip, and click handler
const btn = document.createElement('button')
btn.className = CLASSES.BTN
btn.title = title
btn.appendChild(createSVGIcon(icon))
btn.addEventListener('click', onClick)
return btn
}
// ============ 歷史記錄處理模組 (一般影片 + Shorts 共用單一刪除鍵) / History Processing Module (Regular + Shorts - Single Remove Button) ============
// = 處理:歷史影片項目 (一般/Shorts 共用) / Process: History Video Item (Regular/Shorts Shared) =
async function processHistoryItem(row) { // = 為歷史頁面中的影片卡片添加「移除」懸浮按鈕,適用於一般影片與 Shorts / Add "Remove" float button to history video cards, works for both regular videos and Shorts
const isShorts = row.matches(SELECTORS.HISTORY_SHORTS)
const menuButton = await findMenuButton(row, isShorts)
if (!menuButton) return
const container = document.createElement('div')
container.className = CLASSES.BTN_CONTAINER
const btn = createBtn(ICON_PATHS.REMOVE, 'Remove from History', async (e) => {
e.stopPropagation(); e.preventDefault(); e.stopImmediatePropagation()
const success = await clickMenuByIcon(menuButton, ICON_PATHS.REMOVE, 'yt-list-item-view-model', true)
if (success) {
row.style.opacity = 0.3; row.style.pointerEvents = 'none'
setTimeout(() => { if (row.isConnected) row.style.display = 'none' }, 300)
}
})
container.appendChild(btn)
row.appendChild(container)
row.setAttribute(ATTRS.BTN_PROCESSED, 'true')
state.processedCount++
}
// ============ 播放清單處理模組 / Playlist Processing Module ============
// = 處理:播放清單內影片 (兩按鈕) / Process: Playlist Video Item (2 Buttons) =
async function processPlaylist(row) { // = 為播放清單影片添加「加入佇列」與「移除」兩個懸浮按鈕 / Add "Add to Queue" and "Remove" float buttons to playlist video items
const menuButton = row.querySelector(SELECTORS.PLAYLIST_MENU_BTN)
if (!menuButton) return
const container = document.createElement('div')
container.className = CLASSES.BTN_CONTAINER
container.style.left = `${BTN_CONFIG.PLAYLIST_LEFT_MARGIN}px`
const isWatchLater = location.search.includes('list=WL')
const btnConfig = [
{ icon: ICON_PATHS.ADD_TO_PLAYLIST, path: ICON_PATHS.ADD_TO_PLAYLIST, title: 'Add to Queue', shouldHideRow: false },
{ icon: ICON_PATHS.REMOVE, path: ICON_PATHS.REMOVE, title: isWatchLater ? 'Remove from Watch Later' : 'Remove from Playlist', shouldHideRow: true }
]
for (const cfg of btnConfig) {
const btn = createBtn(cfg.icon, cfg.title, async (e) => {
e.stopPropagation(); e.preventDefault(); e.stopImmediatePropagation()
const success = await clickMenuByIcon(menuButton, cfg.path, 'ytd-menu-service-item-renderer, ytd-menu-navigation-item-renderer', false)
if (success && cfg.shouldHideRow) {
row.style.opacity = 0.3; row.style.pointerEvents = 'none'
setTimeout(() => { if (row.isConnected) row.style.display = 'none' }, 300)
}
})
container.appendChild(btn)
}
row.appendChild(container)
row.setAttribute(ATTRS.BTN_PROCESSED, 'true')
state.playlistCount++
}
// ============ 批量處理工具 / Batch Processing Utility ============
// = 批量處理元素 / Process Elements in Batches =
function processBatch(elements, batchSize, delay, processor, isPlaylist = false) { // = 分批次處理元素以避免卡頓,支援歷史與播放清單兩種模式 / Process elements in batches to avoid lag, supporting both history and playlist modes
const activeState = isPlaylist ? state.playlistActive : state.btnActive
if (!activeState) return
const batch = elements.slice(0, batchSize)
batch.forEach(el => processor(el))
const remaining = elements.slice(batchSize)
if (remaining.length > 0) {
const timerKey = isPlaylist ? 'playlistTimer' : 'timer'
state[timerKey] = setTimeout(() => { processBatch(remaining, batchSize, delay, processor, isPlaylist) }, delay)
}
}
// ============ 功能激活入口 / Feature Activation Entry Points ============
// = 激活:歷史記錄懸浮按鈕 (一般影片 + Shorts) / Activate: History Float Buttons (Regular + Shorts) =
function btnActivate() { // = 初始化樣式並監聽歷史頁面中的一般影片與 Shorts 卡片,兩者皆添加單一移除按鈕 / Initialize styles and observe both regular videos and Shorts on history page, add single remove button to both
if (state.btnActive || !CONFIG.FLOAT_BUTTONS_ENABLED) return
state.btnActive = true
state.processedCount = 0
btnAddStyles()
const isHistory = location.pathname.startsWith('/feed/history')
if (!isHistory) return
const videoExisting = document.querySelectorAll(`${SELECTORS.FLOAT_TARGET}:not([${ATTRS.BTN_PROCESSED}])`)
if (videoExisting.length > 0) processBatch(Array.from(videoExisting), BTN_CONFIG.BATCH_SIZE, BTN_CONFIG.BATCH_DELAY, processHistoryItem)
onElementReady(SELECTORS.FLOAT_TARGET, { once: false }, (el) => {
if (state.processedCount >= BTN_CONFIG.BATCH_SIZE) {
state.timer = setTimeout(() => processHistoryItem(el), BTN_CONFIG.SUBSEQUENT_DELAY)
} else { processHistoryItem(el) }
})
const shortsExisting = document.querySelectorAll(`${SELECTORS.HISTORY_SHORTS}:not([${ATTRS.BTN_PROCESSED}])`)
if (shortsExisting.length > 0) processBatch(Array.from(shortsExisting), BTN_CONFIG.BATCH_SIZE, BTN_CONFIG.BATCH_DELAY, processHistoryItem)
onElementReady(SELECTORS.HISTORY_SHORTS, { once: false }, (el) => {
if (state.processedCount >= BTN_CONFIG.BATCH_SIZE) {
state.timer = setTimeout(() => processHistoryItem(el), BTN_CONFIG.SUBSEQUENT_DELAY)
} else { processHistoryItem(el) }
})
}
// = 激活:播放清單懸浮按鈕 / Activate: Playlist Float Buttons =
function playlistActivate() { // = 初始化樣式並監聽播放清單頁面中的影片項目 / Initialize styles and observe video items on playlist page
if (state.playlistActive || !CONFIG.PLAYLIST_FLOAT_ENABLED) return
state.playlistActive = true
state.playlistCount = 0
btnAddStyles()
const existingElements = document.querySelectorAll(SELECTORS.PLAYLIST_VIDEO + ':not([' + ATTRS.BTN_PROCESSED + '])')
const existingArray = Array.from(existingElements)
if (existingArray.length > 0) {
processBatch(existingArray, BTN_CONFIG.BATCH_SIZE, BTN_CONFIG.BATCH_DELAY, processPlaylist, true)
}
onElementReady(SELECTORS.PLAYLIST_VIDEO, { once: false }, (el) => {
if (state.playlistCount >= BTN_CONFIG.BATCH_SIZE) {
state.playlistTimer = setTimeout(() => { processPlaylist(el) }, BTN_CONFIG.SUBSEQUENT_DELAY)
} else { processPlaylist(el) }
})
}
// ============ 主控制流程 / Main Control Flow ============
// = 清理所有功能 / Cleanup All Features =
function cleanupAll() { // = 調用各模組的 cleanup 函數,重置狀態並移除動態元素 / Call cleanup functions of all modules to reset state and remove dynamic elements
playCleanup()
btnCleanup()
}
// = 激活當前頁面適用功能 / Activate Features for Current Page =
function activateFeatures() { // = 根據當前 URL 判斷並啟動對應的功能模組 / Determine and activate corresponding feature modules based on current URL
if (isPlayTargetPage()) playActivate()
if (isBtnTargetPage()) btnActivate()
if (isPlaylistTargetPage()) playlistActivate()
}
// = 處理頁面導航變更 / Handle Page Navigation Change =
function handleNavigation() { // = 清理舊狀態後延遲重新激活功能,適應 SPA 導航 / Clean old state then reactivate features with delay for SPA navigation
cleanupAll()
setTimeout(activateFeatures, CONFIG.NAVIGATION_DELAY)
}
// = 設置導航事件監聽 / Setup Navigation Event Listeners =
function setupNavigationListener() { // = 監聽 YouTube 內部導航事件與瀏覽器歷史變化 / Listen to YouTube internal navigation events and browser history changes
document.addEventListener('yt-navigate-finish', handleNavigation)
window.addEventListener('popstate', handleNavigation)
window.addEventListener('hashchange', handleNavigation)
}
// = 主入口函數 / Main Entry Function =
function init() { // = 初始化事件監聽並執行首次功能激活 / Initialize event listeners and perform initial feature activation
setupNavigationListener()
handleNavigation()
}
// = 腳本啟動 / Script Startup =
if (document.readyState === 'loading') { // = 若頁面仍在載入則等待 DOMContentLoaded,否則立即執行 / Wait for DOMContentLoaded if page still loading, otherwise execute immediately
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}