YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

目前为 2024-06-13 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube downloader
  3. // @icon https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/images/icon.png
  4. // @namespace aGkgdGhlcmUgOik=
  5. // @source https://github.com/madkarmaa/youtube-downloader
  6. // @supportURL https://github.com/madkarmaa/youtube-downloader
  7. // @version 3.2.1
  8. // @description A simple userscript to download YouTube videos in MAX QUALITY
  9. // @author mk_
  10. // @match *://*.youtube.com/*
  11. // @connect api.cobalt.tools
  12. // @connect raw.githubusercontent.com
  13. // @grant GM_info
  14. // @grant GM_addStyle
  15. // @grant GM_xmlHttpRequest
  16. // @grant GM_xmlhttpRequest
  17. // @run-at document-start
  18. // ==/UserScript==
  19.  
  20. (async () => {
  21. 'use strict'; // prettier-ignore
  22.  
  23. // abort if not on youtube or youtube music
  24. if (!detectYoutubeService()) {
  25. console.log('\x1b[31m[YTDL]\x1b[0m Invalid YouTube service, aborting...');
  26. return;
  27. }
  28.  
  29. // ===== VARIABLES =====
  30. let ADVANCED_SETTINGS = localStorage.getItem('ytdl-advanced-settings')
  31. ? JSON.parse(localStorage.getItem('ytdl-advanced-settings'))
  32. : {
  33. enabled: false,
  34. openUrl: '',
  35. };
  36. localStorage.setItem('ytdl-advanced-settings', JSON.stringify(ADVANCED_SETTINGS));
  37.  
  38. let DEV_MODE = String(localStorage.getItem('ytdl-dev-mode')).toLowerCase() === 'true';
  39. let SHOW_NOTIFICATIONS =
  40. localStorage.getItem('ytdl-notif-enabled') === null
  41. ? true
  42. : String(localStorage.getItem('ytdl-notif-enabled')).toLowerCase() === 'true';
  43.  
  44. let oldILog = console.log;
  45. let oldWLog = console.warn;
  46. let oldELog = console.error;
  47.  
  48. let VIDEO_DATA = {
  49. video_duration: null,
  50. video_url: null,
  51. video_author: null,
  52. video_title: null,
  53. video_id: null,
  54. };
  55. let videoDataReady = false;
  56. // ===== END VARIABLES =====
  57.  
  58. // ===== METHODS =====
  59. function logger(level, ...args) {
  60. if (DEV_MODE && level.toLowerCase() === 'info') oldILog.apply(console, ['%c[YTDL]', 'color: #f00;', ...args]);
  61. else if (DEV_MODE && level.toLowerCase() === 'warn')
  62. oldWLog.apply(console, ['%c[YTDL]', 'color: #f00;', ...args]);
  63. else if (level.toLowerCase() === 'error') oldELog.apply(console, ['%c[YTDL]', 'color: #f00;', ...args]);
  64. }
  65.  
  66. function Cobalt(videoUrl, audioOnly = false) {
  67. // Use Promise because GM.xmlHttpRequest behaves differently with different userscript managers
  68. return new Promise((resolve, reject) => {
  69. // https://github.com/wukko/cobalt/blob/current/docs/api.md
  70. GM_xmlhttpRequest({
  71. method: 'POST',
  72. url: 'https://api.cobalt.tools/api/json',
  73. headers: {
  74. 'Cache-Control': 'no-cache',
  75. Accept: 'application/json',
  76. 'Content-Type': 'application/json',
  77. },
  78. data: JSON.stringify({
  79. url: encodeURI(videoUrl), // video url
  80. vQuality: 'max', // always max quality
  81. filenamePattern: 'basic', // file name = video title
  82. isAudioOnly: audioOnly,
  83. disableMetadata: true, // privacy
  84. }),
  85. onload: (response) => {
  86. const data = JSON.parse(response.responseText);
  87. if (data?.url) resolve(data.url);
  88. else reject(data);
  89. },
  90. onerror: (err) => reject(err),
  91. });
  92. });
  93. }
  94.  
  95. // https://stackoverflow.com/a/61511955
  96. function waitForElement(selector) {
  97. return new Promise((resolve) => {
  98. if (document.querySelector(selector)) return resolve(document.querySelector(selector));
  99.  
  100. const observer = new MutationObserver(() => {
  101. if (document.querySelector(selector)) {
  102. observer.disconnect();
  103. resolve(document.querySelector(selector));
  104. }
  105. });
  106.  
  107. observer.observe(document.body, { childList: true, subtree: true });
  108. });
  109. }
  110.  
  111. function fetchNotifications() {
  112. // Use Promise because GM.xmlHttpRequest behaves differently with different userscript managers
  113. return new Promise((resolve, reject) => {
  114. GM_xmlhttpRequest({
  115. method: 'GET',
  116. url: 'https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/notifications.json',
  117. headers: {
  118. 'Cache-Control': 'no-cache',
  119. Accept: 'application/json',
  120. 'Content-Type': 'application/json',
  121. },
  122. onload: (response) => {
  123. const data = JSON.parse(response.responseText);
  124. if (data?.length) resolve(data);
  125. else reject(data);
  126. },
  127. onerror: (err) => reject(err),
  128. });
  129. });
  130. }
  131.  
  132. class Notification {
  133. constructor(title, body, uuid, storeUUID = true) {
  134. const notification = document.createElement('div');
  135. notification.classList.add('ytdl-notification', 'opened', uuid);
  136.  
  137. hideOnAnimationEnd(notification, 'closeNotif', true);
  138.  
  139. const nTitle = document.createElement('h2');
  140. nTitle.textContent = title;
  141. notification.appendChild(nTitle);
  142.  
  143. const nBody = document.createElement('div');
  144. body.split('\n').forEach((text) => {
  145. const paragraph = document.createElement('p');
  146. paragraph.textContent = text;
  147. nBody.appendChild(paragraph);
  148. });
  149. notification.appendChild(nBody);
  150.  
  151. const nDismissButton = document.createElement('button');
  152. nDismissButton.textContent = 'Dismiss';
  153. nDismissButton.addEventListener('click', () => {
  154. if (storeUUID) {
  155. const localNotificationsHashes = JSON.parse(localStorage.getItem('ytdl-notifications') ?? '[]');
  156. localNotificationsHashes.push(uuid);
  157. localStorage.setItem('ytdl-notifications', JSON.stringify(localNotificationsHashes));
  158. logger('info', `Notification ${uuid} set as read`);
  159. }
  160.  
  161. notification.classList.remove('opened');
  162. notification.classList.add('closed');
  163. });
  164. notification.appendChild(nDismissButton);
  165.  
  166. document.body.appendChild(notification);
  167. logger('info', 'New notification displayed', notification);
  168. }
  169. }
  170.  
  171. async function manageNotifications() {
  172. if (!SHOW_NOTIFICATIONS) {
  173. logger('info', 'Notifications disabled by the user');
  174. return;
  175. }
  176.  
  177. const localNotificationsHashes = JSON.parse(localStorage.getItem('ytdl-notifications')) ?? [];
  178. logger('info', 'Local read notifications hashes\n\n', localNotificationsHashes);
  179.  
  180. const onlineNotifications = await fetchNotifications();
  181. logger(
  182. 'info',
  183. 'Online notifications hashes\n\n',
  184. onlineNotifications.map((n) => n.uuid)
  185. );
  186.  
  187. const unreadNotifications = onlineNotifications.filter((n) => !localNotificationsHashes.includes(n.uuid));
  188. logger(
  189. 'info',
  190. 'Unread notifications hashes\n\n',
  191. unreadNotifications.map((n) => n.uuid)
  192. );
  193.  
  194. unreadNotifications.reverse().forEach((n) => {
  195. new Notification(n.title, n.body, n.uuid);
  196. });
  197. }
  198.  
  199. async function updateVideoData(e) {
  200. videoDataReady = false;
  201.  
  202. const temp_video_data = e.detail?.getVideoData();
  203. VIDEO_DATA.video_duration = e.detail?.getDuration();
  204. VIDEO_DATA.video_url = e.detail?.getVideoUrl();
  205. VIDEO_DATA.video_author = temp_video_data?.author;
  206. VIDEO_DATA.video_title = temp_video_data?.title;
  207. VIDEO_DATA.video_id = temp_video_data?.video_id;
  208.  
  209. videoDataReady = true;
  210. logger('info', 'Video data updated\n\n', VIDEO_DATA);
  211. }
  212.  
  213. async function hookPlayerEvent(...fns) {
  214. document.addEventListener('yt-player-updated', (e) => {
  215. for (let i = 0; i < fns.length; i++) fns[i](e);
  216. });
  217. logger(
  218. 'info',
  219. 'Video player event hooked. Callbacks:\n\n',
  220. fns.map((f) => f.name)
  221. );
  222. }
  223.  
  224. async function hookNavigationEvents(...fns) {
  225. ['yt-navigate', 'yt-navigate-finish', 'yt-navigate-finish', 'yt-page-data-updated'].forEach((evName) => {
  226. document.addEventListener(evName, (e) => {
  227. for (let i = 0; i < fns.length; i++) fns[i](e);
  228. });
  229. });
  230. logger(
  231. 'info',
  232. 'Navigation events hooked. Callbacks:\n\n',
  233. fns.map((f) => f.name)
  234. );
  235. }
  236.  
  237. function hideOnAnimationEnd(target, animationName, alsoRemove = false) {
  238. target.addEventListener('animationend', (e) => {
  239. if (e.animationName === animationName) {
  240. if (alsoRemove) e.target.remove();
  241. else e.target.style.display = 'none';
  242. }
  243. });
  244. }
  245.  
  246. // https://stackoverflow.com/a/10344293
  247. function isTyping() {
  248. const el = document.activeElement;
  249. return (
  250. el &&
  251. (el.tagName.toLowerCase() === 'input' ||
  252. el.tagName.toLowerCase() === 'textarea' ||
  253. String(el.getAttribute('contenteditable')).toLowerCase() === 'true')
  254. );
  255. }
  256.  
  257. function replacePlaceholders(inputString) {
  258. return inputString.replace(/{{\s*([^}\s]+)\s*}}/g, (match, placeholder) => VIDEO_DATA[placeholder] || match);
  259. }
  260.  
  261. async function appendSideMenu() {
  262. const sideMenu = document.createElement('div');
  263. sideMenu.id = 'ytdl-sideMenu';
  264. sideMenu.classList.add('closed');
  265. sideMenu.style.display = 'none';
  266.  
  267. hideOnAnimationEnd(sideMenu, 'closeMenu');
  268.  
  269. const sideMenuHeader = document.createElement('h2');
  270. sideMenuHeader.textContent = 'Youtube downloader settings';
  271. sideMenuHeader.classList.add('header');
  272. sideMenu.appendChild(sideMenuHeader);
  273.  
  274. // ===== templates, don't use, just clone the node =====
  275. const sideMenuSettingContainer = document.createElement('div');
  276. sideMenuSettingContainer.classList.add('setting-row');
  277. const sideMenuSettingLabel = document.createElement('h3');
  278. sideMenuSettingLabel.classList.add('setting-label');
  279. const sideMenuSettingDescription = document.createElement('p');
  280. sideMenuSettingDescription.classList.add('setting-description');
  281. sideMenuSettingContainer.append(sideMenuSettingLabel, sideMenuSettingDescription);
  282.  
  283. const switchContainer = document.createElement('span');
  284. switchContainer.classList.add('ytdl-switch');
  285. const switchCheckbox = document.createElement('input');
  286. switchCheckbox.type = 'checkbox';
  287. const switchLabel = document.createElement('label');
  288. switchContainer.append(switchCheckbox, switchLabel);
  289. // ===== end templates =====
  290.  
  291. // NOTIFICATIONS
  292. const notifContainer = sideMenuSettingContainer.cloneNode(true);
  293. notifContainer.querySelector('.setting-label').textContent = 'Notifications';
  294. notifContainer.querySelector('.setting-description').textContent =
  295. "Disable if you don't want to receive notifications from the developer.";
  296. const notifSwitch = switchContainer.cloneNode(true);
  297. notifSwitch.querySelector('input').checked = SHOW_NOTIFICATIONS;
  298. notifSwitch.querySelector('input').id = 'ytdl-notif-switch';
  299. notifSwitch.querySelector('label').setAttribute('for', 'ytdl-notif-switch');
  300. notifSwitch.querySelector('input').addEventListener('change', (e) => {
  301. SHOW_NOTIFICATIONS = e.target.checked;
  302. localStorage.setItem('ytdl-notif-enabled', SHOW_NOTIFICATIONS);
  303. logger('info', `Notifications ${SHOW_NOTIFICATIONS ? 'enabled' : 'disabled'}`);
  304. });
  305. notifContainer.appendChild(notifSwitch);
  306. sideMenu.appendChild(notifContainer);
  307.  
  308. // DEVELOPER MODE
  309. const devModeContainer = sideMenuSettingContainer.cloneNode(true);
  310. devModeContainer.querySelector('.setting-label').textContent = 'Developer mode';
  311. devModeContainer.querySelector('.setting-description').textContent =
  312. "Show a detailed output of what's happening under the hood in the console.";
  313. const devModeSwitch = switchContainer.cloneNode(true);
  314. devModeSwitch.querySelector('input').checked = DEV_MODE;
  315. devModeSwitch.querySelector('input').id = 'ytdl-dev-mode-switch';
  316. devModeSwitch.querySelector('label').setAttribute('for', 'ytdl-dev-mode-switch');
  317. devModeSwitch.querySelector('input').addEventListener('change', (e) => {
  318. DEV_MODE = e.target.checked;
  319. localStorage.setItem('ytdl-dev-mode', DEV_MODE);
  320. // always use console.log here to show output
  321. console.log(`\x1b[31m[YTDL]\x1b[0m Developer mode ${DEV_MODE ? 'enabled' : 'disabled'}`);
  322. });
  323. devModeContainer.appendChild(devModeSwitch);
  324. sideMenu.appendChild(devModeContainer);
  325.  
  326. // ADVANCED SETTINGS
  327. const advancedSettingsContainer = sideMenuSettingContainer.cloneNode(true);
  328. advancedSettingsContainer.querySelector('.setting-label').textContent = 'Advanced settings';
  329. advancedSettingsContainer.querySelector('.setting-description').textContent =
  330. 'FOR EXPERIENCED USERS ONLY. Modify the behaviour of the download button.';
  331.  
  332. const advancedOptionsContainer = document.createElement('div');
  333. advancedOptionsContainer.classList.add('advanced-options', ADVANCED_SETTINGS.enabled ? 'opened' : 'closed');
  334. advancedOptionsContainer.style.display = ADVANCED_SETTINGS.enabled ? 'flex' : 'none';
  335. hideOnAnimationEnd(advancedOptionsContainer, 'closeNotif');
  336.  
  337. const advancedSwitch = switchContainer.cloneNode(true);
  338. advancedSwitch.querySelector('input').checked = ADVANCED_SETTINGS.enabled;
  339. advancedSwitch.querySelector('input').id = 'ytdl-advanced-switch';
  340. advancedSwitch.querySelector('label').setAttribute('for', 'ytdl-advanced-switch');
  341. advancedSwitch.querySelector('input').addEventListener('change', (e) => {
  342. ADVANCED_SETTINGS.enabled = e.target.checked;
  343. localStorage.setItem('ytdl-advanced-settings', JSON.stringify(ADVANCED_SETTINGS));
  344.  
  345. if (e.target.checked) {
  346. advancedOptionsContainer.style.display = 'flex';
  347. advancedOptionsContainer.classList.remove('closed');
  348. advancedOptionsContainer.classList.add('opened');
  349. } else {
  350. advancedOptionsContainer.classList.remove('opened');
  351. advancedOptionsContainer.classList.add('closed');
  352. }
  353.  
  354. logger('info', `Advanced settings ${ADVANCED_SETTINGS.enabled ? 'enabled' : 'disabled'}`);
  355. });
  356. advancedSettingsContainer.appendChild(advancedSwitch);
  357.  
  358. const openUrlLabel = document.createElement('label');
  359. openUrlLabel.setAttribute('for', 'advanced-settings-open-url');
  360. openUrlLabel.textContent = 'Open the given URL in a new window. GET request only.';
  361.  
  362. const placeholdersLink = document.createElement('a');
  363. placeholdersLink.href = 'https://github.com/madkarmaa/youtube-downloader/blob/main/docs/PLACEHOLDERS.md';
  364. placeholdersLink.target = '_blank';
  365. placeholdersLink.textContent = 'Use placeholders to access video data. Click to know about placeholders';
  366.  
  367. openUrlLabel.appendChild(placeholdersLink);
  368.  
  369. const openUrlInput = document.createElement('input');
  370. openUrlInput.id = 'advanced-settings-open-url';
  371. openUrlInput.type = 'url';
  372. openUrlInput.placeholder = 'URL to open';
  373. openUrlInput.value = ADVANCED_SETTINGS.openUrl ?? null;
  374. openUrlInput.addEventListener('focusout', (e) => {
  375. if (e.target.checkValidity()) {
  376. ADVANCED_SETTINGS.openUrl = e.target.value;
  377. localStorage.setItem('ytdl-advanced-settings', JSON.stringify(ADVANCED_SETTINGS));
  378. logger('info', `Advanced settings: URL to open set to "${e.target.value}"`);
  379. } else {
  380. logger('error', `Invalid URL to open: "${e.target.value}"`);
  381. alert(e.target.validationMessage);
  382. e.target.value = '';
  383. }
  384. });
  385. advancedOptionsContainer.append(openUrlLabel, openUrlInput);
  386.  
  387. advancedSettingsContainer.appendChild(advancedOptionsContainer);
  388. sideMenu.appendChild(advancedSettingsContainer);
  389.  
  390. // SIDE MENU EVENTS
  391. document.addEventListener('mousedown', (e) => {
  392. if (sideMenu.style.display !== 'none' && !sideMenu.contains(e.target)) {
  393. sideMenu.classList.remove('opened');
  394. sideMenu.classList.add('closed');
  395.  
  396. logger('info', 'Side menu closed');
  397. }
  398. });
  399.  
  400. document.addEventListener('keydown', (e) => {
  401. if (e.key !== 'p') return;
  402. if (isTyping()) return;
  403.  
  404. if (sideMenu.style.display === 'none') {
  405. sideMenu.style.top = window.scrollY + 'px';
  406. sideMenu.style.display = 'flex';
  407. sideMenu.classList.remove('closed');
  408. sideMenu.classList.add('opened');
  409.  
  410. logger('info', 'Side menu opened');
  411. } else {
  412. sideMenu.classList.remove('opened');
  413. sideMenu.classList.add('closed');
  414.  
  415. logger('info', 'Side menu closed');
  416. }
  417. });
  418.  
  419. window.addEventListener('scroll', () => {
  420. if (sideMenu.classList.contains('closed')) return;
  421.  
  422. sideMenu.classList.remove('opened');
  423. sideMenu.classList.add('closed');
  424.  
  425. logger('info', 'Side menu closed');
  426. });
  427.  
  428. document.body.appendChild(sideMenu);
  429. logger('info', 'Side menu created\n\n', sideMenu);
  430. }
  431.  
  432. function detectYoutubeService() {
  433. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
  434. return 'SHORTS';
  435. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
  436. return 'WATCH';
  437. else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
  438. else if (window.location.hostname === 'www.youtube.com') return 'YOUTUBE';
  439. else return null;
  440. }
  441.  
  442. function elementInContainer(container, element) {
  443. return container.contains(element);
  444. }
  445.  
  446. async function leftClick() {
  447. const isYtMusic = detectYoutubeService() === 'MUSIC';
  448.  
  449. if (!isYtMusic && !videoDataReady) {
  450. logger('warn', 'Video data not ready');
  451. new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false);
  452. return;
  453. } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) {
  454. logger('warn', 'Video URL not avaiable');
  455. new Notification(
  456. 'Wait!',
  457. 'Open the music player so the song link is visible, then try again.',
  458. 'popup',
  459. false
  460. );
  461. return;
  462. }
  463.  
  464. try {
  465. logger('info', 'Download started');
  466.  
  467. if (!ADVANCED_SETTINGS.enabled)
  468. window.open(
  469. await Cobalt(
  470. isYtMusic
  471. ? window.location.href.replace('music.youtube.com', 'www.youtube.com')
  472. : VIDEO_DATA.video_url
  473. ),
  474. '_blank'
  475. );
  476. else if (ADVANCED_SETTINGS.openUrl) window.open(replacePlaceholders(ADVANCED_SETTINGS.openUrl));
  477.  
  478. logger('info', 'Download completed');
  479. } catch (err) {
  480. logger('error', JSON.parse(JSON.stringify(err)));
  481. new Notification('Error', JSON.stringify(err), 'error', false);
  482. }
  483. }
  484.  
  485. async function rightClick(e) {
  486. const isYtMusic = detectYoutubeService() === 'MUSIC';
  487.  
  488. e.preventDefault();
  489.  
  490. if (!isYtMusic && !videoDataReady) {
  491. logger('warn', 'Video data not ready');
  492. new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false);
  493. return false;
  494. } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) {
  495. logger('warn', 'Video URL not avaiable');
  496. new Notification(
  497. 'Wait!',
  498. 'Open the music player so the song link is visible, then try again.',
  499. 'popup',
  500. false
  501. );
  502. return;
  503. }
  504.  
  505. try {
  506. logger('info', 'Download started');
  507.  
  508. if (!ADVANCED_SETTINGS.enabled)
  509. window.open(
  510. await Cobalt(
  511. isYtMusic
  512. ? window.location.href.replace('music.youtube.com', 'www.youtube.com')
  513. : VIDEO_DATA.video_url,
  514. true
  515. ),
  516. '_blank'
  517. );
  518. else if (ADVANCED_SETTINGS.openUrl) window.open(replacePlaceholders(ADVANCED_SETTINGS.openUrl));
  519.  
  520. logger('info', 'Download completed');
  521. } catch (err) {
  522. logger('error', JSON.parse(JSON.stringify(err)));
  523. new Notification('Error', JSON.stringify(err), 'error', false);
  524. }
  525.  
  526. return false;
  527. }
  528.  
  529. // https://www.30secondsofcode.org/js/s/element-is-visible-in-viewport/
  530. function elementIsVisibleInViewport(el, partiallyVisible = false) {
  531. const { top, left, bottom, right } = el.getBoundingClientRect();
  532. const { innerHeight, innerWidth } = window;
  533. return partiallyVisible
  534. ? ((top > 0 && top < innerHeight) || (bottom > 0 && bottom < innerHeight)) &&
  535. ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth))
  536. : top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth;
  537. }
  538.  
  539. async function appendDownloadButton(e) {
  540. const ytContainerSelector =
  541. '#movie_player > div.ytp-chrome-bottom > div.ytp-chrome-controls > div.ytp-right-controls';
  542. const ytmContainerSelector =
  543. '#layout > ytmusic-player-bar > div.middle-controls.style-scope.ytmusic-player-bar > div.middle-controls-buttons.style-scope.ytmusic-player-bar';
  544. const ytsContainerSelector = '#actions.style-scope.ytd-reel-player-overlay-renderer';
  545.  
  546. // ===== templates, don't use, just clone the node =====
  547. const downloadIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  548. downloadIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  549. downloadIcon.setAttribute('fill', 'currentColor');
  550. downloadIcon.setAttribute('height', '24');
  551. downloadIcon.setAttribute('viewBox', '0 0 24 24');
  552. downloadIcon.setAttribute('width', '24');
  553. downloadIcon.setAttribute('focusable', 'false');
  554. downloadIcon.style.pointerEvents = 'none';
  555. downloadIcon.style.display = 'block';
  556. downloadIcon.style.width = '100%';
  557. downloadIcon.style.height = '100%';
  558. const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  559. path.setAttribute('d', 'M17 18v1H6v-1h11zm-.5-6.6-.7-.7-3.8 3.7V4h-1v10.4l-3.8-3.8-.7.7 5 5 5-4.9z');
  560. downloadIcon.appendChild(path);
  561.  
  562. const downloadButton = document.createElement('button');
  563. downloadButton.id = 'ytdl-download-button';
  564. downloadButton.classList.add('ytp-button');
  565. downloadButton.title = 'Left click to download as video, right click as audio only';
  566. downloadButton.appendChild(downloadIcon);
  567. // ===== end templates =====
  568.  
  569. switch (detectYoutubeService()) {
  570. case 'WATCH':
  571. const ytCont = await waitForElement(ytContainerSelector);
  572. logger('info', 'Download button container found\n\n', ytCont);
  573.  
  574. if (elementInContainer(ytCont, ytCont.querySelector('#ytdl-download-button'))) {
  575. logger('warn', 'Download button already in container');
  576. break;
  577. }
  578.  
  579. const ytDlBtnClone = downloadButton.cloneNode(true);
  580. ytDlBtnClone.classList.add('YT');
  581. ytDlBtnClone.addEventListener('click', leftClick);
  582. ytDlBtnClone.addEventListener('contextmenu', rightClick);
  583. logger('info', 'Download button created\n\n', ytDlBtnClone);
  584.  
  585. ytCont.insertBefore(ytDlBtnClone, ytCont.firstChild);
  586. logger('info', 'Download button inserted in container');
  587.  
  588. break;
  589.  
  590. case 'MUSIC':
  591. const ytmCont = await waitForElement(ytmContainerSelector);
  592. logger('info', 'Download button container found\n\n', ytmCont);
  593.  
  594. if (elementInContainer(ytmCont, ytmCont.querySelector('#ytdl-download-button'))) {
  595. logger('warn', 'Download button already in container');
  596. break;
  597. }
  598.  
  599. const ytmDlBtnClone = downloadButton.cloneNode(true);
  600. ytmDlBtnClone.classList.add('YTM');
  601. ytmDlBtnClone.addEventListener('click', leftClick);
  602. ytmDlBtnClone.addEventListener('contextmenu', rightClick);
  603. logger('info', 'Download button created\n\n', ytmDlBtnClone);
  604.  
  605. ytmCont.insertBefore(ytmDlBtnClone, ytmCont.firstChild);
  606. logger('info', 'Download button inserted in container');
  607.  
  608. break;
  609.  
  610. case 'SHORTS':
  611. if (e.type !== 'yt-navigate-finish') return;
  612.  
  613. await waitForElement(ytsContainerSelector); // wait for the UI to finish loading
  614.  
  615. const visibleYtsConts = Array.from(document.querySelectorAll(ytsContainerSelector)).filter((el) =>
  616. elementIsVisibleInViewport(el)
  617. );
  618. logger('info', 'Download button containers found\n\n', visibleYtsConts);
  619.  
  620. visibleYtsConts.forEach((ytsCont) => {
  621. if (elementInContainer(ytsCont, ytsCont.querySelector('#ytdl-download-button'))) {
  622. logger('warn', 'Download button already in container');
  623. return;
  624. }
  625.  
  626. const ytsDlBtnClone = downloadButton.cloneNode(true);
  627. ytsDlBtnClone.classList.add(
  628. 'YTS',
  629. 'yt-spec-button-shape-next',
  630. 'yt-spec-button-shape-next--tonal',
  631. 'yt-spec-button-shape-next--mono',
  632. 'yt-spec-button-shape-next--size-l',
  633. 'yt-spec-button-shape-next--icon-button'
  634. );
  635. ytsDlBtnClone.addEventListener('click', leftClick);
  636. ytsDlBtnClone.addEventListener('contextmenu', rightClick);
  637. logger('info', 'Download button created\n\n', ytsDlBtnClone);
  638.  
  639. ytsCont.insertBefore(ytsDlBtnClone, ytsCont.firstChild);
  640. logger('info', 'Download button inserted in container');
  641. });
  642.  
  643. break;
  644.  
  645. default:
  646. return;
  647. }
  648. }
  649.  
  650. async function devStuff() {
  651. if (!DEV_MODE) return;
  652.  
  653. logger('info', 'Current service is: ' + detectYoutubeService());
  654. }
  655. // ===== END METHODS =====
  656.  
  657. GM_addStyle(`
  658. #ytdl-sideMenu {
  659. min-height: 100vh;
  660. z-index: 9998;
  661. position: absolute;
  662. top: 0;
  663. left: -100vw;
  664. width: 50vw;
  665. background-color: var(--yt-spec-base-background);
  666. border-right: 2px solid var(--yt-spec-static-grey);
  667. display: flex;
  668. flex-direction: column;
  669. gap: 2rem;
  670. padding: 2rem 2.5rem;
  671. font-family: "Roboto", "Arial", sans-serif;
  672. }
  673.  
  674. #ytdl-sideMenu.opened {
  675. animation: openMenu .3s linear forwards;
  676. }
  677.  
  678. #ytdl-sideMenu.closed {
  679. animation: closeMenu .3s linear forwards;
  680. }
  681.  
  682. #ytdl-sideMenu a {
  683. color: var(--yt-brand-youtube-red);
  684. text-decoration: none;
  685. font-weight: 600;
  686. }
  687.  
  688. #ytdl-sideMenu a:hover {
  689. text-decoration: underline;
  690. }
  691.  
  692. #ytdl-sideMenu label {
  693. display: flex;
  694. flex-direction: column;
  695. gap: 0.5rem;
  696. font-size: 1.4rem;
  697. color: var(--yt-spec-text-primary);
  698. }
  699.  
  700. #ytdl-sideMenu .header {
  701. text-align: center;
  702. font-size: 2.5rem;
  703. color: var(--yt-brand-youtube-red);
  704. }
  705.  
  706. #ytdl-sideMenu .setting-row {
  707. display: flex;
  708. flex-direction: column;
  709. gap: 1rem;
  710. }
  711.  
  712. #ytdl-sideMenu .setting-label {
  713. font-size: 1.8rem;
  714. color: var(--yt-brand-youtube-red);
  715. }
  716.  
  717. #ytdl-sideMenu .setting-description {
  718. font-size: 1.4rem;
  719. color: var(--yt-spec-text-primary);
  720. }
  721.  
  722. .ytdl-switch {
  723. display: inline-block;
  724. }
  725.  
  726. .ytdl-switch input {
  727. display: none;
  728. }
  729.  
  730. .ytdl-switch label {
  731. display: block;
  732. width: 50px;
  733. height: 19.5px;
  734. padding: 3px;
  735. border-radius: 15px;
  736. border: 2px solid var(--yt-brand-medium-red);
  737. cursor: pointer;
  738. transition: 0.3s;
  739. }
  740.  
  741. .ytdl-switch label::after {
  742. content: "";
  743. display: inherit;
  744. width: 20px;
  745. height: 20px;
  746. border-radius: 12px;
  747. background: var(--yt-brand-medium-red);
  748. transition: 0.3s;
  749. }
  750.  
  751. .ytdl-switch input:checked ~ label {
  752. border-color: var(--yt-spec-light-green);
  753. }
  754.  
  755. .ytdl-switch input:checked ~ label::after {
  756. translate: 30px 0;
  757. background: var(--yt-spec-light-green);
  758. }
  759.  
  760. .ytdl-switch input:disabled ~ label {
  761. opacity: 0.5;
  762. cursor: not-allowed;
  763. }
  764.  
  765. #ytdl-sideMenu .advanced-options {
  766. display: flex;
  767. flex-direction: column;
  768. gap: 0.7rem;
  769. margin: 1rem 0;
  770. }
  771.  
  772. #ytdl-sideMenu .advanced-options.opened {
  773. animation: openNotif 0.3s linear forwards;
  774. }
  775. #ytdl-sideMenu .advanced-options.closed {
  776. animation: closeNotif .3s linear forwards;
  777. }
  778.  
  779. #ytdl-sideMenu input[type="url"] {
  780. background: none;
  781. padding: 0.7rem 1rem;
  782. border: none;
  783. outline: none;
  784. border-bottom: 2px solid var(--yt-spec-red-70);
  785. color: var(--yt-spec-text-primary);
  786. font-family: monospace;
  787. transition: border-bottom-color 0.2s ease-in-out;
  788. }
  789.  
  790. #ytdl-sideMenu input[type="url"]:focus {
  791. border-bottom-color: var(--yt-brand-youtube-red);
  792. }
  793.  
  794. .ytdl-notification {
  795. display: flex;
  796. flex-direction: column;
  797. gap: 2rem;
  798. position: fixed;
  799. top: 50vh;
  800. left: 50vw;
  801. transform: translate(-50%, -50%);
  802. background-color: var(--yt-spec-base-background);
  803. border: 2px solid var(--yt-spec-static-grey);
  804. border-radius: 8px;
  805. color: var(--yt-spec-text-primary);
  806. z-index: 9999;
  807. padding: 1.5rem 1.6rem;
  808. font-family: "Roboto", "Arial", sans-serif;
  809. font-size: 1.4rem;
  810. width: fit-content;
  811. height: fit-content;
  812. max-width: 40vw;
  813. max-height: 50vh;
  814. word-wrap: break-word;
  815. line-height: var(--yt-caption-line-height);
  816. }
  817.  
  818. .ytdl-notification.opened {
  819. animation: openNotif 0.3s linear forwards;
  820. }
  821.  
  822. .ytdl-notification.closed {
  823. animation: closeNotif 0.3s linear forwards;
  824. }
  825.  
  826. .ytdl-notification h2 {
  827. color: var(--yt-brand-youtube-red);
  828. }
  829.  
  830. .ytdl-notification > div {
  831. display: flex;
  832. flex-direction: column;
  833. gap: 1rem;
  834. }
  835.  
  836. .ytdl-notification > button {
  837. transition: all 0.2s ease-in-out;
  838. cursor: pointer;
  839. border: 2px solid var(--yt-spec-static-grey);
  840. border-radius: 8px;
  841. background-color: var(--yt-brand-medium-red);
  842. padding: 0.7rem 0.8rem;
  843. color: #fff;
  844. font-weight: 600;
  845. }
  846.  
  847. .ytdl-notification button:hover {
  848. background-color: var(--yt-spec-red-70);
  849. }
  850.  
  851. #ytdl-download-button {
  852. background: none;
  853. border: none;
  854. outline: none;
  855. color: var(--yt-spec-text-primary);
  856. cursor: pointer;
  857. transition: color 0.2s ease-in-out;
  858. display: inline-flex;
  859. justify-content: center;
  860. align-items: center;
  861. }
  862.  
  863. #ytdl-download-button:hover {
  864. color: var(--yt-brand-youtube-red);
  865. }
  866.  
  867. #ytdl-download-button.YTM {
  868. transform: scale(1.5);
  869. margin: 0 1rem;
  870. }
  871.  
  872. #ytdl-download-button > svg {
  873. transform: translateX(3.35%);
  874. }
  875.  
  876. @keyframes openMenu {
  877. 0% {
  878. left: -100vw;
  879. }
  880.  
  881. 100% {
  882. left: 0;
  883. }
  884. }
  885.  
  886. @keyframes closeMenu {
  887. 0% {
  888. left: 0;
  889. }
  890.  
  891. 100% {
  892. left: -100vw;
  893. }
  894. }
  895.  
  896. @keyframes openNotif {
  897. 0% {
  898. opacity: 0;
  899. }
  900.  
  901. 100% {
  902. opacity: 1;
  903. }
  904. }
  905.  
  906. @keyframes closeNotif {
  907. 0% {
  908. opacity: 1;
  909. }
  910.  
  911. 100% {
  912. opacity: 0;
  913. }
  914. }
  915. `);
  916. logger('info', 'Custom styles added');
  917.  
  918. hookPlayerEvent(updateVideoData);
  919. hookNavigationEvents(appendDownloadButton, devStuff);
  920.  
  921. // functions that require the DOM to exist
  922. window.addEventListener('DOMContentLoaded', () => {
  923. appendSideMenu();
  924. appendDownloadButton();
  925. manageNotifications();
  926. });
  927. })();