// ==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)); } })();