|
// ==UserScript== |
|
// @name youtube-save-watch-later |
|
// @namespace http://tampermonkey.net/ |
|
// @version 2025-01-27 |
|
// @description Save YouTube video to Watch Later playlist |
|
// @author beenotung |
|
// @match https://www.youtube.com/* |
|
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com |
|
// @grant none |
|
// ==/UserScript== |
|
|
|
/** |
|
* YouTube Watch Later Userscript - Simplified Passive Version |
|
* 1. Click button → save video ID + title to localStorage |
|
* 2. On every page visit → check if videos in pending list appear in sidebar |
|
* 3. If found → save it → remove from pending list |
|
*/ |
|
;(function () { |
|
'use strict' |
|
|
|
function getCurrentVideoId() { |
|
let url = new URL(window.location.href) |
|
return url.searchParams.get('v') |
|
} |
|
|
|
function getCurrentVideoTitle() { |
|
let titleEl = document.querySelector( |
|
'h1.ytd-watch-metadata yt-formatted-string, h1.ytd-video-primary-info-renderer', |
|
) |
|
return titleEl ? titleEl.textContent.trim() : null |
|
} |
|
|
|
// Pending videos: { videoId: { title: "..." } } |
|
let pendingVideos = (() => { |
|
let data |
|
try { |
|
// Migrate from old key name |
|
let oldData = localStorage.getItem('_ytWatchLaterStack') |
|
if (oldData) { |
|
data = JSON.parse(oldData) |
|
localStorage.setItem('_ytWatchLaterPending', oldData) |
|
localStorage.removeItem('_ytWatchLaterStack') |
|
} else { |
|
data = JSON.parse( |
|
localStorage.getItem('_ytWatchLaterPending') || '{}', |
|
) |
|
} |
|
} catch (e) { |
|
data = {} |
|
} |
|
return { |
|
has: videoId => videoId in data, |
|
get: () => data, |
|
save: () => |
|
localStorage.setItem( |
|
'_ytWatchLaterPending', |
|
JSON.stringify(data), |
|
), |
|
add: (videoId, title) => { |
|
data[videoId] = { title: title || null } |
|
pendingVideos.save() |
|
console.log( |
|
`➕ Added ${videoId} to pending list: ${ |
|
title || 'no title' |
|
}`, |
|
) |
|
}, |
|
remove: videoId => { |
|
let title = data[videoId]?.title || 'unknown' |
|
delete data[videoId] |
|
pendingVideos.save() |
|
console.log(`➖ Removed ${videoId} from pending list: ${title}`) |
|
}, |
|
getAll: () => Object.keys(data), |
|
} |
|
})() |
|
|
|
// Check if video is in sidebar and save it |
|
async function checkAndSaveFromSidebar(targetVideoId) { |
|
// Skip if we're on the target video's own page |
|
let currentVideoId = getCurrentVideoId() |
|
if (currentVideoId === targetVideoId) { |
|
console.log( |
|
`⏭️ Skipping ${targetVideoId} - currently on its own page`, |
|
) |
|
return false |
|
} |
|
|
|
console.log(`🔍 Looking for ${targetVideoId} in sidebar...`) |
|
|
|
// Wait for sidebar links to appear |
|
let links = await waitFor('sidebar links', () => { |
|
let found = document.querySelectorAll( |
|
'yt-lockup-view-model a[href*="/watch?v="], ytd-compact-video-renderer a[href*="/watch?v="]', |
|
) |
|
return found.length > 0 ? found : null |
|
}) |
|
console.log(`📺 Found ${links.length} sidebar videos`) |
|
|
|
for (let link of links) { |
|
try { |
|
let url = new URL(link.href, window.location.origin) |
|
|
|
// Skip playlists FIRST (before checking video ID) |
|
// RD = Radio/Mix playlist (includes RDMM), PL = User playlist |
|
let listParam = url.searchParams.get('list') |
|
if ( |
|
listParam && |
|
(listParam.startsWith('RD') || listParam.startsWith('PL')) |
|
) { |
|
continue |
|
} |
|
|
|
let videoId = url.searchParams.get('v') |
|
if (videoId !== targetVideoId) continue |
|
|
|
let container = link.closest( |
|
'yt-lockup-view-model, ytd-compact-video-renderer', |
|
) |
|
if (!container) continue |
|
|
|
let title = |
|
pendingVideos.get()[targetVideoId]?.title || 'unknown title' |
|
console.log( |
|
`✅ Found ${targetVideoId} (${title}) in sidebar, attempting to save...`, |
|
) |
|
|
|
container.scrollIntoView({ |
|
behavior: 'smooth', |
|
block: 'center', |
|
}) |
|
await sleep(300) |
|
|
|
// Try multiple hover targets and events |
|
let thumbnail = |
|
container.querySelector( |
|
'a.yt-lockup-view-model__content-image, a#thumbnail', |
|
) || link |
|
|
|
// Hover on both container and thumbnail |
|
let hoverTargets = [container, thumbnail, link] |
|
for (let target of hoverTargets) { |
|
target.dispatchEvent( |
|
new MouseEvent('mouseenter', { |
|
bubbles: true, |
|
cancelable: true, |
|
view: window, |
|
}), |
|
) |
|
target.dispatchEvent( |
|
new MouseEvent('mouseover', { |
|
bubbles: true, |
|
cancelable: true, |
|
view: window, |
|
}), |
|
) |
|
} |
|
await sleep(200) |
|
|
|
// Wait for Watch Later button to appear after hover |
|
let watchLaterBtn = await waitFor( |
|
`Watch Later button for ${targetVideoId}`, |
|
() => { |
|
// Check in container first |
|
let btn = container.querySelector( |
|
'button[aria-label="Watch Later"]', |
|
) |
|
if (btn) return btn |
|
|
|
// Check in hover overlay |
|
let overlay = container.querySelector( |
|
'yt-thumbnail-hover-overlay-toggle-actions-view-model', |
|
) |
|
if (overlay) { |
|
btn = overlay.querySelector( |
|
'button[aria-label="Watch Later"]', |
|
) |
|
if (btn) return btn |
|
} |
|
|
|
// Check all overlays and match by position |
|
let overlays = document.querySelectorAll( |
|
'yt-thumbnail-hover-overlay-toggle-actions-view-model', |
|
) |
|
let containerRect = container.getBoundingClientRect() |
|
for (let ov of overlays) { |
|
let rect = ov.getBoundingClientRect() |
|
if ( |
|
Math.abs(rect.top - containerRect.top) < 50 && |
|
Math.abs(rect.left - containerRect.left) < 50 |
|
) { |
|
btn = ov.querySelector( |
|
'button[aria-label="Watch Later"]', |
|
) |
|
if (btn) return btn |
|
} |
|
} |
|
|
|
return null |
|
}, |
|
3000, |
|
).catch(() => null) |
|
|
|
if (!watchLaterBtn) { |
|
console.log( |
|
`⚠️ Watch Later button not found for ${targetVideoId}`, |
|
) |
|
continue |
|
} |
|
|
|
console.log( |
|
`🖱️ Clicking Watch Later button for ${targetVideoId}`, |
|
) |
|
watchLaterBtn.click() |
|
|
|
// Wait for success toast |
|
try { |
|
let toast = await waitFor('success toast', () => { |
|
let toasts = document.querySelectorAll( |
|
'ytd-snackbar, yt-snackbar-renderer', |
|
) |
|
for (let t of toasts) { |
|
let text = (t.textContent || '').toLowerCase() |
|
if ( |
|
text.includes('watch later') && |
|
(text.includes('added') || |
|
text.includes('saved')) |
|
) { |
|
return t |
|
} |
|
} |
|
return null |
|
}) |
|
console.log(`✅ Success toast found for ${targetVideoId}`) |
|
pendingVideos.remove(targetVideoId) |
|
return true |
|
} catch (e) { |
|
// Timeout or error - assume success |
|
console.log( |
|
`✅ Assuming success for ${targetVideoId} (no toast found)`, |
|
) |
|
pendingVideos.remove(targetVideoId) |
|
return true |
|
} |
|
} catch (e) { |
|
console.error(`⚠️ Error processing ${targetVideoId}:`, e) |
|
} |
|
} |
|
|
|
console.log(`❌ ${targetVideoId} not found in sidebar`) |
|
return false |
|
} |
|
|
|
// Process pending videos on page visit |
|
let isProcessing = false |
|
let processingVideoId = null |
|
async function processPendingVideos() { |
|
if (isProcessing) { |
|
console.log('⏳ Already processing pending videos, skipping...') |
|
return |
|
} |
|
isProcessing = true |
|
|
|
try { |
|
let videoIds = pendingVideos.getAll() |
|
console.log(`📋 Processing ${videoIds.length} pending video(s)`) |
|
if (videoIds.length === 0) return |
|
|
|
for (let videoId of videoIds) { |
|
// Skip if we just tried this video (prevent immediate retry loop) |
|
if (processingVideoId === videoId) { |
|
console.log(`⏭️ Skipping ${videoId} - just processed`) |
|
continue |
|
} |
|
processingVideoId = videoId |
|
await checkAndSaveFromSidebar(videoId) |
|
processingVideoId = null |
|
} |
|
} catch (error) { |
|
console.error('❌ Error processing pending videos:', error) |
|
} finally { |
|
isProcessing = false |
|
processingVideoId = null |
|
} |
|
} |
|
|
|
// Add current video to pending list |
|
function addToWatchLater() { |
|
let videoId = getCurrentVideoId() |
|
if (!videoId) { |
|
console.error('❌ Video ID not found') |
|
return |
|
} |
|
|
|
if (pendingVideos.has(videoId)) { |
|
console.log(`ℹ️ ${videoId} already in pending list`) |
|
return |
|
} |
|
|
|
let title = getCurrentVideoTitle() |
|
pendingVideos.add(videoId, title) |
|
} |
|
|
|
// Create button (matching YouTube's button structure) |
|
function createWatchLaterButton() { |
|
let container = document.createElement('div') |
|
container.innerHTML = ` |
|
<yt-button-view-model class="ytd-menu-renderer"> |
|
<button-view-model class="ytSpecButtonViewModelHost"> |
|
<button class="yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading" aria-label="Save to Watch Later" title="Save to Watch Later"> |
|
<div aria-hidden="true" class="yt-spec-button-shape-next__icon"> |
|
<span class="ytIconWrapperHost" style="width: 24px; height: 24px;"> |
|
<span class="yt-icon-shape ytSpecIconShapeHost"> |
|
<div style="width: 100%; height: 100%; display: block; fill: currentcolor;"> |
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true"> |
|
<path d="M14.97 16.95 10 13.87V7h2v5.76l4.03 2.49-1.06 1.7zM12 3c-4.96 0-9 4.04-9 9s4.04 9 9 9 9-4.04 9-9-4.04-9-9-9m0-1c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12 6.48 2 12 2z"></path> |
|
</svg> |
|
</div> |
|
</span> |
|
</span> |
|
</div> |
|
<div class="yt-spec-button-shape-next__button-text-content">Watch Later</div> |
|
</button> |
|
</button-view-model> |
|
</yt-button-view-model> |
|
` |
|
let button = container.querySelector('button') |
|
button.addEventListener('click', addToWatchLater) |
|
return container.firstElementChild |
|
} |
|
|
|
// Add button to menu |
|
async function addButtonToMenu() { |
|
try { |
|
let menu = await waitFor('menu', () => |
|
document.querySelector('#menu ytd-menu-renderer'), |
|
) |
|
|
|
let flexibleButtons = await waitFor('flexible buttons', () => |
|
menu.querySelector('#flexible-item-buttons'), |
|
) |
|
|
|
// Remove existing button if present (for hot-reload support) |
|
let existingBtn = flexibleButtons.querySelector( |
|
'[aria-label="Save to Watch Later"], [aria-label*="Watch Later"]', |
|
) |
|
if (existingBtn) { |
|
existingBtn.closest('yt-button-view-model')?.remove() || |
|
existingBtn.remove() |
|
console.log('🗑️ Removed existing Watch Later button') |
|
} |
|
|
|
// Insert as first button (horizontal layout) |
|
let firstButton = flexibleButtons.querySelector( |
|
'yt-button-view-model', |
|
) |
|
if (firstButton) { |
|
flexibleButtons.insertBefore( |
|
createWatchLaterButton(), |
|
firstButton, |
|
) |
|
} else { |
|
flexibleButtons.appendChild(createWatchLaterButton()) |
|
} |
|
console.log('✅ Added Watch Later button to menu') |
|
} catch (e) { |
|
console.log(`⚠️ Could not add button: ${e.message}`) |
|
} |
|
} |
|
|
|
function sleep(ms) { |
|
return new Promise(resolve => setTimeout(resolve, ms)) |
|
} |
|
|
|
async function waitFor(name, fn, timeout = 5000) { |
|
console.log(`🔍 Waiting for ${name}...`) |
|
let start = Date.now() |
|
for (;;) { |
|
let match = fn() |
|
if (match) { |
|
console.log(`✅ Found ${name}`) |
|
return match |
|
} |
|
if (Date.now() - start > timeout) { |
|
throw new Error(`Timeout waiting for ${name}`) |
|
} |
|
await sleep(33) |
|
} |
|
} |
|
|
|
// Check if we're on Watch Later playlist page |
|
function isWatchLaterPage() { |
|
let url = new URL(window.location.href) |
|
return ( |
|
url.pathname === '/playlist' && |
|
url.searchParams.get('list') === 'WL' |
|
) |
|
} |
|
|
|
// Add pending videos to Watch Later playlist page |
|
let isRenderingPending = false |
|
function addPendingVideosToPlaylist() { |
|
if (!isWatchLaterPage()) return |
|
if (isRenderingPending) return // Prevent loop |
|
|
|
let contents = document.querySelector( |
|
'#contents.ytd-playlist-video-list-renderer', |
|
) |
|
if (!contents) return |
|
|
|
isRenderingPending = true |
|
|
|
// Remove ALL existing pending sections |
|
let existingSections = contents.querySelectorAll( |
|
'[data-pending-videos-section]', |
|
) |
|
existingSections.forEach(el => el.remove()) |
|
|
|
let pending = pendingVideos.getAll() |
|
if (pending.length === 0) { |
|
isRenderingPending = false |
|
return |
|
} |
|
|
|
console.log( |
|
`📋 Adding ${pending.length} pending video(s) to Watch Later page`, |
|
) |
|
|
|
// Create simple header |
|
let header = document.createElement('div') |
|
header.setAttribute('data-pending-videos-section', 'header') |
|
header.style.cssText = |
|
'padding: 16px; border-bottom: 2px solid #ff9800; background: #1f1f1f; margin-bottom: 8px;' |
|
header.innerHTML = ` |
|
<h2 style="margin: 0; font-size: 16px; font-weight: 500; color: #ffffff;"> |
|
⏳ Pending Videos (Not Saved Yet) |
|
</h2> |
|
<p style="margin: 8px 0 0 0; font-size: 14px; color: #aaaaaa;"> |
|
These videos are waiting to be saved |
|
</p> |
|
` |
|
|
|
// Create simple container |
|
let pendingContainer = document.createElement('div') |
|
pendingContainer.setAttribute( |
|
'data-pending-videos-section', |
|
'container', |
|
) |
|
|
|
// Add each pending video with simple DOM |
|
for (let videoId of pending) { |
|
let videoData = pendingVideos.get()[videoId] |
|
let title = videoData?.title || 'Unknown Title' |
|
let escapedTitle = title |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/"/g, '"') |
|
|
|
let item = document.createElement('div') |
|
item.setAttribute('data-pending-video-id', videoId) |
|
item.setAttribute('data-pending-videos-section', 'item') |
|
item.style.cssText = |
|
'display: flex; align-items: center; padding: 12px 16px; border-left: 4px solid #ff9800; background: #181818; margin-bottom: 4px; opacity: 0.8;' |
|
|
|
item.innerHTML = ` |
|
<div style="margin-right: 12px; color: #ff9800; font-size: 20px;">⏳</div> |
|
<div style="flex: 1;"> |
|
<a href="/watch?v=${videoId}" |
|
style="color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 500; display: block;" |
|
title="${escapedTitle}"> |
|
${escapedTitle} |
|
</a> |
|
<div style="margin-top: 4px; font-size: 12px; color: #aaaaaa;"> |
|
<span style="color: #ff9800;">Pending</span> • |
|
<a href="/watch?v=${videoId}" style="color: #3ea6ff; text-decoration: none;">View Video</a> |
|
</div> |
|
</div> |
|
` |
|
|
|
pendingContainer.appendChild(item) |
|
} |
|
|
|
// Insert at the top |
|
if (contents.firstChild) { |
|
contents.insertBefore(header, contents.firstChild) |
|
contents.insertBefore(pendingContainer, header.nextSibling) |
|
} else { |
|
contents.appendChild(header) |
|
contents.appendChild(pendingContainer) |
|
} |
|
|
|
// Reset flag after a short delay to allow DOM to settle |
|
setTimeout(() => { |
|
isRenderingPending = false |
|
}, 100) |
|
} |
|
|
|
// Handle DOM changes |
|
let debounceTimer |
|
async function handleDOMChange() { |
|
clearTimeout(debounceTimer) |
|
debounceTimer = setTimeout(async () => { |
|
console.log('🔄 DOM changed') |
|
await sleep(1000) |
|
addButtonToMenu() |
|
processPendingVideos() |
|
addPendingVideosToPlaylist() |
|
}, 2000) |
|
} |
|
|
|
// Initialize |
|
function init() { |
|
console.log('🚀 Initializing YouTube Watch Later script...') |
|
handleDOMChange() |
|
} |
|
|
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', init) |
|
} else { |
|
init() |
|
} |
|
|
|
// Watch for all DOM changes |
|
new MutationObserver(handleDOMChange).observe(document.body, { |
|
childList: true, |
|
subtree: true, |
|
attributes: true, |
|
attributeFilter: ['href', 'class'], |
|
}) |
|
})() |