LingQ Addon

Provides custom LingQ layouts

安装此脚本?
作者推荐脚本

您可能也喜欢Simplify Embedded YouTube Player

安装此脚本
  1. // ==UserScript==
  2. // @name LingQ Addon
  3. // @description Provides custom LingQ layouts
  4. // @match https://www.lingq.com/*/learn/*/web/reader/*
  5. // @match https://www.lingq.com/*/learn/*/web/library/course/*
  6. // @exclude https://www.lingq.com/*/learn/*/web/editor/*
  7. // @version 5.3.4
  8. // @grant GM_setValue
  9. // @grant GM_getValue
  10. // @namespace https://greasyfork.org/users/1458847
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. "use strict";
  15.  
  16. const storage = {
  17. get: (key, defaultValue) => {
  18. const value = GM_getValue(key);
  19. return value === undefined ? defaultValue : value;
  20. },
  21. set: (key, value) => GM_setValue(key, value)
  22. };
  23. const defaults = {
  24. styleType: "video",
  25. colorMode: "dark",
  26. fontSize: 1.1,
  27. lineHeight: 1.7,
  28. heightBig: 400,
  29. sentenceHeight: 400,
  30. darkColors: {
  31. fontColor: "#e0e0e0",
  32. lingqBackground: "rgba(109, 89, 44, 0.7)",
  33. lingqBorder: "rgba(254, 203, 72, 0.3)",
  34. lingqBorderLearned: "rgba(254, 203, 72, 0.5)",
  35. knownBackground: "rgba(37, 57, 82, 0.7)",
  36. knownBorder: "rgba(72, 154, 254, 0.5)",
  37. playingUnderline: "#ffffff"
  38. },
  39. whiteColors: {
  40. fontColor: "#000000",
  41. lingqBackground: "rgba(255, 200, 0, 0.4)",
  42. lingqBorder: "rgba(255, 200, 0, 0.3)",
  43. lingqBorderLearned: "rgba(255, 200, 0, 1)",
  44. knownBackground: "rgba(198, 223, 255, 0.7)",
  45. knownBorder: "rgba(0, 111, 255, 0.3)",
  46. playingUnderline: "#000000"
  47. },
  48. librarySortOption: 0,
  49. autoFinishing: false,
  50. chatWidget: false,
  51. llmProvider: "openai",
  52. llmApiKey: "",
  53. askSelected: false,
  54. tts: false,
  55. ttsApiKey: "",
  56. ttsVoice: "alloy",
  57. };
  58.  
  59. const settings = {
  60. styleType: storage.get("styleType", defaults.styleType),
  61. colorMode: storage.get("colorMode", defaults.colorMode),
  62. fontSize: storage.get("fontSize", defaults.fontSize),
  63. lineHeight: storage.get("lineHeight", defaults.lineHeight),
  64. heightBig: storage.get("heightBig", defaults.heightBig),
  65. sentenceHeight: storage.get("sentenceHeight", defaults.sentenceHeight),
  66. librarySortOption: storage.get("librarySortOption", defaults.librarySortOption),
  67. get autoFinishing() { return storage.get("autoFinishing", defaults.autoFinishing); },
  68. get chatWidget() { return storage.get("chatWidget", defaults.chatWidget); },
  69. get llmProvider() { return storage.get("llmProvider", defaults.llmProvider); },
  70. get llmApiKey() { return storage.get("llmApiKey", defaults.llmApiKey); },
  71. get askSelected() { return storage.get("askSelected", defaults.askSelected); },
  72. get tts() { return storage.get("tts", defaults.tts); },
  73. get ttsApiKey() { return storage.get("ttsApiKey", defaults.ttsApiKey); },
  74. get ttsVoice() { return storage.get("ttsVoice", defaults.ttsVoice); },
  75. };
  76.  
  77. const colorSettings = getColorSettings(settings.colorMode);
  78.  
  79. let styleElement = null;
  80.  
  81. function getColorSettings(colorMode) {
  82. const prefix = colorMode === "dark" ? "dark_" : "white_";
  83. const defaultColors = colorMode === "dark" ? defaults.darkColors : defaults.whiteColors;
  84.  
  85. return {
  86. fontColor: storage.get(prefix + "fontColor", defaultColors.fontColor),
  87. lingqBackground: storage.get(prefix + "lingqBackground", defaultColors.lingqBackground),
  88. lingqBorder: storage.get(prefix + "lingqBorder", defaultColors.lingqBorder),
  89. lingqBorderLearned: storage.get(prefix + "lingqBorderLearned", defaultColors.lingqBorderLearned),
  90. knownBackground: storage.get(prefix + "knownBackground", defaultColors.knownBackground),
  91. knownBorder: storage.get(prefix + "knownBorder", defaultColors.knownBorder),
  92. playingUnderline: storage.get(prefix + "playingUnderline", defaultColors.playingUnderline)
  93. };
  94. }
  95.  
  96. function createElement(tag, props = {}) {
  97. const element = document.createElement(tag);
  98. Object.entries(props).forEach(([key, value]) => {
  99. if (key === "style" && typeof value === "string") {
  100. element.style.cssText = value;
  101. } else if (key === "textContent") {
  102. element.textContent = value;
  103. } else {
  104. element[key] = value;
  105. }
  106. });
  107. return element;
  108. }
  109.  
  110. function createSettingsPopup() {
  111. const popup = createElement("div", {id: "lingqAddonSettingsPopup"});
  112.  
  113. // drag handle
  114. const dragHandle = createElement("div", {id: "lingqAddonSettingsDragHandle"});
  115.  
  116. const dragHandleTitle = createElement("h3", {textContent: "LingQ Addon Settings"});
  117. dragHandle.appendChild(dragHandleTitle);
  118.  
  119. // popup content
  120. const content = createElement("div", {style: `padding: 0 5px;`});
  121. const popupContentElement = generatePopupContent();
  122. content.appendChild(popupContentElement);
  123.  
  124. popup.appendChild(dragHandle);
  125. popup.appendChild(content);
  126.  
  127. return popup;
  128. }
  129.  
  130. function generatePopupContent() {
  131. function addSelect(parent, id, labelText, options, selectedValue) {
  132. const container = createElement("div", {className: "popup-row"});
  133. container.appendChild(createElement("label", {htmlFor: id, textContent: labelText}));
  134.  
  135. const select = createElement("select", {id});
  136. options.forEach(option => {
  137. select.appendChild(createElement("option", {value: option.value, textContent: option.text, selected: selectedValue === option.value}));
  138. });
  139.  
  140. container.appendChild(select);
  141. parent.appendChild(container);
  142. return container;
  143. }
  144.  
  145. function addSlider(parent, id, labelText, valueId, value, unit, min, max, step) {
  146. const container = createElement("div", {className: "popup-row"});
  147.  
  148. const label = createElement("label", { htmlFor: id });
  149. label.appendChild(document.createTextNode(labelText + " "));
  150. label.appendChild(createElement("span", { id: valueId, textContent: value }));
  151. if (unit) label.appendChild(document.createTextNode(unit));
  152.  
  153. container.appendChild(label);
  154. container.appendChild(createElement("input", {type: "range", id, min, max, step, value, style: "width: 100%;"}));
  155.  
  156. parent.appendChild(container);
  157. return container;
  158. }
  159.  
  160. function addColorPicker(parent, id, labelText, value) {
  161. const container = createElement("div", {className: "popup-row"});
  162. container.appendChild(createElement("label", {htmlFor: id + "Text", textContent: labelText}));
  163.  
  164. const flexContainer = createElement("div", {style: "display: flex; align-items: center;"});
  165. flexContainer.appendChild(createElement("div", {id: id + "Picker", className: "color-picker" }));
  166. flexContainer.appendChild(createElement("input", {type: "text", id: id + "Text", value, style: "margin-left: 10px;", className: "popup-input"}));
  167.  
  168. container.appendChild(flexContainer);
  169. parent.appendChild(container);
  170. return container;
  171. }
  172.  
  173. function addCheckbox(parent, id, labelText, checked) {
  174. const container = createElement("div", {className: "popup-row"});
  175. const label = createElement("label", {htmlFor: id, textContent: labelText});
  176. const checkbox = createElement("input", {type: "checkbox", id, checked, style: "margin-left: 10px;"});
  177.  
  178. label.style.display = "flex";
  179. label.style.alignItems = "center";
  180. container.appendChild(label);
  181. label.appendChild(checkbox);
  182. parent.appendChild(container);
  183.  
  184. return container;
  185. }
  186.  
  187. const popupLayout = createElement("div");
  188. const columns = createElement("div", {style: "display: flex; flex-direction: row;"});
  189.  
  190. const container = createElement("div", {style: "padding: 5px; width: 350px;"});
  191.  
  192. addSelect(container, "styleTypeSelector", "Layout Style:", [
  193. { value: "video", text: "Video" },
  194. { value: "video2", text: "Video2" },
  195. { value: "audio", text: "Audio" },
  196. { value: "off", text: "Off" }
  197. ], settings.styleType);
  198.  
  199. const videoSettings = createElement("div", {
  200. id: "videoSettings",
  201. style: `${settings.styleType === "video" ? "" : "display: none"}`
  202. });
  203. addSlider(videoSettings, "heightBigSlider", "Video Height:", "heightBigValue", settings.heightBig, "px", 300, 800, 10);
  204. container.appendChild(videoSettings);
  205.  
  206. const sentenceVideoSettings = createElement("div", {
  207. id: "sentenceVideoSettings",
  208. style: `${settings.styleType === "off" ? "" : "display: none"}`
  209. });
  210. addSlider(sentenceVideoSettings, "sentenceHeightSlider", "Sentence Video Height:", "sentenceHeightValue", settings.heightBig, "px", 300, 600, 10);
  211. container.appendChild(sentenceVideoSettings);
  212.  
  213. addSlider(container, "fontSizeSlider", "Font Size:", "fontSizeValue", settings.fontSize, "rem", 0.8, 1.8, 0.05);
  214. addSlider(container, "lineHeightSlider", "Line Height:", "lineHeightValue", settings.lineHeight, "", 1.2, 3.0, 0.1);
  215.  
  216. const colorSection = createElement("div", {className: "popup-section"});
  217.  
  218. addSelect(colorSection, "colorModeSelector", "Color Mode:", [
  219. { value: "dark", text: "Dark" },
  220. { value: "white", text: "White" }
  221. ], settings.colorMode);
  222.  
  223. [
  224. { id: "fontColor", label: "Font Color:", value: colorSettings.fontColor },
  225. { id: "lingqBackground", label: "LingQ Background:", value: colorSettings.lingqBackground },
  226. { id: "lingqBorder", label: "LingQ Border:", value: colorSettings.lingqBorder },
  227. { id: "lingqBorderLearned", label: "LingQ Border Learned:", value: colorSettings.lingqBorderLearned },
  228. { id: "knownBackground", label: "Known Background:", value: colorSettings.knownBackground },
  229. { id: "knownBorder", label: "Known Border:", value: colorSettings.knownBorder },
  230. { id: "playingUnderline", label: "Playing Underline:", value: colorSettings.playingUnderline }
  231. ].forEach(config => addColorPicker(colorSection, config.id, config.label, config.value));
  232.  
  233. container.appendChild(colorSection);
  234.  
  235. addCheckbox(container, "autoFinishingCheckbox", "Finish Lesson Automatically", settings.autoFinishing);
  236.  
  237. columns.appendChild(container);
  238.  
  239. const llmContainer = createElement("div", {style: "padding: 10px; width: 350px;"});
  240.  
  241. addCheckbox(llmContainer, "chatWidgetCheckbox", "Enable the Chat Widget", settings.chatWidget);
  242.  
  243. const llmSection = createElement("div", {id: "llmSection", className: "popup-section", style: `${settings.chatWidget ? "" : "display: none"}`});
  244.  
  245. addSelect(llmSection, "llmProviderSelector", "LLM Provider:", [
  246. { value: "openai", text: "OpenAI (GPT-4.1 nano)" },
  247. { value: "google", text: "Google (Gemini 2.0 Flash)" }
  248. ], settings.llmProvider);
  249.  
  250. const apiKeyContainer = createElement("div", {className: "popup-row"});
  251. apiKeyContainer.appendChild(createElement("label", {htmlFor: "llmApiKeyInput", textContent: "API Key:"}));
  252.  
  253. const apiKeyFlexContainer = createElement("div", {style: "display: flex; align-items: center;"});
  254. const apiKeyInput= createElement("input", {type: "password", id: "llmApiKeyInput", value: settings.llmApiKey, className: "popup-input"});
  255. apiKeyFlexContainer.appendChild(apiKeyInput)
  256. apiKeyContainer.appendChild(apiKeyFlexContainer);
  257. llmSection.appendChild(apiKeyContainer);
  258.  
  259. addCheckbox(llmSection, "askSelectedCheckbox", "Enable asking with selected text", settings.askSelected);
  260.  
  261. llmContainer.appendChild(llmSection);
  262.  
  263. addCheckbox(llmContainer, "ttsCheckbox", "Enable AI-TTS", settings.tts);
  264.  
  265. const ttsSection = createElement("div", {id: "ttsSection", className: "popup-section", style: `${settings.tts ? "" : "display: none"}`});
  266.  
  267. const ttsApiKeyContainer = createElement("div", {className: "popup-row"});
  268. ttsApiKeyContainer.appendChild(createElement("label", {htmlFor: "ttsApiKeyInput", textContent: "OpenAI API Key:"}));
  269.  
  270. const ttsApiKeyFlexContainer = createElement("div", {style: "display: flex; align-items: center;"});
  271. const ttsApiKeyInput= createElement("input", {type: "password", id: "ttsApiKeyInput", value: settings.ttsApiKey, className: "popup-input"});
  272. ttsApiKeyFlexContainer.appendChild(ttsApiKeyInput)
  273. ttsApiKeyContainer.appendChild(ttsApiKeyFlexContainer);
  274. ttsSection.appendChild(ttsApiKeyContainer);
  275.  
  276. addSelect(ttsSection, "ttsVoiceSelector", "TTS Voice:", [
  277. { value: "alloy", text: "alloy" },
  278. { value: "ash", text: "ash" },
  279. { value: "ballad", text: "ballad" },
  280. { value: "coral", text: "coral" },
  281. { value: "echo", text: "onyx" },
  282. { value: "fable", text: "onyx" },
  283. { value: "onyx", text: "onyx" },
  284. { value: "nova", text: "nova" },
  285. { value: "sage", text: "sage" },
  286. { value: "shimmer", text: "onyx" },
  287. { value: "verse", text: "verse" },
  288. ], settings.ttsVoice);
  289.  
  290. llmContainer.appendChild(ttsSection);
  291.  
  292. columns.appendChild(llmContainer);
  293.  
  294. const buttonContainer = createElement("div", {style: "display: flex; justify-content: space-between;", className: "popup-row"});
  295. [
  296. {id: "resetSettingsBtn", textContent: "Reset", className: "popup-button"},
  297. {id: "closeSettingsBtn", textContent: "Close", className: "popup-button"}
  298. ].forEach((prop) => {
  299. buttonContainer.appendChild(createElement("button", prop));
  300. });
  301.  
  302. popupLayout.appendChild(columns)
  303. popupLayout.appendChild(buttonContainer);
  304. return popupLayout;
  305. }
  306.  
  307. function createDownloadWordsPopup() {
  308. const popup = createElement("div", {id: "lingqDownloadWordsPopup"});
  309.  
  310. // drag handle
  311. const dragHandle = createElement("div", {id: "lingqDownloadWordsDragHandle"});
  312.  
  313. const dragHandleTitle = createElement("h3", {textContent: "Download Words"});
  314. dragHandle.appendChild(dragHandleTitle);
  315.  
  316. const content = createElement("div", {style: `padding: 0 10px;`});
  317.  
  318. [
  319. {id: "downloadUnknownLingqsBtn", textContent: "Download Unknown LingQs (words + phrases)", className: "popup-button"},
  320. {id: "downloadUnknownLingqWordsBtn", textContent: "Download Unknown LingQ Words (1, 2, 3, 4)", className: "popup-button"},
  321. {id: "downloadUnknownLingqPhrasesBtn", textContent: "Download Unknown LingQ Phrases (1, 2, 3, 4)", className: "popup-button"},
  322. {id: "downloadKnownLingqsBtn", textContent: "Download Known LingQs (✓)", className: "popup-button"},
  323. {id: "downloadKnownWordsBtn", textContent: "Download Known Words ", className: "popup-button"}
  324. ].forEach((prop) => {
  325. let rowContainer = createElement("div", {className: "popup-row"});
  326. rowContainer.appendChild(createElement("button", prop))
  327. content.appendChild(rowContainer);
  328. });
  329.  
  330. // Progress Bar Elements
  331. const progressContainer = createElement("div", {id: "downloadProgressContainer", className: "popup-row"});
  332. const progressText = createElement("div", {id: "downloadProgressText"});
  333. const progressBar = createElement("progress", {id: "downloadProgressBar", value: "0", max: "100"});
  334.  
  335. progressContainer.appendChild(progressText);
  336. progressContainer.appendChild(progressBar);
  337. content.appendChild(progressContainer);
  338.  
  339. const buttonContainer = createElement("div", {style: "display: flex; justify-content: flex-end;", className: "popup-row"});
  340. const closeButton = createElement("button", {id: "closeDownloadWordsBtn", textContent: "Close", className: "popup-button"});
  341. buttonContainer.appendChild(closeButton);
  342. content.appendChild(buttonContainer);
  343.  
  344. popup.appendChild(dragHandle);
  345. popup.appendChild(content);
  346.  
  347. return popup;
  348. }
  349.  
  350. function createUI() {
  351. // Create settings button
  352. const settingsButton = createElement("button", {
  353. id: "lingqAddonSettings",
  354. textContent: "⚙️",
  355. title: "LingQ Addon Settings",
  356. className: "nav-button"
  357. });
  358.  
  359. // Create lesson complete button
  360. const completeLessonButton = createElement("button", {
  361. id: "lingqLessonComplete",
  362. textContent: "✔",
  363. title: "Complete Lesson Button",
  364. className: "nav-button"
  365. });
  366.  
  367. // Create download words button
  368. const downloadWordsButton = createElement("button", {
  369. id: "lingqDownloadWords",
  370. textContent: "💾",
  371. title: "Download Words",
  372. className: "nav-button"
  373. });
  374.  
  375. // Find the #main-nav element
  376. let mainNav = document.querySelector("#main-nav > nav > div:nth-child(2) > div:nth-child(1)");
  377.  
  378. if (mainNav) {
  379. mainNav.appendChild(settingsButton);
  380. mainNav.appendChild(downloadWordsButton);
  381. mainNav.appendChild(completeLessonButton);
  382. } else {
  383. console.error("#main-nav element not found. Buttons not inserted.");
  384. }
  385.  
  386. // Create settings popup
  387. const settingsPopup = createSettingsPopup();
  388. document.body.appendChild(settingsPopup);
  389.  
  390. // Create download words popup
  391. const downloadWordsPopup = createDownloadWordsPopup();
  392. document.body.appendChild(downloadWordsPopup);
  393.  
  394. // Add event listeners
  395. setupSettingEventListeners(settingsButton, settingsPopup);
  396. setupDownloadWordsEventListeners(downloadWordsButton, downloadWordsPopup);
  397. setupEventListeners()
  398. }
  399.  
  400. function makeDraggable(element, handle) {
  401. let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
  402.  
  403. if (handle) {
  404. handle.onmousedown = dragMouseDown;
  405. } else {
  406. element.onmousedown = dragMouseDown;
  407. }
  408.  
  409. function dragMouseDown(e) {
  410. e = e || window.event;
  411. e.preventDefault();
  412.  
  413. if (element.style.transform && element.style.transform.includes('translate')) {
  414. const rect = element.getBoundingClientRect();
  415.  
  416. element.style.transform = 'none';
  417. element.style.top = rect.top + 'px';
  418. element.style.left = rect.left + 'px';
  419. }
  420.  
  421. pos3 = e.clientX;
  422. pos4 = e.clientY;
  423. document.onmouseup = closeDragElement;
  424. document.onmousemove = elementDrag;
  425. }
  426.  
  427. function elementDrag(e) {
  428. e = e || window.event;
  429. e.preventDefault();
  430.  
  431. pos1 = pos3 - e.clientX;
  432. pos2 = pos4 - e.clientY;
  433. pos3 = e.clientX;
  434. pos4 = e.clientY;
  435.  
  436. element.style.top = (element.offsetTop - pos2) + "px";
  437. element.style.left = (element.offsetLeft - pos1) + "px";
  438. }
  439.  
  440. function closeDragElement() {
  441. document.onmouseup = null;
  442. document.onmousemove = null;
  443. }
  444. }
  445.  
  446. function setupSettingEventListeners(settingsButton, settingsPopup) {
  447. function initializePickrs() {
  448. function setupRGBAPickr(pickerId, textId, settingKey, cssVar) {
  449. function saveColorSetting(key, value) {
  450. const currentColorMode = document.getElementById("colorModeSelector").value;
  451. const prefix = currentColorMode === "dark" ? "dark_" : "white_";
  452. storage.set(prefix + key, value);
  453. }
  454.  
  455. const pickerElement = document.getElementById(pickerId);
  456. const textElement = document.getElementById(textId);
  457.  
  458. if (!pickerElement || !textElement) return;
  459.  
  460. pickerElement.style.backgroundColor = textElement.value;
  461.  
  462. const pickr = Pickr.create({
  463. el: pickerElement,
  464. theme: 'nano',
  465. useAsButton: true,
  466. default: textElement.value,
  467. components: {preview: true, opacity: true, hue: true}
  468. });
  469.  
  470. pickr.on('change', (color) => {
  471. const rgbaColor = color.toRGBA();
  472.  
  473. const r = Math.round(rgbaColor[0]);
  474. const g = Math.round(rgbaColor[1]);
  475. const b = Math.round(rgbaColor[2]);
  476. const a = rgbaColor[3];
  477.  
  478. const roundedRGBA = `rgba(${r}, ${g}, ${b}, ${a})`;
  479.  
  480. textElement.value = roundedRGBA;
  481. pickerElement.style.backgroundColor = roundedRGBA;
  482. document.documentElement.style.setProperty(cssVar, roundedRGBA);
  483.  
  484. saveColorSetting(settingKey, roundedRGBA);
  485. });
  486.  
  487. textElement.addEventListener('change', function () {
  488. const rgbaColor = this.value;
  489.  
  490. pickr.setColor(this.value);
  491. saveColorSetting(settingKey, rgbaColor);
  492. document.documentElement.style.setProperty(cssVar, rgbaColor);
  493. pickerElement.style.backgroundColor = rgbaColor;
  494. });
  495.  
  496. pickr.on('hide', () => {
  497. const rgbaColor = pickr.getColor().toRGBA().toString();
  498. pickerElement.style.backgroundColor = rgbaColor;
  499. });
  500. }
  501.  
  502. return new Promise((resolve) => {
  503. const pickrCss = createElement('link', {
  504. rel: 'stylesheet',
  505. href: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css'
  506. });
  507. document.head.appendChild(pickrCss);
  508.  
  509. const pickrScript = createElement('script', {
  510. src: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js',
  511. onload: () => resolve() // Pass function reference directly
  512. });
  513. document.head.appendChild(pickrScript);
  514. }).then(() => {
  515. setupRGBAPickr('lingqBackgroundPicker', 'lingqBackgroundText', 'lingqBackground', '--lingq_background');
  516. setupRGBAPickr('lingqBorderPicker', 'lingqBorderText', 'lingqBorder', '--lingq_border');
  517. setupRGBAPickr('lingqBorderLearnedPicker', 'lingqBorderLearnedText', 'lingqBorderLearned', '--lingq_border_learned');
  518. setupRGBAPickr('knownBackgroundPicker', 'knownBackgroundText', 'knownBackground', '--known_background');
  519. setupRGBAPickr('knownBorderPicker', 'knownBorderText', 'knownBorder', '--known_border');
  520. setupRGBAPickr('fontColorPicker', 'fontColorText', 'fontColor', '--font_color');
  521. setupRGBAPickr('playingUnderlinePicker', 'playingUnderlineText', 'playingUnderline', '--is_playing_underline');
  522. });
  523. }
  524.  
  525. settingsButton.addEventListener("click", () => {
  526. settingsPopup.style.display = "block";
  527. initializePickrs();
  528.  
  529. const dragHandle = document.getElementById("lingqAddonSettingsDragHandle");
  530. makeDraggable(settingsPopup, dragHandle);
  531. });
  532.  
  533. const styleTypeSelector = document.getElementById("styleTypeSelector");
  534. styleTypeSelector.addEventListener("change", (event) => {
  535. const selectedStyleType = event.target.value;
  536. storage.set("styleType", selectedStyleType);
  537. document.getElementById("videoSettings").style.display = selectedStyleType === "video" ? "block" : "none";
  538. document.getElementById("sentenceVideoSettings").style.display = selectedStyleType === "off" ? "block" : "none";
  539. applyStyles(selectedStyleType, document.getElementById("colorModeSelector").value);
  540. });
  541.  
  542. function updateColorInputs(colorSettings) {
  543. document.getElementById("fontColorText").value = colorSettings.fontColor;
  544. document.getElementById("lingqBackgroundText").value = colorSettings.lingqBackground;
  545. document.getElementById("lingqBorderText").value = colorSettings.lingqBorder;
  546. document.getElementById("lingqBorderLearnedText").value = colorSettings.lingqBorderLearned;
  547. document.getElementById("knownBackgroundText").value = colorSettings.knownBackground;
  548. document.getElementById("knownBorderText").value = colorSettings.knownBorder;
  549. document.getElementById("playingUnderlineText").value = colorSettings.playingUnderline;
  550.  
  551. const fontColorPicker = document.getElementById("fontColorPicker");
  552. if (fontColorPicker) fontColorPicker.style.backgroundColor = colorSettings.fontColor;
  553.  
  554. const playingUnderlinePicker = document.getElementById("playingUnderlinePicker");
  555. if (playingUnderlinePicker) playingUnderlinePicker.style.backgroundColor = colorSettings.playingUnderline;
  556. }
  557.  
  558. function updateColorPickerBackgrounds(colorSettings) {
  559. const pickerIds = [
  560. { id: "lingqBackgroundPicker", color: colorSettings.lingqBackground },
  561. { id: "lingqBorderPicker", color: colorSettings.lingqBorder },
  562. { id: "lingqBorderLearnedPicker", color: colorSettings.lingqBorderLearned },
  563. { id: "knownBackgroundPicker", color: colorSettings.knownBackground },
  564. { id: "knownBorderPicker", color: colorSettings.knownBorder },
  565. { id: "fontColorPicker", color: colorSettings.fontColor },
  566. { id: "playingUnderlinePicker", color: colorSettings.playingUnderline }
  567. ];
  568.  
  569. pickerIds.forEach(item => {
  570. const picker = document.getElementById(item.id);
  571. if (picker) {
  572. picker.style.backgroundColor = item.color;
  573. }
  574. });
  575. }
  576.  
  577. function updateCssColorVariables(colorSettings) {
  578. document.documentElement.style.setProperty("--font_color", colorSettings.fontColor);
  579. document.documentElement.style.setProperty("--lingq_background", colorSettings.lingqBackground);
  580. document.documentElement.style.setProperty("--lingq_border", colorSettings.lingqBorder);
  581. document.documentElement.style.setProperty("--lingq_border_learned", colorSettings.lingqBorderLearned);
  582. document.documentElement.style.setProperty("--known_background", colorSettings.knownBackground);
  583. document.documentElement.style.setProperty("--known_border", colorSettings.knownBorder);
  584. document.documentElement.style.setProperty("--is_playing_underline", colorSettings.playingUnderline);
  585. }
  586.  
  587. function updateColorMode(event) {
  588. event.stopPropagation();
  589.  
  590. const selectedColorMode = this.value;
  591. const settingsPopup = document.getElementById("lingqAddonSettingsPopup");
  592. settingsPopup.style.backgroundColor = selectedColorMode === "dark" ? "#2a2c2e" : "#ffffff";
  593.  
  594. storage.set("colorMode", selectedColorMode);
  595.  
  596. const colorSettings = getColorSettings(selectedColorMode);
  597.  
  598. updateColorInputs(colorSettings);
  599.  
  600. document.documentElement.style.setProperty(
  601. "--background-color",
  602. selectedColorMode === "dark" ? "#2a2c2e" : "#ffffff"
  603. );
  604. updateCssColorVariables(colorSettings);
  605.  
  606. applyStyles(document.getElementById("styleTypeSelector").value, selectedColorMode);
  607.  
  608. updateColorPickerBackgrounds(colorSettings);
  609. }
  610.  
  611. document.getElementById("colorModeSelector").addEventListener("change", updateColorMode);
  612.  
  613. function setupSlider(sliderId, valueId, settingKey, unit, cssVar, valueTransform) {
  614. const slider = document.getElementById(sliderId);
  615. const valueDisplay = document.getElementById(valueId);
  616.  
  617. slider.addEventListener("input", function () {
  618. const value = parseFloat(this.value);
  619. const transformedValue = valueTransform(value);
  620.  
  621. valueDisplay.textContent = transformedValue.toString().replace(unit, '');
  622. storage.set(settingKey, value);
  623. document.documentElement.style.setProperty(cssVar, transformedValue);
  624. });
  625. }
  626.  
  627. setupSlider("fontSizeSlider", "fontSizeValue", "fontSize", "rem", "--font_size", (val) => `${val}rem`);
  628. setupSlider("lineHeightSlider", "lineHeightValue", "lineHeight", "", "--line_height", (val) => val);
  629. setupSlider("heightBigSlider", "heightBigValue", "heightBig", "px", "--height_big", (val) => `${val}px`);
  630. setupSlider("sentenceHeightSlider", "sentenceHeightValue", "sentenceHeight", "px", "--sentence_height", (val) => `${val}px`);
  631.  
  632. const autoFinishingCheckbox = document.getElementById("autoFinishingCheckbox");
  633. autoFinishingCheckbox.addEventListener('change', (event) => {
  634. const checked = event.target.checked;
  635. storage.set("autoFinishing", checked);
  636. });
  637.  
  638. const chatWidgetCheckbox = document.getElementById("chatWidgetCheckbox");
  639. chatWidgetCheckbox.addEventListener('change', (event) => {
  640. const checked = event.target.checked;
  641. document.getElementById("llmSection").style.display = checked ? "block" : "none";
  642. storage.set("chatWidget", checked);
  643. });
  644.  
  645. const llmProviderSelector = document.getElementById("llmProviderSelector");
  646. llmProviderSelector.addEventListener("change", (event) => {
  647. const selectedProvider = event.target.value;
  648. storage.set("llmProvider", selectedProvider);
  649. });
  650.  
  651. const llmApiKeyInput = document.getElementById("llmApiKeyInput");
  652. llmApiKeyInput.addEventListener("change", (event) => {
  653. const apiKey = event.target.value;
  654. storage.set("llmApiKey", apiKey);
  655. });
  656.  
  657. const askSelectedCheckbox = document.getElementById("askSelectedCheckbox");
  658. askSelectedCheckbox.addEventListener('change', (event) => {
  659. const checked = event.target.checked;
  660. storage.set("askSelected", checked);
  661. });
  662.  
  663. const ttsCheckbox = document.getElementById("ttsCheckbox");
  664. ttsCheckbox.addEventListener('change', (event) => {
  665. const checked = event.target.checked;
  666. document.getElementById("ttsSection").style.display = checked ? "block" : "none";
  667. storage.set("tts", checked);
  668. });
  669.  
  670. const ttsApiKeyInput = document.getElementById("ttsApiKeyInput");
  671. ttsApiKeyInput.addEventListener("change", (event) => {
  672. const apiKey = event.target.value;
  673. storage.set("ttsApiKey", apiKey);
  674. });
  675.  
  676. const ttsVoiceSelector = document.getElementById("ttsVoiceSelector");
  677. ttsVoiceSelector.addEventListener("change", (event) => {
  678. const selectedVoice = event.target.value;
  679. storage.set("ttsVoice", selectedVoice);
  680. });
  681.  
  682. document.getElementById("closeSettingsBtn").addEventListener("click", () => {
  683. settingsPopup.style.display = "none";
  684. });
  685.  
  686. function resetSettings() {
  687. if (!confirm("Reset all settings to default?")) return;
  688.  
  689. const currentColorMode = document.getElementById("colorModeSelector").value;
  690. const defaultColorSettings = currentColorMode === "dark" ? defaults.darkColors : defaults.whiteColors;
  691.  
  692. document.getElementById("styleTypeSelector").value = defaults.styleType;
  693. document.getElementById("fontSizeSlider").value = defaults.fontSize;
  694. document.getElementById("fontSizeValue").textContent = defaults.fontSize;
  695. document.getElementById("lineHeightSlider").value = defaults.lineHeight;
  696. document.getElementById("lineHeightValue").textContent = defaults.lineHeight;
  697. document.getElementById("heightBigSlider").value = defaults.heightBig;
  698. document.getElementById("heightBigValue").textContent = defaults.heightBig;
  699. document.getElementById("sentenceHeightSlider").value = defaults.sentenceHeight;
  700. document.getElementById("sentenceHeightValue").textContent = defaults.sentenceHeight;
  701.  
  702. updateColorInputs(defaultColorSettings);
  703. updateColorPickerBackgrounds(defaultColorSettings);
  704.  
  705. applyStyles(defaults.styleType, currentColorMode);
  706.  
  707. document.getElementById("videoSettings").style.display = defaults.styleType === "video" ? "block" : "none";
  708. document.getElementById("sentenceVideoSettings").style.display = defaults.styleType === "off" ? "block" : "none";
  709.  
  710. document.documentElement.style.setProperty("--font_size", `${defaults.fontSize}rem`);
  711. document.documentElement.style.setProperty("--line_height", defaults.lineHeight);
  712. document.documentElement.style.setProperty("--height_big", `${defaults.heightBig}px`);
  713. document.documentElement.style.setProperty("--sentence_height", `${defaults.sentenceHeight}px`);
  714. updateCssColorVariables(defaultColorSettings);
  715.  
  716. document.getElementById("autoFinishingCheckbox").checked = defaults.autoFinishing;
  717.  
  718. document.getElementById("chatWidgetCheckbox").value = defaults.chatWidget;
  719. document.getElementById("llmProviderSelector").value = defaults.llmProvider;
  720. document.getElementById("llmApiKeyInput").value = defaults.llmApiKey;
  721. document.getElementById("askSelectedCheckbox").value = defaults.askSelected;
  722.  
  723. document.getElementById("ttsCheckbox").value = defaults.tts;
  724. document.getElementById("ttsApiKeyInput").value = defaults.ttsApiKey;
  725. document.getElementById("ttsVoiceSelector").value = defaults.ttsVoice;
  726.  
  727. for (const [key, value] of Object.entries(defaults)) {
  728. storage.set(key, value);
  729. }
  730.  
  731. const prefix = currentColorMode === "dark" ? "dark_" : "white_";
  732. for (const [key, value] of Object.entries(defaultColorSettings)) {
  733. storage.set(prefix + key, value);
  734. }
  735. }
  736.  
  737. document.getElementById("resetSettingsBtn").addEventListener("click", resetSettings);
  738. }
  739.  
  740. async function setupDownloadWordsEventListeners(downloadWordsButton, downloadWordsPopup) {
  741. async function getAllWords(baseUrl, pageSize, apiType, additionalParams="", progressCallback = () => {}) {
  742. let allResults = [];
  743. let nextUrl = `${baseUrl}?page_size=${pageSize}&page=1${additionalParams}`;
  744. let currentPage = 0;
  745. let totalPages = 0;
  746. let isFirstCall = true;
  747.  
  748. while (nextUrl) {
  749. try {
  750. const response = await fetch(nextUrl);
  751.  
  752. if (!response.ok) {
  753. throw new Error(`HTTP error! Status: ${response.status}`);
  754. }
  755.  
  756. const data = await response.json();
  757. currentPage++;
  758.  
  759. if (isFirstCall) {
  760. isFirstCall = false;
  761. totalPages = Math.ceil(data.count / pageSize);
  762. console.log(`total pages: ${totalPages}`);
  763. }
  764.  
  765. progressCallback(currentPage, totalPages, false, null, data.count);
  766.  
  767. if (apiType === 'lingq') {
  768. const filteredResults = data.results.map(item => ({
  769. pk: item.pk,
  770. term: item.term,
  771. fragment: item.fragment,
  772. status: item.status,
  773. hint: item.hints && item.hints[0] ? item.hints[0].text : null
  774. }));
  775. allResults = allResults.concat(filteredResults);
  776. } else if (apiType === 'known') {
  777. allResults = allResults.concat(data.results);
  778. }
  779.  
  780. nextUrl = data.next;
  781.  
  782. if (nextUrl) {
  783. console.log("Fetched page. Next URL:", nextUrl);
  784. } else {
  785. console.log("Finished fetching all pages");
  786. progressCallback(currentPage, totalPages, true, null, data.count);
  787. }
  788. } catch (error) {
  789. console.error('Error fetching data:', error);
  790. progressCallback(currentPage, totalPages, true, error, 0);
  791. break;
  792. }
  793. }
  794.  
  795. return allResults;
  796. }
  797.  
  798. async function downloadWords(baseUrl, pageSize, fileName, apiType, additionalParams="") {
  799. const progressContainer = document.getElementById("downloadProgressContainer");
  800. const progressBar = document.getElementById("downloadProgressBar");
  801. const progressText = document.getElementById("downloadProgressText");
  802.  
  803. if (progressContainer && progressBar && progressText) {
  804. progressBar.value = 0;
  805. progressBar.max = 100;
  806. progressText.textContent = "Initializing download...";
  807. progressContainer.style.display = "block";
  808. }
  809.  
  810. const progressCallback = (currentPage, totalPages,_isDone, error_isErrorEncountered, totalCount) => {
  811. if (progressBar && progressText) {
  812. if (error_isErrorEncountered) {
  813. progressText.textContent = `Error fetching page ${currentPage}: ${error_isErrorEncountered.message}`;
  814. progressBar.style.backgroundColor = 'red';
  815. return;
  816. }
  817.  
  818. progressBar.max = totalPages;
  819. progressBar.value = currentPage;
  820. progressText.textContent = `Fetching data... Page ${currentPage} of ${totalPages} (Total items: ${totalCount || 'N/A'})`;
  821.  
  822. if (_isDone) {
  823. progressText.textContent = error_isErrorEncountered ? `Export failed: ${error_isErrorEncountered.message}` : `${totalCount} items exported`;
  824. }
  825. }
  826. };
  827.  
  828. try {
  829. const allWords = await getAllWords(baseUrl, pageSize, apiType, additionalParams, progressCallback);
  830.  
  831. if (!allWords || allWords.length === 0) {
  832. console.warn("No words found or an error occurred.");
  833. return;
  834. }
  835.  
  836. let blob;
  837. const fileType = fileName.split(".")[1];
  838.  
  839. if (fileType === 'json') {
  840. const dataString = JSON.stringify(allWords, null, 2);
  841. blob = new Blob([dataString], { type: 'application/json' });
  842. } else if (fileType === 'csv') {
  843. const headers = Object.keys(allWords[0]).join(',');
  844. const rows = allWords.map(item => {
  845. return Object.values(item).map(value => {
  846. if (typeof value === 'string') {
  847. return `"${value.replace(/"/g, '""')}"`;
  848. }
  849. return value;
  850. }).join(',');
  851. }).join('\n');
  852.  
  853. const dataString = headers + '\n' + rows;
  854. blob = new Blob([dataString], { type: 'text/csv' });
  855. }
  856.  
  857. downloadBlob(blob, fileName);
  858. console.log("Export completed.");
  859. } catch (error) {
  860. console.error('Error:', error);
  861. }
  862. }
  863.  
  864. function downloadBlob(blob, fileName) {
  865. const url = URL.createObjectURL(blob);
  866. const a = createElement("a", {href: url, download: fileName});
  867. document.body.appendChild(a);
  868. a.click();
  869. document.body.removeChild(a);
  870. URL.revokeObjectURL(url);
  871. }
  872.  
  873. downloadWordsButton.addEventListener("click", () => {
  874. downloadWordsPopup.style.display = "block";
  875.  
  876. const progressContainer = document.getElementById("downloadProgressContainer");
  877. if (progressContainer) progressContainer.style.display = "none";
  878.  
  879. const dragHandle = document.getElementById("lingqDownloadWordsDragHandle");
  880. if (dragHandle) {
  881. makeDraggable(downloadWordsPopup, dragHandle);
  882. }
  883. });
  884.  
  885. const languageCode = await getLanguageCode();
  886. const pageSize = 1000;
  887.  
  888. const setButtonsDisabled = (disabled) => {
  889. const buttons = downloadWordsPopup.querySelectorAll('.popup-button');
  890. buttons.forEach(button => {
  891. button.disabled = disabled;
  892. });
  893. };
  894.  
  895. const handleDownloadButtonClick = async (url, filename, type, params = '') => {
  896. setButtonsDisabled(true);
  897. try {
  898. await downloadWords(url, pageSize, filename, type, params);
  899. } finally {
  900. setButtonsDisabled(false);
  901. }
  902. };
  903.  
  904. // Download Unknown LingQs button
  905. document.getElementById("downloadUnknownLingqsBtn").addEventListener("click", async () => {
  906. await handleDownloadButtonClick(`https://www.lingq.com/api/v3/${languageCode}/cards/`, "unknown_lingqs.csv", 'lingq', '&status=0&status=1&status=2&status=3');
  907. });
  908.  
  909. // Download Unknown LingQ Words button
  910. document.getElementById("downloadUnknownLingqWordsBtn").addEventListener("click", async () => {
  911. await handleDownloadButtonClick(`https://www.lingq.com/api/v3/${languageCode}/cards/`, "unknown_lingq_words.csv", 'lingq', '&status=0&status=1&status=2&status=3&phrases=false');
  912. });
  913.  
  914. // Download Unknown LingQ phrases button
  915. document.getElementById("downloadUnknownLingqPhrasesBtn").addEventListener("click", async () => {
  916. await handleDownloadButtonClick(`https://www.lingq.com/api/v3/${languageCode}/cards/`, "unknown_lingq_phrases.csv", 'lingq', '&status=0&status=1&status=2&status=3&phrases=True');
  917. });
  918.  
  919. // Download Known LingQs button
  920. document.getElementById("downloadKnownLingqsBtn").addEventListener("click", async () => {
  921. await handleDownloadButtonClick(`https://www.lingq.com/api/v3/${languageCode}/cards/`, "known_lingqs.csv", 'lingq', '&status=4');
  922. });
  923.  
  924. // Download known words button
  925. document.getElementById("downloadKnownWordsBtn").addEventListener("click", async () => {
  926. await handleDownloadButtonClick(`https://www.lingq.com/api/v2/${languageCode}/known-words/`, "known_words.csv", "known");
  927. });
  928.  
  929. // Close button
  930. document.getElementById("closeDownloadWordsBtn").addEventListener("click", () => {
  931. downloadWordsPopup.style.display = "none";
  932. });
  933. }
  934.  
  935. function setupEventListeners() {
  936. document.getElementById("lingqLessonComplete").addEventListener("click", finishLesson);
  937. }
  938.  
  939. function applyStyles(styleType, colorMode) {
  940. const colorSettings = getColorSettings(colorMode);
  941.  
  942. let baseCSS = generateBaseCSS(colorSettings, colorMode);
  943. let layoutCSS = generateLayoutCSS();
  944. let specificCSS = "";
  945.  
  946. switch (colorMode) {
  947. case "dark":
  948. clickElement(".reader-themes-component > button:nth-child(5)");
  949. break;
  950. case "white":
  951. clickElement(".reader-themes-component > button:nth-child(1)");
  952. break;
  953. }
  954.  
  955. switch (styleType) {
  956. case "video":
  957. specificCSS = generateVideoCSS();
  958. break;
  959. case "video2":
  960. specificCSS = generateVideo2CSS();
  961. break;
  962. case "audio":
  963. specificCSS = generateAudioCSS();
  964. break;
  965. case "off":
  966. specificCSS = generateOffModeCSS();
  967. layoutCSS = "";
  968. break;
  969. }
  970.  
  971. baseCSS += layoutCSS;
  972. baseCSS += specificCSS;
  973.  
  974. if (styleElement) {
  975. styleElement.remove();
  976. styleElement = null;
  977. }
  978.  
  979. styleElement = createElement("style", {textContent: baseCSS});
  980. document.querySelector("head").appendChild(styleElement);
  981. }
  982.  
  983. function generateBaseCSS(colorSettings, colorMode) {
  984. return`
  985. :root {
  986. --font_size: ${settings.fontSize}rem;
  987. --line_height: ${settings.lineHeight};
  988.  
  989. --font_color: ${colorSettings.fontColor};
  990. --lingq_background: ${colorSettings.lingqBackground};
  991. --lingq_border: ${colorSettings.lingqBorder};
  992. --lingq_border_learned: ${colorSettings.lingqBorderLearned};
  993. --known_background: ${colorSettings.knownBackground};
  994. --known_border: ${colorSettings.knownBorder};
  995. --is_playing_underline: ${colorSettings.playingUnderline};
  996.  
  997. --background-color: ${colorMode === "dark" ? "#2a2c2e" : "#ffffff"}
  998. }
  999. /*Color picker*/
  1000.  
  1001. .color-picker {
  1002. width: 30px;
  1003. height: 15px;
  1004. border-radius: 4px;
  1005. cursor: pointer;
  1006. }
  1007.  
  1008. .pcr-app {
  1009. z-index: 10001 !important;
  1010. }
  1011.  
  1012. .pcr-app .pcr-interaction .pcr-result {
  1013. color: var(--font_color) !important;
  1014. }
  1015.  
  1016. /*Popup settings*/
  1017.  
  1018. #lingqAddonSettings {
  1019. color: var(--font_color);
  1020. }
  1021.  
  1022. #lingqAddonSettingsPopup, #lingqDownloadWordsPopup {
  1023. position: fixed;
  1024. top: 40%;
  1025. left: 40%;
  1026. transform: translate(-40%, -40%);
  1027. background-color: var(--background-color, #2a2c2e);
  1028. color: var(--font_color, #e0e0e0);
  1029. border: 1px solid rgb(125 125 125 / 30%);
  1030. border-radius: 8px;
  1031. box-shadow: 8px 8px 8px rgba(0, 0, 0, 0.2);
  1032. z-index: 10000;
  1033. display: none;
  1034. max-height: 90vh;
  1035. overflow-y: auto;
  1036. }
  1037.  
  1038. #lingqAddonSettingsDragHandle, #lingqDownloadWordsDragHandle {
  1039. cursor: move;
  1040. background-color: rgba(128, 128, 128, 0.2);
  1041. padding: 8px;
  1042. border-radius: 8px 8px 0 0;
  1043. text-align: center;
  1044. user-select: none;
  1045. }
  1046.  
  1047. .popup-row {
  1048. margin: 5px 0;
  1049. }
  1050.  
  1051. .nav-button {
  1052. background: none;
  1053. border: none;
  1054. cursor: pointer;
  1055. font-size: 1.2rem;
  1056. margin-left: 10px;
  1057. padding: 5px;
  1058. }
  1059.  
  1060. .popup-button {
  1061. padding: 5px 10px;
  1062. border: 1px solid rgb(125, 125, 125, 50%);
  1063. border-radius: 5px;
  1064. margin: 5px 0;
  1065. }
  1066.  
  1067. .popup-section {
  1068. border: 1px solid rgb(125 125 125 / 50%);
  1069. padding: 5px 10px;
  1070. border-radius: 5px;
  1071. margin: 10px 0;
  1072. }
  1073.  
  1074. .popup-input {
  1075. flex-grow: 1;
  1076. border: 1px solid rgb(125 125 125 / 50%);
  1077. border-radius: 5px;
  1078. }
  1079.  
  1080. #downloadProgressContainer {
  1081. display: none;
  1082. }
  1083.  
  1084. #downloadProgressText {
  1085. text-align: center;
  1086. margin-bottom: 5px;
  1087. font-size: 0.9em;
  1088. }
  1089.  
  1090. #downloadProgressBar {
  1091. width: 100%;
  1092. height: 20px;
  1093. }
  1094.  
  1095. progress[value]::-webkit-progress-bar {
  1096. border-radius: 5px;
  1097. }
  1098.  
  1099. progress[value]::-webkit-progress-value {
  1100. border-radius: 5px;
  1101. }
  1102. select {
  1103. width: 100%;
  1104. margin-top: 5px;
  1105. padding: 5px;
  1106. background: rgb(125 125 125 / 10%) !important;
  1107. }
  1108.  
  1109. /*Chat*/
  1110.  
  1111. #chat-container {
  1112. margin-bottom:10px;
  1113. border: 1px solid rgb(125 125 125 / 35%);
  1114. border-radius: 5px;
  1115. height: 180px;
  1116. overflow-y: auto;
  1117. resize: vertical;
  1118. padding: 5px !important;
  1119. }
  1120.  
  1121. .input-container {
  1122. display: flex;
  1123. margin-bottom:10px;
  1124. }
  1125.  
  1126. #user-input {
  1127. flex-grow: 1;
  1128. padding: 5px 10px;
  1129. margin-right: 5px;
  1130. border: 1px solid rgb(125 125 125 / 35%);
  1131. border-radius: 5px;
  1132. font-size: 0.85rem;
  1133. }
  1134.  
  1135. #send-button {
  1136. padding: 5px 10px;
  1137. border: 1px solid rgb(125 125 125 / 35%);
  1138. border-radius: 5px;
  1139. }
  1140.  
  1141. .user-message,
  1142. .bot-message {
  1143. padding: 3px 7px;
  1144. margin: 3px 3px 8px 3px !important;
  1145. border-radius: 8px;
  1146. font-size: 0.85rem;
  1147. }
  1148.  
  1149. .user-message {
  1150. background-color: rgb(125 125 125 / 5%);
  1151. }
  1152.  
  1153. .bot-message {
  1154. background-color: rgb(125 125 125 / 10%);
  1155. }
  1156. #playAudio {
  1157. cursor: pointer;
  1158. font-size: 1.5rem;
  1159. padding: 5px;
  1160. }
  1161.  
  1162. /*font settings*/
  1163.  
  1164. .reader-container {
  1165. line-height: var(--line_height) !important;
  1166. font-size: var(--font_size) !important;
  1167. }
  1168.  
  1169. .sentence-text-head {
  1170. min-height: 4.5rem !important;
  1171. }
  1172.  
  1173. .reader-container p {
  1174. margin-top: 0 !important;
  1175. }
  1176.  
  1177. .reader-container p span.sentence-item,
  1178. .reader-container p .sentence {
  1179. color: var(--font_color) !important;
  1180. }
  1181.  
  1182. .sentence.is-playing,
  1183. .sentence.is-playing span {
  1184. text-underline-offset: .2em !important;
  1185. text-decoration-color: var(--is_playing_underline) !important;
  1186. }
  1187.  
  1188. /*highlightings*/
  1189.  
  1190. .phrase-item {
  1191. padding: 0 !important;
  1192. }
  1193.  
  1194. .phrase-item:not(.phrase-item-status--4, .phrase-item-status--4x2)) {
  1195. background-color: var(--lingq_background) !important;
  1196. }
  1197.  
  1198. .phrase-item.phrase-item-status--4,
  1199. .phrase-item.phrase-item-status--4x2 {
  1200. background-color: rgba(0, 0, 0, 0) !important;
  1201. }
  1202.  
  1203. .phrase-cluster:not(:has(.phrase-item-status--4, .phrase-item-status--4x2)) {
  1204. border: 1px solid var(--lingq_border) !important;
  1205. border-radius: .25rem;
  1206. }
  1207.  
  1208. .phrase-cluster:has(.phrase-item-status--4, .phrase-item-status--4x2) {
  1209. border: 1px solid var(--lingq_border_learned) !important;
  1210. border-radius: .25rem;
  1211. }
  1212.  
  1213. .reader-container .sentence .lingq-word:not(.is-learned) {
  1214. border: 1px solid var(--lingq_border) !important;
  1215. background-color: var(--lingq_background) !important;
  1216. }
  1217.  
  1218. .reader-container .sentence .lingq-word.is-learned {
  1219. border: 1px solid var(--lingq_border_learned) !important;
  1220. }
  1221.  
  1222. .reader-container .sentence .blue-word {
  1223. border: 1px solid var(--known_border) !important;
  1224. background-color: var(--known_background) !important;;
  1225. }
  1226.  
  1227. .phrase-cluster:hover,
  1228. .phrase-created:hover {
  1229. padding: 0 !important;
  1230. }
  1231.  
  1232. .phrase-cluster:hover .phrase-item,
  1233. .phrase-created .phrase-item {
  1234. padding: 0 !important;
  1235. }
  1236.  
  1237. .reader-container .sentence .selected-text {
  1238. padding: 0 !important;
  1239. }
  1240. `;
  1241. }
  1242.  
  1243. function generateLayoutCSS() {
  1244. return `
  1245. :root {
  1246. --article_height: calc(var(--app-height) - var(--height_big) - 10px);
  1247. --grid-layout: var(--article_height) calc(var(--height_big) - 80px) 90px;
  1248. }
  1249.  
  1250. /*header settings*/
  1251.  
  1252. .main-wrapper {
  1253. padding: 0 !important;
  1254. }
  1255.  
  1256. #main-nav {
  1257. z-index: 1;
  1258. }
  1259.  
  1260. #main-nav > nav {
  1261. height: 50px;
  1262. }
  1263.  
  1264. #main-nav > nav > div:nth-child(1) {
  1265. height: 50px;
  1266. }
  1267.  
  1268. .main-header {
  1269. pointer-events: none;
  1270. }
  1271.  
  1272. .main-header > div {
  1273. grid-template-columns: 1fr 150px !important;
  1274. padding-left: 400px !important;
  1275. }
  1276.  
  1277. .main-header section:nth-child(1) {
  1278. display: none;
  1279. }
  1280.  
  1281. .main-header section {
  1282. pointer-events: auto;
  1283. z-index: 1;
  1284. }
  1285.  
  1286. .main-header svg {
  1287. width: 20px !important;
  1288. height: 20px !important;
  1289. }
  1290.  
  1291. .main-header section .dropdown-content {
  1292. position: fixed;
  1293. }
  1294.  
  1295. .lesson-progress-section {
  1296. grid-template-rows: unset !important;
  1297. grid-template-columns: unset !important;
  1298. grid-column: 1 !important;
  1299. pointer-events: auto;
  1300. }
  1301.  
  1302. .lesson-progress-section .rc-slider{
  1303. grid-row: unset !important;
  1304. grid-column: unset !important;
  1305. width: 50% !important;
  1306. }
  1307.  
  1308. /*layout*/
  1309.  
  1310. #lesson-reader {
  1311. grid-template-rows: var(--grid-layout);
  1312. overflow-y: hidden;
  1313. height: auto !important;
  1314. }
  1315.  
  1316. .sentence-text {
  1317. height: calc(var(--article_height) - 70px) !important;
  1318. }
  1319.  
  1320. .reader-container-wrapper {
  1321. height: 100% !important;
  1322. }
  1323.  
  1324. .widget-area {
  1325. padding-top: 50px !important;
  1326. height: 100% !important;
  1327. }
  1328.  
  1329. .main-footer {
  1330. grid-area: 3 / 1 / 3 / 1 !important;
  1331. align-self: end;
  1332. margin: 10px 0;
  1333. }
  1334.  
  1335. .main-content {
  1336. grid-template-rows: 45px 1fr !important;
  1337. overflow: hidden;
  1338. align-items: anchor-center;
  1339. }
  1340.  
  1341. /*make prev/next page buttons compact*/
  1342.  
  1343. .reader-component {
  1344. grid-template-columns: 0rem 1fr 0rem !important;
  1345. align-items: baseline;
  1346. margin-top: 10px;
  1347. }
  1348.  
  1349. .reader-component > div > a.button > span {
  1350. width: 0.5rem !important;
  1351. }
  1352.  
  1353. .reader-component > div > a.button > span > svg {
  1354. width: 15px !important;
  1355. height: 15px !important;
  1356. }
  1357.  
  1358. .loadedContent {
  1359. padding: 0 0 5px 15px !important;;
  1360. }
  1361.  
  1362. /*font settings*/
  1363.  
  1364. .reader-container {
  1365. margin: 0 !important;
  1366. float: left !important;
  1367. columns: unset !important;
  1368. overflow-y: scroll !important;
  1369. max-width: unset !important;
  1370. }
  1371.  
  1372. /*video viewer*/
  1373.  
  1374. .video-player {
  1375. display: flex !important;
  1376. justify-content: flex-end !important;
  1377. align-items: flex-start !important;
  1378. pointer-events: none;
  1379. z-index: 38 !important;
  1380. }
  1381.  
  1382. .video-player > .modal-background {
  1383. background-color: rgb(26 28 30 / 0%) !important;
  1384. }
  1385.  
  1386. .video-player > .modal-content {
  1387. max-width: var(--width_big) !important;
  1388. margin: var(--video_margin) !important;
  1389. border-radius: 0.75rem !important;
  1390. }
  1391.  
  1392. .video-player .modal-section {
  1393. display: none !important;
  1394. }
  1395.  
  1396. .video-wrapper {
  1397. height: var(--height_big) !important;
  1398. overflow: hidden;
  1399. pointer-events: auto;
  1400. }
  1401.  
  1402. /*video controller*/
  1403.  
  1404. .rc-slider-rail {
  1405. background-color: dimgrey !important;
  1406. }
  1407.  
  1408. .rc-slider-step {
  1409. margin-top: -8px !important;
  1410. height: 1.2rem !important;
  1411. }
  1412.  
  1413. .lingq-audio-player {
  1414. margin-left: 10px;
  1415. }
  1416.  
  1417. .section--player.is-expanded {
  1418. padding: 5px 0px !important;
  1419. width: 390px !important;
  1420. margin-left: 10px !important;
  1421. }
  1422.  
  1423. .sentence-mode-button {
  1424. margin: 0 0 10px 0;
  1425. }
  1426.  
  1427. .player-wrapper {
  1428. grid-template-columns: 1fr 40px !important;
  1429. padding: 0 !important;
  1430. }
  1431.  
  1432. .audio-player {
  1433. padding: 0 0.5rem !important;
  1434. }
  1435. `;
  1436. }
  1437.  
  1438. function generateVideoCSS() {
  1439. return `
  1440. :root {
  1441. --width_big: calc(100vw - 424px - 10px);
  1442. --height_big: ${settings.heightBig}px;
  1443. --video_margin: 0 0 10px 10px !important;
  1444. }
  1445.  
  1446. .main-content {
  1447. grid-area: 1 / 1 / 2 / 2 !important;
  1448. }
  1449.  
  1450. .widget-area {
  1451. grid-area: 1 / 2 / 3 / 2 !important;
  1452. }
  1453.  
  1454. .main-footer {
  1455. grid-area: 3 / 2 / 4 / 3 !important;
  1456. align-self: end;
  1457. }
  1458. `;
  1459. }
  1460.  
  1461. function generateVideo2CSS() {
  1462. return `
  1463. :root {
  1464. --width_big: calc(50vw - 217px);
  1465. --height_big: calc(100vh - 80px);
  1466.  
  1467. --grid-layout: var(--article_height) 90px;
  1468. --video_margin: 0 10px 10px 10px !important;
  1469. --article_height: calc(var(--app-height) - 85px);
  1470. }
  1471.  
  1472. .page.reader-page.has-widget-fixed:not(.is-edit-mode):not(.workspace-sentence-reviewer) {
  1473. grid-template-columns: 1fr 424px 1fr;
  1474. }
  1475.  
  1476. .main-content {
  1477. grid-area: 1 / 1 / 2 / 2 !important;
  1478. }
  1479.  
  1480. .widget-area {
  1481. grid-area: 1 / 2 / 2 / 3 !important;
  1482. }
  1483.  
  1484. .main-footer {
  1485. grid-area: 2 / 2 / 3 / 3 !important;
  1486. align-self: end;
  1487. }
  1488.  
  1489. .modal-container .modls {
  1490. align-items: end;
  1491. }
  1492. `;
  1493. }
  1494.  
  1495. function generateAudioCSS() {
  1496. return `
  1497. :root {
  1498. --height_big: 60px;
  1499. }
  1500.  
  1501. .main-content {
  1502. grid-area: 1 / 1 / 2 / 2 !important;
  1503. }
  1504.  
  1505. .widget-area {
  1506. grid-area: 1 / 2 / 2 / 2 !important;
  1507. }
  1508. `;
  1509. }
  1510.  
  1511. function generateOffModeCSS() {
  1512. return `
  1513. :root {
  1514. --width_small: 440px;
  1515. --height_small: 260px;
  1516. --sentence_height: ${settings.sentenceHeight}px;
  1517. --right_pos: 0.5%;
  1518. --bottom_pos: 5.5%;
  1519. }
  1520.  
  1521. /*video player*/
  1522.  
  1523. .video-player.is-minimized .video-wrapper,
  1524. .sent-video-player.is-minimized .video-wrapper {
  1525. height: var(--height_small);
  1526. width: var(--width_small);
  1527. overflow: auto;
  1528. resize: both;
  1529. }
  1530.  
  1531. .video-player.is-minimized .modal-content,
  1532. .sent-video-player.is-minimized .modal-content {
  1533. max-width: calc(var(--width_small)* 3);
  1534. margin-bottom: 0;
  1535. }
  1536.  
  1537. .video-player.is-minimized,
  1538. .sent-video-player.is-minimized {
  1539. left: auto;
  1540. top: auto;
  1541. right: var(--right_pos);
  1542. bottom: var(--bottom_pos);
  1543. z-index: 99999999;
  1544. overflow: visible
  1545. }
  1546.  
  1547. /*sentence mode video player*/
  1548. .loadedContent:has(#sentence-video-player-portal) {
  1549. grid-template-rows: var(--sentence_height) auto auto 1fr !important;
  1550. }
  1551.  
  1552. #sentence-video-player-portal .video-section {
  1553. width: 100% !important;
  1554. max-width: none !important;
  1555. }
  1556.  
  1557. #sentence-video-player-portal .video-wrapper {
  1558. height: 100% !important;
  1559. max-height: none !important;
  1560. }
  1561.  
  1562. #sentence-video-player-portal div:has(> iframe) {
  1563. height: 100% !important;
  1564. }
  1565. `;
  1566. }
  1567.  
  1568. function clickElement(selector) {
  1569. const element = document.querySelector(selector);
  1570. if (element) element.click();
  1571. }
  1572.  
  1573. function focusElement(selector) {
  1574. const element = document.querySelector(selector);
  1575. if (element) {
  1576. element.focus();
  1577. element.setSelectionRange(element.value.length, element.value.length);
  1578. }
  1579. }
  1580.  
  1581. function copySelectedText() {
  1582. const selected_text = document.querySelector(".reference-word");
  1583. if (selected_text) {
  1584. navigator.clipboard.writeText(selected_text.textContent);
  1585. }
  1586. }
  1587.  
  1588. function finishLesson(){
  1589. clickElement(".reader-component > .nav--right > a");
  1590. }
  1591.  
  1592. function setupKeyboardShortcuts() {
  1593. function preventPropagation(event){
  1594. event.preventDefault();
  1595. event.stopPropagation();
  1596. }
  1597.  
  1598. document.addEventListener("keydown", function (event) {
  1599. const targetElement = event.target;
  1600. const isTextInput = targetElement.type === "text" || targetElement.type === "textarea" || targetElement.type === "input";
  1601. const withoutModifierKeys = !event.ctrlKey && !event.shiftKey && !event.altKey;
  1602. const eventKey = event.key.toLowerCase();
  1603. if (isTextInput) {
  1604. if (targetElement.id == "user-input") {
  1605. return;
  1606. }
  1607.  
  1608. if ((eventKey == 'enter' || eventKey == 'escape') && withoutModifierKeys) {
  1609. preventPropagation(event);
  1610. event.target.blur();
  1611. } else {
  1612. return;
  1613. }
  1614. }
  1615.  
  1616. const shortcuts = {
  1617. 'q': () => clickElement(".modal-section > div > button:nth-child(2)"), // video full screen toggle
  1618. 'w': () => clickElement(".audio-player--controllers > div:nth-child(1) > a"), // 5 sec Backward
  1619. 'e': () => clickElement(".audio-player--controllers > div:nth-child(2) > a"), // 5 sec Forward
  1620. 'r': () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "k" })), // Make word Known
  1621. 't': () => clickElement(".dictionary-resources > a:nth-last-child(1)"), // Open Translator
  1622. '`': () => focusElement(".reference-input-text"), // Move cursor to reference input
  1623. 'd': () => clickElement(".dictionary-resources > a:nth-child(1)"), // Open Dictionary
  1624. 'f': () => clickElement(".dictionary-resources > a:nth-child(1)"), // Open Dictionary
  1625. 'c': () => copySelectedText() // Copy selected text
  1626. };
  1627.  
  1628. if (shortcuts[eventKey] && withoutModifierKeys) {
  1629. preventPropagation(event);
  1630. shortcuts[eventKey]();
  1631. }
  1632. }, true);
  1633. }
  1634.  
  1635. async function getUserProfile() {
  1636. const url = `https://www.lingq.com/api/v3/profiles/`;
  1637.  
  1638. const response = await fetch(url);
  1639. const data = await response.json();
  1640.  
  1641. return data.results[0]
  1642. }
  1643.  
  1644. async function getLanguageCode() {
  1645. const userProfile = await getUserProfile();
  1646. return userProfile.active_language;
  1647. }
  1648.  
  1649. async function getDictionaryLanguage() {
  1650. const userProfile = await getUserProfile();
  1651. return await userProfile.dictionary_languages[0];
  1652. }
  1653.  
  1654. function getLessonId() {
  1655. const url = document.URL;
  1656. const regex = /https*:\/\/www\.lingq\.com\/\w+\/learn\/\w+\/web\/reader\/(\d+)/;
  1657. const match = url.match(regex);
  1658.  
  1659. return match[1];
  1660. }
  1661.  
  1662. async function getCollectionId() {
  1663. const url = document.URL;
  1664. const regex = /https*:\/\/www\.lingq\.com\/\w+\/learn\/\w+\/web\/library\/course\/(\d+)/;
  1665. const match = url.match(regex);
  1666.  
  1667. return match[1];
  1668. }
  1669.  
  1670. async function getLessonInfo(lessonId) {
  1671. const languageCode = await getLanguageCode();
  1672. const url = `https://www.lingq.com/api/v3/${languageCode}/lessons/counters/?lesson=${lessonId}`;
  1673.  
  1674. const response = await fetch(url);
  1675. const data = await response.json();
  1676.  
  1677. return data[lessonId];
  1678. }
  1679.  
  1680. async function getAllLessons(languageCode, collectionId) {
  1681. let allLessons = [];
  1682. let nextUrl = `https://www.lingq.com/api/v3/${languageCode}/search/?page=1&page_size=1000&collection=${collectionId}`;
  1683.  
  1684. while (nextUrl) {
  1685. try {
  1686. const response = await fetch(nextUrl);
  1687. if (!response.ok) {
  1688. throw new Error(`HTTP error! Status: ${response.status}`);
  1689. }
  1690.  
  1691. const data = await response.json();
  1692. allLessons = allLessons.concat(data.results);
  1693. nextUrl = data.next;
  1694. } catch (error) {
  1695. console.error('Error fetching lessons:', error);
  1696. break;
  1697. }
  1698. }
  1699.  
  1700. return allLessons;
  1701. }
  1702.  
  1703. async function setLessonProgress(lessonId, wordIndex) {
  1704. const languageCode = await getLanguageCode();
  1705. const url = `https://www.lingq.com/api/v3/${languageCode}/lessons/${lessonId}/bookmark/`;
  1706. const payload = { wordIndex: wordIndex, completedWordIndex: wordIndex, client: 'web' };
  1707.  
  1708. fetch(url, {
  1709. method: 'POST',
  1710. headers: { 'Content-Type': 'application/json' },
  1711. body: JSON.stringify(payload)
  1712. });
  1713. }
  1714.  
  1715. function setupYoutubePlayerCustomization() {
  1716. function replaceNoCookie() {
  1717. document.querySelectorAll("iframe").forEach(function (iframe) {
  1718. let src = iframe.getAttribute("src");
  1719. if (src && src.includes("disablekb=1")) {
  1720. src = src.replace("disablekb=1", "disablekb=0"); // keyboard controls are enabled
  1721. src = src + "&cc_load_policy=1"; // caption is shown by default
  1722. src = src + "&controls=0"; // player controls do not display in the player
  1723. iframe.setAttribute("src", src);
  1724. }
  1725. });
  1726. }
  1727.  
  1728. async function setupSliderObserver() {
  1729. const lessonId = getLessonId();
  1730. const lessonInfo = await getLessonInfo(lessonId);
  1731. let lastCompletedPercentage = lessonInfo["progress"];
  1732. console.log(`last progress: ${lastCompletedPercentage}`);
  1733.  
  1734. const sliderTrack = document.querySelector('.audio-player--progress .rc-slider-track');
  1735.  
  1736. const sliderContainer = createSliderElements();
  1737. const videoContainer = document.querySelector(".modal-content > div");
  1738. videoContainer.appendChild(sliderContainer);
  1739. const videoSliderTrack = sliderContainer.querySelector(".rc-slider-track");
  1740.  
  1741. const syncVideoSliderTrack = (videoSliderTrack, sliderTrack) => {
  1742. videoSliderTrack.style.cssText = sliderTrack.style.cssText;
  1743. };
  1744.  
  1745. const updateLessonProgress = (lessonId, lessonInfo, progressPercentage, lastCompletedPercentage) => {
  1746. const progressUpdatePeriod = 5;
  1747. const flooredProgressPercentage = Math.floor(progressPercentage / progressUpdatePeriod) * progressUpdatePeriod;
  1748.  
  1749. if (flooredProgressPercentage > lastCompletedPercentage) {
  1750. console.log(`progress percentage: ${flooredProgressPercentage}. Updated`);
  1751. const wordIndex = Math.floor(lessonInfo["totalWordsCount"] * (flooredProgressPercentage / 100));
  1752. setLessonProgress(lessonId, wordIndex);
  1753. return flooredProgressPercentage;
  1754. }
  1755. return lastCompletedPercentage;
  1756. };
  1757.  
  1758. const sliderObserver = new MutationObserver(function (mutationsList) {
  1759. for (const mutation of mutationsList) {
  1760. if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
  1761. syncVideoSliderTrack(videoSliderTrack, sliderTrack);
  1762.  
  1763. const progressPercentage = parseFloat(sliderTrack.style.width);
  1764.  
  1765. lastCompletedPercentage = updateLessonProgress(lessonId, lessonInfo, progressPercentage, lastCompletedPercentage);
  1766. const isLessonFinished = progressPercentage >= 99.5;
  1767. if (isLessonFinished && settings.autoFinishing) {
  1768. setTimeout(finishLesson, 3000);
  1769. }
  1770. }
  1771. }
  1772. });
  1773.  
  1774. sliderObserver.observe(sliderTrack, {attributes: true, attributeFilter: ['style']});
  1775. console.log('Observer started for rc-slider-track');
  1776. }
  1777.  
  1778. function createSliderElements() {
  1779. const sliderContainer = createElement("div", {className: "rc-slider rc-slider-horizontal"});
  1780. const sliderRail = createElement("div", {className: "rc-slider-rail"});
  1781. const sliderTrack = createElement("div", {className: "rc-slider-track"});
  1782. sliderContainer.appendChild(sliderRail);
  1783. sliderContainer.appendChild(sliderTrack);
  1784. return sliderContainer;
  1785. }
  1786.  
  1787. const iframeObserver = new MutationObserver(function (mutationsList) {
  1788. for (const mutation of mutationsList) {
  1789. if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
  1790. mutation.addedNodes.forEach((node) => {
  1791. if (node.nodeName === "IFRAME") {
  1792. replaceNoCookie();
  1793. clickElement('.modal-section.modal-section--head button[title="Expand"]');
  1794. setupSliderObserver();
  1795. }
  1796. });
  1797. }
  1798. }
  1799. });
  1800.  
  1801. iframeObserver.observe(document.body, {childList: true, subtree: true, attributes: true, attributeFilter: ["src"]});
  1802. }
  1803.  
  1804. async function changeScrollAmount() {
  1805. const readerContainer = await waitForElement(".reader-container");
  1806.  
  1807. if (readerContainer) {
  1808. readerContainer.addEventListener("wheel", (event) => {
  1809. event.preventDefault();
  1810. const delta = event.deltaY;
  1811. const scrollAmount = 0.3;
  1812. readerContainer.scrollTop += delta * scrollAmount;
  1813. });
  1814. }
  1815. }
  1816.  
  1817. function setupSentenceFocus() {
  1818. function focusPlayingSentence() {
  1819. const playingSentence = document.querySelector(".sentence.is-playing");
  1820. if (playingSentence) {
  1821. /*
  1822. playingSentence.scrollIntoView({
  1823. behavior: "smooth",
  1824. block: "center"
  1825. });
  1826. */
  1827. const scrolling_div = document.querySelector(".reader-container")
  1828. scrolling_div.scrollTop = playingSentence.offsetTop + Math.floor(playingSentence.offsetHeight / 2) - Math.floor(scrolling_div.offsetHeight / 2);
  1829.  
  1830. }
  1831. }
  1832.  
  1833. const observer = new MutationObserver((mutations) => {
  1834. mutations.forEach((mutation) => {
  1835. if (
  1836. mutation.type === "attributes" &&
  1837. mutation.attributeName === "class" &&
  1838. mutation.target.classList.contains("sentence")
  1839. ) {
  1840. focusPlayingSentence();
  1841. }
  1842. });
  1843. });
  1844.  
  1845. const container = document.querySelector(".sentence-text");
  1846. if (container) {
  1847. observer.observe(container, {
  1848. attributes: true,
  1849. subtree: true
  1850. });
  1851. }
  1852. }
  1853.  
  1854. async function waitForElement(selector) {
  1855. return new Promise(resolve => {
  1856. if (document.querySelector(selector)) {
  1857. return resolve(document.querySelector(selector));
  1858. }
  1859.  
  1860. const observer = new MutationObserver(() => {
  1861. if (document.querySelector(selector)) {
  1862. resolve(document.querySelector(selector));
  1863. observer.disconnect();
  1864. }
  1865. });
  1866.  
  1867. observer.observe(document.documentElement, {
  1868. childList: true,
  1869. subtree: true
  1870. });
  1871. });
  1872. }
  1873.  
  1874. async function playAudio(audioData, volume = 0.5) {
  1875. if (typeof volume !== 'number' || volume < 0 || volume > 1) {
  1876. console.warn(`Invalid volume "${volume}". Using default volume 0.5.`);
  1877. volume = 0.5;
  1878. }
  1879.  
  1880. return new Promise((resolve, reject) => {
  1881. const audioContext = new AudioContext();
  1882. const gainNode = audioContext.createGain();
  1883. gainNode.gain.value = volume;
  1884. gainNode.connect(audioContext.destination);
  1885.  
  1886. const audioDataCopy = audioData.slice(0);
  1887.  
  1888. audioContext.decodeAudioData(audioDataCopy)
  1889. .then(buffer => {
  1890. const source = audioContext.createBufferSource();
  1891. source.buffer = buffer;
  1892. source.connect(gainNode);
  1893. source.start(0);
  1894.  
  1895. source.onended = () => {
  1896. resolve();
  1897. audioContext.close();
  1898. };
  1899. source.onerror = (e) => {
  1900. reject("Audio play error : " + e);
  1901. }
  1902. })
  1903. .catch(e => {
  1904. reject("Decoding error : " + e)
  1905. });
  1906. });
  1907. }
  1908.  
  1909. async function openAITTS(text, API_KEY, voice = "nova", playbackRate = 1, instructions) {
  1910. const modelId = "gpt-4o-mini-tts";
  1911. const apiUrl = "https://api.openai.com/v1/audio/speech";
  1912.  
  1913. if (!API_KEY) {
  1914. throw new Error("Invalid or missing OpenAI API key. Please set the API_KEY");
  1915. }
  1916.  
  1917. if (!["nova", "onyx", "alloy", "echo", "fable", "shimmer"].includes(voice)) {
  1918. console.warn(`Invalid voice "${voice}". Using default voice "nova".`);
  1919. voice = "nova";
  1920. }
  1921.  
  1922. if (typeof playbackRate !== 'number' || playbackRate < 0.5 || playbackRate > 1.5) {
  1923. console.warn(`Invalid playback rate "${playbackRate}". Using default rate 1.`);
  1924. playbackRate = 1;
  1925. }
  1926.  
  1927. try {
  1928. const response = await fetch(apiUrl, {
  1929. method: "POST",
  1930. headers: {
  1931. "Accept": "audio/mpeg",
  1932. "Content-Type": "application/json",
  1933. "Authorization": `Bearer ${API_KEY}`
  1934. },
  1935. body: JSON.stringify({
  1936. input: text,
  1937. model: modelId,
  1938. voice: voice,
  1939. instructions: instructions,
  1940. speed: playbackRate
  1941. })
  1942. });
  1943.  
  1944. if (!response.ok) {
  1945. let errorMessage = `HTTP error! Status: ${response.status}`;
  1946. try {
  1947. const errorBody = await response.json();
  1948. errorMessage += ` - OpenAI Error: ${errorBody?.error?.message || JSON.stringify(errorBody)}`;
  1949. } catch (parseError) {
  1950. errorMessage += ` - Failed to parse error response.`;
  1951. }
  1952. throw new Error(errorMessage);
  1953. }
  1954.  
  1955. return await response.arrayBuffer();
  1956.  
  1957. } catch (error) {
  1958. console.error("Error during OpenAI TTS request:", error);
  1959. throw error;
  1960. }
  1961. }
  1962.  
  1963. function setupLLMs() {
  1964. async function updateWidget() {
  1965. if (document.getElementById('chatWidget')) return;
  1966.  
  1967. const targetSectionHead = document.querySelector("#lesson-reader .widget-area > .reader-widget > .section-widget--head");
  1968. if (!targetSectionHead) return;
  1969.  
  1970. const llmProvider = settings.llmProvider;
  1971. const llmApiKey = settings.llmApiKey;
  1972. const llmModel = llmProvider === 'openai' ? 'gpt-4.1-nano' : 'gemini-2.0-flash';
  1973. const userDictionaryLang = await getDictionaryLanguage();
  1974. console.log(llmProvider, llmModel)
  1975.  
  1976. const systemPrompt = `
  1977. Assist users in understanding words and sentences, using HTML tags for formatting, while responding in the language specified by '${userDictionaryLang}'. Focus on accurate translation and explanation based on the input type.
  1978.  
  1979. ## Instructions for Different Input Types
  1980.  
  1981. - **Single Word Input:**
  1982. - Focus on a single word, even if additional context is provided.
  1983. 1. Provide a concise definition in the specified language.
  1984. 2. Include an example sentence that aids in understanding without directly translating the provided context. Discuss the original form, part of speech, meaning, explanation, and create a new example with its translation.
  1985.  
  1986. - **Sentence Input:**
  1987. 1. Translate the entire sentence into the specified language, fully considering the context for accurate translation.
  1988. 2. Identify and explain interesting, difficult, or idiomatic expressions within the user's specified language. Use context whenever needed for clarity.
  1989.  
  1990. - **Plain Text Input:**
  1991. 1. Respond to user requests for additional examples or clarifications based on previous discussions, formatted in HTML.
  1992.  
  1993. ## Formatting
  1994.  
  1995. - Use '<b>', '<p>', '<ul>', '<li>', and '<br>' for formatting.
  1996. - Answer directly without a preface.
  1997.  
  1998. # Output Format
  1999.  
  2000. - Ensure the output is in the language specified by '${userDictionaryLang}'.
  2001.  
  2002. # Steps for Sentence Input
  2003.  
  2004. 1. **Translate the Sentence:** Fully translate the provided sentence, maintaining the original context for accuracy.
  2005. 2. **Explain Expressions:** Identify any expressions that may require additional context or explanation, providing insights in the specified language.
  2006.  
  2007. # Examples
  2008.  
  2009. ### Example 1: Single Word with Context (User's language code: ko)
  2010.  
  2011. **User Input:** "Input: \"translators\", Context: \"However, the ESV translators chose to translate that same word as 'servant,' closing off the potential interpretation that she held any formal position of authority.\""
  2012.  
  2013. **Assistant Output:**
  2014. <b>translators (명사)</b>
  2015. <p><b>뜻:</b> 번역가, 통역사</p>
  2016. <p><b>설명:</b> 다른 언어로 문서나 발화를 번역하는 사람들을 의미합니다.</p>
  2017. <ul>
  2018. <li><b>새로운 예문:</b> Many translators help publish books in multiple languages.</li>
  2019. <li><b>번역 예문:</b> 많은 번역가들이 책을 다양한 언어로 출간하도록 돕고 있습니다.</li>
  2020. </ul>
  2021.  
  2022. ### Example 2: Sentence Input (User's language code: ko)
  2023.  
  2024. **User Input:** "Input: \"Interestingly, elsewhere in the letters of Paul, the ESV editors translated that exact same word as \"minister\"\", Context: \"\""
  2025.  
  2026. **Assistant Output:**
  2027. <p><b>번역:</b> 흥미롭게도, 바울의 다른 서신들에서는 ESV 편집자들이 바로 그 같은 단어를 “minister”(일꾼, 봉사자)로 번역했습니다.</p>
  2028. <ul>
  2029. <li>“Interestingly”는 어떤 사실이 대조적이거나 생각할 거리가 있을 쓰는 연결어입니다.</li>
  2030. </ul>
  2031.  
  2032. ### Example 3: Plain Text Input (User's language code: ko)
  2033.  
  2034. **User Input:** "예문 하나 더 만들어줘"
  2035.  
  2036. **Assistant Output:**
  2037. <ul>
  2038. <li><b>추가 예문:</b> The translators were praised for accurately capturing the author's intent.</li>
  2039. <li><b>번역 예문:</b> 번역가들은 작가의 의도를 정확하게 포착한 것에 대해 칭찬받았습니다.</li>
  2040. </ul>
  2041.  
  2042. # Notes
  2043.  
  2044. - Focus on ensuring output in the user's specified language ('${userDictionaryLang}').
  2045. - Avoid verbosity and unnecessary details; aim for clarity and usefulness in language learning contexts.
  2046. - Do not provide explanations of individual words within sentences unless they are part of a larger idiomatic expression requiring translation.
  2047. `;
  2048. const ttsInstructions = `
  2049. Accent/Affect: Neutral and clear, like a professional voice-over artist. Focus on accuracy.
  2050. Tone: Objective and methodical. Maintain a slightly formal tone without emotion.
  2051. Pacing: Use distinct pauses between words and phrases to demonstrate pronunciation nuances. Emphasize syllabic clarity.
  2052. Pronunciation: Enunciate words with deliberate clarity, focusing on vowel sounds and consonant clusters.
  2053. `;
  2054. let chatHistory = [];
  2055.  
  2056. function updateChatHistoryState(currentHistory, message, role) {
  2057. return [...currentHistory, { role: role, content: message }];
  2058. }
  2059.  
  2060. function addMessageToUI(message, isUser, container) {
  2061. const messageDiv = createElement("div", {
  2062. className: `${isUser ? 'user-message' : 'bot-message'}`,
  2063. innerHTML: message
  2064. });
  2065. container.appendChild(messageDiv);
  2066. container.scrollTop = container.scrollHeight;
  2067. }
  2068.  
  2069. async function getOpenAIResponse(apiKey, model, history) {
  2070. try {
  2071. const api_url = `https://api.openai.com/v1/chat/completions`;
  2072. const response = await fetch(
  2073. api_url,
  2074. {
  2075. method: 'POST',
  2076. headers: {
  2077. 'Content-Type': 'application/json',
  2078. 'Authorization': `Bearer ${apiKey}`
  2079. },
  2080. body: JSON.stringify({
  2081. model: model,
  2082. messages: history,
  2083. max_tokens: 500,
  2084. temperature: 1.0,
  2085. })
  2086. }
  2087. );
  2088.  
  2089. if (!response.ok) {
  2090. const errorData = await response.json();
  2091. console.error('OpenAI API error:', errorData);
  2092. throw new Error(`OpenAI API error: ${response.status} - ${response.statusText}`);
  2093. }
  2094.  
  2095. const data = await response.json();
  2096. return data.choices[0]?.message?.content || "Sorry, could not get a response.";
  2097.  
  2098. } catch (error) {
  2099. console.error('OpenAI API call failed:', error);
  2100. return "Sorry, something went wrong communicating with OpenAI.";
  2101. }
  2102. }
  2103.  
  2104. async function getGoogleResponse(apiKey, model, history) {
  2105. try {
  2106. const formattedMessages = history.map(msg => ({
  2107. role: msg.role === 'assistant' ? 'model' : msg.role,
  2108. parts: [{ text: msg.content }]
  2109. }));
  2110.  
  2111. const api_url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
  2112. const response = await fetch(
  2113. api_url,
  2114. {
  2115. method: 'POST',
  2116. headers: { 'Content-Type': 'application/json' },
  2117. body: JSON.stringify({
  2118. system_instruction: {parts: [{text: systemPrompt}]},
  2119. contents: formattedMessages,
  2120. generationConfig: { temperature: 1.0, maxOutputTokens: 500}
  2121. })
  2122. }
  2123. );
  2124.  
  2125. if (!response.ok) {
  2126. const errorData = await response.json();
  2127. console.error('Google Gemini API error:', errorData);
  2128. const message = errorData?.error?.message || `Google Gemini API error: ${response.status} - ${response.statusText}`;
  2129. throw new Error(message);
  2130. }
  2131.  
  2132. const data = await response.json();
  2133. return data.candidates[0].content.parts[0].text;
  2134. } catch (error) {
  2135. console.error('Google Gemini API call failed:', error);
  2136. return `Sorry, something went wrong communicating with Google. ${error.message || ''}`;
  2137. }
  2138. }
  2139.  
  2140. async function getBotResponse(provider, apiKey, model, history) {
  2141. if (provider === 'openai') {
  2142. return await getOpenAIResponse(apiKey, model, history);
  2143. } else if (provider === 'google') {
  2144. return await getGoogleResponse(apiKey, model, history);
  2145. }
  2146. }
  2147.  
  2148. async function handleSendMessage() {
  2149. const userInput = document.getElementById("user-input")
  2150. const chatContainer = document.getElementById("chat-container")
  2151.  
  2152. const message = userInput.value.trim();
  2153. if (!message) return;
  2154.  
  2155. const userMessage = message;
  2156. userInput.value = '';
  2157.  
  2158. addMessageToUI(userMessage, true, chatContainer);
  2159. chatHistory = updateChatHistoryState(chatHistory, userMessage, "user");
  2160. const botResponse = await getBotResponse(llmProvider, llmApiKey, llmModel, chatHistory);
  2161. addMessageToUI(botResponse, false, chatContainer);
  2162. chatHistory = updateChatHistoryState(chatHistory, botResponse, "assistant");
  2163. }
  2164.  
  2165. function getSelectedWithContext() {
  2166. const selectedTextElement = targetSectionHead.querySelector(".reference-word");
  2167. const contextElement = document.querySelector(".reader-container .sentence:has(.sentence-item.is-selected)");
  2168. const selectedText = selectedTextElement ? selectedTextElement.textContent.trim() : "";
  2169. const contextText = contextElement ? contextElement.innerText.trim() : "";
  2170.  
  2171. return `Input: "${selectedText}"` + `, Context: "${contextText}"`;
  2172. }
  2173.  
  2174. async function updateChatWidget(){
  2175. if (!settings.chatWidget) return;
  2176. const chatWrapper = createElement("div", { id: "chat-widget", style: "margin: 10px 0;" });
  2177. const chatContainer = createElement("div", { id: "chat-container" });
  2178. const inputContainer = createElement("div", { className: "input-container" });
  2179. const userInput = createElement("input", { type: "text", id: "user-input", placeholder: "Ask anything" });
  2180. const sendButton = createElement("button", { id: "send-button", textContent: "Send" });
  2181.  
  2182. inputContainer.appendChild(userInput);
  2183. inputContainer.appendChild(sendButton);
  2184. chatWrapper.appendChild(chatContainer);
  2185. chatWrapper.appendChild(inputContainer);
  2186.  
  2187. userInput.addEventListener('keydown', (event) => {
  2188. if (event.key === 'Enter' && !event.shiftKey) {
  2189. event.preventDefault();
  2190. handleSendMessage();
  2191. }
  2192. event.stopPropagation();
  2193. }, true);
  2194. sendButton.addEventListener('click', handleSendMessage);
  2195.  
  2196. if (llmProvider === 'openai') chatHistory = updateChatHistoryState(chatHistory, systemPrompt, "system");
  2197.  
  2198. if (settings.askSelected) {
  2199. const initialUserMessage = getSelectedWithContext();
  2200.  
  2201. chatHistory = updateChatHistoryState(chatHistory, initialUserMessage, "user");
  2202. const botResponse = await getBotResponse(llmProvider, llmApiKey, llmModel, chatHistory);
  2203. addMessageToUI(botResponse, false, chatContainer);
  2204. chatHistory = updateChatHistoryState(chatHistory, botResponse, "assistant");
  2205. }
  2206.  
  2207. const existingChatWidget = document.getElementById('chat-widget');
  2208. if(existingChatWidget) {
  2209. existingChatWidget.replaceWith(chatWrapper);
  2210. } else {
  2211. targetSectionHead.appendChild(chatWrapper);
  2212. }
  2213. }
  2214.  
  2215. async function updateTTS() {
  2216. if (!settings.tts) return;
  2217.  
  2218. const selectedTextElement = document.querySelector(".reference-word");
  2219. const selectedText = selectedTextElement ? selectedTextElement.textContent.trim() : "";
  2220.  
  2221. let audioData = await openAITTS(`${selectedText}`, settings.ttsApiKey, settings.ttsVoice, 1.0, ttsInstructions);
  2222. if (audioData == null) return;
  2223.  
  2224. const ttsButton = await waitForElement('.is-tts');
  2225. const newTtsButton = createElement("button", {id: "playAudio", textContent: "🔊", className: "is-tts"});
  2226. newTtsButton.addEventListener('click', async (event) => {
  2227. await playAudio(audioData, 1.0);
  2228. })
  2229. ttsButton.replaceWith(newTtsButton);
  2230.  
  2231. playAudio(audioData, 1.0);
  2232. }
  2233.  
  2234. await updateChatWidget();
  2235. await updateTTS();
  2236.  
  2237. const selectedTextElement = targetSectionHead.querySelector(".reference-word");
  2238. const observer = new MutationObserver((mutations) => {
  2239. mutations.forEach((mutation) => {
  2240. if (mutation.type !== 'characterData') return;
  2241. updateChatWidget();
  2242. updateTTS();
  2243. });
  2244. });
  2245. observer.observe(selectedTextElement, {subtree: true, characterData: true});
  2246. }
  2247.  
  2248. const lessonReader = document.getElementById('lesson-reader');
  2249.  
  2250. const observer = new MutationObserver((mutations) => {
  2251. if (!settings.chatWidget) return;
  2252.  
  2253. mutations.forEach((mutation) => {
  2254. mutation.addedNodes.forEach((node) => {
  2255. if (node.nodeType !== Node.ELEMENT_NODE) return;
  2256.  
  2257. if (node.matches(".widget-area")) {
  2258. updateWidget();
  2259. }
  2260. });
  2261. });
  2262. });
  2263. observer.observe(lessonReader, {childList: true});
  2264. }
  2265.  
  2266. async function setupCourse() {
  2267. function createCourseUI(){
  2268. const resetButton = createElement("button", {
  2269. id: "resetLessonPositions",
  2270. textContent: "⏮️",
  2271. title: "Reset all lessons to the first page",
  2272. className: "nav-button"
  2273. });
  2274.  
  2275. let nav = document.querySelector(".library-section > .list-header > .list-header-index");
  2276. nav.appendChild(resetButton);
  2277. }
  2278.  
  2279. function setupCourseStyles() {
  2280. const css = `
  2281. .nav-button {
  2282. background: none;
  2283. border: none;
  2284. cursor: pointer;
  2285. font-size: 1.5rem;
  2286. }
  2287.  
  2288. .library-section > .list-header > .list-header-index {
  2289. grid-template-columns: auto 1fr auto !important;
  2290. }
  2291.  
  2292. .dynamic--word-progress {
  2293. grid-template-columns: repeat(3, auto) !important;
  2294. }
  2295.  
  2296. .word-indicator--box-white {
  2297. background-color: rgb(255 255 255 / 85%);
  2298. border-color: rgb(255 255 255);
  2299. }
  2300. `;
  2301. const styleElement = createElement("style", { textContent: css });
  2302. document.querySelector("head").appendChild(styleElement);
  2303. }
  2304.  
  2305. function enrichLessonDetails() {
  2306. function addKnownWordsIndicator(lessonElement, lessonInfo) {
  2307. const dynamicWordProgress = lessonElement.querySelector('.dynamic--word-progress');
  2308.  
  2309. const knownWordPercentage = Math.round((lessonInfo.knownWordsCount / lessonInfo.uniqueWordsCount) * 100);
  2310.  
  2311. const knownWordsItem = createElement('div', {className: 'word-indicator--item grid-layout grid-align--center grid-item is-fluid--left', title: 'Known Words'});
  2312.  
  2313. const knownWordsBox = createElement('div', {className: 'word-indicator--box word-indicator--box-white'});
  2314. knownWordsItem.appendChild(knownWordsBox);
  2315.  
  2316. const textWrapper = createElement('span', {className: 'text-wrapper is-size-8'});
  2317. textWrapper.appendChild(createElement('span', {textContent: `${lessonInfo.knownWordsCount} (${knownWordPercentage}%)`}));
  2318.  
  2319. knownWordsItem.appendChild(textWrapper);
  2320. dynamicWordProgress.appendChild(knownWordsItem);
  2321. }
  2322.  
  2323. async function updateWordIndicatorPercentages(lessonElement, lessonId) {
  2324. console.log(`lessonId: ${lessonId}`);
  2325. const lessonInfo = await getLessonInfo(lessonId);
  2326.  
  2327. const wordIndicatorItems = lessonElement.querySelector(".word-indicator--item");
  2328. if (!wordIndicatorItems) { return; }
  2329.  
  2330. const lingqsPercentage = Math.round((lessonInfo.cardsCount / lessonInfo.uniqueWordsCount) * 100);
  2331. const lingqsElement = lessonElement.querySelector('.word-indicator--item[title="LingQs"] > span > span');
  2332. lingqsElement.textContent = `${lessonInfo.cardsCount} (${lingqsPercentage}%)`;
  2333.  
  2334. addKnownWordsIndicator(lessonElement, lessonInfo);
  2335. }
  2336.  
  2337. const observer = new MutationObserver((mutations) => {
  2338. mutations.forEach((mutation) => {
  2339. if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
  2340. mutation.addedNodes.forEach((node) => {
  2341. if (node.classList && node.classList.contains('library-item-wrap')) {
  2342. const lessonId = node.id.split("--")[1].split("-")[0];
  2343. updateWordIndicatorPercentages(node, lessonId);
  2344. }
  2345. });
  2346. }
  2347. });
  2348. });
  2349.  
  2350. const targetNode = document.querySelector('.library-section .library-list');
  2351. console.log(targetNode);
  2352. const config = { childList: true, subtree: true };
  2353. observer.observe(targetNode, config);
  2354. }
  2355.  
  2356. function enableCourseSorting() {
  2357. const dropdownItems = document.querySelectorAll('.library-section > .list-header .tw-dropdown--item');
  2358. if (dropdownItems.length) {
  2359. // Setup library sort event listener
  2360. dropdownItems.forEach((item, index) => {
  2361. item.addEventListener('click', () => {
  2362. console.log(`Clicked sort option: ${index}`);
  2363. storage.set('librarySortOption', index);
  2364. settings.librarySortOption = index;
  2365. });
  2366. });
  2367.  
  2368. // Change sort by the setting
  2369. dropdownItems[settings.librarySortOption].click();
  2370. return true;
  2371. } else {
  2372. console.warn("Dropdown items not found for library sort.");
  2373. return false;
  2374. }
  2375. }
  2376.  
  2377. function setupLessonResetButton() {
  2378. const resetButton = document.getElementById("resetLessonPositions");
  2379. resetButton.addEventListener("click", async () => {
  2380. const languageCode = await getLanguageCode();
  2381. const collectionId = await getCollectionId();
  2382.  
  2383. const allLessons = await getAllLessons(languageCode, collectionId);
  2384. const confirmed = confirm(`Reset all ${allLessons.length} lessons to their starting positions?`);
  2385. if (!confirmed) { return; }
  2386.  
  2387. for (const lesson of allLessons) {
  2388. await setLessonProgress(lesson.id, 0);
  2389. console.log(`Reset lesson ID: ${lesson.id} to the first page`);
  2390. }
  2391.  
  2392. alert(`Successfully reset ${allLessons.length} lessons to their starting positions.`);
  2393. });
  2394. }
  2395.  
  2396. const libraryHeader = await waitForElement('.library-section > .list-header');
  2397. createCourseUI();
  2398. setupCourseStyles();
  2399.  
  2400. enrichLessonDetails();
  2401. enableCourseSorting();
  2402. setupLessonResetButton();
  2403. }
  2404.  
  2405. function fixBugs() {
  2406. const resizeToast = () => {
  2407. const css = `
  2408. .toasts {
  2409. height: fit-content;
  2410. }
  2411. `;
  2412. const cssElement = createElement("style", {textContent: css});
  2413. document.querySelector("head").appendChild(cssElement);
  2414. }
  2415.  
  2416. resizeToast();
  2417. }
  2418.  
  2419. function init() {
  2420. fixBugs();
  2421.  
  2422. if (document.URL.includes("reader")) {
  2423. createUI();
  2424. applyStyles(settings.styleType, settings.colorMode);
  2425. setupKeyboardShortcuts();
  2426. setupYoutubePlayerCustomization();
  2427. changeScrollAmount();
  2428. setupSentenceFocus();
  2429. setupLLMs();
  2430. }
  2431. if (document.URL.includes("library")) {
  2432. setupCourse();
  2433. }
  2434. }
  2435.  
  2436. init();
  2437. })();
  2438.  
  2439. // TODO: add the tts toggles for each sentence and word.