YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

当前为 2024-06-11 提交的版本,查看 最新版本

  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.1
  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. async function appendSideMenu() {
  240. const sideMenu = document.createElement('div');
  241. sideMenu.id = 'ytdl-sideMenu';
  242. sideMenu.classList.add('closed');
  243. sideMenu.style.display = 'none';
  244.  
  245. hideOnAnimationEnd(sideMenu, 'closeMenu');
  246.  
  247. const sideMenuHeader = document.createElement('h2');
  248. sideMenuHeader.textContent = 'Youtube downloader settings';
  249. sideMenuHeader.classList.add('header');
  250. sideMenu.appendChild(sideMenuHeader);
  251.  
  252. // ===== templates, don't use, just clone the node =====
  253. const sideMenuSettingContainer = document.createElement('div');
  254. sideMenuSettingContainer.classList.add('setting-row');
  255. const sideMenuSettingLabel = document.createElement('h3');
  256. sideMenuSettingLabel.classList.add('setting-label');
  257. const sideMenuSettingDescription = document.createElement('p');
  258. sideMenuSettingDescription.classList.add('setting-description');
  259. sideMenuSettingContainer.append(sideMenuSettingLabel, sideMenuSettingDescription);
  260.  
  261. const switchContainer = document.createElement('span');
  262. switchContainer.classList.add('ytdl-switch');
  263. const switchCheckbox = document.createElement('input');
  264. switchCheckbox.type = 'checkbox';
  265. const switchLabel = document.createElement('label');
  266. switchContainer.append(switchCheckbox, switchLabel);
  267. // ===== end templates =====
  268.  
  269. const notifContainer = sideMenuSettingContainer.cloneNode(true);
  270. notifContainer.querySelector('.setting-label').textContent = 'Notifications';
  271. notifContainer.querySelector('.setting-description').textContent =
  272. "Disable if you don't want to receive notifications from the developer.";
  273. const notifSwitch = switchContainer.cloneNode(true);
  274. notifSwitch.querySelector('input').checked = SHOW_NOTIFICATIONS;
  275. notifSwitch.querySelector('input').id = 'ytdl-notif-switch';
  276. notifSwitch.querySelector('label').setAttribute('for', 'ytdl-notif-switch');
  277. notifSwitch.querySelector('input').addEventListener('change', (e) => {
  278. SHOW_NOTIFICATIONS = e.target.checked;
  279. localStorage.setItem('ytdl-notif-enabled', SHOW_NOTIFICATIONS);
  280. logger('info', `Notifications ${SHOW_NOTIFICATIONS ? 'enabled' : 'disabled'}`);
  281. });
  282. notifContainer.appendChild(notifSwitch);
  283. sideMenu.appendChild(notifContainer);
  284.  
  285. const devModeContainer = sideMenuSettingContainer.cloneNode(true);
  286. devModeContainer.querySelector('.setting-label').textContent = 'Developer mode';
  287. devModeContainer.querySelector('.setting-description').textContent =
  288. "Show a detailed output of what's happening under the hood in the console.";
  289. const devModeSwitch = switchContainer.cloneNode(true);
  290. devModeSwitch.querySelector('input').checked = DEV_MODE;
  291. devModeSwitch.querySelector('input').id = 'ytdl-dev-mode-switch';
  292. devModeSwitch.querySelector('label').setAttribute('for', 'ytdl-dev-mode-switch');
  293. devModeSwitch.querySelector('input').addEventListener('change', (e) => {
  294. DEV_MODE = e.target.checked;
  295. localStorage.setItem('ytdl-dev-mode', DEV_MODE);
  296. // always use console.log here to show output
  297. console.log(`\x1b[31m[YTDL]\x1b[0m Developer mode ${DEV_MODE ? 'enabled' : 'disabled'}`);
  298. });
  299. devModeContainer.appendChild(devModeSwitch);
  300. sideMenu.appendChild(devModeContainer);
  301.  
  302. document.addEventListener('mousedown', (e) => {
  303. if (sideMenu.style.display !== 'none' && !sideMenu.contains(e.target)) {
  304. sideMenu.classList.remove('opened');
  305. sideMenu.classList.add('closed');
  306.  
  307. logger('info', 'Side menu closed');
  308. }
  309. });
  310.  
  311. document.addEventListener('keydown', (e) => {
  312. if (e.key !== 'p') return;
  313.  
  314. if (sideMenu.style.display === 'none') {
  315. sideMenu.style.top = window.scrollY + 'px';
  316. sideMenu.style.display = 'flex';
  317. sideMenu.classList.remove('closed');
  318. sideMenu.classList.add('opened');
  319.  
  320. logger('info', 'Side menu opened');
  321. } else {
  322. sideMenu.classList.remove('opened');
  323. sideMenu.classList.add('closed');
  324.  
  325. logger('info', 'Side menu closed');
  326. }
  327. });
  328.  
  329. window.addEventListener('scroll', () => {
  330. if (sideMenu.classList.contains('closed')) return;
  331.  
  332. sideMenu.classList.remove('opened');
  333. sideMenu.classList.add('closed');
  334.  
  335. logger('info', 'Side menu closed');
  336. });
  337.  
  338. document.body.appendChild(sideMenu);
  339. logger('info', 'Side menu created\n\n', sideMenu);
  340. }
  341.  
  342. function detectYoutubeService() {
  343. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
  344. return 'SHORTS';
  345. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
  346. return 'WATCH';
  347. else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
  348. else if (window.location.hostname === 'www.youtube.com') return 'YOUTUBE';
  349. else return null;
  350. }
  351.  
  352. function elementInContainer(container, element) {
  353. return container.contains(element);
  354. }
  355.  
  356. async function leftClick() {
  357. const isYtMusic = detectYoutubeService() === 'MUSIC';
  358.  
  359. if (!isYtMusic && !videoDataReady) {
  360. logger('warn', 'Video data not ready');
  361. new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false);
  362. return;
  363. } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) {
  364. logger('warn', 'Video URL not avaiable');
  365. new Notification(
  366. 'Wait!',
  367. 'Open the music player so the song link is visible, then try again.',
  368. 'popup',
  369. false
  370. );
  371. return;
  372. }
  373.  
  374. try {
  375. logger('info', 'Download started');
  376. window.open(
  377. await Cobalt(
  378. isYtMusic
  379. ? window.location.href.replace('music.youtube.com', 'www.youtube.com')
  380. : VIDEO_DATA.video_url
  381. ),
  382. '_blank'
  383. );
  384. logger('info', 'Download completed');
  385. } catch (err) {
  386. logger('error', JSON.parse(JSON.stringify(err)));
  387. new Notification('Error', JSON.stringify(err), 'error', false);
  388. }
  389. }
  390.  
  391. async function rightClick(e) {
  392. const isYtMusic = detectYoutubeService() === 'MUSIC';
  393.  
  394. e.preventDefault();
  395.  
  396. if (!isYtMusic && !videoDataReady) {
  397. logger('warn', 'Video data not ready');
  398. new Notification('Wait!', 'The video data is not ready yet, try again in a few seconds.', 'popup', false);
  399. return false;
  400. } else if (isYtMusic && !window.location.pathname.startsWith('/watch')) {
  401. logger('warn', 'Video URL not avaiable');
  402. new Notification(
  403. 'Wait!',
  404. 'Open the music player so the song link is visible, then try again.',
  405. 'popup',
  406. false
  407. );
  408. return;
  409. }
  410.  
  411. try {
  412. logger('info', 'Download started');
  413. window.open(
  414. await Cobalt(
  415. isYtMusic
  416. ? window.location.href.replace('music.youtube.com', 'www.youtube.com')
  417. : VIDEO_DATA.video_url,
  418. true
  419. ),
  420. '_blank'
  421. );
  422. logger('info', 'Download completed');
  423. } catch (err) {
  424. logger('error', JSON.parse(JSON.stringify(err)));
  425. new Notification('Error', JSON.stringify(err), 'error', false);
  426. }
  427.  
  428. return false;
  429. }
  430.  
  431. // https://www.30secondsofcode.org/js/s/element-is-visible-in-viewport/
  432. function elementIsVisibleInViewport(el, partiallyVisible = false) {
  433. const { top, left, bottom, right } = el.getBoundingClientRect();
  434. const { innerHeight, innerWidth } = window;
  435. return partiallyVisible
  436. ? ((top > 0 && top < innerHeight) || (bottom > 0 && bottom < innerHeight)) &&
  437. ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth))
  438. : top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth;
  439. }
  440.  
  441. async function appendDownloadButton(e) {
  442. const ytContainerSelector =
  443. '#movie_player > div.ytp-chrome-bottom > div.ytp-chrome-controls > div.ytp-right-controls';
  444. const ytmContainerSelector =
  445. '#layout > ytmusic-player-bar > div.middle-controls.style-scope.ytmusic-player-bar > div.middle-controls-buttons.style-scope.ytmusic-player-bar';
  446. const ytsContainerSelector = '#actions.style-scope.ytd-reel-player-overlay-renderer';
  447.  
  448. // ===== templates, don't use, just clone the node =====
  449. const downloadIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  450. downloadIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  451. downloadIcon.setAttribute('fill', 'currentColor');
  452. downloadIcon.setAttribute('height', '24');
  453. downloadIcon.setAttribute('viewBox', '0 0 24 24');
  454. downloadIcon.setAttribute('width', '24');
  455. downloadIcon.setAttribute('focusable', 'false');
  456. downloadIcon.style.pointerEvents = 'none';
  457. downloadIcon.style.display = 'block';
  458. downloadIcon.style.width = '100%';
  459. downloadIcon.style.height = '100%';
  460. const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  461. 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');
  462. downloadIcon.appendChild(path);
  463.  
  464. const downloadButton = document.createElement('button');
  465. downloadButton.id = 'ytdl-download-button';
  466. downloadButton.classList.add('ytp-button');
  467. downloadButton.title = 'Left click to download as video, right click as audio only';
  468. downloadButton.appendChild(downloadIcon);
  469. // ===== end templates =====
  470.  
  471. switch (detectYoutubeService()) {
  472. case 'WATCH':
  473. const ytCont = await waitForElement(ytContainerSelector);
  474. logger('info', 'Download button container found\n\n', ytCont);
  475.  
  476. if (elementInContainer(ytCont, ytCont.querySelector('#ytdl-download-button'))) {
  477. logger('warn', 'Download button already in container');
  478. break;
  479. }
  480.  
  481. const ytDlBtnClone = downloadButton.cloneNode(true);
  482. ytDlBtnClone.classList.add('YT');
  483. ytDlBtnClone.addEventListener('click', leftClick);
  484. ytDlBtnClone.addEventListener('contextmenu', rightClick);
  485. logger('info', 'Download button created\n\n', ytDlBtnClone);
  486.  
  487. ytCont.insertBefore(ytDlBtnClone, ytCont.firstChild);
  488. logger('info', 'Download button inserted in container');
  489.  
  490. break;
  491.  
  492. case 'MUSIC':
  493. const ytmCont = await waitForElement(ytmContainerSelector);
  494. logger('info', 'Download button container found\n\n', ytmCont);
  495.  
  496. if (elementInContainer(ytmCont, ytmCont.querySelector('#ytdl-download-button'))) {
  497. logger('warn', 'Download button already in container');
  498. break;
  499. }
  500.  
  501. const ytmDlBtnClone = downloadButton.cloneNode(true);
  502. ytmDlBtnClone.classList.add('YTM');
  503. ytmDlBtnClone.addEventListener('click', leftClick);
  504. ytmDlBtnClone.addEventListener('contextmenu', rightClick);
  505. logger('info', 'Download button created\n\n', ytmDlBtnClone);
  506.  
  507. ytmCont.insertBefore(ytmDlBtnClone, ytmCont.firstChild);
  508. logger('info', 'Download button inserted in container');
  509.  
  510. break;
  511.  
  512. case 'SHORTS':
  513. if (e.type !== 'yt-navigate-finish') return;
  514.  
  515. await waitForElement(ytsContainerSelector); // wait for the UI to finish loading
  516.  
  517. const visibleYtsConts = Array.from(document.querySelectorAll(ytsContainerSelector)).filter((el) =>
  518. elementIsVisibleInViewport(el)
  519. );
  520. logger('info', 'Download button containers found\n\n', visibleYtsConts);
  521.  
  522. visibleYtsConts.forEach((ytsCont) => {
  523. if (elementInContainer(ytsCont, ytsCont.querySelector('#ytdl-download-button'))) {
  524. logger('warn', 'Download button already in container');
  525. return;
  526. }
  527.  
  528. const ytsDlBtnClone = downloadButton.cloneNode(true);
  529. ytsDlBtnClone.classList.add(
  530. 'YTS',
  531. 'yt-spec-button-shape-next',
  532. 'yt-spec-button-shape-next--tonal',
  533. 'yt-spec-button-shape-next--mono',
  534. 'yt-spec-button-shape-next--size-l',
  535. 'yt-spec-button-shape-next--icon-button'
  536. );
  537. ytsDlBtnClone.addEventListener('click', leftClick);
  538. ytsDlBtnClone.addEventListener('contextmenu', rightClick);
  539. logger('info', 'Download button created\n\n', ytsDlBtnClone);
  540.  
  541. ytsCont.insertBefore(ytsDlBtnClone, ytsCont.firstChild);
  542. logger('info', 'Download button inserted in container');
  543. });
  544.  
  545. break;
  546.  
  547. default:
  548. return;
  549. }
  550. }
  551.  
  552. async function devStuff() {
  553. if (!DEV_MODE) return;
  554.  
  555. logger('info', 'Current service is: ' + detectYoutubeService());
  556. }
  557. // ===== END METHODS =====
  558.  
  559. GM_addStyle(`
  560. #ytdl-sideMenu {
  561. min-height: 100vh;
  562. z-index: 9998;
  563. position: absolute;
  564. top: 0;
  565. left: -100vw;
  566. width: 50vw;
  567. background-color: var(--yt-spec-base-background);
  568. border-right: 2px solid var(--yt-spec-static-grey);
  569. display: flex;
  570. flex-direction: column;
  571. gap: 2rem;
  572. padding: 2rem 2.5rem;
  573. font-family: "Roboto", "Arial", sans-serif;
  574. }
  575.  
  576. #ytdl-sideMenu.opened {
  577. animation: openMenu .3s linear forwards;
  578. }
  579.  
  580. #ytdl-sideMenu.closed {
  581. animation: closeMenu .3s linear forwards;
  582. }
  583.  
  584. #ytdl-sideMenu .header {
  585. text-align: center;
  586. font-size: 2.5rem;
  587. color: var(--yt-brand-youtube-red);
  588. }
  589.  
  590. #ytdl-sideMenu .setting-row {
  591. display: flex;
  592. flex-direction: column;
  593. gap: 1rem;
  594. }
  595.  
  596. #ytdl-sideMenu .setting-label {
  597. font-size: 1.8rem;
  598. color: var(--yt-brand-youtube-red);
  599. }
  600.  
  601. #ytdl-sideMenu .setting-description {
  602. font-size: 1.4rem;
  603. color: var(--yt-spec-text-primary);
  604. }
  605.  
  606. .ytdl-switch {
  607. display: inline-block;
  608. }
  609.  
  610. .ytdl-switch input {
  611. display: none;
  612. }
  613.  
  614. .ytdl-switch label {
  615. display: block;
  616. width: 50px;
  617. height: 19.5px;
  618. padding: 3px;
  619. border-radius: 15px;
  620. border: 2px solid var(--yt-spec-inverted-background);
  621. cursor: pointer;
  622. transition: 0.3s;
  623. }
  624.  
  625. .ytdl-switch label::after {
  626. content: "";
  627. display: inherit;
  628. width: 20px;
  629. height: 20px;
  630. border-radius: 12px;
  631. background: var(--yt-spec-inverted-background);
  632. transition: 0.3s;
  633. }
  634.  
  635. .ytdl-switch input:checked ~ label {
  636. border-color: var(--yt-spec-themed-green);
  637. }
  638.  
  639. .ytdl-switch input:checked ~ label::after {
  640. translate: 30px 0;
  641. background: var(--yt-spec-themed-green);
  642. }
  643.  
  644. .ytdl-switch input:disabled ~ label {
  645. opacity: 0.5;
  646. cursor: not-allowed;
  647. }
  648.  
  649. .ytdl-notification {
  650. display: flex;
  651. flex-direction: column;
  652. gap: 2rem;
  653. position: fixed;
  654. top: 50vh;
  655. left: 50vw;
  656. transform: translate(-50%, -50%);
  657. background-color: var(--yt-spec-base-background);
  658. border: 2px solid var(--yt-spec-static-grey);
  659. border-radius: 8px;
  660. color: var(--yt-spec-text-primary);
  661. z-index: 9999;
  662. padding: 1.5rem 1.6rem;
  663. font-family: "Roboto", "Arial", sans-serif;
  664. font-size: 1.4rem;
  665. width: fit-content;
  666. height: fit-content;
  667. max-width: 40vw;
  668. max-height: 50vh;
  669. word-wrap: break-word;
  670. line-height: var(--yt-caption-line-height);
  671. }
  672.  
  673. .ytdl-notification.opened {
  674. animation: openNotif .3s linear forwards;
  675. }
  676.  
  677. .ytdl-notification.closed {
  678. animation: closeNotif .3s linear forwards;
  679. }
  680.  
  681. .ytdl-notification h2 {
  682. color: var(--yt-brand-youtube-red);
  683. }
  684.  
  685. .ytdl-notification > div {
  686. display: flex;
  687. flex-direction: column;
  688. gap: 1rem;
  689. }
  690.  
  691. .ytdl-notification > button {
  692. transition: all 0.2s ease-in-out;
  693. cursor: pointer;
  694. border: 2px solid var(--yt-spec-static-grey);
  695. border-radius: 8px;
  696. background-color: var(--yt-brand-medium-red);
  697. padding: 0.7rem 0.8rem;
  698. color: #fff;
  699. font-weight: 600;
  700. }
  701.  
  702. .ytdl-notification button:hover {
  703. background-color: var(--yt-spec-red-70);
  704. }
  705.  
  706. #ytdl-download-button {
  707. background: none;
  708. border: none;
  709. outline: none;
  710. color: var(--yt-spec-text-primary);
  711. cursor: pointer;
  712. transition: color 0.2s ease-in-out;
  713. display: inline-flex;
  714. justify-content: center;
  715. align-items: center;
  716. }
  717.  
  718. #ytdl-download-button:hover {
  719. color: var(--yt-brand-youtube-red);
  720. }
  721.  
  722. #ytdl-download-button.YTM {
  723. transform: scale(1.5);
  724. margin: 0 1rem;
  725. }
  726.  
  727. #ytdl-download-button > svg {
  728. transform: translateX(5%);
  729. }
  730.  
  731. @keyframes openMenu {
  732. 0% {
  733. left: -100vw;
  734. }
  735.  
  736. 100% {
  737. left: 0;
  738. }
  739. }
  740.  
  741. @keyframes closeMenu {
  742. 0% {
  743. left: 0;
  744. }
  745.  
  746. 100% {
  747. left: -100vw;
  748. }
  749. }
  750.  
  751. @keyframes openNotif {
  752. 0% {
  753. opacity: 0;
  754. }
  755.  
  756. 100% {
  757. opacity: 1;
  758. }
  759. }
  760.  
  761. @keyframes closeNotif {
  762. 0% {
  763. opacity: 1;
  764. }
  765.  
  766. 100% {
  767. opacity: 0;
  768. }
  769. }
  770. `);
  771. logger('info', 'Custom styles added');
  772.  
  773. hookPlayerEvent(updateVideoData);
  774. hookNavigationEvents(appendDownloadButton, devStuff);
  775.  
  776. // functions that require the DOM to exist
  777. window.addEventListener('DOMContentLoaded', () => {
  778. appendSideMenu();
  779. appendDownloadButton();
  780. manageNotifications();
  781. });
  782. })();