// ==UserScript==
// @name YouTube Save to Playlist filter
// @namespace fred.vatin.yt-playlists-filter
// @version 1.1.9
// @description Tap P key to open the “save to playlist” menu where your can type to filter
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @author Fred Vatin
// @updateURL https://raw.githubusercontent.com/Fred-Vatin/yt-playlist-filter/main/user%20script/yt-playlist-filter.user.js
// @downloadURL https://raw.githubusercontent.com/Fred-Vatin/yt-playlist-filter/main/user%20script/yt-playlist-filter.user.js
// @noframes
// @run-at document-body
// @grant window.onurlchange
// @match https://www.youtube.com/*
// @license MIT
// ==/UserScript==
(() => {
/**==========================================================================
* ℹ SETTINGS
===========================================================================*/
// You can disable or custom the shortkey to open the save to playlist menu
const ENABLE_SHORTKEY = true;
// go to https://www.toptal.com/developers/keycode and press the key you want
// enter here the event.code. The same physical key will work on every keyboard layout
const SHORTKEY = "KeyP"; // if you want to set a modifier key shortcut, edit the handlePlaylistKey function
// Add your language for the Save menu entry name
// - Play a video
// First case scenario: the "save to playlist" button is in the "…" sub-menu
// 1. Click the "…" menu button where there is the "save to playlist" menu entry
// 2. In the dev tool > inspector, search for the selector: yt-button-shape > button[aria-label]
// It should be around the 7th match. Copy the [aria-label] value and add it here.
const MoreActionsButtonText = ["More actions", "Autres actions", "Mehr Aktionen", "Más acciones", "Altre azioni", "Mais ações"];
// 3. search for the selector: #items > ytd-menu-service-item-renderer yt-formatted-string
// copy the text content
const SaveButtonText = ["Save", "Enregistrer", "Speichern", "Guardar", "Salva"];
// Second case scenario, the "Save" to playlist button is in direct access
// 1. In the dev tool > inspector, search for the selector: .ytSpecButtonViewModelHost button[aria-label]
// until your find the one for the save to playlist.
// 2. It should be around the 7th match. Copy the [aria-label] value and add it here.
const DirectSaveButtonText = [
"Save to playlist",
"Enregistrer dans une playlist",
"Zu Playlist hinzufügen",
"Añadir a lista de reproducción",
"Salva in una playlist",
"Salvar na playlist",
"Guardar na playlist",
];
/**==========================================================================
* ℹ GLOBAL DEFINITION
===========================================================================*/
let URL = window.location.href;
let PLAYLISTS = null;
console.log("URL (at first loading): ", URL);
const selector_MoreActionSubMenuItems = "#items > ytd-menu-service-item-renderer yt-formatted-string";
const selector_MenuType1 = "yt-contextual-sheet-layout";
const selector_MenuType2 = "ytd-add-to-playlist-renderer.ytd-popup-container";
const selector_HeaderType1 = "yt-panel-header-view-model";
const selector_HeaderType2 = "ytd-menu-title-renderer";
const selector_ListType1 = ".ytListViewModelHost";
const selector_ListType2 = "#playlists";
const selector_ListItemsType1 = ".toggleableListItemViewModelHost";
const selector_ListItemsType2 = ".ytd-add-to-playlist-renderer";
const selector_SelItemsType1 = `:scope > yt-list-item-view-model[aria-pressed="true"]`;
const selector_SelItemsType2 = `:scope > #checkbox[aria-checked="true"]`;
const selector_ItemType1 = ".yt-core-attributed-string";
const selector_ItemType2 = "#label";
const selector_OpenMenuParentType1 = "ytd-app ytd-popup-container";
const selector_OpenMenuParentType2 = "tp-yt-paper-dialog.ytd-popup-container";
const InputId = "filterPlaylist";
// array of all selectors for playslists menu and its header
const selectors_PlaylistsMenu = [selector_MenuType1, selector_MenuType2];
const selectors_MenuSubElements = [selector_HeaderType1, selector_HeaderType2, selector_ListType1, selector_ListType2];
// Init the P key press listener
let IS_KEYPRESS_LISTENER_ACTIVE = false;
/**==========================================================================
* ℹ DETECT URL CHANGE TO RE-RUN THE SCRIPT
===========================================================================*/
if (window.onurlchange === null) {
console.log("✅ window.onurlchange is supported. Adding 'urlchange' listener.");
window.addEventListener("urlchange", () => {
onUrlChange("window.onurlchange");
});
} else {
console.log("❌ window.onurlchange is not supported by this browser or this Tampermonkey version.");
}
/**==========================================================================
* ℹ CREATE OBSERVERS and Listeners
* This will allow to disconnect them
===========================================================================*/
// Main observer to detect new playlist menu
const obsv_NewPlaylistsMenu = new MutationObserver(callback_NewMenu);
// Observer to detect when menu items and required sub-elements are available
const obsv_MenuItems = new MutationObserver(callback_MenuAvailable);
// Observer to detect when the menu is open
const obsv_MenuOpen = new MutationObserver(callback_MenuOpen);
// Start observer at first launch
connect(obsv_NewPlaylistsMenu, {
strLog: "✅ obsv_NewPlaylistsMenu started 🔌",
});
// Handle P key press at first launch if video playing
if (isPlayer(URL)) {
SetKeydownListener(true);
}
/**==========================================================================
* ℹ ON URL CHANGE
===========================================================================*/
/**
* Handle changes to the current browser URL and update playlist listener state accordingly.
*
* Compares the current window.location.href with the external `URL` variable. If they differ,
* updates `URL`, logs the change, and enables or disables the playlist listener depending
* on whether the new URL represents a player view.
*
* @param {string} [event="first loading"] - Optional string describing what triggered the check (used for logging).
* @returns {void}
*
* @sideEffects
* - Reads window.location.href.
* - Mutates the external `URL` variable.
* - Calls `isPlayer(URL)` to determine if the current URL is a player.
* - Calls `togglePlaylistListener(true|false)` to enable/disable playlist handling.
*
* @requires {global} URL - External module-level variable holding the last-known URL.
* @requires {function} isPlayer - Function that returns a boolean indicating if a URL is a player view.
* @requires {function} togglePlaylistListener - Function to enable/disable playlist listener behavior.
*/
function onUrlChange(event = "first loading") {
const currentUrl = window.location.href;
console.log(`[onUrlChange] call by event: ${event}`);
if (currentUrl !== URL) {
console.log("[onUrlChange] URL changed : ", currentUrl);
URL = currentUrl;
// enable P key press on video page to open the save to playlist menu
if (isPlayer(URL)) {
SetKeydownListener(true);
} else {
SetKeydownListener(false);
}
}
}
/**==========================================================================
* ℹ FUNCTIONS
===========================================================================*/
/**
* MutationObserver callback called when a new node is added to document DOM.
*
* Iterates over the provided MutationRecord list and, for each mutation (added nodes),
* check if the playlist menu is there.
* If yes, it observes it with callback_MenuAvailable to detect when required sub-elements are ready.
*
* Handles different YouTube menu type insertion patterns based on where the menu is first called.
*
* @param {MutationRecord[]} mutationsList - Array of MutationRecord objects describing DOM changes
* @param {MutationObserver} obsv_PlaylistContainer - The MutationObserver instance monitoring playlist container (not used)
* @returns {void}
* @callback MutationCallback
* @listens {MutationEvent} childList - Listens for changes in child elements
*/
// let items = 0;
function callback_NewMenu(mutationsList, obsv_PlaylistContainer) {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
// Iterate only on newly added nodes
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
// console.log(
// `🔍 [callback_NewMenu] Mutation on ${mutation.target.tagName}: ${mutation.addedNodes.length} addedNodes`,
// node
// );
for (const selector of selectors_PlaylistsMenu) {
if (node.matches(selector)) {
let menu = null;
items++;
if (node.matches(selector_MenuType1)) {
// console.log(`✅ [callback_NewMenu] #${items} new selectors_PlaylistsMenu detected`, node);
/**===============================================
* ⚠ WARNING
Youtube inserts menu node differently depending on
from where you call it first.
We need to figure it out according to the
menu element value.
================================================*/
menu = node;
console.log(`✅ [callback_NewMenu] selector_MenuType1 detected`, menu);
header = document.querySelector(selector_HeaderType1);
list = document.querySelector(selector_ListType1);
console.log(`✅ [callback_NewMenu] header ?`, header);
console.log(`✅ [callback_NewMenu] list ?`, list);
if (!header || !list) {
// elegant way to pass the node to the callback_MenuAvailable()
obsv_MenuItems.menu = menu;
connect(obsv_MenuItems, {
parent: menu,
strLog: "✅ obsv_MenuItems started 🔌",
});
} else {
if (header) {
console.log(`✅ [callback_NewMenu] header used`, header);
if (list) {
console.log(`✅ [callback_NewMenu] list used`, list);
addFilterInput({ header: header, list: list });
break;
} else {
console.log(`❌ [callback_NewMenu] list not detected in menu`);
}
} else {
console.log(`❌ [callback_NewMenu] header not detected in menu`);
}
}
} else if (node.matches(selector_MenuType2)) {
menu = node;
console.log(`✅ [callback_NewMenu] selector_MenuType2 detected`, menu);
header = document.querySelector(selector_HeaderType2);
list = document.querySelector(selector_ListType2);
if (!header || !list) {
connect(obsv_MenuItems, {
parent: menu,
strLog: "✅ [callback_NewMenu] obsv_MenuItems started 🔌",
});
} else {
if (header) {
console.log(`✅ [callback_NewMenu] header used`, header);
if (list) {
console.log(`✅ [callback_NewMenu] list used`, list);
addFilterInput({ header: header, list: list });
break;
} else {
console.log(`❌ [callback_NewMenu] list not detected in menu`);
}
} else {
console.log(`❌ [callback_NewMenu] header not detected in menu`);
}
}
} else {
console.log(
`❌ [callback_NewMenu] new node seems to match selectors_PlaylistsMenu but for some unknown reason it doesn’t match selector_MenuType1 or selector_MenuType2`,
node,
);
}
}
}
}
}
}
}
}
/**
* MutationObserver callback called when a new playlists menu is added to DOM (detected by callback_NewMenu)
*
* Iterates over the provided MutationRecord list and, for each mutation (added nodes),
* check if the required menu elements are there.
* If yes, it adds the input filter.
*
* @param {MutationRecord[]} mutationsList - Array of MutationRecord objects provided by the observer.
* @param {MutationObserver} obsv_MenuAvailable - The MutationObserver instance that invoked this callback (not used)
* @returns {void}
* @callback MutationCallback
* @listens {MutationEvent} childList - Listens for changes in child elements
*
*/
function callback_MenuAvailable(mutationsList, obsv_MenuAvailable) {
let header = null;
let list = null;
// let item = null;
let isType1 = false;
const Parent = obsv_MenuAvailable.menu; // check line 207 to understand what is this
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
// Iterate only on newly added nodes
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
// console.log(
// `🔍 [callback_MenuAvailable] Mutation on ${mutation.target.tagName}: ${mutation.addedNodes.length} addedNodes`,
// node
// );
for (const selector of selectors_MenuSubElements) {
// console.log(`✅ [callback_MenuAvailable] test selector: `, selector);
if (node.matches(selector)) {
// item++;
// console.log(`✅ [callback_MenuAvailable] item #${item} new selectors_MenuSubElements detected`, node);
// console.log(`✅ [callback_MenuAvailable] Context > new elements observed own to Parent: `, Parent);
if (node.matches(selector_HeaderType1)) {
header = node;
isType1 = true;
console.log(`✅ [callback_MenuAvailable] selector_HeaderType1 detected`, header);
} else if (node.matches(selector_HeaderType2)) {
header = node;
console.log(`✅ [callback_MenuAvailable] selector_HeaderType2 detected`, header);
} else if (node.matches(selector_ListType1)) {
list = node;
isType1 = true;
console.log(`✅ [callback_MenuAvailable] selector_ListType1 detected`, list);
} else if (node.matches(selector_ListType2)) {
list = node;
console.log(`✅ [callback_MenuAvailable] selector_ListType2 detected`, list);
} else {
console.log(
`❌ [callback_MenuAvailable] new node seems to match selectors_MenuSubElements but for some unknown reason it doesn’t match selector_HeaderType or selector_ListType`,
node,
);
continue;
}
if (header?.textContent.trim().length === 0) {
console.log(`❌ [callback_MenuAvailable] header doesn’t contain title`, header);
header = null;
}
}
}
}
}
}
}
if (header) {
console.log(`✅ [callback_MenuAvailable] header used`, header);
// try to get the list now if it failed to not part of the new mutation but from a previous one
if (!list) {
list = isType1 ? Parent.querySelector(selector_ListType1) : Parent.querySelector(selector_ListType2);
}
if (list) {
console.log(`✅ [callback_MenuAvailable] list used`, list);
addFilterInput({ header: header, list: list });
} else {
console.log(`❌ [callback_MenuAvailable] list not detected in menu`);
}
} else {
console.log(`❌ [callback_MenuAvailable] header not detected in menu`);
}
}
/**
* MutationObserver callback that watches for changes to a menu element's aria-hidden state
* and focuses a target input when the menu becomes visible.
*
* Iterates over the provided MutationRecord list and, for each mutation, inspects
* mutation.target.ariaHidden. If ariaHidden is falsy (indicating the menu is open/visible),
* the function calls setFocus(InputId) to move focus to the configured input element.
*
* @param {MutationRecord[]} mutationsList - Array of MutationRecord objects provided by the observer.
* @param {MutationObserver} obsv_MenuOpen - The MutationObserver instance that invoked this callback (not used)
* @returns {void}
*
* @remarks
* - The implementation expects `mutation.target` to expose an `ariaHidden` property; in browsers
* you may need to use `getAttribute('aria-hidden')` or similar depending on how aria state is read.
* - Ensure `InputId` and `setFocus` are defined in the enclosing scope before using this callback.
* - Typically used when observing attribute changes on a menu element to detect open/close transitions.
*/
function callback_MenuOpen(mutationsList, obsv_MenuOpen) {
const Container = obsv_MenuOpen.container;
let header = null;
let list = null;
for (const mutation of mutationsList) {
const AriaHidden = mutation.target.ariaHidden;
console.log(
`[callback_MenuOpen] mutation type: ${mutation.type}, AriaHidden: ${AriaHidden}, attribute changed: ${mutation.attributeName}`,
mutation.target,
);
if (!AriaHidden) {
// Even if input were created in a previous call,
// on some page, when calling the menu for another video
// the input could be absent, so as header or list. We need to handle this.
header = Container?.querySelector(selector_HeaderType1) ?? Container?.querySelector(selector_HeaderType2);
list = Container?.querySelector(selector_ListType1) ?? Container?.querySelector(selector_ListType2);
console.log(`[callback_MenuOpen] header ?`, header);
console.log(`[callback_MenuOpen] list ?`, list);
if (header && list) {
addFilterInput({ header: header, list: list });
}
// setFocus(InputId);
if (PLAYLISTS) {
console.log(`[callback_MenuOpen] Use PLAYLISTS`, PLAYLISTS);
autoTopList(PLAYLISTS);
}
}
}
}
/**
* Start observing DOM mutations with the provided MutationObserver and options.
*
* @param {MutationObserver} observer - The MutationObserver instance to start observing.
* @param {Object} [options] - Optional configuration object.
* @param {string} [options.strLog=""] - Message to log to the console when observation is started. If an empty string (default), nothing is logged.
* @param {MutationObserverInit} [options.config={ childList: true, subtree: true }] - Options passed to observer.observe().
* @param {Node} [options.parent=document.body] - The root node to observe. Defaults to document.body.
* @returns {void}
*/
function connect(observer, options = {}) {
const { strLog = "", config = { childList: true, subtree: true }, parent = document.body } = options;
observer.observe(parent, config);
if (strLog) {
console.log(strLog);
}
}
/**
* Safely disconnects an observer and optionally logs a message.
*
* @param {{disconnect: function}|MutationObserver} observer - The observer to disconnect. Must implement a `disconnect()` method (e.g., a MutationObserver).
* @param {string} [strLog=""] - Optional message to log to the console if provided and non-empty.
* @returns {void}
*/
function disconnect(observer, strLog = "") {
observer.disconnect();
if (strLog) {
console.log(strLog);
}
}
/**
* Determine whether a URL points to a YouTube watch/player page.
*
* Logs a message to the console indicating the detection result:
* - "✅ Player detected !" when the URL matches the expected player prefix.
* - "❌ Not a player !" when it does not.
*
* @param {string} url - The URL to test.
* @returns {boolean} True if the URL starts with "https://www.youtube.com/watch?v=", otherwise false.
*
* @example
* // Returns true and logs "✅ Player detected !"
* isPlayer('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
*
* @example
* // Returns false and logs "❌ Not a player !"
* isPlayer('https://youtu.be/dQw4w9WgXcQ');
*/
function isPlayer(url) {
if (url.startsWith("https://www.youtube.com/watch?v=") || url.startsWith("https://www.youtube.com/live/")) {
console.log("✅ [isPlayer] Player detected !");
return true;
} else {
console.log("❌ [isPlayer] Not a player !");
return false;
}
}
/**
* Adds a filter input element to a playlist header if it doesn't already exist.
* The input is inserted after the title element and sets up an event listener for filtering.
*
* @param {Object} elements - The elements object containing references to DOM elements
* @param {HTMLElement} [elements.list=null] - The playlist list element
* @param {HTMLElement} [elements.header=null] - The header element containing the title
* @returns {void}
*/
function addFilterInput(elements = {}) {
// it is safer to disconnect this observer here to avoid infinite call to addFilterInput()
disconnect(obsv_MenuOpen, "✅ [addFilterInput] obsv_MenuOpen stopped 🛑");
const { list = null, header = null } = elements;
let existingFilterInput = false;
let selector_FilterInput = "";
PLAYLISTS = list;
autoTopList(PLAYLISTS);
const isType2 = header.matches(selector_HeaderType2);
let title = null;
let selector_OpenMenuParent = null;
if (isType2) {
console.log(`✅ [addFilterInput] isType2`);
title = header?.querySelector('span[role="text"]');
title.style.marginRight = "20px";
selector_OpenMenuParent = selector_OpenMenuParentType2;
selector_FilterInput = `${selector_MenuType2} #${InputId}`
} else {
console.log(`✅ [addFilterInput] isType1`);
title = header?.firstElementChild;
selector_OpenMenuParent = selector_OpenMenuParentType1;
selector_FilterInput = `${selector_MenuType1} #${InputId}`
}
existingFilterInput = document.querySelector(selector_FilterInput);
if (!existingFilterInput) {
// handle MenuType1 vs MenuType2
console.log(`❌ [addFilterInput] existingFilterInput not found. Create it !`);
if (title) {
console.log(`✅ [addFilterInput] title found, insert input`, title);
} else {
console.log(`❌ [addFilterInput] title not found Input will NOT be inserted.`, title);
return;
}
// Create
const filterInput = document.createElement("input");
filterInput.id = InputId;
filterInput.type = "text";
filterInput.placeholder = "Filter";
title.parentNode.insertBefore(filterInput, title.nextSibling);
// On typing => filter
filterInput.addEventListener("input", (evt) => {
const inputText = evt.target.value;
filterPlaylist(list, inputText);
});
NewFilterInput = document.querySelector(selector_FilterInput);
console.log(`✅ [addFilterInput] set focus on "${selector_FilterInput}" `, NewFilterInput);
setFocus(NewFilterInput);
connectObserver(selector_OpenMenuParent);
} else {
console.log(`✅ [addFilterInput] input already exists set focus on "${selector_FilterInput}" : `, existingFilterInput);
setFocus(existingFilterInput);
connectObserver(selector_OpenMenuParent);
}
function connectObserver(selector_OpenMenuParent) {
const PopupContainer = document.querySelector(selector_OpenMenuParent);
// Pass the container to the observer to make it available to callback_MenuOpen()
obsv_MenuOpen.container = PopupContainer;
// observe the future menu open
connect(obsv_MenuOpen, {
parent: PopupContainer,
config: { subtree: true, attributes: true, attributeFilter: ["aria-hidden"] },
strLog: "✅ [addFilterInput] obsv_MenuOpen started 🔌",
});
}
}
/**
* Filters the items in a playlist element based on the provided filter text.
*
* @param {Element} playlists - The DOM element containing the playlist items.
* @param {string} filterText - The text to filter playlist items by. Items not containing this text will be hidden.
*/
function filterPlaylist(playlists, filterText) {
if (!playlists) {
console.log("❌ [filterPlaylist] playlists is null or invalid");
return;
}
// handle MenuType1 vs MenuType2
const isType2 = playlists.matches(selector_ListType2);
let listItems = null;
if (isType2) {
listItems = playlists.querySelectorAll(selector_ListItemsType2);
} else {
listItems = playlists.querySelectorAll(selector_ListItemsType1);
}
// const listItems = query(playlist, selector_ListItems);
const text = filterText.trim().toLowerCase();
listItems.forEach((item) => {
// const formattedString = query(item, ".yt-core-attributed-string")[0];
const formattedString = isType2 ? item.querySelector(selector_ItemType2) : item.querySelector(selector_ItemType1);
const title = formattedString?.textContent.trim().toLowerCase();
item.style.display = text && !title.includes(text) ? "none" : "block";
});
}
/**
* Automatically sorts playlist items by placing selected items at the top.
* Selected items are playlists in which the current video already exists.
* @param {HTMLElement} playlists - The playlist container element to sort
* @returns {void}
* @description
* This function reorganizes playlist items so that selected items appear first,
* followed by unselected items. It filters items using predefined selectors,
* logs the operation status, and reinserts items in the new order.
* @example
* const playlistElement = document.querySelector('.playlist');
* autoTopList(playlistElement);
*/
function autoTopList(playlists) {
if (!playlists) {
console.log("❌ [autoTopList] playlists is null or invalid");
return;
}
// handle MenuType1 vs MenuType2
const isType2 = playlists.matches(selector_ListType2);
let listItems = null;
let selector_SelItems = null;
let selector_ListItems = null;
if (isType2) {
selector_ListItems = selector_ListItemsType2;
selector_SelItems = selector_SelItemsType2;
} else {
selector_ListItems = selector_ListItemsType1;
selector_SelItems = selector_SelItemsType1;
}
// Collect all items
listItems = Array.from(playlists.querySelectorAll(selector_ListItems));
console.log("✅ [autoTopList] listItems number: ", listItems.length);
// Separates the selected ones from the rest
const selected = listItems.filter((item) => item.querySelector(selector_SelItems));
const unselected = listItems.filter((item) => !item.querySelector(selector_SelItems));
if (selected.length > 0) {
console.log(`✅ [autoTopList] this video belongs to ${selected.length} playlist(s)`);
// Reinsert in the desired order
[...selected, ...unselected].forEach((item) => {
playlists.appendChild(item);
});
console.log(`✅ [autoTopList] playlists have been sorted.`);
const sortedItems = Array.from(playlists.querySelectorAll(selector_ListItems));
console.log("✅ [autoTopList] sortedItems number: ", sortedItems.length);
} else {
console.log("❌ [autoTopList] this video doesn’t belong to any existing playlist. No sorting needed.");
}
}
/**
* Sets focus to the input element with the given ID, resets its value,
* and dispatches an "input" event to trigger any associated handlers.
* If the element is not found, logs a message to the console.
*
* @param {string} el - The ID of the input element to focus.
*/
async function setFocus(el) {
// const input = document.getElementById(el);
const input = el;
if (input) {
// reset filter
input.value = "";
// Dispatch input event to trigger the handler
const inputEvent = new InputEvent("input", {
target: input,
bubbles: true,
});
input.dispatchEvent(inputEvent);
const timeout = 100;
console.log(`✅ [setFocus] "input" found. Set focus! Timeout = ${timeout}`);
// delay required to set the focus
await sleep(timeout);
input.focus();
} else {
console.log(`❌ [setFocus] "input" not found. Focus not set!`);
}
}
/**
* Attempts to open the "Save to playlist" dialog on a YouTube video page.
*
* The function first tries to find and click the direct "Save to playlist" button.
* If not found, it searches for a "More actions" button using possible button texts,
* clicks it, and then looks for the "Save" option in the submenu to trigger the dialog.
* Logs an error if neither button is found.
*
* @returns {void}
*/
async function openSaveToPlaylistDialog() {
let directSaveButton = null;
for (const text of DirectSaveButtonText) {
const selector = `.ytSpecButtonViewModelHost button[aria-label="${text}"]`;
directSaveButton = document.querySelector(selector);
if (directSaveButton) {
break;
}
}
if (directSaveButton) {
console.log("✅ [openSaveToPlaylistDialog] direct save button found. Click it", directSaveButton);
directSaveButton.click();
} else {
console.log("❌ [openSaveToPlaylistDialog] direct save button NOT found. Search for More Actions menu");
let moreActionsButton = null;
for (const text of MoreActionsButtonText) {
const selector = `yt-button-shape > button[aria-label="${text}"]`;
moreActionsButton = document.querySelector(selector);
if (moreActionsButton) {
break;
}
}
if (moreActionsButton) {
console.log("✅ [openSaveToPlaylistDialog] More Actions menu found. Click it");
moreActionsButton.click();
await sleep(250);
const submenuItems = document.querySelectorAll(selector_MoreActionSubMenuItems);
submenuItems.forEach((item) => {
if (SaveButtonText.includes(item.textContent.trim())) {
item.click();
console.log("✅ [openSaveToPlaylistDialog] Click on Save item:", item);
}
});
} else {
console.error("❌ [openSaveToPlaylistDialog] Neither a direct 'Save' button nor a 'More actions' button was found.");
}
}
}
/**
* Handles the 'p' key press event to open the "Save to playlist" dialog on YouTube.
* Ignores the event if Ctrl, Alt, or Meta keys are held, or if the focus is on an input, textarea, or editable element.
* Prevents default YouTube behavior for the 'p' key and stops event propagation.
*
* @param {KeyboardEvent} evt - The keyboard event triggered by a key press.
*/
function handlePlaylistKey(evt) {
// Avoid capturing if user holds Ctrl/Alt/Meta, or if in a text field, etc.
if (
evt.code === SHORTKEY &&
// isPlayer(URL) &&
!evt.ctrlKey &&
!evt.altKey &&
!evt.metaKey &&
!evt.shiftKey &&
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();
}
}
/**
* Enables or disables the keydown event listener for handling playlist key actions.
* When enabled, adds a keydown event listener to the document that triggers `handlePlaylistKey`.
* When disabled, removes the event listener if it is active.
*
* @param {boolean} enable - If true, adds the event listener; if false, removes it.
*/
function SetKeydownListener(enable) {
if (!ENABLE_SHORTKEY) return;
if (enable && !IS_KEYPRESS_LISTENER_ACTIVE) {
console.log(`✅ [SetKeydownListener] Add press P event listerner`);
document.addEventListener("keydown", handlePlaylistKey, true);
IS_KEYPRESS_LISTENER_ACTIVE = true;
} else if (!enable && IS_KEYPRESS_LISTENER_ACTIVE) {
console.log(`❌ [SetKeydownListener] Remove press P event listerner`);
document.removeEventListener("keydown", handlePlaylistKey, true);
IS_KEYPRESS_LISTENER_ACTIVE = false;
}
}
/**
* Pauses execution for a specified duration.
* @param {number} ms - The number of milliseconds to sleep.
* @returns {Promise} A promise that resolves after the specified delay.
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
})();