Youtube Save To Playlist Hotkey And Filter

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.

  1. // ==UserScript==
  2. // @name Youtube Save To Playlist Hotkey And Filter
  3. // @namespace https://gist.github.com/f-steff
  4. // @version 1.2
  5. // @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.
  6. // @author Flemming Steffensen
  7. // @license MIT
  8. // @match http://www.youtube.com/*
  9. // @match https://www.youtube.com/*
  10. // @include http://www.youtube.com/*
  11. // @include https://www.youtube.com/*
  12. // @grant none
  13. // @homepageURL https://gist.github.com/f-steff/4d765eef037e9b751c58d43490ebad62
  14. // @run-at document-start
  15. // ==/UserScript==
  16.  
  17. (function (d) {
  18.  
  19. /**
  20. * Pressing 'p' simulates a click on YouTube's "Save" button to open the dialog.
  21. */
  22. function openSaveToPlaylistDialog() {
  23. // Commonly, there's a button with aria-label="Save" or aria-label^="Save"
  24. // near the Like/Dislike/Share row on the watch page.
  25. const saveButton = d.querySelector('button[aria-label^="Save"]');
  26. if (saveButton) {
  27. saveButton.click();
  28. } else {
  29. console.log("Could not find 'Save' button. Adjust the selector if needed.");
  30. }
  31. }
  32. // Listen for the 'p' key at the document level
  33. d.addEventListener('keydown', evt => {
  34. // Avoid capturing if user holds Ctrl/Alt/Meta, or if in a text field, etc.
  35. if (
  36. evt.key === 'p' &&
  37. !evt.ctrlKey &&
  38. !evt.altKey &&
  39. !evt.metaKey &&
  40. evt.target.tagName !== 'INPUT' &&
  41. evt.target.tagName !== 'TEXTAREA' && evt.target.contentEditable !== "true"
  42. ) {
  43. // Prevent YouTube from interpreting 'p' in any other way
  44. evt.preventDefault();
  45. evt.stopPropagation();
  46.  
  47. // Attempt to open the "Save to playlist" dialog
  48. openSaveToPlaylistDialog();
  49. }
  50. }, true);
  51.  
  52. /**
  53. * Sort playlists such that:
  54. * - checked items (aria-checked="true") come first,
  55. * - then everything else in alphabetical (0-9,A→Z).
  56. */
  57. function sortPlaylist(playlist) {
  58. let options = query(playlist, 'ytd-playlist-add-to-option-renderer');
  59. let optionsMap = new Map();
  60.  
  61. // Collect items by playlist title
  62. options.forEach(op => {
  63. let formattedString = query(op, 'yt-formatted-string')[0];
  64. let title = formattedString?.getAttribute('title') || '';
  65. if (!optionsMap.has(title)) {
  66. optionsMap.set(title, []);
  67. }
  68. optionsMap.get(title).push(op);
  69. });
  70.  
  71. // Sort so "checked" groups come first, then A→Z
  72. let sortedEntries = [...optionsMap.entries()].sort(([titleA, groupA], [titleB, groupB]) => {
  73. const checkedA = groupA.some(
  74. op => query(op, 'tp-yt-paper-checkbox[aria-checked="true"]').length
  75. );
  76. const checkedB = groupB.some(
  77. op => query(op, 'tp-yt-paper-checkbox[aria-checked="true"]').length
  78. );
  79.  
  80. if (checkedA && !checkedB) return -1;
  81. if (checkedB && !checkedA) return 1;
  82.  
  83. // Otherwise alphabetical
  84. const upA = titleA.toUpperCase();
  85. const upB = titleB.toUpperCase();
  86. if (upA < upB) return -1;
  87. if (upA > upB) return 1;
  88. return 0;
  89. });
  90.  
  91. // Re-insert items in sorted order
  92. for (const [, group] of sortedEntries) {
  93. for (const opNode of group) {
  94. playlist.appendChild(opNode);
  95. }
  96. }
  97. }
  98.  
  99. /**
  100. * Filter all playlist entries based on user-typed substring.
  101. * If the filter is blank, show everything; otherwise hide non-matches.
  102. */
  103. function filterPlaylist(playlist, filterText) {
  104. let options = query(playlist, 'ytd-playlist-add-to-option-renderer');
  105. const text = filterText.trim().toLowerCase();
  106.  
  107. options.forEach(op => {
  108. let formattedString = query(op, 'yt-formatted-string')[0];
  109. let title = (formattedString?.getAttribute('title') || '').toLowerCase();
  110. op.style.display = (text && !title.includes(text)) ? 'none' : 'block';
  111. });
  112. }
  113.  
  114. /**
  115. * When the dialog closes, re-attach the observer so we see the next open event.
  116. */
  117. function observePaperDialogClose(paperDialog, onCloseDialog) {
  118. let ob = new MutationObserver((mutations, me) => {
  119. mutations.forEach(mutation => {
  120. if (mutation.type === 'attributes' && mutation.attributeName === 'aria-hidden') {
  121. me.disconnect();
  122. onCloseDialog();
  123. }
  124. });
  125. });
  126. ob.observe(paperDialog, { attributes: true });
  127. }
  128.  
  129. /**
  130. * Main logic: watch for the "Add to Playlist" dialog, then
  131. * Insert input, sort once, filter, focus the input, etc.
  132. * Added a small setTimeout to avoid first-time invocation timing issues.
  133. */
  134. function handlePopupContainer(popupContainer) {
  135. let currentFilter = '';
  136. const popupObserverConfig = { subtree: true, childList: true };
  137.  
  138. const popupContainerObserver = new MutationObserver(function (mut, me) {
  139. // Defer so the DOM has time to settle on first invocation
  140. setTimeout(() => {
  141. const paperDialog = query(popupContainer, 'tp-yt-paper-dialog')[0];
  142. if (paperDialog) {
  143. // Sort/insert only once per open
  144. me.disconnect();
  145.  
  146. // Re-attach after closing
  147. observePaperDialogClose(paperDialog, function () {
  148. popupContainerObserver.observe(popupContainer, popupObserverConfig);
  149. });
  150.  
  151. // Look for "Save video to..."
  152. const headingSpan = query(
  153. paperDialog,
  154. 'span.yt-core-attributed-string[role="text"]'
  155. ).find(el => el.textContent.trim() === 'Save video to...');
  156.  
  157. // Grab #playlists container
  158. const playlistContainer = query(paperDialog, '#playlists')[0];
  159.  
  160. if (headingSpan && playlistContainer) {
  161. // If we haven't inserted our input yet, do it now
  162. const existingFilterInput = d.getElementById('filterText');
  163. if (!existingFilterInput) {
  164. // Create <input> and <br>
  165. const filterInput = d.createElement('input');
  166. filterInput.id = 'filterText';
  167. filterInput.type = 'text';
  168. filterInput.placeholder = 'Filter';
  169.  
  170. const br = d.createElement('br');
  171.  
  172. headingSpan.parentNode.appendChild(filterInput);
  173. headingSpan.parentNode.appendChild(br);
  174.  
  175. // On typing => filter
  176. filterInput.addEventListener('input', evt => {
  177. currentFilter = evt.target.value;
  178. filterPlaylist(playlistContainer, currentFilter);
  179. });
  180. }
  181.  
  182. // Sort once for this session
  183. sortPlaylist(playlistContainer);
  184.  
  185. // Re-apply current filter (if any)
  186. filterPlaylist(playlistContainer, currentFilter);
  187.  
  188. // Focus
  189. const input = d.getElementById('filterText');
  190. if (input) {
  191. input.focus();
  192. }
  193. }
  194. }
  195. }, 10); // 10ms delay
  196. });
  197.  
  198. // Start observing
  199. popupContainerObserver.observe(popupContainer, popupObserverConfig);
  200. }
  201.  
  202. /**
  203. * A top-level observer that waits for <ytd-popup-container> to show up,
  204. * then calls handlePopupContainer() exactly once.
  205. */
  206. const documentObserver = new MutationObserver(function (mutations, me) {
  207. const popupContainer = query(d, 'ytd-popup-container')[0];
  208. if (popupContainer) {
  209. console.log("Found ytd-popup-container");
  210. handlePopupContainer(popupContainer);
  211. me.disconnect(); // stop once found
  212. }
  213. });
  214. documentObserver.observe(d, { childList: true, subtree: true });
  215.  
  216. /**
  217. * Helper: safely do querySelectorAll, returns an Array
  218. */
  219. function query(startNode, selector) {
  220. try {
  221. return Array.prototype.slice.call(startNode.querySelectorAll(selector));
  222. } catch (e) {
  223. return [];
  224. }
  225. }
  226.  
  227. })(document);