Skip to content

Instantly share code, notes, and snippets.

@f-steff
Last active November 14, 2025 04:21
Show Gist options
  • Save f-steff/4d765eef037e9b751c58d43490ebad62 to your computer and use it in GitHub Desktop.
Save f-steff/4d765eef037e9b751c58d43490ebad62 to your computer and use it in GitHub Desktop.
Userscript to update Youtube's Save to Playlist with a hotkey, sorting and filtering of available playlists.

YouTube “Save to Playlist” Enhancer

This userscript adds a convenient hotkey p to YouTube, which opens the Save to Playlist dialog on the currently playing video. It also sorts and filters your playlists in a more useful way. Just the way it should have been fron the start.

Features

  1. Hotkey: p
  • Press p (without Ctrl, Alt, or Meta) on any YouTube watch page to open the Save to Playlist dialog.
  1. Sorting
  • Any playlists that the current video is already in will be shown at the top.
  • All other playlists are displayed in alphabetical order (0-9,A–Z).
  1. Filter Input
  • Just below the “Save video to…” text, the script adds an input box (in focus automatically).
  • If empty: all playlists are shown (with already-added ones on top).
  • If any text is typed: only those playlists whose name contains the typed letters are displayed.
  1. ESC Key
  • Pressing Escape closes (exits) the Save to Playlist dialog.

How It Works

  1. Auto-Injection
  • When the “Save to Playlist” dialog appears, the script automatically inserts a small input field right below the text “Save video to…”.
  1. Immediate Focus
  • The script focuses that new input field so you can start typing to filter playlists immediately.
  1. Filtering
  • As soon as you type text into the input, the list updates in real-time, showing only matching playlist titles.
  1. Sorting Only Once
  • Each time the dialog is opened, the script sorts the list. It does not attempt to re-sort if you check/uncheck a playlist; however, closing and reopening the dialog will re-sort.

