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