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