YouTube 功能增強:歷史記錄懸浮按鈕 + 播放清單懸浮按鈕 + 頻道頁面播放全部按鈕 (純 Icon Path 判定)
// ==UserScript==
// @name Youtube Button Come Back
// @namespace http://tampermonkey.net/
// @version 4.5
// @description YouTube 功能增強:歷史記錄懸浮按鈕 + 播放清單懸浮按鈕 + 頻道頁面播放全部按鈕 (純 Icon Path 判定)
// @author AI
// @match https://www.youtube.com/*
// @require https://update.greasyfork.org/scripts/419640/1775486/onElementReady.js
// @grant none
// @license MIT
// ==/UserScript==
// = CONFIG: 全局設定 =
const CONFIG = {
NAVIGATION_DELAY: 200,
PLAY_ALL_ENABLED: true,
FLOAT_BUTTONS_ENABLED: true,
PLAYLIST_FLOAT_ENABLED: true
}
// = CONFIG: 播放全部功能 =
const PLAY_CONFIG = {
ENABLED_PATHS: [{ type: 'regex', value: '^/@[^/]+/(videos|shorts|streams)$' }],
BTN_TEXT: { all: 'Play all', popular: 'Play popular', oldest: 'Play oldest' },
BTN_SPACING: '0.5em'
}
// = CONFIG: 按鈕通用設定 =
const BTN_CONFIG = {
FLOAT_PATHS: [
{ type: 'exact', value: '/' },
{ type: 'startsWith', value: '/feed/subscriptions' },
{ type: 'startsWith', value: '/feed/history' }
],
PLAYLIST_PATHS: [{ type: 'startsWith', value: '/playlist?list=' }],
SIZE: 36,
SPACING: 4,
BG_OPACITY: 0.9,
TOP_MARGIN: 6,
FLOAT_LEFT_MARGIN: 12,
PLAYLIST_LEFT_MARGIN: 160,
MENU_WAIT_TIMEOUT: 1000,
MENU_CHECK_INTERVAL: 30,
TRANSITION_SPEED: '0.15s',
BATCH_SIZE: 15,
BATCH_DELAY: 20,
SUBSEQUENT_DELAY: 100
}
// = 常量定義:圖示路徑 =
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',
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',
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',
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'
}
// = 常量定義:選擇器與樣式 =
const STYLE_IDS = { PLAY: 'yt-play-all-style', CUSTOM_BTN: 'yt-custom-btn-style' }
const ATTRS = { PLAY_PAGE_INIT: 'data-play-all-init', PLAY_ELEM_ADDED: 'data-play-all-added', BTN_PROCESSED: 'data-btn-added' }
const CLASSES = { PLAY_CONTAINER: 'ytpa-container', PLAY_BTN: 'ytpa-btn', BTN_CONTAINER: 'ytcb-container', BTN: 'yt-custom-btn' }
const SELECTORS = {
PLAY_TARGET: 'ytd-rich-item-renderer, ytd-playlist-video-renderer, ytd-video-renderer',
FLOAT_TARGET: 'yt-lockup-view-model',
HISTORY_SHORTS: 'ytm-shorts-lockup-view-model-v2',
MENU_BTN: '.yt-spec-button-shape-next.yt-spec-button-shape-next--icon-button',
PLAYLIST_VIDEO: 'ytd-playlist-video-renderer',
PLAYLIST_MENU_BTN: 'ytd-menu-renderer button, .yt-icon-button',
MENU_ITEM_BTN: 'yt-list-item-view-model button.ytButtonOrAnchorHost.ytButtonOrAnchorButton.yt-list-item-view-model__button-or-anchor, button.ytButtonOrAnchorHost.ytButtonOrAnchorButton.ytListItemViewModelButtonOrAnchor'
}
// = 運行狀態 =
const state = {
playActive: false,
btnActive: false,
playlistActive: false,
processedCount: 0,
playlistCount: 0,
timer: null,
playlistTimer: null
}
// = 工具函數 =
const safeOn = (el, evt, fn) => el?.addEventListener(evt, fn)
function matchPathRules(path, rules) {
return rules.some(rule => {
if (rule.type === 'startsWith') return path.startsWith(rule.value)
if (rule.type === 'includes') return path.includes(rule.value)
if (rule.type === 'regex') return new RegExp(rule.value).test(path)
if (rule.type === 'exact') return path === rule.value || path === rule.value + '/'
return false
})
}
function isPlayTargetPage() {
if (!CONFIG.PLAY_ALL_ENABLED) return false
return matchPathRules(location.pathname, PLAY_CONFIG.ENABLED_PATHS)
}
function isBtnTargetPage() {
if (!CONFIG.FLOAT_BUTTONS_ENABLED) return false
return matchPathRules(location.pathname, BTN_CONFIG.FLOAT_PATHS)
}
function isPlaylistTargetPage() {
if (!CONFIG.PLAYLIST_FLOAT_ENABLED) return false
return matchPathRules(location.pathname + location.search, BTN_CONFIG.PLAYLIST_PATHS)
}
function createSVGIcon(path) {
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
}
// = 播放全部功能 =
function playCleanup() {
state.playActive = false
document.querySelectorAll(`.${CLASSES.PLAY_BTN}`).forEach(btn => btn.remove())
document.querySelectorAll(`.${CLASSES.PLAY_CONTAINER}`).forEach(container => container.remove())
}
function playAddStyles() {
if (document.getElementById(STYLE_IDS.PLAY)) return
const style = document.createElement('style')
style.id = STYLE_IDS.PLAY
style.textContent = `
.${CLASSES.PLAY_BTN}:hover { background: var(--yt-spec-additive-background-hover, rgba(128,128,128,0.2)) !important; }
.${CLASSES.PLAY_CONTAINER} { display: flex !important; flex-wrap: wrap !important; align-items: center !important; margin: 8px 0 !important; }
`
document.head.appendChild(style)
}
function playCreateBtn(text, href) {
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;background:var(--yt-spec-additive-background);color:var(--yt-spec-text-primary);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
}
async function playGetChannelId() {
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', '')
}
function playInsertButtons(channelId) {
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))
}
async function playActivate() {
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) }
}
// = 統一按鈕樣式 (歷史 + 播放清單共用) =
function btnCleanup() {
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
}
function btnAddStyles() {
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.TOP_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)
}
// = 通用選單點擊函數 =
async function clickMenuByIcon(menuButton, targetIconPath, itemSelector, isHistoryStyle = false) {
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(itemSelector)
for (const item of menuItems) {
const svg = item.querySelector('svg path')
if (svg && svg.getAttribute('d') === targetIconPath) {
clearInterval(checkInterval)
const actionBtn = isHistoryStyle ? 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)
})
}
function findMenuButton(row) {
let btn = row.querySelector('.shortsLockupViewModelHostOutsideMetadataMenu button')
if (btn) return btn
btn = row.querySelector('ytd-menu-renderer button, .yt-lockup-metadata-view-model__menu-button button')
if (btn) return btn
return row.querySelector(SELECTORS.MENU_BTN)
}
// = 創建按鈕函數 (統一風格) =
function createBtn(icon, title, onClick) {
const btn = document.createElement('button')
btn.className = CLASSES.BTN
btn.title = title
btn.appendChild(createSVGIcon(icon))
btn.addEventListener('click', onClick)
return btn
}
// = 處理 Shorts (四鍵) =
async function processShorts(row) {
const menuButton = findMenuButton(row)
if (!menuButton) return
const container = document.createElement('div')
container.className = CLASSES.BTN_CONTAINER
container.style.left = `${BTN_CONFIG.FLOAT_LEFT_MARGIN}px`
const btnConfig = [
{ icon: ICON_PATHS.ADD_TO_PLAYLIST, title: 'Add to Queue', path: ICON_PATHS.ADD_TO_PLAYLIST },
{ icon: ICON_PATHS.WATCH_LATER, title: 'Watch Later', path: ICON_PATHS.WATCH_LATER },
{ icon: ICON_PATHS.SAVE_TO_PLAYLIST, title: 'Save to Playlist', path: ICON_PATHS.SAVE_TO_PLAYLIST },
{ icon: ICON_PATHS.REMOVE, title: 'Remove', path: ICON_PATHS.REMOVE }
]
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, 'yt-list-item-view-model', true)
if (success && cfg.path === ICON_PATHS.REMOVE) {
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++
}
// = 處理歷史一般影片 (單一移除鍵) =
async function processHistoryRegular(row) {
const menuButton = findMenuButton(row)
if (!menuButton) return
const container = document.createElement('div')
container.className = CLASSES.BTN_CONTAINER
container.style.left = `${BTN_CONFIG.FLOAT_LEFT_MARGIN}px`
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++
}
// = 處理播放清單 (兩鍵) =
async function processPlaylist(row) {
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++
}
// = 批量處理 =
function processBatch(elements, batchSize, delay, processor, isPlaylist = false) {
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)
}
}
// = 激活懸浮按鈕 =
function btnActivate() {
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 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, processShorts)
onElementReady(SELECTORS.HISTORY_SHORTS, { once: false }, (el) => {
if (state.processedCount >= BTN_CONFIG.BATCH_SIZE) {
state.timer = setTimeout(() => processShorts(el), BTN_CONFIG.SUBSEQUENT_DELAY)
} else { processShorts(el) }
})
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, processHistoryRegular)
onElementReady(SELECTORS.FLOAT_TARGET, { once: false }, (el) => {
if (state.processedCount >= BTN_CONFIG.BATCH_SIZE) {
state.timer = setTimeout(() => processHistoryRegular(el), BTN_CONFIG.SUBSEQUENT_DELAY)
} else { processHistoryRegular(el) }
})
}
// = 激活播放清單按鈕 =
function playlistActivate() {
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) }
})
}
// = 主控制函數 =
function cleanupAll() {
playCleanup()
btnCleanup()
}
function activateFeatures() {
if (isPlayTargetPage()) playActivate()
if (isBtnTargetPage()) btnActivate()
if (isPlaylistTargetPage()) playlistActivate()
}
function handleNavigation() {
cleanupAll()
setTimeout(activateFeatures, CONFIG.NAVIGATION_DELAY)
}
function setupNavigationListener() {
document.addEventListener('yt-navigate-finish', handleNavigation)
window.addEventListener('popstate', handleNavigation)
window.addEventListener('hashchange', handleNavigation)
}
// = 主入口 =
function init() {
setupNavigationListener()
handleNavigation()
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}