Installation

  • Install a userscript manager such as Install Tampermonkey (https://www.tampermonkey.net) or Violentmonkey.
  • Create a new script and copy/paste the code from this repository - or follow this link to install.
  • Go to YouTube and verify you see the userscript log messages in the console (optional).
  • Press p while watching any video to open the “Save to Playlist” dialog!
1.0 Initial version.
1.1 Added license, shortened description.4
1.2 Modified to avoid accidentally opening when typing in a text field. Thanks @karimawi
// ==UserScript==
// @name Youtube Save To Playlist Hotkey And Filter
// @namespace https://gist.github.com/f-steff
// @version 1.2
// @description Adds a P hotkey to Youtube, to bring up the Save to Playlist dialog. Playlists will be displayed alfabetically sorted, any any playlist the current video belongs to, will be shown at the top. Also adds a focuced filter input field. If nothing is written into the filter input, all playlists will be shown, alternatively only the playlists where the name containes the sequence of letters typed in the input field, will be displayed. Press escape to exit the dialog.
// @author Flemming Steffensen
// @license MIT
// @match http://www.youtube.com/*
// @match https://www.youtube.com/*
// @include http://www.youtube.com/*
// @include https://www.youtube.com/*
// @grant none
// @homepageURL https://gist.github.com/f-steff/4d765eef037e9b751c58d43490ebad62
// @updateURL https://gist.githubusercontent.com/f-steff/4d765eef037e9b751c58d43490ebad62/raw/YoutubeSaveToPlaylistHotkeyAndFilter.user.js
// @downloadURL https://gist.githubusercontent.com/f-steff/4d765eef037e9b751c58d43490ebad62/raw/YoutubeSaveToPlaylistHotkeyAndFilter.user.js
// @run-at document-start
// ==/UserScript==
(function (d) {
/**
* Pressing 'p' simulates a click on YouTube's "Save" button to open the dialog.
*/
function openSaveToPlaylistDialog() {
// Commonly, there's a button with aria-label="Save" or aria-label^="Save"
// near the Like/Dislike/Share row on the watch page.
const saveButton = d.querySelector('button[aria-label^="Save"]');
if (saveButton) {
saveButton.click();
} else {
console.log("Could not find 'Save' button. Adjust the selector if needed.");
}
}
// Listen for the 'p' key at the document level
d.addEventListener('keydown', evt => {
// Avoid capturing if user holds Ctrl/Alt/Meta, or if in a text field, etc.
if (
evt.key === 'p' &&
!evt.ctrlKey &&
!evt.altKey &&
!evt.metaKey &&
evt.target.tagName !== 'INPUT' &&
evt.target.tagName !== 'TEXTAREA' && evt.target.contentEditable !== "true"
) {
// Prevent YouTube from interpreting 'p' in any other way
evt.preventDefault();
evt.stopPropagation();
// Attempt to open the "Save to playlist" dialog
openSaveToPlaylistDialog();
}
}, true);
/**
* Sort playlists such that:
* - checked items (aria-checked="true") come first,
* - then everything else in alphabetical (0-9,A→Z).
*/
function sortPlaylist(playlist) {
let options = query(playlist, 'ytd-playlist-add-to-option-renderer');
let optionsMap = new Map();
// Collect items by playlist title
options.forEach(op => {
let formattedString = query(op, 'yt-formatted-string')[0];
let title = formattedString?.getAttribute('title') || '';
if (!optionsMap.has(title)) {
optionsMap.set(title, []);
}
optionsMap.get(title).push(op);
});
// Sort so "checked" groups come first, then A→Z
let sortedEntries = [...optionsMap.entries()].sort(([titleA, groupA], [titleB, groupB]) => {
const checkedA = groupA.some(
op => query(op, 'tp-yt-paper-checkbox[aria-checked="true"]').length
);
const checkedB = groupB.some(
op => query(op, 'tp-yt-paper-checkbox[aria-checked="true"]').length
);
if (checkedA && !checkedB) return -1;
if (checkedB && !checkedA) return 1;
// Otherwise alphabetical
const upA = titleA.toUpperCase();
const upB = titleB.toUpperCase();
if (upA < upB) return -1;
if (upA > upB) return 1;
return 0;
});
// Re-insert items in sorted order
for (const [, group] of sortedEntries) {
for (const opNode of group) {
playlist.appendChild(opNode);
}
}
}
/**
* Filter all playlist entries based on user-typed substring.
* If the filter is blank, show everything; otherwise hide non-matches.
*/
function filterPlaylist(playlist, filterText) {
let options = query(playlist, 'ytd-playlist-add-to-option-renderer');
const text = filterText.trim().toLowerCase();
options.forEach(op => {
let formattedString = query(op, 'yt-formatted-string')[0];
let title = (formattedString?.getAttribute('title') || '').toLowerCase();
op.style.display = (text && !title.includes(text)) ? 'none' : 'block';
});
}
/**
* When the dialog closes, re-attach the observer so we see the next open event.
*/
function observePaperDialogClose(paperDialog, onCloseDialog) {
let ob = new MutationObserver((mutations, me) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-hidden') {
me.disconnect();
onCloseDialog();
}
});
});
ob.observe(paperDialog, { attributes: true });
}
/**
* Main logic: watch for the "Add to Playlist" dialog, then
* Insert input, sort once, filter, focus the input, etc.
* Added a small setTimeout to avoid first-time invocation timing issues.
*/
function handlePopupContainer(popupContainer) {
let currentFilter = '';
const popupObserverConfig = { subtree: true, childList: true };
const popupContainerObserver = new MutationObserver(function (mut, me) {
// Defer so the DOM has time to settle on first invocation
setTimeout(() => {
const paperDialog = query(popupContainer, 'tp-yt-paper-dialog')[0];
if (paperDialog) {
// Sort/insert only once per open
me.disconnect();
// Re-attach after closing
observePaperDialogClose(paperDialog, function () {
popupContainerObserver.observe(popupContainer, popupObserverConfig);
});
// Look for "Save video to..."
const headingSpan = query(
paperDialog,
'span.yt-core-attributed-string[role="text"]'
).find(el => el.textContent.trim() === 'Save video to...');
// Grab #playlists container
const playlistContainer = query(paperDialog, '#playlists')[0];
if (headingSpan && playlistContainer) {
// If we haven't inserted our input yet, do it now
const existingFilterInput = d.getElementById('filterText');
if (!existingFilterInput) {
// Create <input> and <br>
const filterInput = d.createElement('input');
filterInput.id = 'filterText';
filterInput.type = 'text';
filterInput.placeholder = 'Filter';
const br = d.createElement('br');
headingSpan.parentNode.appendChild(filterInput);
headingSpan.parentNode.appendChild(br);
// On typing => filter
filterInput.addEventListener('input', evt => {
currentFilter = evt.target.value;
filterPlaylist(playlistContainer, currentFilter);
});
}
// Sort once for this session
sortPlaylist(playlistContainer);
// Re-apply current filter (if any)
filterPlaylist(playlistContainer, currentFilter);
// Focus
const input = d.getElementById('filterText');
if (input) {
input.focus();
}
}
}
}, 10); // 10ms delay
});
// Start observing
popupContainerObserver.observe(popupContainer, popupObserverConfig);
}
/**
* A top-level observer that waits for <ytd-popup-container> to show up,
* then calls handlePopupContainer() exactly once.
*/
const documentObserver = new MutationObserver(function (mutations, me) {
const popupContainer = query(d, 'ytd-popup-container')[0];
if (popupContainer) {
console.log("Found ytd-popup-container");
handlePopupContainer(popupContainer);
me.disconnect(); // stop once found
}
});
documentObserver.observe(d, { childList: true, subtree: true });
/**
* Helper: safely do querySelectorAll, returns an Array
*/
function query(startNode, selector) {
try {
return Array.prototype.slice.call(startNode.querySelectorAll(selector));
} catch (e) {
return [];
}
}
})(document);
@karimawi
Copy link

