Coursera Auto Subtitle

Automatically enables, enhances, and translates subtitles on Coursera. Features include a draggable icon, customizable language selection, and real-time translation using Google Translate.

  1. // ==UserScript==
  2. // @name Coursera Auto Subtitle
  3. // @namespace https://github.com/htrnguyen/Coursera-Auto-Subtitle
  4. // @version 1.0
  5. // @description Automatically enables, enhances, and translates subtitles on Coursera. Features include a draggable icon, customizable language selection, and real-time translation using Google Translate.
  6. // @author Hà Trọng Nguyễn (htrnguyen)
  7. // @match https://www.coursera.org/learn/*
  8. // @grant GM_xmlhttpRequest
  9. // @connect translate.googleapis.com
  10. // @license MIT
  11. // @icon https://github.com/htrnguyen/Coursera-Auto-Subtitle/raw/main/coursera-auto-subtitle-logo.png
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16.  
  17. const CONFIG = {
  18. translateSubtitles: true,
  19. maxRetries: 3,
  20. retryDelay: 1000,
  21. };
  22.  
  23. let isSubtitlesEnabled = false;
  24. let subtitleDisplayElement = null;
  25. let targetLanguage = 'en'; // Default language is English
  26.  
  27. const LANGUAGES = {
  28. vi: 'Tiếng Việt',
  29. en: 'English',
  30. zh: '中文 (简体)',
  31. ja: '日本語',
  32. ko: '한국어',
  33. fr: 'Français',
  34. };
  35.  
  36. let icon, menu;
  37.  
  38. function createDraggableIcon() {
  39. icon = document.createElement('img');
  40. icon.src = 'https://github.com/htrnguyen/Coursera-Auto-Subtitle/raw/main/coursera-auto-subtitle-logo.png';
  41. icon.style.position = 'fixed';
  42. icon.style.top = '20px';
  43. icon.style.left = '20px';
  44. icon.style.zIndex = '9999';
  45. icon.style.cursor = 'pointer';
  46. icon.style.width = '32px';
  47. icon.style.height = '32px';
  48. icon.style.userSelect = 'none';
  49.  
  50. let isDragging = false;
  51. let offsetX, offsetY;
  52.  
  53. icon.addEventListener('mousedown', (event) => {
  54. isDragging = true;
  55. offsetX = event.clientX - icon.getBoundingClientRect().left;
  56. offsetY = event.clientY - icon.getBoundingClientRect().top;
  57. icon.style.cursor = 'grabbing';
  58. });
  59.  
  60. document.addEventListener('mousemove', (event) => {
  61. if (isDragging) {
  62. const newLeft = event.clientX - offsetX;
  63. const newTop = event.clientY - offsetY;
  64.  
  65. // Giới hạn icon trong phạm vi màn hình
  66. icon.style.left = `${Math.max(0, Math.min(window.innerWidth - icon.offsetWidth, newLeft))}px`;
  67. icon.style.top = `${Math.max(0, Math.min(window.innerHeight - icon.offsetHeight, newTop))}px`;
  68.  
  69. if (menu) {
  70. updateMenuPosition();
  71. }
  72. }
  73. });
  74.  
  75. document.addEventListener('mouseup', () => {
  76. if (isDragging) {
  77. isDragging = false;
  78. icon.style.cursor = 'pointer';
  79. }
  80. });
  81.  
  82. icon.addEventListener('click', (event) => {
  83. event.stopPropagation();
  84. showMenu();
  85. });
  86.  
  87. document.body.appendChild(icon);
  88. }
  89.  
  90. function updateMenuPosition() {
  91. const iconRect = icon.getBoundingClientRect();
  92. const menuWidth = 180; // Chiều rộng menu
  93. const menuHeight = 120; // Chiều cao menu (ước lượng)
  94.  
  95. // Kiểm tra vị trí icon để hiển thị menu phù hợp
  96. if (iconRect.left + icon.offsetWidth + menuWidth > window.innerWidth) {
  97. // Icon ở viền phải, hiển thị menu bên trái
  98. menu.style.left = `${iconRect.left - menuWidth}px`;
  99. menu.style.top = `${iconRect.top}px`;
  100. } else if (iconRect.top + icon.offsetHeight + menuHeight > window.innerHeight) {
  101. // Icon ở viền dưới, hiển thị menu bên trên
  102. menu.style.left = `${iconRect.left + icon.offsetWidth}px`;
  103. menu.style.top = `${iconRect.top - menuHeight}px`;
  104. } else if (iconRect.top - menuHeight < 0) {
  105. // Icon ở viền trên, hiển thị menu bên dưới
  106. menu.style.left = `${iconRect.left + icon.offsetWidth}px`;
  107. menu.style.top = `${iconRect.top + icon.offsetHeight}px`;
  108. } else {
  109. // Mặc định hiển thị menu bên phải
  110. menu.style.left = `${iconRect.left + icon.offsetWidth}px`;
  111. menu.style.top = `${iconRect.top}px`;
  112. }
  113. }
  114.  
  115. function showMenu() {
  116. if (menu) {
  117. menu.remove();
  118. menu = null;
  119. return;
  120. }
  121.  
  122. menu = document.createElement('div');
  123. menu.classList.add('subtitle-menu');
  124. menu.style.position = 'fixed';
  125. menu.style.backgroundColor = 'white';
  126. menu.style.border = '1px solid #ccc';
  127. menu.style.borderRadius = '5px';
  128. menu.style.padding = '10px';
  129. menu.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
  130. menu.style.zIndex = '10000';
  131. menu.style.width = '180px';
  132.  
  133. updateMenuPosition();
  134.  
  135. const toggleButton = document.createElement('button');
  136. toggleButton.textContent = isSubtitlesEnabled ? 'Disable Subtitles' : 'Enable Subtitles';
  137. toggleButton.style.display = 'block';
  138. toggleButton.style.width = '100%';
  139. toggleButton.style.marginBottom = '10px';
  140. toggleButton.style.padding = '8px';
  141. toggleButton.style.cursor = 'pointer';
  142. toggleButton.style.fontSize = '14px';
  143.  
  144. toggleButton.addEventListener('click', (event) => {
  145. event.stopPropagation();
  146. isSubtitlesEnabled = !isSubtitlesEnabled;
  147. toggleButton.textContent = isSubtitlesEnabled ? 'Disable Subtitles' : 'Enable Subtitles';
  148. if (isSubtitlesEnabled) {
  149. enableSubtitles();
  150. } else {
  151. disableSubtitles();
  152. }
  153. menu.remove();
  154. menu = null;
  155. });
  156.  
  157. const languageSelect = document.createElement('select');
  158. languageSelect.style.display = 'block';
  159. languageSelect.style.width = '100%';
  160. languageSelect.style.padding = '8px';
  161. languageSelect.style.cursor = 'pointer';
  162. languageSelect.style.fontSize = '14px';
  163.  
  164. for (const [code, name] of Object.entries(LANGUAGES)) {
  165. const option = document.createElement('option');
  166. option.value = code;
  167. option.textContent = name;
  168. if (code === targetLanguage) option.selected = true;
  169. languageSelect.appendChild(option);
  170. }
  171.  
  172. languageSelect.addEventListener('click', (event) => {
  173. event.stopPropagation();
  174. });
  175.  
  176. languageSelect.addEventListener('change', (event) => {
  177. event.stopPropagation();
  178. targetLanguage = event.target.value;
  179. if (isSubtitlesEnabled) {
  180. handleSubtitles();
  181. }
  182. });
  183.  
  184. menu.appendChild(toggleButton);
  185. menu.appendChild(languageSelect);
  186. document.body.appendChild(menu);
  187.  
  188. document.addEventListener('click', (event) => {
  189. if (!menu.contains(event.target) && !icon.contains(event.target)) {
  190. menu.remove();
  191. menu = null;
  192. }
  193. });
  194. }
  195.  
  196. function enableSubtitles() {
  197. if (!subtitleDisplayElement) {
  198. createSubtitleDisplay();
  199. }
  200. subtitleDisplayElement.style.display = 'block';
  201. handleSubtitles();
  202. }
  203.  
  204. function disableSubtitles() {
  205. if (subtitleDisplayElement) {
  206. subtitleDisplayElement.style.display = 'none';
  207. }
  208. }
  209.  
  210. function createSubtitleDisplay() {
  211. subtitleDisplayElement = document.createElement('div');
  212. subtitleDisplayElement.style.position = 'absolute';
  213. subtitleDisplayElement.style.bottom = '20px';
  214. subtitleDisplayElement.style.left = '50%';
  215. subtitleDisplayElement.style.transform = 'translateX(-50%)';
  216. subtitleDisplayElement.style.color = 'white';
  217. subtitleDisplayElement.style.fontSize = '16px';
  218. subtitleDisplayElement.style.zIndex = '10000';
  219. subtitleDisplayElement.style.textAlign = 'center';
  220. subtitleDisplayElement.style.maxWidth = '80%';
  221. subtitleDisplayElement.style.whiteSpace = 'pre-wrap';
  222. subtitleDisplayElement.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
  223. subtitleDisplayElement.style.padding = '10px';
  224. subtitleDisplayElement.style.borderRadius = '5px';
  225.  
  226. const videoElement = document.querySelector('video.vjs-tech');
  227. if (videoElement) {
  228. videoElement.parentElement.appendChild(subtitleDisplayElement);
  229. }
  230. }
  231.  
  232. async function translateSubtitles(text, targetLang) {
  233. return new Promise((resolve, reject) => {
  234. const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
  235.  
  236. GM_xmlhttpRequest({
  237. method: 'GET',
  238. url: url,
  239. onload: (response) => {
  240. try {
  241. const data = JSON.parse(response.responseText);
  242. if (data && data[0] && data[0][0] && data[0][0][0]) {
  243. resolve(data[0][0][0]);
  244. } else {
  245. reject('Translation failed: No translated text');
  246. }
  247. } catch (error) {
  248. reject(error);
  249. }
  250. },
  251. onerror: (error) => {
  252. reject(error);
  253. },
  254. });
  255. });
  256. }
  257.  
  258. async function handleSubtitles() {
  259. const videoElement = document.querySelector('video.vjs-tech');
  260. if (!videoElement) return;
  261.  
  262. const tracks = videoElement.textTracks;
  263. if (!tracks || tracks.length === 0) return;
  264.  
  265. const track = tracks[0];
  266. track.mode = 'hidden';
  267.  
  268. track.oncuechange = () => {
  269. const activeCue = track.activeCues[0];
  270. if (activeCue && isSubtitlesEnabled) {
  271. const originalText = activeCue.text;
  272.  
  273. if (CONFIG.translateSubtitles && originalText) {
  274. translateSubtitles(originalText, targetLanguage)
  275. .then((translatedText) => {
  276. if (subtitleDisplayElement) {
  277. subtitleDisplayElement.textContent = translatedText;
  278. }
  279. })
  280. .catch(() => {});
  281. }
  282. }
  283. };
  284. }
  285.  
  286. window.addEventListener('load', () => {
  287. createDraggableIcon();
  288. });
  289. })();