Skip to content

Instantly share code, notes, and snippets.

@beenotung
Created December 27, 2025 14:12
Show Gist options
  • Select an option

  • Save beenotung/6cfb46bd5f4f800ac5393317536714fe to your computer and use it in GitHub Desktop.

Select an option

Save beenotung/6cfb46bd5f4f800ac5393317536714fe to your computer and use it in GitHub Desktop.
userscript to save youtube video for watch later in firefox
// ==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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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'],
})
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment