YouTube Smart Subtitle Downloader

Enhanced YouTube subtitle downloader with smart selection and improved code structure

  1. // ==UserScript==
  2. // @name YouTube Smart Subtitle Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1
  5. // @description Enhanced YouTube subtitle downloader with smart selection and improved code structure
  6. // @author anassk
  7. // @match https://www.youtube.com/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_registerMenuCommand
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14.  
  15. // Core configuration
  16. const CONFIG = {
  17. MESSAGES: {
  18. NO_SUBTITLE: 'No Subtitles Available',
  19. HAVE_SUBTITLE: 'Available Subtitles',
  20. LOADING: 'Loading Subtitles...',
  21. COPY_SUCCESS: '✓ Copied!',
  22. ERROR: {
  23. COPY: 'Failed to copy to clipboard',
  24. FETCH: 'Failed to fetch subtitles',
  25. NO_VIDEO: 'No video found'
  26. }
  27. },
  28. FORMATS: {
  29. SRT: 'srt',
  30. TEXT: 'txt'
  31. },
  32. TIMINGS: {
  33. DOWNLOAD_DELAY: 500,
  34. },
  35. SELECTORS: {
  36. VIDEO_ELEMENTS: [
  37. 'ytd-playlist-panel-video-renderer',
  38. 'ytd-playlist-video-renderer',
  39. 'yt-lockup-view-model',
  40. 'ytd-rich-item-renderer',
  41. 'ytd-video-renderer',
  42. 'ytd-compact-video-renderer',
  43. 'ytd-grid-video-renderer'
  44. ].join(','),
  45. TITLE_SELECTORS: [
  46. '#video-title',
  47. 'a#video-title',
  48. 'span#video-title',
  49. '[title]'
  50. ]
  51. },
  52. STYLES: {
  53. CHECKBOX_WRAPPER: `
  54. position: absolute;
  55. left: 5px;
  56. top: 0;
  57. bottom: 0;
  58. width: 20px;
  59. display: flex;
  60. align-items: center;
  61. z-index: 1;
  62. `,
  63. CHECKBOX: `
  64. width: 16px;
  65. height: 16px;
  66. cursor: pointer;
  67. margin: 0;
  68. `,
  69. DIALOG: `
  70. position: fixed;
  71. top: 0;
  72. left: 0;
  73. width: 100%;
  74. height: 100%;
  75. background: rgba(0, 0, 0, 0.7);
  76. display: flex;
  77. justify-content: center;
  78. align-items: center;
  79. z-index: 9999;
  80. `,
  81. DIALOG_CONTENT: `
  82. background: white;
  83. padding: 20px;
  84. border-radius: 8px;
  85. min-width: 300px;
  86. max-width: 600px;
  87. max-height: 80vh;
  88. overflow-y: auto;
  89. color: black;
  90. `
  91. }
  92. };
  93.  
  94. // Utility functions
  95. const Utils = {
  96. createError: (message, code, originalError = null) => {
  97. const error = new Error(message);
  98. error.code = code;
  99. error.originalError = originalError;
  100. return error;
  101. },
  102.  
  103. safeJSONParse: (str, fallback = null) => {
  104. try {
  105. return JSON.parse(str);
  106. } catch (e) {
  107. console.error('JSON Parse Error:', e);
  108. return fallback;
  109. }
  110. },
  111.  
  112. sanitizeFileName: (name) => {
  113. return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').substring(0, 100);
  114. },
  115.  
  116. delay: (ms) => new Promise(resolve => setTimeout(resolve, ms))
  117. };
  118.  
  119. // Subtitle Service - New centralized service for subtitle operations
  120. class SubtitleService {
  121. static async fetchSubtitleTracks(videoId) {
  122. try {
  123. const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
  124. const html = await response.text();
  125.  
  126. const playerDataMatch = html.match(/ytInitialPlayerResponse\s*=\s*({.+?});/);
  127. if (!playerDataMatch) return null;
  128.  
  129. const playerData = Utils.safeJSONParse(playerDataMatch[1]);
  130. const captionTracks = playerData?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
  131.  
  132. if (!captionTracks?.length) return null;
  133.  
  134. return captionTracks.map(track => ({
  135. languageCode: track.languageCode,
  136. languageName: track.name.simpleText,
  137. baseUrl: track.baseUrl
  138. }));
  139. } catch (error) {
  140. throw Utils.createError('Failed to fetch subtitles', 'SUBTITLE_FETCH_ERROR', error);
  141. }
  142. }
  143.  
  144. static async getSubtitleContent(track, format = CONFIG.FORMATS.SRT) {
  145. try {
  146. const response = await fetch(track.baseUrl);
  147. const xml = await response.text();
  148. return format === CONFIG.FORMATS.SRT ?
  149. this.convertToSRT(xml) :
  150. this.convertToText(xml);
  151. } catch (error) {
  152. throw Utils.createError('Failed to fetch subtitle content', 'CONTENT_FETCH_ERROR', error);
  153. }
  154. }
  155.  
  156. static convertToSRT(xml) {
  157. try {
  158. const parser = new DOMParser();
  159. const doc = parser.parseFromString(xml, "text/xml");
  160. const textNodes = doc.getElementsByTagName('text');
  161. let srt = '';
  162.  
  163. Array.from(textNodes).forEach((node, index) => {
  164. const start = parseFloat(node.getAttribute('start'));
  165. const duration = parseFloat(node.getAttribute('dur') || '0');
  166. const end = start + duration;
  167.  
  168. srt += `${index + 1}\n`;
  169. srt += `${this.formatTime(start)} --> ${this.formatTime(end)}\n`;
  170. srt += `${node.textContent}\n\n`;
  171. });
  172.  
  173. return srt;
  174. } catch (error) {
  175. throw Utils.createError('Failed to convert to SRT', 'SRT_CONVERSION_ERROR', error);
  176. }
  177. }
  178.  
  179. static convertToText(xml) {
  180. try {
  181. const parser = new DOMParser();
  182. const doc = parser.parseFromString(xml, "text/xml");
  183. const textNodes = doc.getElementsByTagName('text');
  184.  
  185. return Array.from(textNodes)
  186. .map(node => node.textContent.trim())
  187. .filter(text => text)
  188. .join('\n');
  189. } catch (error) {
  190. throw Utils.createError('Failed to convert to text', 'TEXT_CONVERSION_ERROR', error);
  191. }
  192. }
  193.  
  194. static formatTime(seconds) {
  195. const pad = num => String(num).padStart(2, '0');
  196. const ms = String(Math.floor((seconds % 1) * 1000)).padStart(3, '0');
  197.  
  198. seconds = Math.floor(seconds);
  199. const hours = Math.floor(seconds / 3600);
  200. const minutes = Math.floor((seconds % 3600) / 60);
  201. const secs = seconds % 60;
  202.  
  203. return `${pad(hours)}:${pad(minutes)}:${pad(secs)},${ms}`;
  204. }
  205.  
  206. static async downloadSubtitles(tracks, format) {
  207. const loading = UIComponents.showLoading('Downloading subtitles...');
  208.  
  209. try {
  210. for (const track of tracks) {
  211. const content = await this.getSubtitleContent(track, format);
  212. const filename = `${Utils.sanitizeFileName(track.videoTitle)}_${track.languageCode}.${format}`;
  213.  
  214. const blob = new Blob(['\ufeff' + content], { type: 'text/plain;charset=utf-8' });
  215. const url = URL.createObjectURL(blob);
  216. const link = document.createElement('a');
  217.  
  218. link.href = url;
  219. link.download = filename;
  220. document.body.appendChild(link);
  221. link.click();
  222. document.body.removeChild(link);
  223. URL.revokeObjectURL(url);
  224.  
  225. await Utils.delay(CONFIG.TIMINGS.DOWNLOAD_DELAY);
  226. }
  227. } catch (error) {
  228. throw Utils.createError('Failed to download subtitles', 'DOWNLOAD_ERROR', error);
  229. } finally {
  230. loading.remove();
  231. }
  232. }
  233.  
  234. static async copySubtitles(tracks, format, videoTitle = '') {
  235. const loading = UIComponents.showLoading('Copying subtitles...');
  236.  
  237. try {
  238. let content = '';
  239. for (const track of tracks) {
  240. const subtitleContent = await this.getSubtitleContent(track, format);
  241. const title = videoTitle ? `${videoTitle} - ` : '';
  242. content += `=== ${title}${track.languageName} ===\n${subtitleContent}\n\n`;
  243. }
  244.  
  245. await navigator.clipboard.writeText(content);
  246. UIComponents.showToast(CONFIG.MESSAGES.COPY_SUCCESS);
  247. } catch (error) {
  248. throw Utils.createError('Failed to copy subtitles', 'COPY_ERROR', error);
  249. } finally {
  250. loading.remove();
  251. }
  252. }
  253. }
  254.  
  255. // UI Components
  256. class UIComponents {
  257. static showDialog(title, content, onClose) {
  258. const dialog = document.createElement('div');
  259. dialog.style.cssText = CONFIG.STYLES.DIALOG;
  260.  
  261. const dialogContent = document.createElement('div');
  262. dialogContent.style.cssText = CONFIG.STYLES.DIALOG_CONTENT;
  263.  
  264. const header = document.createElement('div');
  265. header.style.cssText = `
  266. display: flex;
  267. justify-content: space-between;
  268. align-items: center;
  269. margin-bottom: 15px;
  270. `;
  271.  
  272. const titleElem = document.createElement('h2');
  273. titleElem.style.margin = '0';
  274. titleElem.textContent = title;
  275.  
  276. const closeButton = document.createElement('button');
  277. closeButton.textContent = '×';
  278. closeButton.style.cssText = `
  279. background: none;
  280. border: none;
  281. font-size: 24px;
  282. cursor: pointer;
  283. padding: 0 5px;
  284. `;
  285. closeButton.onclick = () => {
  286. dialog.remove();
  287. if (onClose) onClose();
  288. };
  289.  
  290. header.appendChild(titleElem);
  291. header.appendChild(closeButton);
  292. dialogContent.appendChild(header);
  293. dialogContent.appendChild(content);
  294. dialog.appendChild(dialogContent);
  295.  
  296. document.body.appendChild(dialog);
  297. return dialog;
  298. }
  299.  
  300. static showToast(message, duration = 3000) {
  301. const toast = document.createElement('div');
  302. toast.style.cssText = `
  303. position: fixed;
  304. bottom: 20px;
  305. left: 50%;
  306. transform: translateX(-50%);
  307. background: rgba(0, 0, 0, 0.8);
  308. color: white;
  309. padding: 10px 20px;
  310. border-radius: 4px;
  311. z-index: 10001;
  312. `;
  313. toast.textContent = message;
  314. document.body.appendChild(toast);
  315. setTimeout(() => toast.remove(), duration);
  316. }
  317.  
  318. static showLoading(message = CONFIG.MESSAGES.LOADING) {
  319. const overlay = document.createElement('div');
  320. overlay.style.cssText = CONFIG.STYLES.DIALOG;
  321.  
  322. const content = document.createElement('div');
  323. content.style.textAlign = 'center';
  324. content.style.color = 'white';
  325.  
  326. const spinner = document.createElement('div');
  327. spinner.style.cssText = `
  328. width: 40px;
  329. height: 40px;
  330. border: 4px solid #f3f3f3;
  331. border-top: 4px solid #3498db;
  332. border-radius: 50%;
  333. margin: 0 auto 10px;
  334. animation: spin 1s linear infinite;
  335. `;
  336.  
  337. if (!document.getElementById('spinner-style')) {
  338. const style = document.createElement('style');
  339. style.id = 'spinner-style';
  340. style.textContent = '@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }';
  341. document.head.appendChild(style);
  342. }
  343.  
  344. content.appendChild(spinner);
  345. content.appendChild(document.createTextNode(message));
  346. overlay.appendChild(content);
  347. document.body.appendChild(overlay);
  348.  
  349. return overlay;
  350. }
  351.  
  352. static createSubtitleDialog(tracks, format = CONFIG.FORMATS.SRT, onDownload, onCopy) {
  353. const content = document.createElement('div');
  354.  
  355. // Format selector
  356. const formatDiv = document.createElement('div');
  357. formatDiv.innerHTML = `
  358. <div style="margin-bottom: 15px;">
  359. <label style="margin-right: 10px;">
  360. <input type="radio" name="format" value="srt" ${format === 'srt' ? 'checked' : ''}> SRT
  361. </label>
  362. <label>
  363. <input type="radio" name="format" value="txt" ${format === 'txt' ? 'checked' : ''}> Plain Text
  364. </label>
  365. </div>
  366. `;
  367. content.appendChild(formatDiv);
  368. //batch select
  369. if (tracks.length > 0) {
  370. const selectAllDiv = document.createElement('div');
  371. selectAllDiv.style.marginBottom = '15px';
  372. const selectAllBtn = document.createElement('button');
  373. selectAllBtn.textContent = 'Select All Subtitles';
  374. selectAllBtn.style.cssText = 'background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;';
  375. selectAllBtn.onclick = () => {
  376. const checkboxes = content.querySelectorAll('input[type="checkbox"][data-lang]');
  377. checkboxes.forEach(checkbox => {
  378. checkbox.checked = true;
  379. });
  380. };
  381. selectAllDiv.appendChild(selectAllBtn);
  382. content.appendChild(selectAllDiv);
  383. }
  384.  
  385. // Tracks list
  386. if (tracks.length > 0) {
  387. tracks.forEach(track => {
  388. const trackDiv = document.createElement('div');
  389. trackDiv.style.margin = '5px 0';
  390. trackDiv.innerHTML = `
  391. <label>
  392. <input type="checkbox" data-lang="${track.languageCode}">
  393. ${track.languageName}
  394. </label>
  395. `;
  396. content.appendChild(trackDiv);
  397. });
  398. } else {
  399. const noSubs = document.createElement('p');
  400. noSubs.style.color = '#c00';
  401. noSubs.textContent = CONFIG.MESSAGES.NO_SUBTITLE;
  402. content.appendChild(noSubs);
  403. }
  404.  
  405. // Action buttons
  406. const actions = document.createElement('div');
  407. actions.style.cssText = 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;';
  408.  
  409. if (tracks.length > 0) {
  410. const downloadBtn = document.createElement('button');
  411. downloadBtn.textContent = 'Download Selected';
  412. downloadBtn.onclick = onDownload;
  413. actions.appendChild(downloadBtn);
  414.  
  415. const copyBtn = document.createElement('button');
  416. copyBtn.textContent = 'Copy Selected';
  417. copyBtn.onclick = onCopy;
  418. actions.appendChild(copyBtn);
  419. }
  420.  
  421. content.appendChild(actions);
  422. return content;
  423. }
  424. }
  425.  
  426. // Video Selector with improved structure
  427. class VideoSelector {
  428. constructor() {
  429. this.selectedVideos = new Map();
  430. this.selectionActive = false;
  431. }
  432.  
  433. toggleVideoSelection() {
  434. if (!this.selectionActive) {
  435. this.activateSelection();
  436. } else {
  437. this.deactivateSelection();
  438. }
  439. }
  440.  
  441. activateSelection() {
  442. this.selectionActive = true;
  443. this.selectedVideos.clear();
  444. this.addSpacingStyle();
  445.  
  446. const videos = document.querySelectorAll(CONFIG.SELECTORS.VIDEO_ELEMENTS);
  447. videos.forEach(video => {
  448. // Skip shorts
  449. if (video.closest('ytd-reel-shelf-renderer') ||
  450. video.closest('ytd-shorts') ||
  451. video.closest('ytm-shorts-lockup-view-model')) {
  452. return;
  453. }
  454.  
  455. video.classList.add('yt-sub-video-padding');
  456. this.addCheckbox(video);
  457. });
  458.  
  459. this.addSelectionUI();
  460. }
  461.  
  462. addSpacingStyle() {
  463. if (!document.getElementById('yt-sub-styles')) {
  464. const style = document.createElement('style');
  465. style.id = 'yt-sub-styles';
  466. style.textContent = `
  467. .yt-sub-video-padding {
  468. padding-left: 30px !important;
  469. position: relative !important;
  470. }
  471. `;
  472. document.head.appendChild(style);
  473. }
  474. }
  475.  
  476. addCheckbox(videoElement) {
  477. if (videoElement.querySelector('.yt-sub-checkbox-wrapper')) return;
  478.  
  479. const wrapper = document.createElement('div');
  480. wrapper.className = 'yt-sub-checkbox-wrapper';
  481. wrapper.style.cssText = CONFIG.STYLES.CHECKBOX_WRAPPER;
  482.  
  483. const checkbox = document.createElement('input');
  484. checkbox.type = 'checkbox';
  485. checkbox.className = 'yt-sub-checkbox';
  486. checkbox.style.cssText = CONFIG.STYLES.CHECKBOX;
  487.  
  488. wrapper.appendChild(checkbox);
  489. videoElement.insertBefore(wrapper, videoElement.firstChild);
  490.  
  491. checkbox.addEventListener('change', (e) => {
  492. const videoId = this.extractVideoId(videoElement);
  493. const title = this.extractTitle(videoElement);
  494.  
  495. if (videoId && title) {
  496. if (e.target.checked) {
  497. this.selectedVideos.set(videoId, { title });
  498. } else {
  499. this.selectedVideos.delete(videoId);
  500. }
  501. this.updateSelectionCount();
  502. }
  503. });
  504. }
  505.  
  506. extractVideoId(video) {
  507. const thumbnail = video.querySelector('a#thumbnail[href*="/watch?v="]');
  508. if (thumbnail?.href) {
  509. const url = new URL(thumbnail.href);
  510. return url.searchParams.get('v');
  511. }
  512.  
  513. const links = video.querySelectorAll('a[href*="/watch?v="]');
  514. for (const link of links) {
  515. try {
  516. const url = new URL(link.href);
  517. const videoId = url.searchParams.get('v');
  518. if (videoId) return videoId;
  519. } catch {
  520. continue;
  521. }
  522. }
  523. return null;
  524. }
  525.  
  526. extractTitle(video) {
  527. for (const selector of CONFIG.SELECTORS.TITLE_SELECTORS) {
  528. const element = video.querySelector(selector);
  529. if (element) {
  530. const title = element.textContent?.trim() ||
  531. element.getAttribute('title')?.trim();
  532. if (title) return title;
  533. }
  534. }
  535.  
  536. const videoId = this.extractVideoId(video);
  537. return videoId ? `Video_${videoId}` : 'Untitled Video';
  538. }
  539.  
  540. addSelectionUI() {
  541. const ui = document.createElement('div');
  542. ui.id = 'yt-sub-selection-ui';
  543. ui.style.cssText = `
  544. position: fixed;
  545. top: 10px;
  546. right: 10px;
  547. background: rgba(0, 0, 0, 0.8);
  548. color: white;
  549. padding: 10px 20px;
  550. border-radius: 4px;
  551. z-index: 9999;
  552. font-size: 14px;
  553. `;
  554.  
  555. ui.innerHTML = `
  556. <div style="margin-bottom: 10px;">
  557. Selected: <span id="yt-sub-count">0</span> videos
  558. </div>
  559. <div style="display: flex; gap: 10px; margin-bottom: 10px;">
  560. <button id="yt-sub-select-all" style="background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Select All</button>
  561. <button id="yt-sub-select-x" style="background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Select First X</button>
  562. </div>
  563. <div style="display: flex; gap: 10px;">
  564. <button id="yt-sub-download" style="background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Download</button>
  565. <button id="yt-sub-cancel" style="background: #909090; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Cancel</button>
  566. </div>
  567. `;
  568.  
  569. document.body.appendChild(ui);
  570.  
  571. document.getElementById('yt-sub-download').onclick = () => {
  572. if (this.selectedVideos.size > 0) {
  573. this.processSelectedVideos();
  574. } else {
  575. UIComponents.showToast('Please select at least one video');
  576. }
  577. };
  578.  
  579. document.getElementById('yt-sub-cancel').onclick = () => {
  580. this.deactivateSelection();
  581. };
  582.  
  583.  
  584. document.getElementById('yt-sub-select-all').onclick = () => {
  585. const checkboxes = document.querySelectorAll('.yt-sub-checkbox');
  586. checkboxes.forEach(checkbox => {
  587. if (!checkbox.checked) {
  588. checkbox.click();
  589. }
  590. });
  591. };
  592.  
  593. document.getElementById('yt-sub-select-x').onclick = () => {
  594. const input = prompt('How many videos would you like to select?');
  595. const number = parseInt(input);
  596.  
  597. if (!isNaN(number) && number > 0) {
  598. const checkboxes = document.querySelectorAll('.yt-sub-checkbox');
  599. checkboxes.forEach((checkbox, index) => {
  600. if (index < number) {
  601. if (!checkbox.checked) {
  602. checkbox.click();
  603. }
  604. } else if (checkbox.checked) {
  605. checkbox.click();
  606. }
  607. });
  608. } else if (input !== null) {
  609. UIComponents.showToast('Please enter a valid number');
  610. }
  611. };
  612.  
  613.  
  614. }
  615.  
  616. updateSelectionCount() {
  617. const countElement = document.getElementById('yt-sub-count');
  618. if (countElement) {
  619. countElement.textContent = this.selectedVideos.size.toString();
  620. }
  621. }
  622.  
  623. deactivateSelection() {
  624. this.selectionActive = false;
  625. this.selectedVideos.clear();
  626.  
  627. document.querySelectorAll('.yt-sub-video-padding').forEach(video => {
  628. video.classList.remove('yt-sub-video-padding');
  629. });
  630.  
  631. document.querySelectorAll('.yt-sub-checkbox-wrapper').forEach(cb => cb.remove());
  632. document.getElementById('yt-sub-selection-ui')?.remove();
  633. document.getElementById('yt-sub-styles')?.remove();
  634. }
  635.  
  636. async processSelectedVideos() {
  637. if (this.selectedVideos.size === 0) {
  638. UIComponents.showToast('Please select at least one video');
  639. return;
  640. }
  641.  
  642. const loading = UIComponents.showLoading('Fetching subtitles...');
  643.  
  644. try {
  645. const videos = Array.from(this.selectedVideos.entries());
  646. const results = await Promise.all(
  647. videos.map(async ([videoId, data]) => {
  648. try {
  649. const tracks = await SubtitleService.fetchSubtitleTracks(videoId);
  650. return { ...data, videoId, subtitles: tracks || [] };
  651. } catch (error) {
  652. console.error(`Failed to fetch subtitles for ${videoId}:`, error);
  653. return { ...data, videoId, subtitles: [] };
  654. }
  655. })
  656. );
  657.  
  658. this.showSubtitleDialog(results);
  659. } catch (error) {
  660. UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
  661. console.error('Failed to process videos:', error);
  662. } finally {
  663. loading.remove();
  664. }
  665. }
  666.  
  667. showSubtitleDialog(videos) {
  668. const getSelectedTracks = () => {
  669. return Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
  670. .map(cb => {
  671. const videoId = cb.dataset.videoId;
  672. const langCode = cb.dataset.lang;
  673. const video = videos.find(v => v.videoId === videoId);
  674. const track = video?.subtitles.find(t => t.languageCode === langCode);
  675. return track ? { ...track, videoTitle: video.title } : null;
  676. })
  677. .filter(Boolean);
  678. };
  679.  
  680. const content = document.createElement('div');
  681.  
  682. // Format selector
  683. const formatDiv = document.createElement('div');
  684. formatDiv.innerHTML = `
  685. <div style="margin-bottom: 15px;">
  686. <label style="margin-right: 10px;">
  687. <input type="radio" name="format" value="srt" checked> SRT
  688. </label>
  689. <label>
  690. <input type="radio" name="format" value="txt"> Plain Text
  691. </label>
  692. </div>
  693. `;
  694. content.appendChild(formatDiv);
  695. //batch Select
  696. const selectAllDiv = document.createElement('div');
  697. selectAllDiv.style.marginBottom = '15px';
  698. const selectAllBtn = document.createElement('button');
  699. selectAllBtn.textContent = 'Select All Subtitles';
  700. selectAllBtn.style.cssText = 'background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;';
  701. selectAllBtn.onclick = () => {
  702. const checkboxes = content.querySelectorAll('input[type="checkbox"][data-video-id]');
  703. checkboxes.forEach(checkbox => {
  704. checkbox.checked = true;
  705. });
  706. };
  707. selectAllDiv.appendChild(selectAllBtn);
  708. content.appendChild(selectAllDiv);
  709.  
  710.  
  711.  
  712. // Videos and their subtitles
  713. videos.forEach(video => {
  714. const videoDiv = document.createElement('div');
  715. videoDiv.style.cssText = 'margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; border-radius: 4px;';
  716.  
  717. const title = document.createElement('h3');
  718. title.style.margin = '0 0 10px 0';
  719. title.textContent = video.title;
  720. videoDiv.appendChild(title);
  721.  
  722. if (video.subtitles.length > 0) {
  723. video.subtitles.forEach(track => {
  724. const trackDiv = document.createElement('div');
  725. trackDiv.style.margin = '5px 0';
  726. trackDiv.innerHTML = `
  727. <label>
  728. <input type="checkbox"
  729. data-video-id="${video.videoId}"
  730. data-lang="${track.languageCode}">
  731. ${track.languageName}
  732. </label>
  733. `;
  734. videoDiv.appendChild(trackDiv);
  735. });
  736. } else {
  737. const noSubs = document.createElement('p');
  738. noSubs.style.color = '#c00';
  739. noSubs.textContent = CONFIG.MESSAGES.NO_SUBTITLE;
  740. videoDiv.appendChild(noSubs);
  741. }
  742.  
  743. content.appendChild(videoDiv);
  744. });
  745.  
  746. // Action buttons
  747. const actions = document.createElement('div');
  748. actions.style.cssText = 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;';
  749.  
  750. const downloadBtn = document.createElement('button');
  751. downloadBtn.textContent = 'Download Selected';
  752. downloadBtn.onclick = async () => {
  753. const tracks = getSelectedTracks();
  754. const format = document.querySelector('input[name="format"]:checked').value;
  755.  
  756. if (tracks.length === 0) {
  757. UIComponents.showToast('Please select at least one subtitle');
  758. return;
  759. }
  760.  
  761. try {
  762. const selectedTracks = tracks.map(track => ({
  763. ...track,
  764. baseUrl: track.baseUrl
  765. }));
  766. await SubtitleService.downloadSubtitles(selectedTracks, format);
  767. UIComponents.showToast('Download complete!');
  768. } catch (error) {
  769. UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
  770. console.error('Download error:', error);
  771. }
  772. };
  773. actions.appendChild(downloadBtn);
  774.  
  775. const copyBtn = document.createElement('button');
  776. copyBtn.textContent = 'Copy Selected';
  777. copyBtn.onclick = async () => {
  778. const tracks = getSelectedTracks();
  779. const format = document.querySelector('input[name="format"]:checked').value;
  780.  
  781. if (tracks.length === 0) {
  782. UIComponents.showToast('Please select at least one subtitle');
  783. return;
  784. }
  785.  
  786. try {
  787. await SubtitleService.copySubtitles(tracks, format);
  788. } catch (error) {
  789. UIComponents.showToast(CONFIG.MESSAGES.ERROR.COPY);
  790. console.error('Copy error:', error);
  791. }
  792. };
  793. actions.appendChild(copyBtn);
  794.  
  795. content.appendChild(actions);
  796.  
  797. UIComponents.showDialog('Select Subtitles to Download', content, () => {
  798. this.deactivateSelection();
  799. });
  800. }
  801. }
  802.  
  803. // Single Video Downloader
  804. class SingleVideoDownloader {
  805. async downloadCurrentVideo() {
  806. const videoId = this.getCurrentVideoId();
  807. if (!videoId) {
  808. UIComponents.showToast(CONFIG.MESSAGES.ERROR.NO_VIDEO);
  809. return;
  810. }
  811.  
  812. const loading = UIComponents.showLoading();
  813.  
  814. try {
  815. const tracks = await SubtitleService.fetchSubtitleTracks(videoId);
  816. if (!tracks?.length) {
  817. UIComponents.showToast(CONFIG.MESSAGES.NO_SUBTITLE);
  818. return;
  819. }
  820.  
  821. const videoTitle = document.title.split(' - YouTube')[0] || `Video_${videoId}`;
  822. this.showSubtitleDialog(tracks, videoTitle);
  823. } catch (error) {
  824. UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
  825. console.error('Failed to fetch subtitles:', error);
  826. } finally {
  827. loading.remove();
  828. }
  829. }
  830.  
  831. getCurrentVideoId() {
  832. const urlParams = new URLSearchParams(window.location.search);
  833. return urlParams.get('v');
  834. }
  835.  
  836. showSubtitleDialog(tracks, videoTitle) {
  837. const getSelectedTracks = () => {
  838. return Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
  839. .map(cb => {
  840. const track = tracks.find(t => t.languageCode === cb.dataset.lang);
  841. return track ? { ...track, videoTitle } : null;
  842. })
  843. .filter(Boolean);
  844. };
  845.  
  846. const content = UIComponents.createSubtitleDialog(
  847. tracks,
  848. CONFIG.FORMATS.SRT,
  849. async () => {
  850. const selectedTracks = getSelectedTracks();
  851. const format = document.querySelector('input[name="format"]:checked').value;
  852.  
  853. if (selectedTracks.length === 0) {
  854. UIComponents.showToast('Please select at least one subtitle');
  855. return;
  856. }
  857.  
  858. try {
  859. const tracksWithTitle = selectedTracks.map(track => ({
  860. ...track,
  861. videoTitle
  862. }));
  863. await SubtitleService.downloadSubtitles(tracksWithTitle, format);
  864. UIComponents.showToast('Download complete!');
  865. } catch (error) {
  866. UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
  867. console.error('Download error:', error);
  868. }
  869. },
  870. async () => {
  871. const selectedTracks = getSelectedTracks();
  872. const format = document.querySelector('input[name="format"]:checked').value;
  873.  
  874. if (selectedTracks.length === 0) {
  875. UIComponents.showToast('Please select at least one subtitle');
  876. return;
  877. }
  878.  
  879. try {
  880. await SubtitleService.copySubtitles(selectedTracks, format, videoTitle);
  881. } catch (error) {
  882. UIComponents.showToast(CONFIG.MESSAGES.ERROR.COPY);
  883. console.error('Copy error:', error);
  884. }
  885. }
  886. );
  887.  
  888. UIComponents.showDialog('Select Subtitles to Download', content);
  889. }
  890. }
  891.  
  892. // Main manager class
  893. class YouTubeSubtitleManager {
  894. constructor() {
  895. this.singleMode = new SingleVideoDownloader();
  896. this.bulkMode = new VideoSelector();
  897. this.registerCommands();
  898. this.setupNavigationHandler();
  899. }
  900.  
  901. registerCommands() {
  902. GM_registerMenuCommand('Download Current Video Subtitles',
  903. () => this.singleMode.downloadCurrentVideo());
  904. GM_registerMenuCommand('Select Videos for Subtitles',
  905. () => this.bulkMode.toggleVideoSelection());
  906. }
  907.  
  908. setupNavigationHandler() {
  909. document.addEventListener('yt-navigate-finish', () => {
  910. if (this.bulkMode.selectionActive) {
  911. this.bulkMode.deactivateSelection();
  912. this.bulkMode.activateSelection();
  913. }
  914. });
  915. }
  916. }
  917.  
  918. // Initialize the manager
  919. new YouTubeSubtitleManager();
  920.  
  921. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址