Modified to avoid accidentally opening when typing in a text field:

 // Listen for the 'p' key at the document level
d.addEventListener('keydown', evt => {
  // Avoid capturing if user holds Ctrl/Alt/Meta, or if in a text field, etc.
  if (
      evt.key === 'p' && 
      !evt.ctrlKey && 
      !evt.altKey && 
      !evt.metaKey && 
      evt.target.tagName !== 'INPUT' && 
      evt.target.tagName !== 'TEXTAREA' && evt.target.contentEditable !== "true"
  ) {
      // Prevent YouTube from interpreting 'p' in any other way
      evt.preventDefault();
      evt.stopPropagation();

      // Attempt to open the "Save to playlist" dialog
      openSaveToPlaylistDialog();
  }
}, true);

@f-steff
Copy link
Author

f-steff commented Feb 18, 2025

@karimawi.

Modified to avoid accidentally opening when typing in a text field

Seems like I didn't test it properly before release to the world. Sorry!
Thank you for the fix - i've integrated it 1:1 now.

@karimawi
Copy link

No worries, Steff thank you <3

@Fred-Vatin
Copy link

Fred-Vatin commented Feb 27, 2025

Thanks @f-steff. Very useful script. Would you consider to add an option to disable the alphabetical order ? By default, youtube lists by recent order and I like it because the most recent and frequently used lists are at the top. It could be a checkbox named alphabetical sorting next to the input that would use GM_setValue and GM_getValue to store user choice.

@danielma156
Copy link

Love this as an alternative to getting the Save button out of the three dot menu. Thanks so much!

Thanks @f-steff. Very useful script. Would you consider to add an option to disable the alphabetical order ? By default, youtube lists by recent order and I like it because the most recent and frequently used lists are at the top. It could be a checkbox named alphabetical sorting next to the input that would use GM_setValue and GM_getValue to store user choice.

I commented lines 79-83 out and reloaded the video and the alphabetical order was removed :)

