// ==UserScript== // @name LingQ Addon // @description Provides custom LingQ layouts // @match https://www.lingq.com/*/learn/*/web/reader/* // @match https://www.lingq.com/*/learn/*/web/library/course/* // @match https://www.youtube-nocookie.com/* // @match https://www.youtube.com/embed/* // @version 5.13.2 // @grant GM_setValue // @grant GM_getValue // @namespace https://greasyfork.org/users/1458847 // @downloadURL none // ==/UserScript== (function () { "use strict"; const storage = { get: (key, defaultValue) => { const value = GM_getValue(key); return value === undefined ? defaultValue : value; }, set: (key, value) => GM_setValue(key, value) }; const defaults = { styleType: "video", heightBig: 400, sentenceHeight: 400, widgetWidth: 400, fontSize: 1.1, lineHeight: 1.7, colorMode: "white", white_fontColor: "rgb(0, 0, 0)", white_translationFontColor: "rgb(0, 0, 0)", white_lingqBackground: "rgba(255, 232, 149, 1)", white_lingqBorder: "rgba(255, 200, 0, 0)", white_lingqBorderLearned: "rgba(255, 200, 0, 0)", white_unknownBackground: "rgba(198, 223, 255, 1)", white_unknownBorder: "rgba(0, 111, 255, 0)", white_playingUnderline: "rgb(0, 0, 0)", dark_fontColor: "rgb(255, 255, 255)", dark_translationFontColor: "rgb(255, 255, 255)", dark_lingqBackground: "rgba(108, 87, 43, 1)", dark_lingqBorder: "rgba(254, 203, 72, 0)", dark_lingqBorderLearned: "rgba(254, 203, 72, 0)", dark_unknownBackground: "rgba(37, 57, 82, 1)", dark_unknownBorder: "rgba(72, 154, 254, 0)", dark_playingUnderline: "rgb(255, 255, 255)", librarySortOption: 0, autoFinishing: false, focusPlayingSentence: false, keyboardShortcut: false, shortcutVideoFullscreen: 'p', shortcutBackward5s: 'a', shortcutForward5s: 's', shortcutTTSPlay: 'w', shortcutTranslator: 'e', shortcutMakeKnown: 'd', shortcutDictionary: 'f', shortcutCopySelected: 'c', shortcutMeaningInput: '`', shortcutChatInput: 'q', chatWidget: false, llmProviderModel: "openai gpt-4.1-nano", llmApiKey: "", askSelected: false, tts: false, ttsApiKey: "", ttsVoice: "openai alloy", ttsWord: false, ttsSentence: false, }; const settings = new Proxy({}, { get: (target, key) => { if (key in target) return target[key]; return storage.get(key, defaults[key]); }, set: (target, key, value) => { storage.set(key, value); target[key] = value; return true; } }); /* Main Setup Functions */ function setupReader() { function getColorSettings(colorMode) { const prefix = colorMode === "dark" ? "dark_" : "white_"; return { fontColor: settings[prefix + "fontColor"], translationFontColor: settings[prefix + "translationFontColor"], lingqBackground: settings[prefix + "lingqBackground"], lingqBorder: settings[prefix + "lingqBorder"], lingqBorderLearned: settings[prefix + "lingqBorderLearned"], unknownBackground: settings[prefix + "unknownBackground"], unknownBorder: settings[prefix + "unknownBorder"], playingUnderline: settings[prefix + "playingUnderline"], }; } function createSettingsPopup() { const popup = createElement("div", {id: "lingqAddonSettingsPopup"}); const dragHandle = createElement("div", {id: "lingqAddonSettingsDragHandle"}); const dragHandleTitle = createElement("h3", {textContent: "LingQ Addon Settings"}); dragHandle.appendChild(dragHandleTitle); const content = createElement("div", {style: `padding: 0 5px;`}); const popupContentElement = generatePopupContent(); content.appendChild(popupContentElement); popup.appendChild(dragHandle); popup.appendChild(content); return popup; } function generatePopupContent() { function addSelect(parent, id, labelText, options, selectedValue) { const container = createElement("div", {className: "popup-row"}); container.appendChild(createElement("label", {htmlFor: id, textContent: labelText})); const select = createElement("select", {id}); options.forEach(option => { select.appendChild(createElement("option", {value: option.value, textContent: option.text, selected: selectedValue === option.value})); }); container.appendChild(select); parent.appendChild(container); return container; } function addSlider(parent, id, labelText, valueId, value, unit, min, max, step) { const container = createElement("div", {className: "popup-row"}); const label = createElement("label", { htmlFor: id }); label.appendChild(document.createTextNode(labelText + " ")); label.appendChild(createElement("span", { id: valueId, textContent: value })); if (unit) label.appendChild(document.createTextNode(unit)); container.appendChild(label); container.appendChild(createElement("input", {type: "range", id, min, max, step, value, style: "width: 100%;"})); parent.appendChild(container); return container; } function addColorPicker(parent, id, labelText, value) { const container = createElement("div", {className: "popup-row"}); container.appendChild(createElement("label", {htmlFor: id + "Text", textContent: labelText})); const flexContainer = createElement("div", {style: "display: flex; align-items: center;"}); flexContainer.appendChild(createElement("div", {id: id + "Picker", className: "color-picker" })); flexContainer.appendChild(createElement("input", {type: "text", id: id + "Text", value, style: "margin-left: 10px;", className: "popup-input"})); container.appendChild(flexContainer); parent.appendChild(container); return container; } function addCheckbox(parent, id, labelText, checked) { const container = createElement("div", {className: "popup-row"}); const label = createElement("label", {htmlFor: id, textContent: labelText}); const checkbox = createElement("input", {type: "checkbox", id, checked, style: "margin-left: 10px;"}); label.style.display = "flex"; label.style.alignItems = "center"; container.appendChild(label); label.appendChild(checkbox); parent.appendChild(container); return container; } function addShortcutInput(parent, id, labelText, value) { const container = createElement("div", {className: "popup-row", style: "display: flex; justify-content: space-between;"}); container.appendChild(createElement("label", {htmlFor: id, textContent: labelText})); const input = createElement("input", { type: "text", id, value, maxLength: 1, // Restrict to single character className: "popup-input", style: "width: 30px; text-transform: lowercase; flex-grow: unset; text-align: center;" }); container.appendChild(input); parent.appendChild(container); return container; } const popupLayout = createElement("div"); const columns = createElement("div", {style: "display: flex; flex-direction: row;"}); const container1 = createElement("div", {style: "padding: 5px; width: 350px;"}); addSelect(container1, "styleTypeSelector", "Layout Style:", [ { value: "video", text: "Video" }, { value: "video2", text: "Video2" }, { value: "audio", text: "Audio" }, { value: "off", text: "Off" } ], settings.styleType); const videoSettings = createElement("div", { id: "videoSettings", style: `${settings.styleType === "video" ? "" : "display: none"}` }); addSlider(videoSettings, "heightBigSlider", "Video Height:", "heightBigValue", settings.heightBig, "px", 300, 800, 10); container1.appendChild(videoSettings); const sentenceVideoSettings = createElement("div", { id: "sentenceVideoSettings", style: `${settings.styleType === "off" ? "" : "display: none"}` }); addSlider(sentenceVideoSettings, "sentenceHeightSlider", "Sentence Video Height:", "sentenceHeightValue", settings.sentenceHeight, "px", 300, 600, 10); container1.appendChild(sentenceVideoSettings); addSlider(container1, "widgetWidthSlider", "Widget Width:", "widgetWidthValue", settings.widgetWidth, "px", 330, 500, 10); addSlider(container1, "fontSizeSlider", "Font Size:", "fontSizeValue", settings.fontSize, "rem", 0.8, 1.8, 0.05); addSlider(container1, "lineHeightSlider", "Line Height:", "lineHeightValue", settings.lineHeight, "", 1.2, 3.0, 0.1); const colorSection = createElement("div", {className: "popup-section"}); addSelect(colorSection, "colorModeSelector", "Color Mode:", [ { value: "white", text: "White" }, { value: "dark", text: "Dark" } ], settings.colorMode); [ { id: "fontColor", label: "Font Color:", value: colorSettings.fontColor }, { id: "translationFontColor", label: "Translation Font Color:", value: colorSettings.translationFontColor }, { id: "lingqBackground", label: "LingQ Background:", value: colorSettings.lingqBackground }, { id: "lingqBorder", label: "LingQ Border:", value: colorSettings.lingqBorder }, { id: "lingqBorderLearned", label: "LingQ Border Learned:", value: colorSettings.lingqBorderLearned }, { id: "unknownBackground", label: "Unknown Background:", value: colorSettings.unknownBackground }, { id: "unknownBorder", label: "Unknown Border:", value: colorSettings.unknownBorder }, { id: "playingUnderline", label: "Playing Underline:", value: colorSettings.playingUnderline } ].forEach(config => addColorPicker(colorSection, config.id, config.label, config.value)); container1.appendChild(colorSection); addCheckbox(container1, "autoFinishingCheckbox", "Finish Lesson Automatically", settings.autoFinishing); addCheckbox(container1, "focusPlayingSentenceCheckbox", "Focus on Playing Sentence", settings.focusPlayingSentence); columns.appendChild(container1); const container2 = createElement("div", {style: "padding: 10px; width: 350px;"}); addCheckbox(container2, "keyboardShortcutCheckbox", "Enable the Keyboard Shortcuts", settings.keyboardShortcut); const shortcutSection = createElement("div", {id: "keyboardShortcutSection", className: "popup-section", style: `${settings.keyboardShortcut ? "" : "display: none"}`}); addShortcutInput(shortcutSection, "shortcutVideoFullscreenInput", "Video Fullscreen Toggle:", settings.shortcutVideoFullscreen); addShortcutInput(shortcutSection, "shortcutBackward5sInput", "5 Sec Backward:", settings.shortcutBackward5s); addShortcutInput(shortcutSection, "shortcutForward5sInput", "5 Sec Forward:", settings.shortcutForward5s); addShortcutInput(shortcutSection, "shortcutTTSPlayInput", "Play TTS Audio:", settings.shortcutTTSPlay); addShortcutInput(shortcutSection, "shortcutTranslatorOpenInput", "Open Translator:", settings.shortcutTranslator); addShortcutInput(shortcutSection, "shortcutMakeKnownInput", "Make Word Known:", settings.shortcutMakeKnown); addShortcutInput(shortcutSection, "shortcutDictionaryOpenInput", "Open Dictionary:", settings.shortcutDictionary); addShortcutInput(shortcutSection, "shortcutCopySelectedInput", "Copy Selected Text:", settings.shortcutCopySelected); addShortcutInput(shortcutSection, "shortcutMeaningInputInput", "Meaning Input Focus:", settings.shortcutMeaningInput); addShortcutInput(shortcutSection, "shortcutChatInputInput", "Chat Input Focus:", settings.shortcutChatInput); container2.appendChild(shortcutSection); addCheckbox(container2, "chatWidgetCheckbox", "Enable the Chat Widget", settings.chatWidget); const chatWidgetSection = createElement("div", {id: "chatWidgetSection", className: "popup-section", style: `${settings.chatWidget ? "" : "display: none"}`}); addSelect(chatWidgetSection, "llmProviderModelSelector", "Chat Provider: (Price per 1M tokens)", [ { value: "openai gpt-4.1", text: "OpenAI GPT-4.1 ($2.0/$8.0)" }, { value: "openai gpt-4.1-mini", text: "OpenAI GPT-4.1 mini ($0.4/$1.6)" }, { value: "openai gpt-4.1-nano", text: "OpenAI GPT-4.1 nano ($0.1/$0.4)" }, { value: "google gemini-2.5-flash-preview-05-20", text: "Google Gemini 2.5 Flash ($0.15/$0.6)" }, { value: "google gemini-2.0-flash", text: "Google Gemini 2.0 Flash ($0.1/$0.4)" } ], settings.llmProviderModel); const apiKeyContainer = createElement("div", {className: "popup-row"}); apiKeyContainer.appendChild(createElement("label", {htmlFor: "llmApiKeyInput", textContent: "Chat API Key:"})); const apiKeyFlexContainer = createElement("div", {style: "display: flex; align-items: center;"}); const apiKeyInput= createElement("input", {type: "password", id: "llmApiKeyInput", value: settings.llmApiKey, className: "popup-input"}); apiKeyFlexContainer.appendChild(apiKeyInput) apiKeyContainer.appendChild(apiKeyFlexContainer); chatWidgetSection.appendChild(apiKeyContainer); addCheckbox(chatWidgetSection, "askSelectedCheckbox", "Enable asking with selected text", settings.askSelected); container2.appendChild(chatWidgetSection); addCheckbox(container2, "ttsCheckbox", "Enable AI-TTS", settings.tts); const ttsSection = createElement("div", {id: "ttsSection", className: "popup-section", style: `${settings.tts ? "" : "display: none"}`}); const ttsApiKeyContainer = createElement("div", {className: "popup-row"}); ttsApiKeyContainer.appendChild(createElement("label", {htmlFor: "ttsApiKeyInput", textContent: "TTS API Key:"})); const ttsApiKeyFlexContainer = createElement("div", {style: "display: flex; align-items: center;"}); const ttsApiKeyInput= createElement("input", {type: "password", id: "ttsApiKeyInput", value: settings.ttsApiKey, className: "popup-input"}); ttsApiKeyFlexContainer.appendChild(ttsApiKeyInput) ttsApiKeyContainer.appendChild(ttsApiKeyFlexContainer); ttsSection.appendChild(ttsApiKeyContainer); addSelect(ttsSection, "ttsVoiceSelector", "TTS Voice:", [ { value: "openai random", text: "OpenAI Random" }, { value: "openai alloy", text: "OpenAI Alloy" }, { value: "openai ash", text: "OpenAI Ash" }, { value: "openai ballad", text: "OpenAI Ballad" }, { value: "openai coral", text: "OpenAI Coral" }, { value: "openai echo", text: "OpenAI Echo" }, { value: "openai fable", text: "OpenAI Fable" }, { value: "openai onyx", text: "OpenAI Onyx" }, { value: "openai nova", text: "OpenAI Nova" }, { value: "openai sage", text: "OpenAI Sage" }, { value: "openai shimmer", text: "OpenAI Shimmer" }, { value: "openai verse", text: "OpenAI Verse" }, { value: "google random", text: "Google Random" }, { value: "google Zephyr", text: "Google Zephyr (Bright)" }, { value: "google Puck", text: "Google Puck (Upbeat)" }, { value: "google Charon", text: "Google Charon (Informative)" }, { value: "google Kore", text: "Google Kore (Firm)" }, { value: "google Fenrir", text: "Google Fenrir (Excitable)" }, { value: "google Leda", text: "Google Leda (Youthful)" }, { value: "google Orus", text: "Google Orus (Firm)" }, { value: "google Aoede", text: "Google Aoede (Breezy)" }, { value: "google Callirrhoe", text: "Google Callirrhoe (Easy-going)" }, { value: "google Autonoe", text: "Google Autonoe (Bright)" }, { value: "google Enceladus", text: "Google Enceladus (Breathy)" }, { value: "google Iapetus", text: "Google Iapetus (Clear)" }, { value: "google Umbriel", text: "Google Umbriel (Easy-going)" }, { value: "google Algieba", text: "Google Algieba (Smooth)" }, { value: "google Despina", text: "Google Despina (Smooth)" }, { value: "google Erinome", text: "Google Erinome (Clear)" }, { value: "google Algenib", text: "Google Algenib (Gravelly)" }, { value: "google Rasalgethi", text: "Google Rasalgethi (Informative)" }, { value: "google Laomedeia", text: "Google Laomedeia (Upbeat)" }, { value: "google Achernar", text: "Google Achernar (Soft)" }, { value: "google Alnilam", text: "Google Alnilam (Firm)" }, { value: "google Schedar", text: "Google Schedar (Even)" }, { value: "google Gacrux", text: "Google Gacrux (Mature)" }, { value: "google Pulcherrima", text: "Google Pulcherrima (Forward)" }, { value: "google Achird", text: "Google Achird (Friendly)" }, { value: "google Zubenelgenubi", text: "Google Zubenelgenubi (Casual)" }, { value: "google Vindemiatrix", text: "Google Vindemiatrix (Gentle)" }, { value: "google Sadachbia", text: "Google Sadachbia (Lively)" }, { value: "google Sadaltager", text: "Google Sadaltager (Knowledgeable)" }, { value: "google Sulafat", text: "Google Sulafat (Warm)" } ], settings.ttsVoice); addCheckbox(ttsSection, "ttsWordCheckbox", "Enable AI-TTS for words", settings.ttsWord); addCheckbox(ttsSection, "ttsSentenceCheckbox", "Enable AI-TTS for sentences", settings.ttsSentence); container2.appendChild(ttsSection); columns.appendChild(container2); const buttonContainer = createElement("div", {style: "display: flex; justify-content: space-between;", className: "popup-row"}); [ {id: "resetSettingsBtn", textContent: "Reset", className: "popup-button"}, {id: "closeSettingsBtn", textContent: "Close", className: "popup-button"} ].forEach((prop) => { buttonContainer.appendChild(createElement("button", prop)); }); popupLayout.appendChild(columns) popupLayout.appendChild(buttonContainer); return popupLayout; } function createDownloadWordsPopup() { const popup = createElement("div", {id: "lingqDownloadWordsPopup"}); const dragHandle = createElement("div", {id: "lingqDownloadWordsDragHandle"}); const dragHandleTitle = createElement("h3", {textContent: "Download Words"}); dragHandle.appendChild(dragHandleTitle); const content = createElement("div", {style: `padding: 0 10px;`}); [ {id: "downloadUnknownLingqsBtn", textContent: "Download Unknown LingQs (words + phrases)", className: "popup-button"}, {id: "downloadUnknownLingqWordsBtn", textContent: "Download Unknown LingQ Words (1, 2, 3, 4)", className: "popup-button"}, {id: "downloadUnknownLingqPhrasesBtn", textContent: "Download Unknown LingQ Phrases (1, 2, 3, 4)", className: "popup-button"}, {id: "downloadKnownLingqsBtn", textContent: "Download Known LingQs (✓)", className: "popup-button"}, {id: "downloadKnownWordsBtn", textContent: "Download Known Words ", className: "popup-button"} ].forEach((prop) => { let rowContainer = createElement("div", {className: "popup-row"}); rowContainer.appendChild(createElement("button", prop)) content.appendChild(rowContainer); }); const progressContainer = createElement("div", {id: "downloadProgressContainer", className: "popup-row"}); const progressText = createElement("div", {id: "downloadProgressText"}); const progressBar = createElement("progress", {id: "downloadProgressBar", value: "0", max: "100"}); progressContainer.appendChild(progressText); progressContainer.appendChild(progressBar); content.appendChild(progressContainer); const buttonContainer = createElement("div", {style: "display: flex; justify-content: flex-end;", className: "popup-row"}); const closeButton = createElement("button", {id: "closeDownloadWordsBtn", textContent: "Close", className: "popup-button"}); buttonContainer.appendChild(closeButton); content.appendChild(buttonContainer); popup.appendChild(dragHandle); popup.appendChild(content); return popup; } function makeDraggable(element, handle) { let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; if (handle) { handle.onmousedown = dragMouseDown; } else { element.onmousedown = dragMouseDown; } function dragMouseDown(e) { e = e || window.event; e.preventDefault(); if (element.style.transform && element.style.transform.includes('translate')) { const rect = element.getBoundingClientRect(); element.style.transform = 'none'; element.style.top = rect.top + 'px'; element.style.left = rect.left + 'px'; } pos3 = e.clientX; pos4 = e.clientY; document.onmouseup = closeDragElement; document.onmousemove = elementDrag; } function elementDrag(e) { e = e || window.event; e.preventDefault(); pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; element.style.top = (element.offsetTop - pos2) + "px"; element.style.left = (element.offsetLeft - pos1) + "px"; } function closeDragElement() { document.onmouseup = null; document.onmousemove = null; } } function setupSettingEventListeners() { function initializePickrs() { function setupRGBAPickr(pickerId, textId, settingKey, cssVar) { function saveColorSetting(key, value) { const currentColorMode = document.getElementById("colorModeSelector").value; const prefix = currentColorMode === "dark" ? "dark_" : "white_"; settings[prefix + key] = value; } const pickerElement = document.getElementById(pickerId); const textElement = document.getElementById(textId); if (!pickerElement || !textElement) return; pickerElement.style.backgroundColor = textElement.value; const pickr = Pickr.create({ el: pickerElement, theme: 'nano', useAsButton: true, default: textElement.value, components: {preview: true, opacity: true, hue: true} }); pickr.on('change', (color) => { const rgbaColor = color.toRGBA(); const r = Math.round(rgbaColor[0]); const g = Math.round(rgbaColor[1]); const b = Math.round(rgbaColor[2]); const a = rgbaColor[3]; const roundedRGBA = `rgba(${r}, ${g}, ${b}, ${a})`; textElement.value = roundedRGBA; pickerElement.style.backgroundColor = roundedRGBA; document.documentElement.style.setProperty(cssVar, roundedRGBA); saveColorSetting(settingKey, roundedRGBA); }); textElement.addEventListener('change', function () { const rgbaColor = this.value; pickr.setColor(this.value); saveColorSetting(settingKey, rgbaColor); document.documentElement.style.setProperty(cssVar, rgbaColor); pickerElement.style.backgroundColor = rgbaColor; }); pickr.on('hide', () => { const rgbaColor = pickr.getColor().toRGBA().toString(); pickerElement.style.backgroundColor = rgbaColor; }); } return new Promise((resolve) => { const pickrCss = createElement('link', { rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css' }); document.head.appendChild(pickrCss); const pickrScript = createElement('script', { src: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js', onload: () => resolve() // Pass function reference directly }); document.head.appendChild(pickrScript); }).then(() => { setupRGBAPickr('lingqBackgroundPicker', 'lingqBackgroundText', 'lingqBackground', '--lingq-background'); setupRGBAPickr('lingqBorderPicker', 'lingqBorderText', 'lingqBorder', '--lingq-border'); setupRGBAPickr('lingqBorderLearnedPicker', 'lingqBorderLearnedText', 'lingqBorderLearned', '--lingq-border-learned'); setupRGBAPickr('unknownBackgroundPicker', 'unknownBackgroundText', 'unknownBackground', '--unknown-background'); setupRGBAPickr('unknownBorderPicker', 'unknownBorderText', 'unknownBorder', '--unknown-border'); setupRGBAPickr('fontColorPicker', 'fontColorText', 'fontColor', '--font-color'); setupRGBAPickr('translationFontColorPicker', 'translationFontColorText', 'translationFontColor', '--translation-font-color'); setupRGBAPickr('playingUnderlinePicker', 'playingUnderlineText', 'playingUnderline', '--is-playing-underline'); }); } function updateColorInputs(colorSettings) { document.getElementById("fontColorText").value = colorSettings.fontColor; document.getElementById("translationFontColorText").value = colorSettings.translationFontColor; document.getElementById("lingqBackgroundText").value = colorSettings.lingqBackground; document.getElementById("lingqBorderText").value = colorSettings.lingqBorder; document.getElementById("lingqBorderLearnedText").value = colorSettings.lingqBorderLearned; document.getElementById("unknownBackgroundText").value = colorSettings.unknownBackground; document.getElementById("unknownBorderText").value = colorSettings.unknownBorder; document.getElementById("playingUnderlineText").value = colorSettings.playingUnderline; const fontColorPicker = document.getElementById("fontColorPicker"); if (fontColorPicker) fontColorPicker.style.backgroundColor = colorSettings.fontColor; const translationFontColorPicker = document.getElementById("translationFontColorPicker"); if (translationFontColorPicker) translationFontColorPicker.style.backgroundColor = colorSettings.translationFontColor; const playingUnderlinePicker = document.getElementById("playingUnderlinePicker"); if (playingUnderlinePicker) playingUnderlinePicker.style.backgroundColor = colorSettings.playingUnderline; } function updateColorPickerBackgrounds(colorSettings) { const pickerIds = [ { id: "lingqBackgroundPicker", color: colorSettings.lingqBackground }, { id: "lingqBorderPicker", color: colorSettings.lingqBorder }, { id: "lingqBorderLearnedPicker", color: colorSettings.lingqBorderLearned }, { id: "unknownBackgroundPicker", color: colorSettings.unknownBackground }, { id: "unknownBorderPicker", color: colorSettings.unknownBorder }, { id: "fontColorPicker", color: colorSettings.fontColor }, { id: "translationFontColorPicker", color: colorSettings.translationFontColor }, { id: "playingUnderlinePicker", color: colorSettings.playingUnderline } ]; pickerIds.forEach(item => { const picker = document.getElementById(item.id); if (picker) { picker.style.backgroundColor = item.color; } }); } function updateCssColorVariables(colorSettings) { document.documentElement.style.setProperty("--font-color", colorSettings.fontColor); document.documentElement.style.setProperty("--translation-font-color", colorSettings.translationFontColor); document.documentElement.style.setProperty("--lingq-background", colorSettings.lingqBackground); document.documentElement.style.setProperty("--lingq-border", colorSettings.lingqBorder); document.documentElement.style.setProperty("--lingq-border-learned", colorSettings.lingqBorderLearned); document.documentElement.style.setProperty("--unknown-background", colorSettings.unknownBackground); document.documentElement.style.setProperty("--unknown-border", colorSettings.unknownBorder); document.documentElement.style.setProperty("--is-playing-underline", colorSettings.playingUnderline); } function updateColorMode(event) { event.stopPropagation(); const selectedColorMode = this.value; settings.colorMode = selectedColorMode; document.documentElement.style.setProperty("--background-color", selectedColorMode === "dark" ? "#2a2c2e" : "#ffffff"); const colorSettings = getColorSettings(selectedColorMode); updateColorInputs(colorSettings); updateCssColorVariables(colorSettings); updateColorPickerBackgrounds(colorSettings); applyStyles(document.getElementById("styleTypeSelector").value, selectedColorMode); } function setupSlider(sliderId, valueId, settingKey, unit, cssVar, valueTransform) { const slider = document.getElementById(sliderId); const valueDisplay = document.getElementById(valueId); slider.addEventListener("input", function () { const value = parseFloat(this.value); const transformedValue = valueTransform(value); valueDisplay.textContent = transformedValue.toString().replace(unit, ''); settings[settingKey] = value; document.documentElement.style.setProperty(cssVar, transformedValue); }); } const settingsButton = document.getElementById('lingqAddonSettings'); const settingsPopup = document.getElementById('lingqAddonSettingsPopup'); settingsButton.addEventListener("click", () => { settingsPopup.style.display = "block"; initializePickrs(); const dragHandle = document.getElementById("lingqAddonSettingsDragHandle"); makeDraggable(settingsPopup, dragHandle); }); const styleTypeSelector = document.getElementById("styleTypeSelector"); styleTypeSelector.addEventListener("change", (event) => { const selectedStyleType = event.target.value; settings.styleType = selectedStyleType; document.getElementById("videoSettings").style.display = selectedStyleType === "video" ? "block" : "none"; document.getElementById("sentenceVideoSettings").style.display = selectedStyleType === "off" ? "block" : "none"; applyStyles(selectedStyleType, document.getElementById("colorModeSelector").value); }); setupSlider("heightBigSlider", "heightBigValue", "heightBig", "px", "--height-big", (val) => `${val}px`); setupSlider("sentenceHeightSlider", "sentenceHeightValue", "sentenceHeight", "px", "--sentence-height", (val) => `${val}px`); setupSlider("widgetWidthSlider", "widgetWidthValue", "widgetWidth", "px", "--widget-width", (val) => `${val}px`); setupSlider("fontSizeSlider", "fontSizeValue", "fontSize", "rem", "--font-size", (val) => `${val}rem`); setupSlider("lineHeightSlider", "lineHeightValue", "lineHeight", "", "--line-height", (val) => val); document.getElementById("colorModeSelector").addEventListener("change", updateColorMode); const autoFinishingCheckbox = document.getElementById("autoFinishingCheckbox"); autoFinishingCheckbox.addEventListener('change', (event) => {settings.autoFinishing = event.target.checked}); const focusPlayingSentenceCheckbox = document.getElementById("focusPlayingSentenceCheckbox"); focusPlayingSentenceCheckbox.addEventListener('change', (event) => {settings.focusPlayingSentence = event.target.checked}); function setupShortcutInput(inputId, settingKey) { const input = document.getElementById(inputId); if (!input) return; input.addEventListener("input", function() { const allowedPattern = /^[a-z0-9`~!@#$%^&*()_+=-]*$/; let value = this.value.toLowerCase(); if (!allowedPattern.test(value)) { this.value = ""; return; } if (value.length > 1) { value = value.at(-1); this.value = value; } settings[settingKey] = value; }); } const keyboardShortcutCheckbox = document.getElementById("keyboardShortcutCheckbox"); keyboardShortcutCheckbox.addEventListener('change', (event) => { const checked = event.target.checked; document.getElementById("keyboardShortcutSection").style.display = checked ? "block" : "none"; settings.keyboardShortcut = checked; }); setupShortcutInput("shortcutVideoFullscreenInput", "shortcutVideoFullscreen"); setupShortcutInput("shortcutBackward5sInput", "shortcutBackward5s"); setupShortcutInput("shortcutForward5sInput", "shortcutForward5s"); setupShortcutInput("shortcutTTSPlayInput", "shortcutTTSPlay"); setupShortcutInput("shortcutTranslatorOpenInput", "shortcutTranslator"); setupShortcutInput("shortcutMakeKnownInput", "shortcutMakeKnown"); setupShortcutInput("shortcutDictionaryOpenInput", "shortcutDictionary"); setupShortcutInput("shortcutCopySelectedInput", "shortcutCopySelected"); setupShortcutInput("shortcutMeaningInputInput", "shortcutMeaningInput"); setupShortcutInput("shortcutChatInputInput", "shortcutChatInput"); const chatWidgetCheckbox = document.getElementById("chatWidgetCheckbox"); chatWidgetCheckbox.addEventListener('change', (event) => { const checked = event.target.checked; document.getElementById("chatWidgetSection").style.display = checked ? "block" : "none"; settings.chatWidget = checked; }); const llmProviderModelSelector = document.getElementById("llmProviderModelSelector"); llmProviderModelSelector.addEventListener("change", (event) => {settings.llmProviderModel = event.target.value}); const llmApiKeyInput = document.getElementById("llmApiKeyInput"); llmApiKeyInput.addEventListener("change", (event) => {settings.llmApiKey = event.target.value}); const askSelectedCheckbox = document.getElementById("askSelectedCheckbox"); askSelectedCheckbox.addEventListener('change', (event) => {settings.askSelected = event.target.checked}); const ttsCheckbox = document.getElementById("ttsCheckbox"); ttsCheckbox.addEventListener('change', (event) => { const checked = event.target.checked; document.getElementById("ttsSection").style.display = checked ? "block" : "none"; settings.tts = checked; }); const ttsApiKeyInput = document.getElementById("ttsApiKeyInput"); ttsApiKeyInput.addEventListener("change", (event) => {settings.ttsApiKey = event.target.value}); const ttsVoiceSelector = document.getElementById("ttsVoiceSelector"); ttsVoiceSelector.addEventListener("change", (event) => {settings.ttsVoice = event.target.value}); const ttsWordCheckbox = document.getElementById("ttsWordCheckbox"); ttsWordCheckbox.addEventListener('change', (event) => {settings.ttsWord = event.target.checked}); const ttsSentenceCheckbox = document.getElementById("ttsSentenceCheckbox"); ttsSentenceCheckbox.addEventListener('change', (event) => {settings.ttsSentence = event.target.checked}); function resetSettings() { if (!confirm("Reset all settings to default?")) return; const currentColorMode = document.getElementById("colorModeSelector").value; const defaultColorSettings = getColorSettings(currentColorMode); document.getElementById("styleTypeSelector").value = defaults.styleType; document.getElementById("heightBigSlider").value = defaults.heightBig; document.getElementById("heightBigValue").textContent = defaults.heightBig; document.getElementById("sentenceHeightSlider").value = defaults.sentenceHeight; document.getElementById("sentenceHeightValue").textContent = defaults.sentenceHeight; document.getElementById("widgetWidthSlider").value = defaults.widgetWidth; document.getElementById("widgetWidthValue").value = defaults.widgetWidth; document.getElementById("fontSizeSlider").value = defaults.fontSize; document.getElementById("fontSizeValue").textContent = defaults.fontSize; document.getElementById("lineHeightSlider").value = defaults.lineHeight; document.getElementById("lineHeightValue").textContent = defaults.lineHeight; updateColorInputs(defaultColorSettings); updateColorPickerBackgrounds(defaultColorSettings); applyStyles(defaults.styleType, currentColorMode); document.getElementById("videoSettings").style.display = defaults.styleType === "video" ? "block" : "none"; document.getElementById("sentenceVideoSettings").style.display = defaults.styleType === "off" ? "block" : "none"; document.documentElement.style.setProperty("--font-size", `${defaults.fontSize}rem`); document.documentElement.style.setProperty("--line-height", defaults.lineHeight); document.documentElement.style.setProperty("--height-big", `${defaults.heightBig}px`); document.documentElement.style.setProperty("--sentence-height", `${defaults.sentenceHeight}px`); document.documentElement.style.setProperty("--widget-width", `${defaults.widgetWidth}px`); updateCssColorVariables(defaultColorSettings); document.getElementById("autoFinishingCheckbox").checked = defaults.autoFinishing; document.getElementById("focusPlayingSentenceCheckbox").checked = defaults.focusPlayingSentence; document.getElementById("keyboardShortcutCheckbox").value = defaults.keyboardShortcut; document.getElementById("shortcutVideoFullscreenInput").value = defaults.shortcutVideoFullscreen; document.getElementById("shortcutBackward5sInput").value = defaults.shortcutBackward5s; document.getElementById("shortcutForward5sInput").value = defaults.shortcutForward5s; document.getElementById("shortcutTTSPlayInput").value = defaults.shortcutTTSPlay; document.getElementById("shortcutTranslatorOpenInput").value = defaults.shortcutTranslator; document.getElementById("shortcutMakeKnownInput").value = defaults.shortcutMakeKnown; document.getElementById("shortcutDictionaryOpenInput").value = defaults.shortcutDictionary; document.getElementById("shortcutCopySelectedInput").value = defaults.shortcutCopySelected; document.getElementById("shortcutMeaningInputInput").value = defaults.shortcutMeaningInput; document.getElementById("shortcutChatInputInput").value = defaults.shortcutChatInput; document.getElementById("chatWidgetCheckbox").value = defaults.chatWidget; document.getElementById("llmProviderModelSelector").value = defaults.llmProviderModel; document.getElementById("llmApiKeyInput").value = defaults.llmApiKey; document.getElementById("askSelectedCheckbox").value = defaults.askSelected; document.getElementById("ttsCheckbox").value = defaults.tts; document.getElementById("ttsApiKeyInput").value = defaults.ttsApiKey; document.getElementById("ttsVoiceSelector").value = defaults.ttsVoice; document.getElementById("ttsWordCheckbox").value = defaults.ttsWord; document.getElementById("ttsSentenceCheckbox").value = defaults.ttsSentence; for (const [key, value] of Object.entries(defaults)) { settings[key] = value } } document.getElementById("resetSettingsBtn").addEventListener("click", resetSettings); document.getElementById("closeSettingsBtn").addEventListener("click", () => {settingsPopup.style.display = "none"}); } async function setupDownloadWordsEventListeners() { async function getAllWords(baseUrl, pageSize, apiType, additionalParams="", progressCallback = () => {}) { let allResults = []; let nextUrl = `${baseUrl}?page_size=${pageSize}&page=1${additionalParams}`; let currentPage = 0; let totalPages = 0; let isFirstCall = true; while (nextUrl) { try { const response = await fetch(nextUrl); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); currentPage++; if (isFirstCall) { isFirstCall = false; totalPages = Math.ceil(data.count / pageSize); console.log(`total pages: ${totalPages}`); } progressCallback(currentPage, totalPages, false, null, data.count); if (apiType === 'lingq') { const filteredResults = data.results.map(item => ({ pk: item.pk, term: item.term, fragment: item.fragment, status: item.status, hint: item.hints && item.hints[0] ? item.hints[0].text : null })); allResults = allResults.concat(filteredResults); } else if (apiType === 'known') { allResults = allResults.concat(data.results); } nextUrl = data.next; if (nextUrl) { console.log("Fetched page. Next URL:", nextUrl); } else { console.log("Finished fetching all pages"); progressCallback(currentPage, totalPages, true, null, data.count); } } catch (error) { console.error('Error fetching data:', error); progressCallback(currentPage, totalPages, true, error, 0); break; } } return allResults; } async function downloadWords(baseUrl, pageSize, fileName, apiType, additionalParams="") { const progressContainer = document.getElementById("downloadProgressContainer"); const progressBar = document.getElementById("downloadProgressBar"); const progressText = document.getElementById("downloadProgressText"); if (progressContainer && progressBar && progressText) { progressBar.value = 0; progressBar.max = 100; progressText.textContent = "Initializing download..."; progressContainer.style.display = "block"; } const progressCallback = (currentPage, totalPages,_isDone, error_isErrorEncountered, totalCount) => { if (progressBar && progressText) { if (error_isErrorEncountered) { progressText.textContent = `Error fetching page ${currentPage}: ${error_isErrorEncountered.message}`; progressBar.style.backgroundColor = 'red'; return; } progressBar.max = totalPages; progressBar.value = currentPage; progressText.textContent = `Fetching data... Page ${currentPage} of ${totalPages} (Total items: ${totalCount || 'N/A'})`; if (_isDone) { progressText.textContent = error_isErrorEncountered ? `Export failed: ${error_isErrorEncountered.message}` : `${totalCount} items exported`; } } }; try { const allWords = await getAllWords(baseUrl, pageSize, apiType, additionalParams, progressCallback); if (!allWords || allWords.length === 0) { console.warn("No words found or an error occurred."); return; } let blob; const fileType = fileName.split(".")[1]; if (fileType === 'json') { const dataString = JSON.stringify(allWords, null, 2); blob = new Blob([dataString], { type: 'application/json' }); } else if (fileType === 'csv') { const headers = Object.keys(allWords[0]).join(','); const rows = allWords.map(item => { return Object.values(item).map(value => { if (typeof value === 'string') { return `"${value.replace(/"/g, '""')}"`; } return value; }).join(','); }).join('\n'); const dataString = headers + '\n' + rows; blob = new Blob([dataString], { type: 'text/csv' }); } downloadBlob(blob, fileName); console.log("Export completed."); } catch (error) { console.error('Error:', error); } } function downloadBlob(blob, fileName) { const url = URL.createObjectURL(blob); const a = createElement("a", {href: url, download: fileName}); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } const downloadWordsButton = document.getElementById('lingqDownloadWords'); const downloadWordsPopup = document.getElementById('lingqDownloadWordsPopup'); downloadWordsButton.addEventListener("click", () => { downloadWordsPopup.style.display = "block"; const progressContainer = document.getElementById("downloadProgressContainer"); if (progressContainer) progressContainer.style.display = "none"; const dragHandle = document.getElementById("lingqDownloadWordsDragHandle"); if (dragHandle) { makeDraggable(downloadWordsPopup, dragHandle); } }); const languageCode = await getLanguageCode(); const pageSize = 1000; const setButtonsDisabled = (disabled) => { const buttons = downloadWordsPopup.querySelectorAll('.popup-button'); buttons.forEach(button => { button.disabled = disabled; }); }; const handleDownloadButtonClick = async (url, filename, type, params = '') => { setButtonsDisabled(true); try { await downloadWords(url, pageSize, filename, type, params); } finally { setButtonsDisabled(false); } }; document.getElementById("downloadUnknownLingqsBtn").addEventListener("click", async () => { await handleDownloadButtonClick(`https://www.lingq.com/api/v3/${languageCode}/cards/`, "unknown_lingqs.csv", 'lingq', '&status=0&status=1&status=2&status=3'); }); document.getElementById("downloadUnknownLingqWordsBtn").addEventListener("click", async () => { 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'); }); document.getElementById("downloadUnknownLingqPhrasesBtn").addEventListener("click", async () => { 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'); }); document.getElementById("downloadKnownLingqsBtn").addEventListener("click", async () => { await handleDownloadButtonClick(`https://www.lingq.com/api/v3/${languageCode}/cards/`, "known_lingqs.csv", 'lingq', '&status=4'); }); document.getElementById("downloadKnownWordsBtn").addEventListener("click", async () => { await handleDownloadButtonClick(`https://www.lingq.com/api/v2/${languageCode}/known-words/`, "known_words.csv", "known"); }); document.getElementById("closeDownloadWordsBtn").addEventListener("click", () => { downloadWordsPopup.style.display = "none"; }); } function setupLessonCompletion() { document.getElementById("lingqLessonComplete").addEventListener("click", finishLesson); } function applyStyles() { const colorSettings = getColorSettings(settings.colorMode); let baseCSS = generateBaseCSS(colorSettings); let layoutCSS = generateLayoutCSS(); let specificCSS = ""; switch (settings.colorMode) { case "white": clickElement(".reader-themes-component > button:nth-child(1)"); break; case "dark": clickElement(".reader-themes-component > button:nth-child(5)"); break; } switch (settings.styleType) { case "video": specificCSS = generateVideoCSS(); break; case "video2": specificCSS = generateVideo2CSS(); break; case "audio": specificCSS = generateAudioCSS(); break; case "off": specificCSS = generateOffModeCSS(); layoutCSS = ""; break; } baseCSS += layoutCSS; baseCSS += specificCSS; if (styleElement) styleElement.remove(); styleElement = createElement("style", {textContent: baseCSS}); document.querySelector("head").appendChild(styleElement); } function generateBaseCSS(colorSettings) { return` :root { --font-size: ${settings.fontSize}rem; --line-height: ${settings.lineHeight}; --font-color: ${colorSettings.fontColor}; --lingq-background: ${colorSettings.lingqBackground}; --lingq-border: ${colorSettings.lingqBorder}; --lingq-border-learned: ${colorSettings.lingqBorderLearned}; --unknown-background: ${colorSettings.unknownBackground}; --unknown-border: ${colorSettings.unknownBorder}; --is-playing-underline: ${colorSettings.playingUnderline}; --background-color: ${settings.colorMode === "dark" ? "#2a2c2e" : "#ffffff"} } /*Color picker*/ .color-picker { width: 30px; height: 15px; border-radius: 4px; cursor: pointer; border: 1px solid rgba(125, 125, 125, 30%); } .pcr-app { z-index: 10001 !important; } .pcr-app .pcr-interaction .pcr-result { color: var(--font-color) !important; } /*Popup settings*/ #lingqAddonSettings { color: var(--font-color); } #lingqAddonSettingsPopup, #lingqDownloadWordsPopup { position: fixed; top: 40%; left: 40%; transform: translate(-40%, -40%); background-color: var(--background-color, #2a2c2e); color: var(--font-color, #e0e0e0); border: 1px solid rgb(125 125 125 / 30%); border-radius: 8px; box-shadow: 8px 8px 8px rgba(0, 0, 0, 0.2); z-index: 10000; display: none; max-height: 90vh; overflow-y: auto; } #lingqAddonSettingsDragHandle, #lingqDownloadWordsDragHandle { cursor: move; background-color: rgba(128, 128, 128, 0.2); padding: 8px; border-radius: 8px 8px 0 0; text-align: center; user-select: none; } .popup-row { margin: 5px 0; } .nav-button { background: none; border: none; cursor: pointer; font-size: 1.2rem; margin-left: 10px; padding: 5px; } .popup-button { padding: 5px 10px; border: 1px solid rgb(125, 125, 125, 50%); border-radius: 5px; margin: 5px 0; } .popup-section { border: 1px solid rgb(125 125 125 / 50%); padding: 5px 10px; border-radius: 5px; margin: 10px 0; } .popup-input { flex-grow: 1; border: 1px solid rgb(125 125 125 / 50%); border-radius: 5px; } #downloadProgressContainer { display: none; } #downloadProgressText { text-align: center; margin-bottom: 5px; font-size: 0.9em; } #downloadProgressBar { width: 100%; height: 20px; } progress[value]::-webkit-progress-bar { border-radius: 5px; } progress[value]::-webkit-progress-value { border-radius: 5px; } select { width: 100%; margin-top: 5px; padding: 5px; background: rgb(125 125 125 / 10%) !important; } option { background: var(--background-color) !important; } /*Chat*/ #chat-container { margin-bottom:5px; border: 1px solid rgb(125 125 125 / 35%); border-radius: 5px; min-height: 100px; max-height: 300px; overflow-y: auto; resize: vertical; padding: 5px !important; scrollbar-width: none !important; } .input-container { display: flex; margin-bottom: 10px; border: 1px solid rgb(125 125 125 / 35%); border-radius: 5px; } #user-input { flex-grow: 1; padding: 5px 10px; font-size: 0.9rem; background: none !important; outline: none; } #user-input::placeholder { color: rgb(125 125 125) !important; } #send-button { padding: 5px 10px; } .chat-message { padding: 5px; margin-bottom: 5px; border-radius: 8px; color: var(--font-color); font-size: 0.9rem; line-height: 1.2; } .user-message { background-color: rgb(125 125 125 / 5%); } .bot-message { background-color: rgb(125 125 125 / 15%); } #chat-container .word-message :is(b:nth-child(1), span:nth-child(2)) { font-size: 1.05rem; } @keyframes gradient-move { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .loading-message { background: linear-gradient( 90deg, rgb( from var(--font-color) r g b / 0.5) 0%, var(--font-color) 50%, rgb( from var(--font-color) r g b / 0.5) 100% ); background-size: 200% 200%; -webkit-text-fill-color: transparent; background-clip: text; animation: gradient-move 3s linear infinite; } #chat-container li { list-style: inside !important; } #chat-container ui { margin-top: 0.5rem; } #chat-container hr { margin: 0.3rem 0 0.4rem; border: 0; height: 1px; background-color: rgb(125 125 125 / 50%); } /*tts*/ #playAudio { } /*font settings*/ .reader-container { line-height: var(--line-height) !important; font-size: var(--font-size) !important; padding: 0 !important; } .sentence-text-head { min-height: 4.5rem !important; } .reader-container p { margin-top: 0 !important; } .reader-container p span.sentence-item, .reader-container p .sentence { color: var(--font-color) !important; } .sentence.is-playing, .sentence.is-playing span { text-underline-offset: .2em !important; text-decoration-color: var(--is-playing-underline) !important; } /*highlightings*/ .phrase-item { padding: 0 !important; } .phrase-item:not(.phrase-item-status--4, .phrase-item-status--4x2) { background-color: var(--lingq-background) !important; } .phrase-item.phrase-item-status--4, .phrase-item.phrase-item-status--4x2 { background-color: rgba(0, 0, 0, 0) !important; } .phrase-cluster:not(:has(.phrase-item-status--4, .phrase-item-status--4x2)) { border: 1px solid var(--lingq-border) !important; border-radius: .25rem; } .phrase-cluster:has(.phrase-item-status--4, .phrase-item-status--4x2) { border: 1px solid var(--lingq-border-learned) !important; border-radius: .25rem; } .reader-container .sentence .lingq-word:not(.is-learned) { border: 1px solid var(--lingq-border) !important; background-color: var(--lingq-background) !important; } .reader-container .sentence .lingq-word.is-learned { border: 1px solid var(--lingq-border-learned) !important; } .reader-container .sentence .blue-word { border: 1px solid var(--unknown-border) !important; background-color: var(--unknown-background) !important;; } .phrase-cluster:hover, .phrase-created:hover { padding: 0 !important; } .phrase-cluster:hover .phrase-item, .phrase-created .phrase-item { padding: 0 !important; } .reader-container .sentence .selected-text { padding: 0 !important; } `; } function generateLayoutCSS() { return ` :root { --article-height: calc(var(--app-height) - var(--height-big)); --header-height: 50px; --widget-width: ${settings.widgetWidth}px; --footer-height: 80px; --reader-layout-columns: 1fr var(--widget-width); --reader-layout-rows: var(--article-height) calc(var(--height-big) - var(--footer-height)) var(--footer-height); } /*header settings*/ .main-wrapper { padding: 0 !important; } #main-nav { z-index: 1; } #main-nav > nav { height: var(--header-height); } #main-nav > nav > div:nth-child(1) { height: var(--header-height); } .main-header { pointer-events: none; } .main-header > div { grid-template-columns: 1fr 150px !important; padding: 0 0 0 420px !important; } .main-header section:nth-child(1) { display: none; } .main-header section { pointer-events: auto; z-index: 1; } .main-header svg { width: 20px !important; height: 20px !important; } .main-header section .dropdown-content { position: fixed; } .lesson-progress-section { grid-template-rows: unset !important; grid-template-columns: unset !important; grid-column: 1 !important; pointer-events: auto; } .lesson-progress-section .rc-slider{ grid-row: unset !important; grid-column: unset !important; width: 50% !important; } /*layout*/ #lesson-reader { grid-template-columns: var(--reader-layout-columns); grid-template-rows: var(--reader-layout-rows); overflow: hidden; height: auto !important; } .sentence-text { height: calc(var(--article-height) - var(--header-height)) !important; padding: 0 0 20px !important; } .reader-container-wrapper { height: 100% !important; } .widget-area { padding: var(--header-height) 0 10px !important; margin: 0 10px !important; height: 100% !important; justify-content: center; display: flex; } .reader-widget { display: flow !important; overflow-y: auto; width: 100% !important; height: fit-content !important; max-width: none !important; scrollbar-width: none !important; } .reader-widget:not(.reader-widget--resources) { padding: 10px !important; } .reader-widget.reader-widget--resources { padding: 10px 15px !important; } .reference-main { margin-bottom: 5px; } .reference-word { white-space: pre-line !important; } .section-widget--main { margin: 0 !important; padding: 0 !important; } .appCue-poular-hints { max-height: 250px; overflow-y: auto; scrollbar-width: none !important; } .reference-input-text { font-size: 0.9rem !important; scrollbar-width: none !important; } .section-widget--foot { margin: 0 !important; padding: 10px 0 0 !important; display: block !important; } .dictionary-resources { width: 100% !important; } .word-status-bar { width: 100%; grid-template-columns: repeat(6, 1fr) !important; grid-gap: 10px !important; } .reference-helpers { display: none !important; } .userToast { position: fixed; top: 60px; right: 20px; background-color: var(--background-color); color: ${settings.colorMode === "dark" ? "white" : "#ffffff"}; padding: 5px 10px; border-radius: 10px; z-index: 9999; opacity: 0; transition: opacity 0.5s ease-in-out; font-size: 0.8rem; font-weight: lighter; pointer-events: none; } .main-footer { grid-area: 3 / 1 / 3 / 1 !important; align-self: end; padding: 5px 10px 10px; height: 100%; } .main-footer > div { height: 100%; } .lesson-bottom > div { position: unset !important; } .main-content { grid-template-rows: var(--header-height) 1fr !important; overflow: hidden; align-items: center; } /*make prev/next page buttons compact*/ .reader-component { grid-template-columns: 0 1fr 0 !important; align-items: baseline; } .reader-component > div > a.button > span { width: 0.5rem !important; } .reader-component > div > a.button > span > svg { width: 15px !important; height: 15px !important; } .loadedContent { padding: 0 0 0 10px !important;; } /*font settings*/ .reader-container { margin: 0 !important; float: left !important; columns: unset !important; overflow-y: scroll !important; max-width: unset !important; } /*video viewer*/ .video-player { display: flex !important; justify-content: flex-end !important; pointer-events: none; z-index: 38 !important; } .video-player > .modal-background { background-color: rgb(26 28 30 / 0%) !important; } .video-player > .modal-content { max-width: var(--width-big) !important; margin: 0 0 10px 10px !important; border-radius: 0.75rem !important; } .video-player .modal-section { display: none !important; } .video-wrapper { height: var(--height-big) !important; overflow: hidden; pointer-events: auto; } /*video controller*/ .rc-slider-rail { background-color: dimgrey !important; } .rc-slider-step { margin-top: -8px !important; height: 1.2rem !important; } .lingq-audio-player { margin-left: 10px; } .section--player.is-expanded { width: 100% !important; height: 100%; padding: 0 !important; } .sentence-mode-button { margin: 0 0 10px 0; } .player-wrapper { grid-template-columns: 1fr 40px !important; padding: 0 !important; } .audio-player { padding: 0 0.5rem !important; grid-template-rows: 16px 16px auto !important; } .audio-player--controllers { grid-gap: unset !important; } .audio-player--controllers a { height: 25px !important; padding: 0 1em !important; margin: 5px 0; } .audio-player--controllers span { height: 25px !important; } `; } function generateVideoCSS() { return ` :root { --width-big: calc(100vw - var(--widget-width) - 10px); --height-big: ${settings.heightBig}px; } .main-content { grid-area: 1 / 1 / 2 / 2 !important; } .widget-area { grid-area: 1 / 2 / 3 / 2 !important; } .main-footer { grid-area: 3 / 2 / 4 / 3 !important; align-self: end; } .video-player { align-items: flex-start !important; } `; } function generateVideo2CSS() { return ` :root { --width-big: calc(50vw - calc(var(--widget-width) / 2) - 10px); --height-big: calc(100vh - 65px); --reader-layout-columns: 1fr var(--widget-width) 1fr; --reader-layout-rows: var(--article-height) var(--footer-height); --article-height: calc(var(--app-height) - var(--footer-height)); } #lesson-reader { grid-template-columns: var(--reader-layout-columns); } .main-content { grid-area: 1 / 1 / 2 / 2 !important; } .widget-area { grid-area: 1 / 2 / 2 / 3 !important; } .main-footer { grid-area: 2 / 2 / 3 / 3 !important; align-self: end; } .video-player { align-items: end !important; } `; } function generateAudioCSS() { return ` :root { --width-big: calc(var(--widget-width) - 20px); --height-big: cald(var(--footer-height) - 10px); --reader-layout-rows: var(--article-height) var(--footer-height); --article-height: calc(var(--app-height) - var(--footer-height)); } .main-content { grid-area: 1 / 1 / 2 / 2 !important; } .widget-area { grid-area: 1 / 2 / 2 / 2 !important; } .main-footer { grid-area: 2 / 1 / 3 / 2 !important; align-self: end; } .video-player { align-items: end !important; } .video-player > .modal-content { margin: 0 10px 10px !important; } `; } function generateOffModeCSS() { return ` :root { --width-small: 440px; --height-small: 260px; --sentence-height: ${settings.sentenceHeight}px; --right-pos: 0.5%; --bottom-pos: 5.5%; } /*video player*/ .video-player.is-minimized .video-wrapper, .sent-video-player.is-minimized .video-wrapper { height: var(--height-small); width: var(--width-small); overflow: auto; resize: both; } .video-player.is-minimized .modal-content, .sent-video-player.is-minimized .modal-content { max-width: calc(var(--width-small)* 3); margin-bottom: 0; } .video-player.is-minimized, .sent-video-player.is-minimized { left: auto; top: auto; right: var(--right-pos); bottom: var(--bottom-pos); z-index: 99999999; overflow: visible } /*sentence mode video player*/ .loadedContent:has(#sentence-video-player-portal) { grid-template-rows: var(--sentence-height) auto auto 1fr !important; } #sentence-video-player-portal .video-section { width: 100% !important; max-width: none !important; } #sentence-video-player-portal .video-wrapper { height: 100% !important; max-height: none !important; } #sentence-video-player-portal div:has(> iframe) { height: 100% !important; } `; } const colorSettings = getColorSettings(settings.colorMode); let styleElement = null; const settingsButton = createElement("button", { id: "lingqAddonSettings", textContent: "⚙️", title: "LingQ Addon Settings", className: "nav-button" }); const completeLessonButton = createElement("button", { id: "lingqLessonComplete", textContent: "✔", title: "Complete Lesson Button", className: "nav-button" }); const downloadWordsButton = createElement("button", { id: "lingqDownloadWords", textContent: "💾", title: "Download Words", className: "nav-button" }); let mainNav = document.querySelector("#main-nav > nav > div:nth-child(2) > div:nth-child(1)"); if (mainNav) { mainNav.appendChild(settingsButton); mainNav.appendChild(downloadWordsButton); mainNav.appendChild(completeLessonButton); } else { console.error("#main-nav element not found. Buttons not inserted."); } const settingsPopup = createSettingsPopup(); document.body.appendChild(settingsPopup); const downloadWordsPopup = createDownloadWordsPopup(); document.body.appendChild(downloadWordsPopup); setupSettingEventListeners(); setupDownloadWordsEventListeners(); setupLessonCompletion(); applyStyles(); } async function setupCourse() { function createCourseUI(){ const resetButton = createElement("button", { id: "resetLessonPositions", textContent: "⏮️", title: "Reset all lessons to the first page", className: "nav-button" }); let nav = document.querySelector(".library-section > .list-header > .list-header-index"); nav.appendChild(resetButton); } function setupCourseStyles() { const css = ` .nav-button { background: none; border: none; cursor: pointer; font-size: 1.5rem; } .library-section > .list-header > .list-header-index { grid-template-columns: auto 1fr auto !important; } .dynamic--word-progress { grid-template-columns: repeat(3, auto) !important; } .word-indicator--box-white { background-color: rgb(255 255 255 / 85%); border-color: rgb(255 255 255); } `; applyCSS(css); } function enrichLessonDetails() { function addKnownWordsIndicator(lessonElement, lessonInfo) { const dynamicWordProgress = lessonElement.querySelector('.dynamic--word-progress'); const knownWordPercentage = Math.round((lessonInfo.knownWordsCount / lessonInfo.uniqueWordsCount) * 100); const knownWordsItem = createElement('div', {className: 'word-indicator--item grid-layout grid-align--center grid-item is-fluid--left', title: 'Known Words'}); const knownWordsBox = createElement('div', {className: 'word-indicator--box word-indicator--box-white'}); knownWordsItem.appendChild(knownWordsBox); const textWrapper = createElement('span', {className: 'text-wrapper is-size-8'}); textWrapper.appendChild(createElement('span', {textContent: `${lessonInfo.knownWordsCount} (${knownWordPercentage}%)`})); knownWordsItem.appendChild(textWrapper); dynamicWordProgress.appendChild(knownWordsItem); } async function updateWordIndicatorPercentages(lessonElement, lessonId) { const lessonInfo = await getLessonInfo(lessonId); const wordIndicatorItems = lessonElement.querySelector(".word-indicator--item"); if (!wordIndicatorItems) return; const lingqsPercentage = Math.round((lessonInfo.cardsCount / lessonInfo.uniqueWordsCount) * 100); const lingqsElement = lessonElement.querySelector('.word-indicator--item[title="LingQs"] > span > span'); lingqsElement.textContent = `${lessonInfo.cardsCount} (${lingqsPercentage}%)`; addKnownWordsIndicator(lessonElement, lessonInfo); } const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { console.debug('Observer:', `Library item added. ${mutation.type}`, mutation.addedNodes); mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (!node.matches('.library-item-wrap')) return; const lessonId = /l-search--(\d*)-horizontal/.exec(node.id)[1]; updateWordIndicatorPercentages(node, lessonId); }); }); }); const targetNode = document.querySelector('.library-section .library-list > .grid-layout'); observer.observe(targetNode, {childList: true}); } function enableCourseSorting() { const dropdownItems = document.querySelectorAll('.library-section > .list-header .tw-dropdown--item'); if (dropdownItems.length) { dropdownItems.forEach((item, index) => { item.addEventListener('click', () => { console.log(`Clicked sort option: ${index}`); settings.librarySortOption = index; }); }); dropdownItems[settings.librarySortOption].click(); return true; } else { console.warn("Dropdown items not found for library sort."); return false; } } function setupLessonResetButton() { const resetButton = document.getElementById("resetLessonPositions"); resetButton.addEventListener("click", async () => { const languageCode = await getLanguageCode(); const collectionId = await getCollectionId(); const allLessons = await getAllLessons(languageCode, collectionId); const confirmed = confirm(`Reset all ${allLessons.length} lessons to their starting positions?`); if (!confirmed) return; for (const lesson of allLessons) { await setLessonProgress(lesson.id, 0); console.log(`Reset lesson ID: ${lesson.id} to the first page`); } alert(`Successfully reset ${allLessons.length} lessons to their starting positions.`); }); } const libraryHeader = await waitForElement('.library-section > .list-header', 5000); createCourseUI(); setupCourseStyles(); enrichLessonDetails(); enableCourseSorting(); setupLessonResetButton(); } /* Get LingQ Data */ function getLessonId() { const url = document.URL; const regex = /(http|https):\/\/www\.lingq\.com\/\w+\/learn\/\w+\/web\/reader\/(\d+)/; const match = url.match(regex); return match[2]; } function getCollectionId() { const url = document.URL; const regex = /(http|https):\/\/www\.lingq\.com\/\w+\/learn\/\w+\/web\/library\/course\/(\d+)/; const match = url.match(regex); return match[2]; } function getLessonLanguage() { const url = document.URL; const regex = /(http|https)*:\/\/www\.lingq\.com\/\w+\/learn\/(\w+)\/web\/reader\/\d+/; const match = url.match(regex); return match[2]; } async function getUserProfile() { const url = `https://www.lingq.com/api/v3/profiles/`; const response = await fetch(url); const data = await response.json(); return data.results[0] } async function getLanguageCode() { const userProfile = await getUserProfile(); return userProfile.active_language; } async function getDictionaryLanguage() { const userProfile = await getUserProfile(); return await userProfile.dictionary_languages[0]; } async function getDictionaryLocalePairs() { const url = `https://www.lingq.com/api/v2/dictionary-locales/`; const response = await fetch(url); const data = await response.json(); return Object.fromEntries(data.map(item => [item.code, item.title])); } async function getLessonInfo(lessonId) { const languageCode = await getLanguageCode(); const url = `https://www.lingq.com/api/v3/${languageCode}/lessons/counters/?lesson=${lessonId}`; const response = await fetch(url); const data = await response.json(); return data[lessonId]; } async function getAllLessons(languageCode, collectionId) { let allLessons = []; let nextUrl = `https://www.lingq.com/api/v3/${languageCode}/search/?page=1&page_size=1000&collection=${collectionId}`; while (nextUrl) { try { const response = await fetch(nextUrl); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); allLessons = allLessons.concat(data.results); nextUrl = data.next; } catch (error) { console.error('Error fetching lessons:', error); break; } } return allLessons; } async function setLessonProgress(lessonId, wordIndex) { const languageCode = await getLanguageCode(); const url = `https://www.lingq.com/api/v3/${languageCode}/lessons/${lessonId}/bookmark/`; const payload = { wordIndex: wordIndex, completedWordIndex: wordIndex, client: 'web' }; fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } /* Utils */ function createElement(tag, props = {}) { const element = document.createElement(tag); Object.entries(props).forEach(([key, value]) => { if (key === "style" && typeof value === "string") { element.style.cssText = value; } else if (key === "textContent") { element.textContent = value; } else { element[key] = value; } }); return element; } function clickElement(selector) { const element = document.querySelector(selector); if (element) element.click(); } function focusElement(selector) { const element = document.querySelector(selector); if (element) { element.focus(); element.setSelectionRange(element.value.length, element.value.length); } } function waitForElement(selector, timeout=1000) { return new Promise((resolve, reject) => { const element = document.querySelector(selector); if (element) return resolve(element); let timeoutId; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (node.matches(selector)) { clearTimeout(timeoutId); resolve(node); observer.disconnect(); } else { const foundElement = node.querySelector(selector); if (foundElement) { clearTimeout(timeoutId); resolve(foundElement); observer.disconnect(); } } } }); }); observer.observe(document.documentElement, {childList: true, subtree: true}); timeoutId = setTimeout(() => { resolve(null); observer.disconnect(); console.log('Wait', `"${selector}" not found`); }, timeout); }); } function copySelectedText() { const selected_text = document.querySelector(".reference-word"); if (selected_text) { navigator.clipboard.writeText(selected_text.textContent); } } function extractTextFromDOM(domElement) { function getAllLeafNodes(root) { const leaves = []; function traverse(node) { if (node.nodeType === Node.TEXT_NODE) { if (node.textContent.trim() !== "") leaves.push(node); return; } if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { if (node.childNodes.length === 0) { leaves.push(node); return; } for (const child of node.childNodes) { traverse(child); } } } traverse(root); return leaves; } const textParts = []; let sentenceElements = domElement.querySelectorAll('.sentence'); sentenceElements = sentenceElements.length ? sentenceElements : [domElement]; if (domElement.childNodes.length === 0) return null; sentenceElements.forEach(sentenceElement => { for (const childNode of getAllLeafNodes(sentenceElement)) { const text = childNode.textContent.trim(); if (text) textParts.push(text); const parentNodeType = childNode.parentNode.nodeType; if (parentNodeType === Node.ELEMENT_NODE && childNode.parentNode.matches('.has-end-punctuation-question')) textParts.push('?'); if (parentNodeType === Node.ELEMENT_NODE && childNode.parentNode.matches('.has-end-punctuation-period')) textParts.push('.'); } textParts.push('\n'); }); return textParts.slice(0, -1).join(' ') .replace(/[^\S\n]?(\?|\.|\-|\n)[^\S\n]?/g, '$1') .replace(/[^\S\n]?(,)/g, '$1'); } function showToast(inputMessage, success=true) { const toast = createElement("div", { className: 'userToast', textContent: inputMessage, style: `box-shadow: 0 0 10px 0 ${success ? 'rgb(76, 175, 80)': 'rgb(175, 76, 80)'}` }); document.body.appendChild(toast); setTimeout(() => {toast.style.opacity = '1'}, 10); setTimeout(() => { toast.style.opacity = '0'; setTimeout(toast.remove, 1000); }, 1500); } function finishLesson(){ clickElement(".reader-component > .nav--right > a"); } function preventPropagation(event){ event.preventDefault(); event.stopPropagation(); } async function changeScrollAmount(selector, scrollAmount) { const readerContainer = await waitForElement(selector, 1000); if (readerContainer) { readerContainer.addEventListener("wheel", (event) => { event.preventDefault(); const delta = event.deltaY; readerContainer.scrollTop += delta * scrollAmount; }); } } function smoothScrollTo(element, to, duration) { const start = element.scrollTop; const change = to - start; const startTime = performance.now(); function easeInOutCubic(t) { t *= 2; if (t < 1) return 0.5 * t * t * t; t -= 2; return 0.5 * (t * t * t + 2); } function animateScroll(currentTime) { const elapsedTime = currentTime - startTime; const progress = Math.min(elapsedTime / duration, 1); const easedProgress = easeInOutCubic(progress); element.scrollTop = start + change * easedProgress; if (elapsedTime < duration) { requestAnimationFrame(animateScroll); } else { element.scrollTop = to; } } requestAnimationFrame(animateScroll); } function getRandomElement(arr) { const randomIndex = Math.floor(Math.random() * arr.length); return arr?.[randomIndex]; } function applyCSS(css) { const cssElement = createElement("style", {textContent: css}); document.querySelector("head").appendChild(cssElement); } /* Modules */ function stopPlayingAudio(autioContext) { if (!autioContext || audioContext.state !== 'running') return; try { autioContext.close(); autioContext = null; } catch (error) { console.error("Stop Audio:", error); throw error; } } let audioContext = null; async function playAudio(audioData, volume = 0.5) { stopPlayingAudio(audioContext); return new Promise((resolve, reject) => { audioContext = new (window.AudioContext || window.webkitAudioContext)(); const gainNode = audioContext.createGain(); gainNode.gain.value = volume; gainNode.connect(audioContext.destination); const audioDataCopy = audioData.slice(0); audioContext.decodeAudioData(audioDataCopy) .then(buffer => { const source = audioContext.createBufferSource(); source.buffer = buffer; source.connect(gainNode); source.start(0); source.onended = () => { resolve(); audioContext.close(); }; source.onerror = (e) => { reject("Audio play error : " + e); } }) .catch(e => { reject("Decoding error : " + e) }); }); } async function openAITTS(text, API_KEY, voice = "nova", instructions) { const modelId = "gpt-4o-mini-tts"; const apiUrl = "https://api.openai.com/v1/audio/speech"; console.log('TTS', `${modelId}, ${voice}`); if (!API_KEY) throw new Error("Invalid or missing OpenAI API key. Please set the API_KEY"); try { const response = await fetch(apiUrl, { method: "POST", headers: { "Accept": "audio/mpeg", "Content-Type": "application/json", "Authorization": `Bearer ${API_KEY}` }, body: JSON.stringify({ input: text, model: modelId, voice: voice, instructions: instructions, }) }); if (!response.ok) { let errorMessage = `HTTP error! Status: ${response.status}`; try { const errorBody = await response.json(); errorMessage += ` - OpenAI Error: ${errorBody?.error?.message || JSON.stringify(errorBody)}`; } catch (parseError) { errorMessage += ` - Failed to parse error response.`; } throw new Error(errorMessage); } return await response.arrayBuffer(); } catch (error) { console.error("Error during OpenAI TTS request:", error); throw error; } } async function googleTTS(text, API_KEY, voice = "Zephyr", ttsInstructions) { function createWavHeader(dataLength) { const sampleRate = 24000; const numChannels = 1; const bitsPerSample = 16; const headerLength = 44; const view = new DataView(new ArrayBuffer(headerLength)); function writeString(view, offset, s) { for (let i = 0; i < s.length; i++) { view.setUint8(offset + i, s.charCodeAt(i)); } } // RIFF chunk writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + dataLength, true); // ChunkSize writeString(view, 8, 'WAVE'); // fmt sub-chunk writeString(view, 12, 'fmt '); view.setUint32(16, 16, true); // Subchunk1Size view.setUint16(20, 1, true); // AudioFormat (1 = PCM) view.setUint16(22, numChannels, true); // NumChannels view.setUint32(24, sampleRate, true); // SampleRate view.setUint32(28, sampleRate * numChannels * (bitsPerSample / 8), true); // ByteRate view.setUint16(32, numChannels * (bitsPerSample / 8), true); // BlockAlign view.setUint16(34, bitsPerSample, true); // BitsPerSample // data sub-chunk writeString(view, 36, 'data'); // Subchunk2ID view.setUint32(40, dataLength, true); // Subchunk2Size return view.buffer; } const modelId = "gemini-2.5-flash-preview-tts"; const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${API_KEY}`; if (!API_KEY) throw new Error("Invalid or missing Google API key. Please set the API_KEY"); const maxRetries = 3; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: ttsInstructions + text }] }], generationConfig: { speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } }, responseModalities: ["AUDIO"] } }) }); if (!response.ok) { let errorMessage = `HTTP error! Status: ${response.status}`; try { const errorBody = await response.json(); errorMessage += ` - Google Error: ${errorBody?.error?.message || JSON.stringify(errorBody)}`; } catch (parseError) { errorMessage += ` - Failed to parse error response.`; } throw new Error(errorMessage); } const data = await response.json(); const audioDataBase64 = data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; if (audioDataBase64) { const inputTokens = data.usageMetadata.promptTokenCount; const outputTokens = data.usageMetadata.candidatesTokenCount; const approxCost = inputTokens * 0.5/1000000 + outputTokens * 10/1000000; console.log('TTS', `${modelId}, ${voice}, tokens: (${inputTokens}/${outputTokens}) cost: $${approxCost.toFixed(6)}`); const binaryString = atob(audioDataBase64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } const wavHeader = createWavHeader(bytes.length); const completeBuffer = new Uint8Array(wavHeader.byteLength + bytes.byteLength); completeBuffer.set(new Uint8Array(wavHeader), 0); completeBuffer.set(bytes, wavHeader.byteLength); return completeBuffer.buffer; } else { console.warn(`Google TTS Warning (Attempt ${attempt + 1}/${maxRetries})`, data.candidates?.[0]); } } catch (error) { console.error("Error during Google TTS request:", error); throw error; } } } /* Features */ function setupKeyboardShortcuts() { document.addEventListener("keydown", function (event) { if (!settings.keyboardShortcut) return; const targetElement = event.target; const isTextInput = targetElement.localName === "text" || targetElement.localName === "textarea" || targetElement.localName === "input"; const withoutModifierKeys = !event.ctrlKey && !event.shiftKey && !event.altKey; const eventKey = event.key.toLowerCase(); if (isTextInput) { if (targetElement.id === "user-input") return; if ((eventKey === 'enter' || eventKey === 'escape') && withoutModifierKeys) { preventPropagation(event); event.target.blur() } else { event.stopPropagation(); return; } } const shortcuts = { [settings.shortcutVideoFullscreen]: () => clickElement(".modal-section > div > button:nth-child(2)"), // video full screen toggle [settings.shortcutMeaningInput]: () => focusElement(".reference-input-text"), // Move cursor to meaning input [settings.shortcutChatInput]: () => focusElement("#user-input"), // Move cursor to the chat widget input [settings.shortcutTTSPlay]: () => clickElement(".is-tts"), // Play tts audio [settings.shortcutTranslator]: () => clickElement(".dictionary-resources > a:nth-last-child(1)"), // Open Translator [settings.shortcutBackward5s]: () => clickElement(".audio-player--controllers > div:nth-child(1) > a"), // 5 sec Backward [settings.shortcutForward5s]: () => clickElement(".audio-player--controllers > div:nth-child(2) > a"), // 5 sec Forward [settings.shortcutMakeKnown]: () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "k" })), // Simulate original 'k' for Make Word Known [settings.shortcutDictionary]: () => clickElement(".dictionary-resources > a:nth-child(1)"), // Open Dictionary [settings.shortcutCopySelected]: () => copySelectedText() // Copy selected text }; if (shortcuts[eventKey] && withoutModifierKeys) { preventPropagation(event); shortcuts[eventKey](); } }, true); } function setupYoutubePlayerCustomization() { async function changeVideoPlayerSettings() { const iframe = await waitForElement('.modal-container iframe', 1000); let src = iframe.getAttribute("src"); src = src.replace("disablekb=1", "disablekb=0"); src = src + "&cc_load_policy=1"; src = src + "&controls=0"; iframe.setAttribute("src", src); } async function setupSliderObserver() { function createSliderElements() { const sliderContainer = createElement("div", {className: "rc-slider rc-slider-horizontal"}); const sliderRail = createElement("div", {className: "rc-slider-rail"}); const sliderTrack = createElement("div", {className: "rc-slider-track"}); sliderContainer.appendChild(sliderRail); sliderContainer.appendChild(sliderTrack); return sliderContainer; } function updateLessonProgress(lessonId, lessonInfo, progressPercentage, lastCompletedPercentage) { const progressUpdatePeriod = 5; const flooredProgressPercentage = Math.floor(progressPercentage / progressUpdatePeriod) * progressUpdatePeriod; if (flooredProgressPercentage > lastCompletedPercentage) { console.log('Slider', `progress percentage: ${flooredProgressPercentage}`); const wordIndex = Math.floor(lessonInfo["totalWordsCount"] * (flooredProgressPercentage / 100)); setLessonProgress(lessonId, wordIndex); return flooredProgressPercentage; } return lastCompletedPercentage; } const lessonId = getLessonId(); const lessonInfo = await getLessonInfo(lessonId); let lastCompletedPercentage = lessonInfo["progress"]; console.log(`last progress: ${lastCompletedPercentage}`); const sliderTrack = document.querySelector('.audio-player--progress .rc-slider-track'); const videoContainer = document.querySelector(".modal-content > div"); const sliderContainer = createSliderElements(); const videoSliderTrack = sliderContainer.querySelector(".rc-slider-track"); videoContainer.appendChild(sliderContainer); const sliderObserver = new MutationObserver(function (mutations) { for (const mutation of mutations) { videoSliderTrack.style.cssText = sliderTrack.style.cssText; const progressPercentage = parseFloat(sliderTrack.style.width); lastCompletedPercentage = updateLessonProgress(lessonId, lessonInfo, progressPercentage, lastCompletedPercentage); console.debug('Observer:', `Slider Changed. Progress: ${progressPercentage}`); const isLessonFinished = progressPercentage >= 99.9; if (isLessonFinished && settings.autoFinishing) { console.log('Slider', 'lesson finished.') setTimeout(finishLesson, 1000); sliderObserver.disconnect(); } } }); sliderObserver.observe(sliderTrack, {attributes: true, attributeFilter: ['style']}); } const observer = new MutationObserver(function (mutations) { mutations.forEach((mutation) => { console.debug('Observer:', `Modal container created. ${mutation.type}`, mutation.addedNodes) mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (!node.matches(".modal-container")) return; changeVideoPlayerSettings(); clickElement('.modal-section button:nth-child(2)[title="Expand"]'); setupSliderObserver(); }); }); }); observer.observe(document.body, {childList: true}); } async function setupReaderContainer() { function setupSentenceFocus(readerContainer) { function focusPlayingSentence(playingSentence) { const scrolling_div = document.querySelector(".reader-container"); const offsetTop = playingSentence.parentElement.matches(".has-translation") ? playingSentence.parentElement.offsetTop : playingSentence.offsetTop; const targetScrollTop = offsetTop + Math.floor(playingSentence.offsetHeight / 2) - Math.floor(scrolling_div.offsetHeight / 2); smoothScrollTo(scrolling_div, targetScrollTop, 300); } const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { const target = mutation.target; if (!(target.matches(".sentence.is-playing") && settings.focusPlayingSentence)) return; focusPlayingSentence(target); }); }); observer.observe(readerContainer, {attributes: true, subtree: true, attributeFilter: ['class']}); } function changeTranslationColor(readerContainer) { const observer = new MutationObserver((mutations) => { for(const mutation of mutations) { for(const node of mutation.addedNodes) { if (!(node.nodeType === Node.ELEMENT_NODE && node.matches('p:has(.sentence)'))) continue; for(const sentence of node.querySelectorAll('.sentence')) { if (!(sentence.style.borderImageSource)) continue; const fontColor = settings[`${settings.colorMode}_translationFontColor`]; const colorRegex = /color:%23[0-9a-fA-F]{6}/g; sentence.style.borderImageSource = sentence.style.borderImageSource.replace(colorRegex, `color:${fontColor}`); } } } }); observer.observe(readerContainer, {childList: true, subtree: true}); } const observer = new MutationObserver(function (mutations) { mutations.forEach((mutation) => { console.debug('Observer:', `Sentence text child created. ${mutation.type}`, mutation.addedNodes); mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (!node.matches(".loadedContent")) return; changeScrollAmount(".reader-container", 0.3); setupSentenceFocus(node); changeTranslationColor(node); }); }); }); const sentenceText = await waitForElement('.sentence-text', 1000); observer.observe(sentenceText, {childList: true}); } async function setupLLMs() { async function updateWidget() { function getSectionHead() { let targetSectionHead = document.querySelector("#lesson-reader .widget-area > .reader-widget > .section-widget--head"); targetSectionHead = targetSectionHead ? targetSectionHead : document.querySelector("#lesson-reader .widget-area > .reader-widget"); return targetSectionHead; } function getSelectedWithContext() { const selectedTextElement = document.querySelector(".reference-word"); const contextElement = document.querySelector("span.selected-text, span.is-selected")?.closest('.sentence'); const selectedText = selectedTextElement ? extractTextFromDOM(selectedTextElement).trim() : ""; const contextText = contextElement ? extractTextFromDOM(contextElement).trim() : ""; return `Input: "${selectedText}"` + (!isSentence ? `, Context: "${contextText}"` : ``); } let isProgrammaticReferenceWordUpdate = false; function updateReferenceWord(){ isProgrammaticReferenceWordUpdate = true; const selection = window.getSelection(); if (selection.rangeCount === 0) { console.log('Selection rangeCount is zero.') return; } const referenceWord = document.querySelector(".reference-word"); const extractedText = extractTextFromDOM(selection.getRangeAt(0).cloneContents()); if (referenceWord && extractedText && isSentence) { referenceWord.textContent = extractedText; } isProgrammaticReferenceWordUpdate = false; } function updateChatHistoryState(currentHistory, message, role) { return [...currentHistory, { role: role, content: message }]; } function addMessageToUI(message, messageClass, container) { const messageDiv = createElement("div", { className: `chat-message ${messageClass}`, innerHTML: message }); container.appendChild(messageDiv); if (container.childElementCount > 1) smoothScrollTo(container, container.scrollHeight, 300); } function getLLMPricing(llmProviderModel) { const llmInfo = document.querySelector(`#llmProviderModelSelector > option[value="${llmProviderModel}"]`).text; const [inputPrice, outputPrice] = llmInfo.match(/\$(\d+(?:\.\d+)?)\/\$(\d+(?:\.\d+)?)\)/).slice(1, 3).map(num => parseFloat(num)/1000000); return [inputPrice, outputPrice]; } async function getOpenAIResponse(apiKey, model, history) { try { const api_url = `https://api.openai.com/v1/chat/completions`; const response = await fetch( api_url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ model: model, messages: history, max_tokens: 500, temperature: 0.7, }) } ); if (!response.ok) { const errorData = await response.json(); console.error('OpenAI API error:', errorData); return errorData.error.message; } const data = await response.json(); const [inputPrice, outputPrice] = getLLMPricing(settings.llmProviderModel); const inputTokens = data.usage.prompt_tokens; const cachedTokens = data.usage.prompt_tokens_details.cached_tokens; const outputTokens = data.usage.completion_tokens; const approxCost = (inputTokens - cachedTokens) * inputPrice + cachedTokens * (inputPrice/4) + outputTokens * outputPrice; console.log('Chat', `${model}, tokens: (${inputTokens-cachedTokens}/${cachedTokens}/${outputTokens}), cost: $${approxCost.toFixed(6)}`); return data.choices[0]?.message?.content || "Sorry, could not get a response."; } catch (error) { console.error('OpenAI API call failed:', error); return "Sorry, something went wrong communicating with OpenAI."; } } async function getGoogleResponse(apiKey, model, history) { try { const formattedMessages = history.map(msg => ({ role: msg.role === 'assistant' ? 'model' : msg.role, parts: [{ text: msg.content }] })); const api_url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; const response = await fetch( api_url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ system_instruction: {parts: [{text: systemPrompt}]}, contents: formattedMessages, generationConfig: { temperature: 0.7, ...(model.includes("2.5") ? { thinkingConfig: { thinkingBudget: 0 } } : {})} }) } ); if (!response.ok) { const errorData = await response.json(); console.error('Google Gemini API error:', errorData); return errorData.error.message; } const data = await response.json(); const [inputPrice, outputPrice] = getLLMPricing(settings.llmProviderModel); const inputTokens = data.usageMetadata.promptTokenCount; const cachedTokens = data.usageMetadata?.cachedContentTokenCount ?? 0; const outputTokens = data.usageMetadata.candidatesTokenCount; const approxCost = inputTokens * inputPrice + cachedTokens * (inputPrice/4) + outputTokens * outputPrice; console.log('Chat', `${model}, tokens: (${inputTokens-cachedTokens}/${cachedTokens}/${outputTokens}), cost: $${approxCost.toFixed(6)}`); return data.candidates[0].content.parts[0].text || "Sorry, could not get a response."; } catch (error) { console.error('Google Gemini API call failed:', error); return "Sorry, something went wrong communicating with Google."; } } async function getBotResponse(provider, apiKey, model, history) { if (provider === 'openai') { return await getOpenAIResponse(apiKey, model, history); } else if (provider === 'google') { return await getGoogleResponse(apiKey, model, history); } } async function getTTSResponse(provider, apiKey, voice, text) { const ttsInstructions = `Read the text in a realistic, genuine, neutral, and clear manner. vary your rhythm and pace naturally, like a professional voice actor: `; const voices = Array.from(document.querySelector("#ttsVoiceSelector").options) .filter(option => option.value.startsWith(provider)) .map(option => option.value) .slice(1); if (voice === "random") voice = getRandomElement(voices).split(" ")[1]; if (provider === 'openai') { return await openAITTS(text, apiKey, voice, ttsInstructions); } else if (provider === 'google') { return await googleTTS(text, apiKey, voice, ttsInstructions); } } async function handleSendMessage() { const userInput = document.getElementById("user-input") const chatContainer = document.getElementById("chat-container") const message = userInput.value.trim(); if (!message) { console.log('Message is empty.') return; } const userMessage = message; userInput.value = ''; addMessageToUI(userMessage, 'user-message', chatContainer); chatHistory = updateChatHistoryState(chatHistory, userMessage, "user"); addMessageToUI("loading ...", 'loading-message', chatContainer); const botResponse = await getBotResponse(llmProvider, llmApiKey, llmModel, chatHistory); chatContainer.removeChild(chatContainer.lastChild); addMessageToUI(botResponse, 'bot-message', chatContainer); chatHistory = updateChatHistoryState(chatHistory, botResponse, "assistant"); } async function updateChatWidget(){ if (!settings.chatWidget) return; const chatWrapper = createElement("div", { id: "chat-widget", style: "margin-top: 5px 0 10px;" }); const chatContainer = createElement("div", { id: "chat-container" }); const inputContainer = createElement("div", { className: "input-container" }); const userInput = createElement("input", { type: "text", id: "user-input", placeholder: "Ask anything" }); const sendButton = createElement("button", { id: "send-button" , innerHTML: ``}); inputContainer.appendChild(userInput); inputContainer.appendChild(sendButton); chatWrapper.appendChild(chatContainer); chatWrapper.appendChild(inputContainer); const sectionHead = getSectionHead(); const existingChatWidget = document.getElementById('chat-widget'); if(existingChatWidget) { existingChatWidget.replaceWith(chatWrapper); } else if (sectionHead.matches(".section-widget--head")) { sectionHead.appendChild(chatWrapper); } else { sectionHead.prepend(chatWrapper); } changeScrollAmount("#chat-container", 0.2) userInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); handleSendMessage(); } else if (event.key === 'Escape') { event.preventDefault(); event.target.blur(); } event.stopPropagation(); }, true); sendButton.addEventListener('click', handleSendMessage); if (llmProvider === 'openai') chatHistory = updateChatHistoryState(chatHistory, systemPrompt, "system"); if (settings.askSelected && sectionHead.matches(".section-widget--head")) { const initialUserMessage = getSelectedWithContext(); chatHistory = updateChatHistoryState(chatHistory, !isSentence ? wordPhrasePrompt: sentencePrompt, "user"); chatHistory = updateChatHistoryState(chatHistory, "Understood.", "assistant"); chatHistory = updateChatHistoryState(chatHistory, initialUserMessage, "user"); addMessageToUI("loading ...", 'loading-message', chatContainer); const botResponse = await getBotResponse(llmProvider, llmApiKey, llmModel, chatHistory); chatContainer.removeChild(chatContainer.lastChild); const messageType = isSentence ? "sentence-message" : "word-message" addMessageToUI(botResponse, `bot-message ${messageType}`, chatContainer); chatHistory = updateChatHistoryState(chatHistory, botResponse, "assistant"); chatHistory = updateChatHistoryState(chatHistory, plainTextPrompt, "user"); chatHistory = updateChatHistoryState(chatHistory, "Understood.", "assistant"); const meaning = document.querySelector("#chat-container > .bot-message > p"); if (meaning) { const meaningElement = document.querySelector(".reference-input-text"); const hasMeaning = meaningElement ? meaningElement.value : false; const textToCopy = (hasMeaning ? '\n': '') + meaning.textContent; navigator.clipboard.writeText(textToCopy) .then(() => { showToast("Meaning Copied!", true); }) .catch(() => { showToast("Failed to copy meaning.", false); }); } } } async function updateTTS(click=true) { async function replaceTTSButton() { const selectedTextElement = document.querySelector(".reference-word"); const selectedText = selectedTextElement ? selectedTextElement.textContent.trim() : ""; if (!selectedText) return; if (selectedText.length > 1000) { console.log("The length of the selected text exceeds 1,000.") return; } ttsButton.disabled = true; let audioData = await getTTSResponse(ttsProvider, settings.ttsApiKey, ttsVoice, selectedText); if (audioData == null) { console.log("audioData can't be got.") return; } ttsButton.disabled = false; const newTTSButton = ttsButton.cloneNode(true); newTTSButton.id = "playAudio"; newTTSButton.addEventListener('click', async (event) => { await playAudio(audioData, 0.7); }) ttsButton.replaceWith(newTTSButton); showToast("TTS Replaced", true); playAudio(audioData, 0.7); } if (!settings.tts) return; const ttsButton = await waitForElement('.is-tts', 100); if (!ttsButton) return; const isWord = document.querySelector("span.selected-text, span.is-selected"); const ttsWordOffCondition = !settings.ttsWord && isWord; const ttsSentenceOffCondition = !settings.ttsSentence && !isWord; if (ttsWordOffCondition || ttsSentenceOffCondition) { if (click) ttsButton.click(); ttsButton.addEventListener('click', (event) => { event.stopImmediatePropagation(); replaceTTSButton(); }, true); } else { replaceTTSButton(); } } if (document.getElementById('chatWidget')) { console.log('chatWidget already exists.') return; } const isSentence = !document.querySelector(".section-widget--main"); const [llmProvider, llmModel] = settings.llmProviderModel.split(" "); const [ttsProvider, ttsVoice] = settings.ttsVoice.split(" "); const llmApiKey = settings.llmApiKey; const systemPrompt = ` System Prompt (Formatting & Core Rules): Your primary function is to serve as a language assistant. Your responses must meticulously adhere to the following guidelines to ensure clarity, accuracy, and consistency. # Core Principles ## Language Use Your primary response language for all explanatory text, definitions, part-of-speech labels, and translations of examples is '${userLanguage}'. Content specifically designated to be in the language of the original input (e.g., original example sentences before their translation) should be presented in '${lessonLanguage}'. The vast majority of your response visible to the user must be in '${userLanguage}'. ## Strict HTML Formatting: - Utilize the HTML tags for presentation: '' (for bolding key terms like the base form or selected sentence elements), '' (for part of speech or emphasizing specific words within explanations), '

' (for paragraphs of text like definitions and explanations), '