YouTube Enhancer (Subtitle Downloader)

Allows you to download available subtitles for YouTube videos in various languages directly from the video page.

目前为 2024-11-10 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Enhancer (Subtitle Downloader)
  3. // @description Allows you to download available subtitles for YouTube videos in various languages directly from the video page.
  4. // @icon https://raw.githubusercontent.com/exyezed/youtube-enhancer/refs/heads/main/extras/youtube-enhancer.png
  5. // @version 1.1
  6. // @author exyezed
  7. // @namespace https://github.com/exyezed/youtube-enhancer/
  8. // @supportURL https://github.com/exyezed/youtube-enhancer/issues
  9. // @license MIT
  10. // @match https://www.youtube.com/*
  11. // @match https://youtube.com/*
  12. // @grant none
  13. // @run-at document-idle
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. function xmlToSrt(xmlText) {
  20. const parser = new DOMParser();
  21. const xmlDoc = parser.parseFromString(xmlText, "text/xml");
  22. const textNodes = xmlDoc.getElementsByTagName("text");
  23. let srtContent = '';
  24.  
  25. for (let i = 0; i < textNodes.length; i++) {
  26. const node = textNodes[i];
  27. const start = parseFloat(node.getAttribute("start"));
  28. const duration = parseFloat(node.getAttribute("dur") || "0");
  29. const end = start + duration;
  30.  
  31. const formatTime = (time) => {
  32. const hours = Math.floor(time / 3600);
  33. const minutes = Math.floor((time % 3600) / 60);
  34. const seconds = Math.floor(time % 60);
  35. const milliseconds = Math.floor((time % 1) * 1000);
  36.  
  37. return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(milliseconds).padStart(3, '0')}`;
  38. };
  39.  
  40. srtContent += `${i + 1}\n`;
  41. srtContent += `${formatTime(start)} --> ${formatTime(end)}\n`;
  42. srtContent += `${node.textContent}\n\n`;
  43. }
  44.  
  45. return srtContent;
  46. }
  47.  
  48. function createSVGIcon(className, isHover = false) {
  49. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  50. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  51.  
  52. svg.setAttribute("viewBox", "0 0 576 512");
  53. svg.classList.add(className);
  54.  
  55. path.setAttribute("d", isHover
  56. ? "M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm56 208l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm256 0l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zM120 336l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm160 0l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
  57. : "M64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l448 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16L64 80zM0 96C0 60.7 28.7 32 64 32l448 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM120 240l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm256 0l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zM120 336l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm160 0l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
  58. );
  59.  
  60. svg.appendChild(path);
  61. return svg;
  62. }
  63.  
  64. function getVideoId() {
  65. const urlParams = new URLSearchParams(window.location.search);
  66. return urlParams.get('v');
  67. }
  68.  
  69. function createDropdown(languages, videoTitle) {
  70. const dropdown = document.createElement('div');
  71. dropdown.className = 'subtitle-dropdown';
  72. const titleDiv = document.createElement('div');
  73. titleDiv.className = 'subtitle-dropdown-title';
  74. titleDiv.textContent = `Download Subtitles (${languages.length})`;
  75. dropdown.appendChild(titleDiv);
  76. languages.forEach((lang) => {
  77. const option = document.createElement('div');
  78. option.className = 'subtitle-option';
  79. option.dataset.url = lang.url;
  80. option.textContent = lang.label;
  81. dropdown.appendChild(option);
  82. });
  83. return dropdown;
  84. }
  85.  
  86. async function handleSubtitleDownload(e) {
  87. e.preventDefault();
  88. const videoId = getVideoId();
  89. if (!videoId) {
  90. console.error('Video ID not found');
  91. return;
  92. }
  93. try {
  94. const player = document.querySelector('#movie_player');
  95. if (!player || !player.getPlayerResponse) {
  96. console.error('Player not found or API not available');
  97. return;
  98. }
  99. const playerResponse = player.getPlayerResponse();
  100. const captions = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
  101. if (!captions || captions.length === 0) {
  102. alert('No subtitles available for this video');
  103. return;
  104. }
  105. const languages = captions.map(caption => ({
  106. label: caption.name.simpleText,
  107. url: caption.baseUrl
  108. }));
  109. const backdrop = document.createElement('div');
  110. backdrop.className = 'subtitle-backdrop';
  111. document.body.appendChild(backdrop);
  112. const videoTitleElement = document.querySelector('yt-formatted-string.style-scope.ytd-watch-metadata');
  113. const videoTitle = videoTitleElement ? videoTitleElement.textContent.trim() : 'video';
  114. const dropdown = createDropdown(languages, videoTitle);
  115. document.body.appendChild(dropdown);
  116. const closeDropdown = (e) => {
  117. if (!dropdown.contains(e.target) && !e.target.closest('.custom-subtitle-btn')) {
  118. dropdown.remove();
  119. backdrop.remove();
  120. document.removeEventListener('click', closeDropdown);
  121. }
  122. };
  123. dropdown.addEventListener('click', async (event) => {
  124. const option = event.target.closest('.subtitle-option');
  125. if (!option) return;
  126. const url = option.dataset.url;
  127. const langLabel = option.textContent.trim();
  128. try {
  129. option.classList.add('loading');
  130. const response = await fetch(url);
  131. if (!response.ok) throw new Error('Network response was not ok');
  132. const xmlContent = await response.text();
  133. const srtContent = xmlToSrt(xmlContent);
  134. const blob = new Blob([srtContent], { type: 'text/plain' });
  135. const downloadUrl = URL.createObjectURL(blob);
  136. const link = document.createElement('a');
  137. link.href = downloadUrl;
  138. link.download = `${videoTitle} - ${langLabel}.srt`;
  139. document.body.appendChild(link);
  140. link.click();
  141. document.body.removeChild(link);
  142. URL.revokeObjectURL(downloadUrl);
  143. dropdown.remove();
  144. backdrop.remove();
  145. } catch (error) {
  146. console.error('Error downloading subtitles:', error);
  147. option.classList.remove('loading');
  148. alert('Error downloading subtitles. Please try again.');
  149. }
  150. });
  151. setTimeout(() => {
  152. document.addEventListener('click', closeDropdown);
  153. }, 100);
  154. } catch (error) {
  155. console.error('Error handling subtitle download:', error);
  156. alert('Error accessing video subtitles. Please try again.');
  157. }
  158. }
  159.  
  160. function initializeStyles(computedStyle) {
  161. if (document.querySelector('#yt-subtitle-downloader-styles')) return;
  162.  
  163. const style = document.createElement('style');
  164. style.id = 'yt-subtitle-downloader-styles';
  165. style.textContent = `
  166. .custom-subtitle-btn {
  167. background: none;
  168. border: none;
  169. cursor: pointer;
  170. padding: 0;
  171. width: ${computedStyle.width};
  172. height: ${computedStyle.height};
  173. display: flex;
  174. align-items: center;
  175. justify-content: center;
  176. position: relative;
  177. }
  178. .custom-subtitle-btn svg {
  179. width: 24px;
  180. height: 24px;
  181. fill: #fff;
  182. position: absolute;
  183. top: 50%;
  184. left: 50%;
  185. transform: translate(-50%, -50%);
  186. opacity: 1;
  187. transition: opacity 0.2s ease-in-out;
  188. }
  189. .custom-subtitle-btn .hover-icon {
  190. opacity: 0;
  191. }
  192. .custom-subtitle-btn:hover .default-icon {
  193. opacity: 0;
  194. }
  195. .custom-subtitle-btn:hover .hover-icon {
  196. opacity: 1;
  197. }
  198. .subtitle-dropdown {
  199. position: fixed;
  200. background: rgba(28, 28, 28, 0.95);
  201. border: 1px solid rgba(255, 255, 255, 0.1);
  202. border-radius: 8px;
  203. padding: 12px;
  204. z-index: 9999;
  205. top: 50%;
  206. left: 50%;
  207. transform: translate(-50%, -50%);
  208. min-width: 200px;
  209. box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
  210. backdrop-filter: blur(10px);
  211. }
  212. .subtitle-dropdown-title {
  213. color: #fff;
  214. font-size: 14px;
  215. font-weight: 500;
  216. margin-bottom: 8px;
  217. padding: 0 8px;
  218. text-align: center;
  219. }
  220. .subtitle-option {
  221. color: #fff;
  222. padding: 8px 12px;
  223. margin: 2px 0;
  224. cursor: pointer;
  225. border-radius: 4px;
  226. transition: all 0.2s;
  227. display: flex;
  228. align-items: center;
  229. font-size: 13px;
  230. white-space: nowrap;
  231. }
  232. .subtitle-option:hover {
  233. background-color: rgba(255, 255, 255, 0.1);
  234. }
  235. .subtitle-option::before {
  236. content: "●";
  237. margin-right: 8px;
  238. font-size: 8px;
  239. color: #aaa;
  240. }
  241. .subtitle-option.loading {
  242. opacity: 0.5;
  243. pointer-events: none;
  244. }
  245. .subtitle-backdrop {
  246. position: fixed;
  247. top: 0;
  248. left: 0;
  249. width: 100%;
  250. height: 100%;
  251. background: rgba(0, 0, 0, 0.5);
  252. z-index: 9998;
  253. }
  254. `;
  255. document.head.appendChild(style);
  256. }
  257.  
  258. function initializeButton() {
  259. if (document.querySelector('.custom-subtitle-btn')) return;
  260.  
  261. const originalButton = document.querySelector('.ytp-subtitles-button');
  262. if (!originalButton) return;
  263.  
  264. const newButton = document.createElement('button');
  265. const computedStyle = window.getComputedStyle(originalButton);
  266.  
  267. Object.assign(newButton, {
  268. className: 'ytp-button custom-subtitle-btn',
  269. title: 'Download Subtitles'
  270. });
  271.  
  272. newButton.setAttribute('aria-pressed', 'false');
  273. initializeStyles(computedStyle);
  274.  
  275. newButton.append(
  276. createSVGIcon('default-icon', false),
  277. createSVGIcon('hover-icon', true)
  278. );
  279.  
  280. newButton.addEventListener('click', (e) => {
  281. const existingDropdown = document.querySelector('.subtitle-dropdown');
  282. existingDropdown ? existingDropdown.remove() : handleSubtitleDownload(e);
  283. });
  284. originalButton.insertAdjacentElement('afterend', newButton);
  285. }
  286.  
  287. function initializeObserver() {
  288. const observer = new MutationObserver((mutations) => {
  289. mutations.forEach((mutation) => {
  290. if (mutation.addedNodes.length) {
  291. const isVideoPage = window.location.pathname === '/watch';
  292. if (isVideoPage && !document.querySelector('.custom-subtitle-btn')) {
  293. initializeButton();
  294. }
  295. }
  296. });
  297. });
  298.  
  299. function startObserving() {
  300. const playerContainer = document.getElementById('player-container');
  301. const contentContainer = document.getElementById('content');
  302.  
  303. if (playerContainer) {
  304. observer.observe(playerContainer, {
  305. childList: true,
  306. subtree: true
  307. });
  308. }
  309.  
  310. if (contentContainer) {
  311. observer.observe(contentContainer, {
  312. childList: true,
  313. subtree: true
  314. });
  315. }
  316.  
  317. if (window.location.pathname === '/watch') {
  318. initializeButton();
  319. }
  320. }
  321.  
  322. startObserving();
  323.  
  324. if (!document.getElementById('player-container')) {
  325. const retryInterval = setInterval(() => {
  326. if (document.getElementById('player-container')) {
  327. startObserving();
  328. clearInterval(retryInterval);
  329. }
  330. }, 1000);
  331.  
  332. setTimeout(() => clearInterval(retryInterval), 10000);
  333. }
  334.  
  335. const handleNavigation = () => {
  336. if (window.location.pathname === '/watch') {
  337. initializeButton();
  338. }
  339. };
  340.  
  341. window.addEventListener('yt-navigate-finish', handleNavigation);
  342.  
  343. return () => {
  344. observer.disconnect();
  345. window.removeEventListener('yt-navigate-finish', handleNavigation);
  346. };
  347. }
  348.  
  349. addSubtitleButton();
  350. function addSubtitleButton() {
  351. initializeObserver();
  352. }
  353. console.log('YouTube Enhancer (Subtitle Downloader) is running');
  354. })();