@Fred-Vatin
Copy link

It seems it was not necessary. The list is no more ordered alphabetically already.

@danielma156
Copy link

It was showing up in alphabetical order for me (after any playlists the video was already in) when I just installed it a few hours ago.

However now the popup does not show at all and I'm getting "Could not find 'Save' button. Adjust the selector if needed." - so the script is unable to find the Save button right now for some odd reason.

image

@nythz
Copy link

nythz commented Sep 10, 2025

If the save button isn't directly visible, it fails to find it.

This modified openSaveToPlaylistDialog() function opens up the more action menu and then uses the save there after a 250ms delay:

function openSaveToPlaylistDialog() {
  	const directSaveButton = document.querySelector('button[aria-label="Save to playlist"]');
  	if (directSaveButton) {
  		directSaveButton.click();
  	} else {
  		const moreActionsButton = document.querySelector('button[aria-label="More actions"]');
  		if (moreActionsButton) {
  			moreActionsButton.click();
  			setTimeout(() => {
  				const submenuItems = document.querySelectorAll('ytd-menu-service-item-renderer');
  				let found = false;
  				submenuItems.forEach(item => {
  					if (item.textContent.trim() === 'Save') {
  						item.click();
  						found = true;
  					}
  				});
  			}, 250);
  		} else {
  			console.error("Neither a direct 'Save' button nor a 'More actions' button was found.");
  		}
  	}
  }

@danielma156
Copy link

danielma156 commented Sep 11, 2025

@nythz Amazing stuff! Works like a charm so far, thanks!

@Nyanpasu1
Copy link

can you add option to change the key? i cannot press p button on search button with this userscript active

@danielma156
Copy link

@Nyanpasu1 if you change the character in line 39 to a different character that should do what you want. I just tried it with 'o'. If you want a different non-letter character I'm not sure what you would need to put then, possibly unicode?

this one
evt.key === 'p' &&

@Nyanpasu1
Copy link

@Nyanpasu1 if you change the character in line 39 to a different character that should do what you want. I just tried it with 'o'. If you want a different non-letter character I'm not sure what you would need to put then, possibly unicode?

this one evt.key === 'p' &&

will prefer extra key so it wont disturb when you search something with p letter

as example ` or \

@Nyanpasu1
Copy link

also this script doesnt work on video that has save on ... button

it only works when save button are in main player without ... button

@nythz
Copy link

nythz commented Oct 12, 2025

@Nyanpasu1

also this script doesnt work on video that has save on ... button

it only works when save button are in main player without ... button

Check slightly above for an extra bit of code to handle that:
https://gist.github.com/f-steff/4d765eef037e9b751c58d43490ebad62?permalink_comment_id=5754744#gistcomment-5754744

@Fred-Vatin
Copy link

I rewrote the script completely here.

All bugs are fixed:

  • p will work every time you play a video
  • The filter is added on any “save to playlist” menu no matter from where you call this menu
  • It works even if you set another language than English for the Youtube UI (see the link to check the currently supported languages)

@Nyanpasu1
Copy link

I rewrote the script completely here.

All bugs are fixed:

  • p will work every time you play a video
  • The filter is added on any “save to playlist” menu no matter from where you call this menu
  • It works even if you set another language than English for the Youtube UI (see the link to check the currently supported languages)

thank you, now it works

@f-steff
Copy link
Author

f-steff commented Nov 11, 2025

Hi @Fred-Vatin

I rewrote the script completely here.

Thank you. It looks like you did a good job - and you documented it much better than what I have done. It would have been nice if you mentioned that you got inspired from this script, though.

Youtube didn't roll out the new features until recently for me, so I haven't had the opportunity to try to fix the script.
I did spend a few hours fixing it yesterday, though... but then I realized you had made a version, so perhaps there's no point for me to continue. Time will tell if I finish the update.

@Fred-Vatin
Copy link

It would have been nice if you mentioned that you got inspired from this script, though.

It was an oversight on my part, which I will hasten to rectify.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment