YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

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

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