YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

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

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