YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

  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.4.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 download 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. qualitySelect.disabled = ADVANCED_SETTINGS.enabled;
  328.  
  329. Object.entries(QUALITIES).forEach(([name, value]) => {
  330. const qualityOption = document.createElement('option');
  331. qualityOption.textContent = name;
  332. qualityOption.value = value;
  333. qualitySelect.appendChild(qualityOption);
  334. });
  335.  
  336. qualitySelect.value = localStorage.getItem('ytdl-quality') ?? 'max';
  337.  
  338. qualitySelect.addEventListener('change', (e) => {
  339. localStorage.setItem('ytdl-quality', String(e.target.value));
  340. logger('info', `Download quality set to ${e.target.value}`);
  341. });
  342.  
  343. qualityContainer.appendChild(qualitySelect);
  344. sideMenu.appendChild(qualityContainer);
  345.  
  346. // DEVELOPER MODE
  347. const devModeContainer = sideMenuSettingContainer.cloneNode(true);
  348. devModeContainer.querySelector('.setting-label').textContent = 'Developer mode';
  349. devModeContainer.querySelector('.setting-description').textContent =
  350. "Show a detailed output of what's happening under the hood in the console.";
  351. const devModeSwitch = switchContainer.cloneNode(true);
  352. devModeSwitch.querySelector('input').checked = DEV_MODE;
  353. devModeSwitch.querySelector('input').id = 'ytdl-dev-mode-switch';
  354. devModeSwitch.querySelector('label').setAttribute('for', 'ytdl-dev-mode-switch');
  355. devModeSwitch.querySelector('input').addEventListener('change', (e) => {
  356. DEV_MODE = e.target.checked;
  357. localStorage.setItem('ytdl-dev-mode', DEV_MODE);
  358. // always use console.log here to show output
  359. console.log(`\x1b[31m[YTDL]\x1b[0m Developer mode ${DEV_MODE ? 'enabled' : 'disabled'}`);
  360. });
  361. devModeContainer.appendChild(devModeSwitch);
  362. sideMenu.appendChild(devModeContainer);
  363.  
  364. // ADVANCED SETTINGS
  365. const advancedSettingsContainer = sideMenuSettingContainer.cloneNode(true);
  366. advancedSettingsContainer.querySelector('.setting-label').textContent = 'Advanced settings';
  367. advancedSettingsContainer.querySelector('.setting-description').textContent =
  368. 'FOR EXPERIENCED USERS ONLY. Modify the behaviour of the download button.';
  369.  
  370. const advancedOptionsContainer = document.createElement('div');
  371. advancedOptionsContainer.classList.add('advanced-options', ADVANCED_SETTINGS.enabled ? 'opened' : 'closed');
  372. advancedOptionsContainer.style.display = ADVANCED_SETTINGS.enabled ? 'flex' : 'none';
  373. hideOnAnimationEnd(advancedOptionsContainer, 'closeNotif');
  374.  
  375. const advancedSwitch = switchContainer.cloneNode(true);
  376. advancedSwitch.querySelector('input').checked = ADVANCED_SETTINGS.enabled;
  377. advancedSwitch.querySelector('input').id = 'ytdl-advanced-switch';
  378. advancedSwitch.querySelector('label').setAttribute('for', 'ytdl-advanced-switch');
  379. advancedSwitch.querySelector('input').addEventListener('change', (e) => {
  380. ADVANCED_SETTINGS.enabled = e.target.checked;
  381. localStorage.setItem('ytdl-advanced-settings', JSON.stringify(ADVANCED_SETTINGS));
  382.  
  383. qualitySelect.disabled = e.target.checked;
  384.  
  385. if (e.target.checked) {
  386. advancedOptionsContainer.style.display = 'flex';
  387. advancedOptionsContainer.classList.remove('closed');
  388. advancedOptionsContainer.classList.add('opened');
  389. } else {
  390. advancedOptionsContainer.classList.remove('opened');
  391. advancedOptionsContainer.classList.add('closed');
  392. }
  393.  
  394. logger('info', `Advanced settings ${ADVANCED_SETTINGS.enabled ? 'enabled' : 'disabled'}`);
  395. });
  396. advancedSettingsContainer.appendChild(advancedSwitch);
  397.  
  398. const openUrlLabel = document.createElement('label');
  399. openUrlLabel.setAttribute('for', 'advanced-settings-open-url');
  400. openUrlLabel.textContent = 'Open the given URL in a new window. GET request only.';
  401.  
  402. const placeholdersLink = document.createElement('a');
  403. placeholdersLink.href = 'https://github.com/madkarmaa/youtube-downloader/blob/main/docs/PLACEHOLDERS.md';
  404. placeholdersLink.target = '_blank';
  405. placeholdersLink.textContent = 'Use placeholders to access video data. Click to know about placeholders';
  406.  
  407. openUrlLabel.appendChild(placeholdersLink);
  408.  
  409. const openUrlInput = document.createElement('input');
  410. openUrlInput.id = 'advanced-settings-open-url';
  411. openUrlInput.type = 'url';
  412. openUrlInput.placeholder = 'URL to open';
  413. openUrlInput.value = ADVANCED_SETTINGS.openUrl ?? null;
  414. openUrlInput.addEventListener('focusout', (e) => {
  415. if (e.target.checkValidity()) {
  416. ADVANCED_SETTINGS.openUrl = e.target.value;
  417. localStorage.setItem('ytdl-advanced-settings', JSON.stringify(ADVANCED_SETTINGS));
  418. logger('info', `Advanced settings: URL to open set to "${e.target.value}"`);
  419. } else {
  420. logger('error', `Invalid URL to open: "${e.target.value}"`);
  421. alert(e.target.validationMessage);
  422. e.target.value = '';
  423. }
  424. });
  425. advancedOptionsContainer.append(openUrlLabel, openUrlInput);
  426.  
  427. advancedSettingsContainer.appendChild(advancedOptionsContainer);
  428. sideMenu.appendChild(advancedSettingsContainer);
  429.  
  430. // SIDE MENU EVENTS
  431. document.addEventListener('mousedown', (e) => {
  432. if (sideMenu.style.display !== 'none' && !sideMenu.contains(e.target)) {
  433. sideMenu.classList.remove('opened');
  434. sideMenu.classList.add('closed');
  435.  
  436. logger('info', 'Side menu closed');
  437. }
  438. });
  439.  
  440. document.addEventListener('keydown', (e) => {
  441. if (e.key !== 'p') return;
  442. if (isTyping()) return;
  443.  
  444. if (sideMenu.style.display === 'none') {
  445. sideMenu.style.top = window.scrollY + 'px';
  446. sideMenu.style.display = 'flex';
  447. sideMenu.classList.remove('closed');
  448. sideMenu.classList.add('opened');
  449.  
  450. logger('info', 'Side menu opened');
  451. } else {
  452. sideMenu.classList.remove('opened');
  453. sideMenu.classList.add('closed');
  454.  
  455. logger('info', 'Side menu closed');
  456. }
  457. });
  458.  
  459. window.addEventListener('scroll', () => {
  460. if (sideMenu.classList.contains('closed')) return;
  461.  
  462. sideMenu.classList.remove('opened');
  463. sideMenu.classList.add('closed');
  464.  
  465. logger('info', 'Side menu closed');
  466. });
  467.  
  468. document.body.appendChild(sideMenu);
  469. logger('info', 'Side menu created\n\n', sideMenu);
  470. }
  471.  
  472. function detectYoutubeService() {
  473. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
  474. return 'SHORTS';
  475. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
  476. return 'WATCH';
  477. else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
  478. else if (window.location.hostname === 'www.youtube.com') return 'YOUTUBE';
  479. else return null;
  480. }
  481.  
  482. function elementInContainer(container, element) {
  483. return container.contains(element);
  484. }
  485.  
  486. async function leftClick() {
  487. const isYtMusic = detectYoutubeService() === 'MUSIC';
  488.  
  489. if (!isYtMusic && !videoDataReady) {
  490. logger('warn', 'Video data not ready');
  491. new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false);
  492. return;
  493. } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) {
  494. logger('warn', 'Video URL not avaiable');
  495. new Notification(
  496. 'Wait!',
  497. 'Open the music player so the song link is visible, then try again.',
  498. 'popup',
  499. false
  500. );
  501. return;
  502. }
  503.  
  504. try {
  505. logger('info', 'Download started');
  506.  
  507. if (!ADVANCED_SETTINGS.enabled)
  508. window.open(
  509. await Cobalt(
  510. isYtMusic
  511. ? window.location.href.replace('music.youtube.com', 'www.youtube.com')
  512. : VIDEO_DATA.video_url
  513. ),
  514. '_blank'
  515. );
  516. else if (ADVANCED_SETTINGS.openUrl) window.open(replacePlaceholders(ADVANCED_SETTINGS.openUrl));
  517.  
  518. logger('info', 'Download completed');
  519. } catch (err) {
  520. logger('error', JSON.parse(JSON.stringify(err)));
  521. new Notification('Error', JSON.stringify(err), 'error', false);
  522. }
  523. }
  524.  
  525. async function rightClick(e) {
  526. const isYtMusic = detectYoutubeService() === 'MUSIC';
  527.  
  528. e.preventDefault();
  529.  
  530. if (!isYtMusic && !videoDataReady) {
  531. logger('warn', 'Video data not ready');
  532. new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false);
  533. return false;
  534. } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) {
  535. logger('warn', 'Video URL not avaiable');
  536. new Notification(
  537. 'Wait!',
  538. 'Open the music player so the song link is visible, then try again.',
  539. 'popup',
  540. false
  541. );
  542. return;
  543. }
  544.  
  545. try {
  546. logger('info', 'Download started');
  547.  
  548. if (!ADVANCED_SETTINGS.enabled)
  549. window.open(
  550. await Cobalt(
  551. isYtMusic
  552. ? window.location.href.replace('music.youtube.com', 'www.youtube.com')
  553. : VIDEO_DATA.video_url,
  554. true
  555. ),
  556. '_blank'
  557. );
  558. else if (ADVANCED_SETTINGS.openUrl) window.open(replacePlaceholders(ADVANCED_SETTINGS.openUrl));
  559.  
  560. logger('info', 'Download completed');
  561. } catch (err) {
  562. logger('error', JSON.parse(JSON.stringify(err)));
  563. new Notification('Error', JSON.stringify(err), 'error', false);
  564. }
  565.  
  566. return false;
  567. }
  568.  
  569. // https://www.30secondsofcode.org/js/s/element-is-visible-in-viewport/
  570. function elementIsVisibleInViewport(el, partiallyVisible = false) {
  571. const { top, left, bottom, right } = el.getBoundingClientRect();
  572. const { innerHeight, innerWidth } = window;
  573. return partiallyVisible
  574. ? ((top > 0 && top < innerHeight) || (bottom > 0 && bottom < innerHeight)) &&
  575. ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth))
  576. : top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth;
  577. }
  578.  
  579. async function appendDownloadButton(e) {
  580. const ytContainerSelector =
  581. '#movie_player > div.ytp-chrome-bottom > div.ytp-chrome-controls > div.ytp-right-controls';
  582. const ytmContainerSelector =
  583. '#layout > ytmusic-player-bar > div.middle-controls.style-scope.ytmusic-player-bar > div.middle-controls-buttons.style-scope.ytmusic-player-bar';
  584. const ytsContainerSelector = '#actions.style-scope.ytd-reel-player-overlay-renderer';
  585.  
  586. // ===== templates, don't use, just clone the node =====
  587. const downloadIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  588. downloadIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  589. downloadIcon.setAttribute('fill', 'currentColor');
  590. downloadIcon.setAttribute('height', '24');
  591. downloadIcon.setAttribute('viewBox', '0 0 24 24');
  592. downloadIcon.setAttribute('width', '24');
  593. downloadIcon.setAttribute('focusable', 'false');
  594. downloadIcon.style.pointerEvents = 'none';
  595. downloadIcon.style.display = 'block';
  596. downloadIcon.style.width = '100%';
  597. downloadIcon.style.height = '100%';
  598. const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  599. 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');
  600. downloadIcon.appendChild(path);
  601.  
  602. const downloadButton = document.createElement('button');
  603. downloadButton.id = 'ytdl-download-button';
  604. downloadButton.classList.add('ytp-button');
  605. downloadButton.title = 'Left click to download as video, right click as audio only';
  606. downloadButton.appendChild(downloadIcon);
  607. // ===== end templates =====
  608.  
  609. switch (detectYoutubeService()) {
  610. case 'WATCH':
  611. const ytCont = await waitForElement(ytContainerSelector);
  612. logger('info', 'Download button container found\n\n', ytCont);
  613.  
  614. if (elementInContainer(ytCont, ytCont.querySelector('#ytdl-download-button'))) {
  615. logger('warn', 'Download button already in container');
  616. break;
  617. }
  618.  
  619. const ytDlBtnClone = downloadButton.cloneNode(true);
  620. ytDlBtnClone.classList.add('YT');
  621. ytDlBtnClone.addEventListener('click', leftClick);
  622. ytDlBtnClone.addEventListener('contextmenu', rightClick);
  623. logger('info', 'Download button created\n\n', ytDlBtnClone);
  624.  
  625. ytCont.insertBefore(ytDlBtnClone, ytCont.firstChild);
  626. logger('info', 'Download button inserted in container');
  627.  
  628. break;
  629.  
  630. case 'MUSIC':
  631. const ytmCont = await waitForElement(ytmContainerSelector);
  632. logger('info', 'Download button container found\n\n', ytmCont);
  633.  
  634. if (elementInContainer(ytmCont, ytmCont.querySelector('#ytdl-download-button'))) {
  635. logger('warn', 'Download button already in container');
  636. break;
  637. }
  638.  
  639. const ytmDlBtnClone = downloadButton.cloneNode(true);
  640. ytmDlBtnClone.classList.add('YTM');
  641. ytmDlBtnClone.addEventListener('click', leftClick);
  642. ytmDlBtnClone.addEventListener('contextmenu', rightClick);
  643. logger('info', 'Download button created\n\n', ytmDlBtnClone);
  644.  
  645. ytmCont.insertBefore(ytmDlBtnClone, ytmCont.firstChild);
  646. logger('info', 'Download button inserted in container');
  647.  
  648. break;
  649.  
  650. case 'SHORTS':
  651. if (e.type !== 'yt-navigate-finish') return;
  652.  
  653. await waitForElement(ytsContainerSelector); // wait for the UI to finish loading
  654.  
  655. const visibleYtsConts = Array.from(document.querySelectorAll(ytsContainerSelector)).filter((el) =>
  656. elementIsVisibleInViewport(el)
  657. );
  658. logger('info', 'Download button containers found\n\n', visibleYtsConts);
  659.  
  660. visibleYtsConts.forEach((ytsCont) => {
  661. if (elementInContainer(ytsCont, ytsCont.querySelector('#ytdl-download-button'))) {
  662. logger('warn', 'Download button already in container');
  663. return;
  664. }
  665.  
  666. const ytsDlBtnClone = downloadButton.cloneNode(true);
  667. ytsDlBtnClone.classList.add(
  668. 'YTS',
  669. 'yt-spec-button-shape-next',
  670. 'yt-spec-button-shape-next--tonal',
  671. 'yt-spec-button-shape-next--mono',
  672. 'yt-spec-button-shape-next--size-l',
  673. 'yt-spec-button-shape-next--icon-button'
  674. );
  675. ytsDlBtnClone.addEventListener('click', leftClick);
  676. ytsDlBtnClone.addEventListener('contextmenu', rightClick);
  677. logger('info', 'Download button created\n\n', ytsDlBtnClone);
  678.  
  679. ytsCont.insertBefore(ytsDlBtnClone, ytsCont.firstChild);
  680. logger('info', 'Download button inserted in container');
  681. });
  682.  
  683. break;
  684.  
  685. default:
  686. return;
  687. }
  688. }
  689.  
  690. async function devStuff() {
  691. if (!DEV_MODE) return;
  692.  
  693. logger('info', 'Current service is: ' + detectYoutubeService());
  694. }
  695. // ===== END METHODS =====
  696.  
  697. GM_addStyle(`
  698. #ytdl-sideMenu {
  699. min-height: 100vh;
  700. z-index: 9998;
  701. position: absolute;
  702. top: 0;
  703. left: -100vw;
  704. width: 50vw;
  705. background-color: var(--yt-spec-base-background);
  706. border-right: 2px solid var(--yt-spec-static-grey);
  707. display: flex;
  708. flex-direction: column;
  709. gap: 2rem;
  710. padding: 2rem 2.5rem;
  711. font-family: "Roboto", "Arial", sans-serif;
  712. }
  713.  
  714. #ytdl-sideMenu.opened {
  715. animation: openMenu .3s linear forwards;
  716. }
  717.  
  718. #ytdl-sideMenu.closed {
  719. animation: closeMenu .3s linear forwards;
  720. }
  721.  
  722. #ytdl-sideMenu a {
  723. color: var(--yt-brand-youtube-red);
  724. text-decoration: none;
  725. font-weight: 600;
  726. }
  727.  
  728. #ytdl-sideMenu a:hover {
  729. text-decoration: underline;
  730. }
  731.  
  732. #ytdl-sideMenu label {
  733. display: flex;
  734. flex-direction: column;
  735. gap: 0.5rem;
  736. font-size: 1.4rem;
  737. color: var(--yt-spec-text-primary);
  738. }
  739.  
  740. #ytdl-sideMenu .header {
  741. text-align: center;
  742. font-size: 2.5rem;
  743. color: var(--yt-brand-youtube-red);
  744. }
  745.  
  746. #ytdl-sideMenu .setting-row {
  747. display: flex;
  748. flex-direction: column;
  749. gap: 1rem;
  750. transition: all 0.2s ease-in-out;
  751. }
  752.  
  753. #ytdl-sideMenu .setting-label {
  754. font-size: 1.8rem;
  755. color: var(--yt-brand-youtube-red);
  756. }
  757.  
  758. #ytdl-sideMenu .setting-description {
  759. font-size: 1.4rem;
  760. color: var(--yt-spec-text-primary);
  761. }
  762.  
  763. .ytdl-switch {
  764. display: inline-block;
  765. }
  766.  
  767. .ytdl-switch input {
  768. display: none;
  769. }
  770.  
  771. .ytdl-switch label {
  772. display: block;
  773. width: 50px;
  774. height: 19.5px;
  775. padding: 3px;
  776. border-radius: 15px;
  777. border: 2px solid var(--yt-brand-medium-red);
  778. cursor: pointer;
  779. transition: 0.3s;
  780. }
  781.  
  782. .ytdl-switch label::after {
  783. content: "";
  784. display: inherit;
  785. width: 20px;
  786. height: 20px;
  787. border-radius: 12px;
  788. background: var(--yt-brand-medium-red);
  789. transition: 0.3s;
  790. }
  791.  
  792. .ytdl-switch input:checked ~ label {
  793. border-color: var(--yt-spec-light-green);
  794. }
  795.  
  796. .ytdl-switch input:checked ~ label::after {
  797. translate: 30px 0;
  798. background: var(--yt-spec-light-green);
  799. }
  800.  
  801. .ytdl-switch input:disabled ~ label {
  802. opacity: 0.5;
  803. cursor: not-allowed;
  804. }
  805.  
  806. #ytdl-sideMenu .advanced-options {
  807. display: flex;
  808. flex-direction: column;
  809. gap: 0.7rem;
  810. margin: 1rem 0;
  811. }
  812.  
  813. #ytdl-sideMenu .advanced-options.opened {
  814. animation: openNotif 0.3s linear forwards;
  815. }
  816. #ytdl-sideMenu .advanced-options.closed {
  817. animation: closeNotif .3s linear forwards;
  818. }
  819.  
  820. #ytdl-sideMenu input[type="url"] {
  821. background: none;
  822. padding: 0.7rem 1rem;
  823. border: none;
  824. outline: none;
  825. border-bottom: 2px solid var(--yt-spec-red-70);
  826. color: var(--yt-spec-text-primary);
  827. font-family: monospace;
  828. transition: border-bottom-color 0.2s ease-in-out;
  829. }
  830.  
  831. #ytdl-sideMenu input[type="url"]:focus {
  832. border-bottom-color: var(--yt-brand-youtube-red);
  833. }
  834.  
  835. .ytdl-notification {
  836. display: flex;
  837. flex-direction: column;
  838. gap: 2rem;
  839. position: fixed;
  840. top: 50vh;
  841. left: 50vw;
  842. transform: translate(-50%, -50%);
  843. background-color: var(--yt-spec-base-background);
  844. border: 2px solid var(--yt-spec-static-grey);
  845. border-radius: 8px;
  846. color: var(--yt-spec-text-primary);
  847. z-index: 9999;
  848. padding: 1.5rem 1.6rem;
  849. font-family: "Roboto", "Arial", sans-serif;
  850. font-size: 1.4rem;
  851. width: fit-content;
  852. height: fit-content;
  853. max-width: 40vw;
  854. max-height: 50vh;
  855. word-wrap: break-word;
  856. line-height: var(--yt-caption-line-height);
  857. }
  858.  
  859. .ytdl-notification.opened {
  860. animation: openNotif 0.3s linear forwards;
  861. }
  862.  
  863. .ytdl-notification.closed {
  864. animation: closeNotif 0.3s linear forwards;
  865. }
  866.  
  867. .ytdl-notification h2 {
  868. color: var(--yt-brand-youtube-red);
  869. }
  870.  
  871. .ytdl-notification > div {
  872. display: flex;
  873. flex-direction: column;
  874. gap: 1rem;
  875. }
  876.  
  877. .ytdl-notification > button {
  878. transition: all 0.2s ease-in-out;
  879. cursor: pointer;
  880. border: 2px solid var(--yt-spec-static-grey);
  881. border-radius: 8px;
  882. background-color: var(--yt-brand-medium-red);
  883. padding: 0.7rem 0.8rem;
  884. color: #fff;
  885. font-weight: 600;
  886. }
  887.  
  888. .ytdl-notification button:hover {
  889. background-color: var(--yt-spec-red-70);
  890. }
  891.  
  892. #ytdl-download-button {
  893. background: none;
  894. border: none;
  895. outline: none;
  896. color: var(--yt-spec-text-primary);
  897. cursor: pointer;
  898. transition: color 0.2s ease-in-out;
  899. display: inline-flex;
  900. justify-content: center;
  901. align-items: center;
  902. }
  903.  
  904. #ytdl-download-button:hover {
  905. color: var(--yt-brand-youtube-red);
  906. }
  907.  
  908. #ytdl-download-button.YTM {
  909. transform: scale(1.5);
  910. margin: 0 1rem;
  911. }
  912.  
  913. #ytdl-download-button > svg {
  914. transform: translateX(3.35%);
  915. }
  916.  
  917. #ytdl-dl-quality-select {
  918. background-color: var(--yt-spec-base-background);
  919. color: var(--yt-spec-text-primary);
  920. padding: 0.7rem 1rem;
  921. border: none;
  922. outline: none;
  923. border-bottom: 2px solid var(--yt-spec-red-70);
  924. border-left: 2px solid var(--yt-spec-red-70);
  925. transition: all 0.2s ease-in-out;
  926. font-family: "Roboto", "Arial", sans-serif;
  927. font-size: 1.4rem;
  928. }
  929.  
  930. #ytdl-dl-quality-select:focus {
  931. border-bottom-color: var(--yt-brand-youtube-red);
  932. border-left-color: var(--yt-brand-youtube-red);
  933. }
  934.  
  935. #ytdl-sideMenu > div:has(> #ytdl-dl-quality-select:disabled) {
  936. filter: grayscale(0.8);
  937. }
  938.  
  939. #ytdl-dl-quality-select:disabled {
  940. cursor: not-allowed;
  941. }
  942.  
  943. @keyframes openMenu {
  944. 0% {
  945. left: -100vw;
  946. }
  947.  
  948. 100% {
  949. left: 0;
  950. }
  951. }
  952.  
  953. @keyframes closeMenu {
  954. 0% {
  955. left: 0;
  956. }
  957.  
  958. 100% {
  959. left: -100vw;
  960. }
  961. }
  962.  
  963. @keyframes openNotif {
  964. 0% {
  965. opacity: 0;
  966. }
  967.  
  968. 100% {
  969. opacity: 1;
  970. }
  971. }
  972.  
  973. @keyframes closeNotif {
  974. 0% {
  975. opacity: 1;
  976. }
  977.  
  978. 100% {
  979. opacity: 0;
  980. }
  981. }
  982. `);
  983. logger('info', 'Custom styles added');
  984.  
  985. hookPlayerEvent(updateVideoData);
  986. hookNavigationEvents(appendDownloadButton, devStuff);
  987.  
  988. // functions that require the DOM to exist
  989. window.addEventListener('DOMContentLoaded', () => {
  990. appendSideMenu();
  991. appendDownloadButton();
  992. manageNotifications();
  993. });
  994. })();