From e9bf002ec60eeff13cf542da8a70a9fb25cc933b Mon Sep 17 00:00:00 2001 From: lianzthang Date: Fri, 30 Jan 2026 17:01:56 -0600 Subject: [PATCH 1/3] Confirmed V1.3.72 update and restored subtitle translation logic --- .../player/ui/subtitles/SubtitlesManager.mjs | 772 ++++++++---------- 1 file changed, 354 insertions(+), 418 deletions(-) diff --git a/chrome/player/ui/subtitles/SubtitlesManager.mjs b/chrome/player/ui/subtitles/SubtitlesManager.mjs index 9fece71f..cd92ecfa 100644 --- a/chrome/player/ui/subtitles/SubtitlesManager.mjs +++ b/chrome/player/ui/subtitles/SubtitlesManager.mjs @@ -12,312 +12,344 @@ import {OpenSubtitlesSearch, OpenSubtitlesSearchEvents} from './OpenSubtitlesSea import {SubtitlesSettingsManager, SubtitlesSettingsManagerEvents} from './SubtitlesSettingsManager.mjs'; import {SubtitleSyncer} from './SubtitleSyncer.mjs'; +// NOTE: Removed top-level SubtitleTranslator import to prevent player crash if file missing + +const LANGUAGES = { + "af": "Afrikaans", "sq": "Albanian", "am": "Amharic", "ar": "Arabic", "hy": "Armenian", + "az": "Azerbaijani", "eu": "Basque", "be": "Belarusian", "bn": "Bengali", "bs": "Bosnian", + "bg": "Bulgarian", "ca": "Catalan", "ceb": "Cebuano", "ny": "Chichewa", "zh-CN": "Chinese (Simplified)", + "zh-TW": "Chinese (Traditional)", "co": "Corsican", "hr": "Croatian", "cs": "Czech", "da": "Danish", + "nl": "Dutch", "en": "English", "eo": "Esperanto", "et": "Estonian", "tl": "Filipino", + "fi": "Finnish", "fr": "French", "fy": "Frisian", "gl": "Galician", "ka": "Georgian", + "de": "German", "el": "Greek", "gu": "Gujarati", "ht": "Haitian Creole", "ha": "Hausa", + "haw": "Hawaiian", "iw": "Hebrew", "hi": "Hindi", "hmn": "Hmong", "hu": "Hungarian", + "is": "Icelandic", "ig": "Igbo", "id": "Indonesian", "ga": "Irish", "it": "Italian", + "ja": "Japanese", "jw": "Javanese", "kn": "Kannada", "kk": "Kazakh", "km": "Khmer", + "ko": "Korean", "ku": "Kurdish (Kurmanji)", "ky": "Kyrgyz", "lo": "Lao", "la": "Latin", + "lv": "Latvian", "lt": "Lithuanian", "lb": "Luxembourgish", "mk": "Macedonian", "mg": "Malagasy", + "ms": "Malay", "ml": "Malayalam", "mt": "Maltese", "mi": "Maori", "mr": "Marathi", + "mn": "Mongolian", "my": "Myanmar (Burmese)", "ne": "Nepali", "no": "Norwegian", "ps": "Pashto", + "fa": "Persian", "pl": "Polish", "pt": "Portuguese", "pa": "Punjabi", "ro": "Romanian", + "ru": "Russian", "sm": "Samoan", "gd": "Scots Gaelic", "sr": "Serbian", "st": "Sesotho", + "sn": "Shona", "sd": "Sindhi", "si": "Sinhala", "sk": "Slovak", "sl": "Slovenian", + "so": "Somali", "es": "Spanish", "su": "Sundanese", "sw": "Swahili", "sv": "Swedish", + "tg": "Tajik", "ta": "Tamil", "te": "Telugu", "th": "Thai", "tr": "Turkish", + "uk": "Ukrainian", "ur": "Urdu", "uz": "Uzbek", "vi": "Vietnamese", "cy": "Welsh", + "xh": "Xhosa", "yi": "Yiddish", "yo": "Yoruba", "zu": "Zulu" +}; + export class SubtitlesManager extends EventEmitter { constructor(client) { super(); this.client = client; - this.tracks = []; this.activeTracks = []; this.isTestSubtitleActive = false; - this.subtitleTrackListElements = []; this.subtitleTrackDisplayElements = []; - this.settingsManager = new SubtitlesSettingsManager(); this.settingsManager.on(SubtitlesSettingsManagerEvents.SETTINGS_CHANGED, this.onSettingsChanged.bind(this)); this.settingsManager.loadSettings(); - this.openSubtitlesSearch = new OpenSubtitlesSearch(client.version); this.openSubtitlesSearch.on(OpenSubtitlesSearchEvents.TRACK_DOWNLOADED, this.onSubtitleTrackDownloaded.bind(this)); - this.subtitleSyncer = new SubtitleSyncer(client); - + this.setupUI(); } - loadTrackAndActivateBest(subtitleTrack, autoset = false) { - const returnedTrack = this.addTrack(subtitleTrack); - if (returnedTrack !== subtitleTrack) { - return returnedTrack; - } + loadTrackAndActivateBest(t,a=false){const r=this.addTrack(t);if(r!==t)return r;const d=this.settingsManager.getSettings().defaultLanguage;if(a&&this.activeTracks.length<=1&&this.client.options.autoEnableBestSubtitles){if(Localize.getLanguageMatchLevel(t.language,d)>0){const e=this.activeTracks.find(k=>k.language&&t.language&&k.language.substring(0,d.length)===d);if(!e)this.activateTrack(t);else if(e.label&&e.label.toLowerCase().includes('auto')&&t.label&&!t.label.toLowerCase().includes('auto')){this.deactivateTrack(e);this.activateTrack(t)}}}return r} + addTrack(t){const e=this.tracks.find(k=>k.equals(t));if(e)return e;this.tracks.push(t);this.updateTrackList();this.client.interfaceController.showControlBar();this.client.interfaceController.queueControlsHide(1e3);return t} + activateTrack(t){if(this.tracks.indexOf(t)===-1)return;if(this.activeTracks.indexOf(t)===-1){this.activeTracks.push(t);this.updateTrackList()}} + deactivateTrack(t){const i=this.activeTracks.indexOf(t);if(i!==-1){this.activeTracks.splice(i,1);this.updateTrackList()}} + toggleSubtitles(){if(this.activeTracks.length===0){if(this.lastActiveTracks){this.lastActiveTracks.forEach(t=>this.activateTrack(t));this.lastActiveTracks=null}else this.activateTrack(this.tracks[0])}else{this.lastActiveTracks=this.activeTracks.slice();this.activeTracks.length=0;this.updateTrackList()}} + clearTracks(){this.tracks.length=0;this.activeTracks.length=0;this.updateTrackList();this.subtitleSyncer.stop()} + removeTrack(t){let i=this.tracks.indexOf(t);if(i!==-1)this.tracks.splice(i,1);i=this.activeTracks.indexOf(t);if(i!==-1)this.activeTracks.splice(i,1);this.updateTrackList();this.subtitleSyncer.toggleTrack(t,true)} + onSettingsChanged(s){this.openSubtitlesSearch.setLanguageInputValue(s.defaultLanguage);this.refreshSubtitleStyles();this.renderSubtitles()} + onSubtitleTrackDownloaded(t){this.activateTrack(this.addTrack(t))} + onCaptionsButtonInteract(e){if(e.shiftKey){this.openSubtitlesSearch.toggleUI();e.stopPropagation();return}if(!this.isOpen())this.openUI();else this.closeUI();e.stopPropagation()} + closeUI(){if(DOMElements.subtitlesMenu.style.display==='none')return false;DOMElements.subtitlesMenu.style.display='none';WebUtils.setLabels(DOMElements.subtitles,Localize.getMessage('player_subtitlesmenu_open_label'));return true} + openUI(){this.emit('open',{target:DOMElements.subtitles});DOMElements.subtitlesMenu.style.display='';WebUtils.setLabels(DOMElements.subtitles,Localize.getMessage('player_subtitlesmenu_close_label'))} + isOpen(){return DOMElements.subtitlesMenu.style.display!=='none'} - const defLang = this.settingsManager.getSettings().defaultLanguage; - if (autoset && this.activeTracks.length <= 1 && this.client.options.autoEnableBestSubtitles) { - if (Localize.getLanguageMatchLevel(subtitleTrack.language, defLang) > 0) { - // check if active tracks exist with same language - const existingActive = this.activeTracks.find((t) => { - return t.language && subtitleTrack.language && t.language.substring(0, defLang.length) === defLang; + setupUI() { + try { + DOMElements.subtitles.addEventListener('click', this.onCaptionsButtonInteract.bind(this)); + DOMElements.subtitles.tabIndex = 0; + DOMElements.subtitles.addEventListener('keydown', (e) => { + if (e.key === 'Enter') this.onCaptionsButtonInteract(e); }); - if (!existingActive) { - this.activateTrack(subtitleTrack); - } else { - // Check if existing has "auto" in label, if new one doesn't, prefer new one - if (existingActive.label && existingActive.label.toLowerCase().includes('auto') && - subtitleTrack.label && !subtitleTrack.label.toLowerCase().includes('auto')) { - this.deactivateTrack(existingActive); - this.activateTrack(subtitleTrack); - } - } - } - } - - return returnedTrack; - } - - addTrack(track) { - const existing = this.tracks.find((t) => t.equals(track)); - if (existing) { - return existing; - } - - this.tracks.push(track); - - this.updateTrackList(); - this.client.interfaceController.showControlBar(); - this.client.interfaceController.queueControlsHide(1000); - - return track; - } - - activateTrack(track) { - if (this.tracks.indexOf(track) === -1) { - console.error('Cannot activate track that is not loaded', track); - return; - } - - if (this.activeTracks.indexOf(track) === -1) { - this.activeTracks.push(track); - this.updateTrackList(); - } - } - deactivateTrack(track) { - const ind = this.activeTracks.indexOf(track); - if (ind !== -1) { - this.activeTracks.splice(ind, 1); - this.updateTrackList(); - } - } - - toggleSubtitles() { - if (this.activeTracks.length === 0) { - if (this.lastActiveTracks) { - this.lastActiveTracks.forEach((track) => { - this.activateTrack(track); + DOMElements.subtitlesOptionsTestButton.addEventListener('click', (e) => { + this.isTestSubtitleActive = !this.isTestSubtitleActive; + if (this.isTestSubtitleActive) { + DOMElements.subtitlesOptionsTestButton.textContent = Localize.getMessage('player_subtitlesmenu_testbtn_stop'); + DOMElements.playerContainer.style.backgroundImage = 'linear-gradient(to right, black, white)'; + } else { + DOMElements.subtitlesOptionsTestButton.textContent = Localize.getMessage('player_subtitlesmenu_testbtn'); + DOMElements.playerContainer.style.backgroundImage = ''; + } + this.renderSubtitles(); }); - this.lastActiveTracks = null; - } else { - this.activateTrack(this.tracks[0]); - } - } else { - this.lastActiveTracks = this.activeTracks.slice(); - this.activeTracks.length = 0; - this.updateTrackList(); - } - } - - clearTracks() { - this.tracks.length = 0; - this.activeTracks.length = 0; - this.updateTrackList(); - this.subtitleSyncer.stop(); - } - - removeTrack(track) { - let ind = this.tracks.indexOf(track); - if (ind !== -1) this.tracks.splice(ind, 1); - ind = this.activeTracks.indexOf(track); - if (ind !== -1) this.activeTracks.splice(ind, 1); - this.updateTrackList(); - this.subtitleSyncer.toggleTrack(track, true); - } - - onSettingsChanged(settings) { - this.openSubtitlesSearch.setLanguageInputValue(settings.defaultLanguage); - this.refreshSubtitleStyles(); - this.renderSubtitles(); - } - - onSubtitleTrackDownloaded(track) { - this.activateTrack(this.addTrack(track)); - } - - onCaptionsButtonInteract(e) { - if (e.shiftKey) { - this.openSubtitlesSearch.toggleUI(); - e.stopPropagation(); - return; - } - - if (!this.isOpen()) { - this.openUI(); - } else { - this.closeUI(); - } - e.stopPropagation(); - } - - closeUI() { - if (DOMElements.subtitlesMenu.style.display === 'none') { - return false; - } - DOMElements.subtitlesMenu.style.display = 'none'; - WebUtils.setLabels(DOMElements.subtitles, Localize.getMessage('player_subtitlesmenu_open_label')); - return true; - } - - openUI() { - this.emit('open', { - target: DOMElements.subtitles, - }); - DOMElements.subtitlesMenu.style.display = ''; - - WebUtils.setLabels(DOMElements.subtitles, Localize.getMessage('player_subtitlesmenu_close_label')); - } - - isOpen() { - return DOMElements.subtitlesMenu.style.display !== 'none'; - } - - setupUI() { - DOMElements.subtitles.addEventListener('click', this.onCaptionsButtonInteract.bind(this)); - DOMElements.subtitles.tabIndex = 0; - - DOMElements.subtitles.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - this.onCaptionsButtonInteract(e); - } - }); - - DOMElements.subtitlesOptionsTestButton.addEventListener('click', (e) => { - this.isTestSubtitleActive = !this.isTestSubtitleActive; - if (this.isTestSubtitleActive) { - DOMElements.subtitlesOptionsTestButton.textContent = Localize.getMessage('player_subtitlesmenu_testbtn_stop'); - DOMElements.playerContainer.style.backgroundImage = 'linear-gradient(to right, black, white)'; - } else { - DOMElements.subtitlesOptionsTestButton.textContent = Localize.getMessage('player_subtitlesmenu_testbtn'); - DOMElements.playerContainer.style.backgroundImage = ''; - } - - this.renderSubtitles(); - }); - WebUtils.setupTabIndex(DOMElements.subtitlesOptionsTestButton); - - const filechooser = document.createElement('input'); - filechooser.type = 'file'; - filechooser.style.display = 'none'; - filechooser.accept = '.vtt, .srt'; - filechooser.ariaHidden = true; - filechooser.ariaLabel = 'Upload subtitle file'; - - filechooser.addEventListener('change', () => { - const files = filechooser.files; - if (!files || !files[0]) return; - const file = files[0]; - const name = file.name; - // var ext = name.substring(name.length - 4); - - const reader = new FileReader(); - reader.onload = () => { - const dt = reader.result; - const track = new SubtitleTrack(name, null); - track.loadText(dt); - - this.addTrack(track); - }; - reader.readAsText(file); - }); - DOMElements.playerContainer.appendChild(filechooser); - - const filebutton = document.createElement('div'); - filebutton.classList.add('subtitle-menu-option'); - WebUtils.setupTabIndex(filebutton); - filebutton.textContent = Localize.getMessage('player_subtitlesmenu_uploadbtn'); - - filebutton.addEventListener('click', (e) => { - filechooser.click(); - }); - DOMElements.subtitlesView.appendChild(filebutton); - - const urlbutton = document.createElement('div'); - urlbutton.classList.add('subtitle-menu-option'); - urlbutton.textContent = Localize.getMessage('player_subtitlesmenu_urlbtn'); - WebUtils.setupTabIndex(urlbutton); - urlbutton.addEventListener('click', async (e) => { - const url = await AlertPolyfill.prompt(Localize.getMessage('player_subtitlesmenu_urlprompt'), '', undefined, 'url'); - - if (url) { - AlertPolyfill.toast('info', Localize.getMessage('player_subtitles_addtrack_downloading')); - RequestUtils.requestSimple(url, (err, req, body) => { - if (!err && body) { - try { - const track = new SubtitleTrack('URL Track', null); - track.loadText(body); - - this.addTrack(track); - - AlertPolyfill.toast('success', Localize.getMessage('player_subtitles_addtrack_success')); - } catch (e) { - AlertPolyfill.toast('error', Localize.getMessage('player_subtitles_addtrack_error'), e?.message); + WebUtils.setupTabIndex(DOMElements.subtitlesOptionsTestButton); + + const filechooser = document.createElement('input'); + filechooser.type = 'file'; + filechooser.style.display = 'none'; + filechooser.accept = '.vtt, .srt'; + filechooser.ariaHidden = true; + filechooser.ariaLabel = 'Upload subtitle file'; + filechooser.addEventListener('change', () => { + const files = filechooser.files; + if (!files || !files[0]) return; + const file = files[0]; + const name = file.name; + const reader = new FileReader(); + reader.onload = () => { + const dt = reader.result; + const track = new SubtitleTrack(name, null); + track.loadText(dt); + this.addTrack(track); + }; + reader.readAsText(file); + }); + DOMElements.playerContainer.appendChild(filechooser); + + const filebutton = document.createElement('div'); + filebutton.classList.add('subtitle-menu-option'); + WebUtils.setupTabIndex(filebutton); + filebutton.textContent = Localize.getMessage('player_subtitlesmenu_uploadbtn'); + filebutton.addEventListener('click', () => filechooser.click()); + DOMElements.subtitlesView.appendChild(filebutton); + + const urlbutton = document.createElement('div'); + urlbutton.classList.add('subtitle-menu-option'); + urlbutton.textContent = Localize.getMessage('player_subtitlesmenu_urlbtn'); + WebUtils.setupTabIndex(urlbutton); + urlbutton.addEventListener('click', async () => { + const url = await AlertPolyfill.prompt(Localize.getMessage('player_subtitlesmenu_urlprompt'), '', undefined, 'url'); + if (url) { + AlertPolyfill.toast('info', Localize.getMessage('player_subtitles_addtrack_downloading')); + RequestUtils.requestSimple(url, (err, req, body) => { + if (!err && body) { + try { + const track = new SubtitleTrack('URL Track', null); + track.loadText(body); + this.addTrack(track); + AlertPolyfill.toast('success', Localize.getMessage('player_subtitles_addtrack_success')); + } catch (e) { + AlertPolyfill.toast('error', Localize.getMessage('player_subtitles_addtrack_error'), e?.message); + } + } else { + AlertPolyfill.toast('error', Localize.getMessage('player_subtitles_addtrack_error'), err?.message); + } + }); } - } else { - AlertPolyfill.toast('error', Localize.getMessage('player_subtitles_addtrack_error'), err?.message); - } }); - } - }); - - DOMElements.subtitlesView.appendChild(urlbutton); - - const internetbutton = document.createElement('div'); - internetbutton.textContent = Localize.getMessage('player_subtitlesmenu_searchbtn'); - internetbutton.classList.add('subtitle-menu-option'); - internetbutton.classList.add('disable-when-mini'); - WebUtils.setupTabIndex(internetbutton); - internetbutton.addEventListener('click', (e) => { - this.openSubtitlesSearch.toggleUI(); - }); - DOMElements.subtitlesView.appendChild(internetbutton); - - const clearbutton = document.createElement('div'); - clearbutton.textContent = Localize.getMessage('player_subtitlesmenu_clearbtn'); - WebUtils.setupTabIndex(clearbutton); - clearbutton.classList.add('subtitle-menu-option'); - - clearbutton.addEventListener('click', (e) => { - this.clearTracks(); - }); - DOMElements.subtitlesView.appendChild(clearbutton); - - const optionsbutton = document.createElement('div'); - optionsbutton.classList.add('subtitle-menu-option'); - optionsbutton.textContent = Localize.getMessage('player_subtitlesmenu_settingsbtn'); - WebUtils.setupTabIndex(optionsbutton); - - optionsbutton.addEventListener('click', (e) => { - this.settingsManager.openUI(); - }); - - WebUtils.setupTabIndex(DOMElements.subtitlesOptionsBackButton); - - DOMElements.subtitlesView.appendChild(optionsbutton); - - DOMElements.subtitlesMenu.addEventListener('click', (e) => { - e.stopPropagation(); - }); + DOMElements.subtitlesView.appendChild(urlbutton); + + const internetbutton = document.createElement('div'); + internetbutton.textContent = Localize.getMessage('player_subtitlesmenu_searchbtn'); + internetbutton.classList.add('subtitle-menu-option'); + internetbutton.classList.add('disable-when-mini'); + WebUtils.setupTabIndex(internetbutton); + internetbutton.addEventListener('click', () => this.openSubtitlesSearch.toggleUI()); + DOMElements.subtitlesView.appendChild(internetbutton); + + // --- TRANSLATOR UI WITH COMPACT LAYOUT (No Scroll) --- + try { + const translateContainer = document.createElement('div'); + translateContainer.className = 'subtitle-menu-option'; + translateContainer.style.display = 'flex'; + translateContainer.style.justifyContent = 'space-between'; + translateContainer.style.alignItems = 'center'; + translateContainer.style.padding = '8px 8px'; + translateContainer.style.cursor = 'default'; + translateContainer.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; + + const translateLabel = document.createElement('span'); + translateLabel.textContent = "Trans:"; + translateLabel.style.marginRight = '5px'; + translateLabel.style.fontSize = '13px'; + translateLabel.style.color = '#ccc'; + translateContainer.appendChild(translateLabel); + + const langSelect = document.createElement('select'); + langSelect.style.flex = '1'; + langSelect.style.width = '0'; + langSelect.style.marginRight = '5px'; + + langSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; + langSelect.style.color = 'white'; + langSelect.style.border = '1px solid #555'; + langSelect.style.borderRadius = '3px'; + langSelect.style.padding = '2px 2px'; + langSelect.style.fontSize = '12px'; + langSelect.style.outline = 'none'; + + Object.keys(LANGUAGES).forEach(code => { + const option = document.createElement('option'); + option.value = code; + option.textContent = LANGUAGES[code]; + if (code === 'my') option.selected = true; + option.style.backgroundColor = '#222'; + option.style.color = 'white'; + langSelect.appendChild(option); + }); + + langSelect.addEventListener('click', (e) => e.stopPropagation()); + langSelect.addEventListener('mousedown', (e) => e.stopPropagation()); + translateContainer.appendChild(langSelect); + + const goBtn = document.createElement('button'); + goBtn.textContent = "Go"; + goBtn.className = "fs-control-button"; + goBtn.style.cursor = 'pointer'; + goBtn.style.color = 'white'; + goBtn.style.backgroundColor = 'transparent'; + goBtn.style.border = '1px solid #777'; + goBtn.style.borderRadius = '3px'; + goBtn.style.padding = '2px 8px'; + goBtn.style.fontSize = '12px'; + goBtn.style.fontWeight = 'bold'; + + goBtn.onmouseover = () => { goBtn.style.borderColor = '#fff'; goBtn.style.backgroundColor = 'rgba(255,255,255,0.1)'; }; + goBtn.onmouseout = () => { goBtn.style.borderColor = '#777'; goBtn.style.backgroundColor = 'transparent'; }; + + goBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (this.activeTracks.length === 0) { + AlertPolyfill.toast('error', 'No active subtitles to translate.'); + return; + } + + const targetLang = langSelect.value; + const targetName = LANGUAGES[targetLang]; + AlertPolyfill.toast('info', `Translation Started: ${targetName}`); + + const sourceCues = this.activeTracks[0].cues; + const totalCues = sourceCues.length; + let processedCount = 0; + + // --- CREATE STATUS BADGE WITH AUTO-HIDE --- + const statusEl = document.createElement('div'); + statusEl.id = 'fs-translation-status'; + statusEl.style.cssText = ` + position: absolute; + top: 20px; + right: 20px; + z-index: 2147483647; + background-color: rgba(20, 20, 20, 0.9); + color: white; + padding: 10px 15px; + border-radius: 6px; + font-family: sans-serif; + font-size: 14px; + border: 1px solid rgba(255,255,255,0.2); + display: flex; + align-items: center; + gap: 10px; + pointer-events: none; + box-shadow: 0 4px 6px rgba(0,0,0,0.3); + transition: opacity 0.5s ease-in-out; + opacity: 1; + `; + statusEl.innerHTML = `Translating... 0%`; + + if (DOMElements.playerContainer) { + DOMElements.playerContainer.appendChild(statusEl); + } + + // --- AUTO-HIDE LOGIC --- + let hideTimeout; + const resetHideTimer = () => { + statusEl.style.opacity = '1'; + clearTimeout(hideTimeout); + hideTimeout = setTimeout(() => { + statusEl.style.opacity = '0'; + }, 2500); + }; + + DOMElements.playerContainer.addEventListener('mousemove', resetHideTimer); + DOMElements.playerContainer.addEventListener('mouseenter', resetHideTimer); + resetHideTimer(); + + const removeStatus = () => { + DOMElements.playerContainer.removeEventListener('mousemove', resetHideTimer); + DOMElements.playerContainer.removeEventListener('mouseenter', resetHideTimer); + clearTimeout(hideTimeout); + statusEl.remove(); + }; + + if (!this.translator) { + try { + const { SubtitleTranslator } = await import('../../modules/SubtitleTranslator.mjs'); + this.translator = new SubtitleTranslator(); + } catch (err) { + console.error(err); + AlertPolyfill.toast('error', 'Missing "SubtitleTranslator.mjs" in modules folder!'); + statusEl.remove(); + return; + } + } + + const newTrack = new SubtitleTrack(`Auto (${targetLang})`, targetLang); + newTrack.loadText("WEBVTT\n\n"); + newTrack.cues = []; + + const tracksToDisable = [...this.activeTracks]; + tracksToDisable.forEach(track => { + this.deactivateTrack(track); + }); + + this.addTrack(newTrack); + this.activateTrack(newTrack); + this.closeUI(); + + try { + await this.translator.translateStream(sourceCues, targetLang, (newSegmentCues, statusMsg) => { + if (statusMsg) { + statusEl.innerHTML = `${statusMsg}`; + return; + } + + newSegmentCues.forEach(c => { + if (!c.dom) { + c.dom = WebVTT.convertCueToDOMTree(window, c.text); + } + newTrack.cues.push(c); + }); + + newTrack.cues.sort((a, b) => a.startTime - b.startTime); + this.renderSubtitles(); + + processedCount += newSegmentCues.length; + const percent = Math.min(100, Math.floor((processedCount / totalCues) * 100)); + statusEl.innerHTML = `Translating... ${percent}%`; + }); + + statusEl.innerHTML = `✔ Complete`; + setTimeout(() => removeStatus(), 3000); + + } catch (err) { + console.error(err); + statusEl.innerHTML = `Error: ${err.message}`; + setTimeout(() => removeStatus(), 5000); + } + }); + translateContainer.appendChild(goBtn); + + DOMElements.subtitlesView.appendChild(translateContainer); + } catch (uiErr) { + console.error("Failed to add Translate UI", uiErr); + } - window.addEventListener('resize', () => { - this.checkTrackBounds(); - }); + const clearBtn = document.createElement('div'); clearBtn.className='subtitle-menu-option'; clearBtn.textContent=Localize.getMessage('player_subtitlesmenu_clearbtn'); + clearBtn.onclick = () => this.clearTracks(); DOMElements.subtitlesView.appendChild(clearBtn); - DOMElements.subtitlesMenu.addEventListener('mousedown', (e) => { - e.stopPropagation(); - }); + const optBtn = document.createElement('div'); optBtn.className='subtitle-menu-option'; optBtn.textContent=Localize.getMessage('player_subtitlesmenu_settingsbtn'); + optBtn.onclick = () => this.settingsManager.openUI(); DOMElements.subtitlesView.appendChild(optBtn); - DOMElements.subtitlesMenu.addEventListener('mouseup', (e) => { - e.stopPropagation(); - }); + } catch (err) { console.error("UI Setup Error", err); } } createTrackEntryElements(i) { @@ -345,7 +377,6 @@ export class SubtitlesManager extends EventEmitter { resyncTool.title = Localize.getMessage('player_subtitlesmenu_resynctool_label'); resyncTool.className = 'fluid_button fluid_button_wand subtitle-resync-tool subtitle-tool'; trackElement.appendChild(resyncTool); - // svg use const svgIconHourglass = WebUtils.createSVGIcon('assets/fluidplayer/static/icons.svg#hourglass'); resyncTool.appendChild(svgIconHourglass); @@ -354,46 +385,6 @@ export class SubtitlesManager extends EventEmitter { e.stopPropagation(); }, true); - const downloadTrack = document.createElement('div'); - downloadTrack.title = Localize.getMessage('player_subtitlesmenu_savetool_label'); - downloadTrack.className = 'fluid_button fluid_button_download subtitle-download-tool subtitle-tool'; - - // svg use - const svgIconDownload = WebUtils.createSVGIcon('assets/fluidplayer/static/icons.svg#download'); - downloadTrack.appendChild(svgIconDownload); - - trackElement.appendChild(downloadTrack); - - downloadTrack.addEventListener('click', async (e) => { - e.stopPropagation(); - const suggestedName = trackElement.textContent.replaceAll(' ', '_'); - const dlname = chrome?.extension?.inIncognitoContext ? suggestedName : await AlertPolyfill.prompt(Localize.getMessage('player_filename_prompt'), suggestedName); - - if (!dlname) { - return; - } - - const srt = SubtitleUtils.cuesToSrt(this.tracks[i].cues); - const blob = new Blob([srt], { - type: 'text/plain', - }); - const url = window.URL.createObjectURL(blob); - await Utils.downloadURL(url, dlname + '.srt'); - window.URL.revokeObjectURL(url); - }, true); - - const removeTrack = document.createElement('div'); - removeTrack.classList.add('subtitle-remove-tool'); - removeTrack.classList.add('subtitle-tool'); - removeTrack.title = Localize.getMessage('player_subtitlesmenu_removetool_label'); - trackElement.appendChild(removeTrack); - - removeTrack.addEventListener('click', (e) => { - this.removeTrack(this.tracks[i]); - e.stopPropagation(); - }, true); - - const shiftLTrack = document.createElement('div'); shiftLTrack.classList.add('subtitle-shiftl-tool'); shiftLTrack.classList.add('subtitle-tool'); @@ -420,33 +411,45 @@ export class SubtitlesManager extends EventEmitter { e.stopPropagation(); }, true); + const downloadTrack = document.createElement('div'); + downloadTrack.title = Localize.getMessage('player_subtitlesmenu_savetool_label'); + downloadTrack.className = 'fluid_button fluid_button_download subtitle-download-tool subtitle-tool'; + const svgIconDownload = WebUtils.createSVGIcon('assets/fluidplayer/static/icons.svg#download'); + downloadTrack.appendChild(svgIconDownload); + trackElement.appendChild(downloadTrack); - trackElement.addEventListener('mouseenter', () => { - trackElement.focus(); - }); + downloadTrack.addEventListener('click', async (e) => { + e.stopPropagation(); + const suggestedName = trackElement.textContent.replaceAll(' ', '_'); + const dlname = chrome?.extension?.inIncognitoContext ? suggestedName : await AlertPolyfill.prompt(Localize.getMessage('player_filename_prompt'), suggestedName); + if (!dlname) return; + const srt = SubtitleUtils.cuesToSrt(this.tracks[i].cues); + const blob = new Blob([srt], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + await Utils.downloadURL(url, dlname + '.srt'); + window.URL.revokeObjectURL(url); + }, true); - trackElement.addEventListener('mouseleave', () => { - trackElement.blur(); - }); + const removeTrack = document.createElement('div'); + removeTrack.classList.add('subtitle-remove-tool'); + removeTrack.classList.add('subtitle-tool'); + removeTrack.title = Localize.getMessage('player_subtitlesmenu_removetool_label'); + trackElement.appendChild(removeTrack); + removeTrack.addEventListener('click', (e) => { + this.removeTrack(this.tracks[i]); + e.stopPropagation(); + }, true); + + trackElement.addEventListener('mouseenter', () => trackElement.focus()); + trackElement.addEventListener('mouseleave', () => trackElement.blur()); trackElement.addEventListener('keydown', (e) => { - if (e.code === 'Delete' || e.code === 'Backspace') { - e.stopPropagation(); - removeTrack.click(); - } else if (e.code === 'BracketRight') { - e.stopPropagation(); - shiftRTrack.click(); - } else if (e.code === 'BracketLeft') { - e.stopPropagation(); - shiftLTrack.click(); - } else if (e.code === 'KeyD') { - e.stopPropagation(); - downloadTrack.click(); - } else if (e.code === 'KeyR') { - e.stopPropagation(); - resyncTool.click(); - } + if (e.code === 'Delete' || e.code === 'Backspace') { e.stopPropagation(); removeTrack.click(); } + else if (e.code === 'BracketRight') { e.stopPropagation(); shiftRTrack.click(); } + else if (e.code === 'BracketLeft') { e.stopPropagation(); shiftLTrack.click(); } + else if (e.code === 'KeyD') { e.stopPropagation(); downloadTrack.click(); } + else if (e.code === 'KeyR') { e.stopPropagation(); resyncTool.click(); } }); return { @@ -456,24 +459,15 @@ export class SubtitlesManager extends EventEmitter { const activeIndex = this.activeTracks.indexOf(track); const nameCandidate = (track.language ? ('(' + track.language + ') ') : '') + (track.label || `Track ${i + 1}`); let name = nameCandidate; - // limit to 30 chars - if (name.length > 30) { - name = name.substring(0, 30) + '...'; - } + if (name.length > 30) name = name.substring(0, 30) + '...'; if (activeIndex !== -1) { trackElement.classList.add('subtitle-track-active'); - - if (this.activeTracks.length > 1) { - trackName.textContent = (activeIndex + 1) + ': ' + name; - } else { - trackName.textContent = name; - } + trackName.textContent = (this.activeTracks.length > 1) ? (activeIndex + 1) + ': ' + name : name; } else { trackElement.classList.remove('subtitle-track-active'); trackName.textContent = name; } - trackName.title = nameCandidate; }, }; @@ -482,26 +476,19 @@ export class SubtitlesManager extends EventEmitter { updateTrackList() { const cachedElements = this.subtitleTrackListElements; const tracks = this.tracks; - - // Remove extra elements for (let i = cachedElements.length - 1; i >= tracks.length; i--) { const el = cachedElements[i]; el.trackElement.remove(); cachedElements.splice(i, 1); } - - // Add new elements for (let i = cachedElements.length; i < tracks.length; i++) { const elements = this.createTrackEntryElements(i); cachedElements.push(elements); DOMElements.subtitlesList.appendChild(elements.trackElement); } - - // Update elements for (let i = 0; i < tracks.length; i++) { cachedElements[i].update(); } - this.renderSubtitles(); } @@ -523,20 +510,15 @@ export class SubtitlesManager extends EventEmitter { const wrapper = document.createElement('div'); wrapper.className = 'subtitle-track-wrapper'; wrapper.appendChild(trackContainer); - wrapper.style.marginBottom = '5px'; - let yStart = 0; - const mouseup = (e) => { DOMElements.playerContainer.removeEventListener('mousemove', mousemove); DOMElements.playerContainer.removeEventListener('mouseup', mouseup); e.stopPropagation(); }; - const mousemove = (e) => { - // drag by adjusting margin-bottom const oldDiff = yStart - e.clientY; let diff = oldDiff; let current = wrapper; @@ -547,49 +529,36 @@ export class SubtitlesManager extends EventEmitter { diff -= (newMarginBottom - marginBottom); current = current.nextElementSibling; } while (current && diff < 0); - const adjustedDiff = oldDiff - diff; - const previousSibling = wrapper.previousElementSibling; if (previousSibling) { const previousMarginBottom = parseInt(previousSibling.style.marginBottom) || 0; const newPreviousMarginBottom = Math.max(previousMarginBottom - adjustedDiff, 5); previousSibling.style.marginBottom = newPreviousMarginBottom + 'px'; } - yStart = e.clientY; this.checkTrackBounds(); e.stopPropagation(); }; - wrapper.addEventListener('mousedown', (e) => { yStart = e.clientY; DOMElements.playerContainer.addEventListener('mousemove', mousemove); DOMElements.playerContainer.addEventListener('mouseup', mouseup); e.stopPropagation(); }); - - - return { - trackContainer, - wrapper, - }; + return { trackContainer, wrapper }; } - // Make sure subtitles are not outside of the video checkTrackBounds() { const trackElements = this.subtitleTrackDisplayElements; const playerHeight = DOMElements.playerContainer.offsetHeight - parseInt(window.getComputedStyle(DOMElements.subtitlesContainer).bottom); - let totalTrackHeight = 0; for (let i = 0; i < trackElements.length; i++) { const trackWrapper = trackElements[i].parentElement; const marginBottom = parseInt(trackWrapper.style.marginBottom); totalTrackHeight += trackWrapper.offsetHeight + marginBottom; } - let shrinkAmount = Math.max(totalTrackHeight - playerHeight, 0); - // shrink margin-bottom from topmost track downwards for (let i = 0; i < trackElements.length && shrinkAmount > 0; i++) { const trackWrapper = trackElements[i].parentElement; const marginBottom = parseInt(trackWrapper.style.marginBottom) || 0; @@ -615,89 +584,57 @@ export class SubtitlesManager extends EventEmitter { const cachedElements = this.subtitleTrackDisplayElements; const tracks = this.activeTracks; let trackLen = tracks.length; + if (this.isTestSubtitleActive) trackLen++; - if (this.isTestSubtitleActive) { - trackLen++; - } - - // Remove extra elements for (let i = cachedElements.length - 1; i >= trackLen; i--) { const el = cachedElements[i]; el.parentElement.remove(); cachedElements.splice(i, 1); } - - // Add new elements for (let i = cachedElements.length; i < trackLen; i++) { const {trackContainer, wrapper} = this.createSubtitleDisplayElements(i); - cachedElements.push(trackContainer); DOMElements.subtitlesContainer.appendChild(wrapper); } - // Update elements const currentTime = this.client.state.currentTime; let subtitlesVisible = 0; for (let i = 0; i < tracks.length; i++) { const trackContainer = cachedElements[i]; - // trackContainer.replaceChildren(); const cues = tracks[i].cues; - let cueIndex = Utils.binarySearch(cues, this.client.state.currentTime, (time, cue) => { - if (cue.startTime > time) { - return -1; - } else if (cue.startTime < time) { - return 1; - } - return 0; + if (cue.startTime > time) return -1; else if (cue.startTime < time) return 1; return 0; }); - const toAdd = []; - if (cueIndex < -1) { - cueIndex = -cueIndex - 2; - } - - while (cueIndex > 0 && cues[cueIndex - 1].endTime >= currentTime && cues[cueIndex - 1].startTime <= currentTime) { - cueIndex--; - } - + if (cueIndex < -1) cueIndex = -cueIndex - 2; + while (cueIndex > 0 && cues[cueIndex - 1].endTime >= currentTime && cues[cueIndex - 1].startTime <= currentTime) cueIndex--; while (cueIndex >= 0 &&cueIndex < cues.length && cues[cueIndex].endTime >= currentTime && cues[cueIndex].startTime <= currentTime) { const cue = cues[cueIndex]; - if (!cue.dom) { - cue.dom = WebVTT.convertCueToDOMTree(window, cue.text); - } + if (!cue.dom) cue.dom = WebVTT.convertCueToDOMTree(window, cue.text); toAdd.push(cue.dom); cueIndex++; } - if (!toAdd.length) { trackContainer.style.opacity = 0; - - // Remove all children except one const fillerCue = trackContainer.children[0] || document.createElement('div'); WebUtils.replaceChildrenPerformant(trackContainer, [fillerCue]); if (!fillerCue.textContent) fillerCue.textContent = '|'; } else { trackContainer.style.opacity = ''; - WebUtils.replaceChildrenPerformant(trackContainer, toAdd); subtitlesVisible++; } } - if (this.isTestSubtitleActive) { const trackContainer = cachedElements[trackLen - 1]; - trackContainer.style.opacity = ''; - if (!this.testCue) { const cue = document.createElement('div'); cue.textContent = Localize.getMessage('player_testsubtitle'); this.testCue = cue; } - WebUtils.replaceChildrenPerformant(trackContainer, [this.testCue]); subtitlesVisible++; } @@ -709,11 +646,10 @@ export class SubtitlesManager extends EventEmitter { } else { DOMElements.subtitlesContainer.style.display = 'none'; } - this.checkTrackBounds(); } mediaInfoSet() { this.openSubtitlesSearch.setMediaInfo(this.client.mediaInfo); } -} +} \ No newline at end of file From d7d8c7a8695da152525c9e5e79d50a8899ca4f0a Mon Sep 17 00:00:00 2001 From: lianzthang Date: Fri, 30 Jan 2026 21:17:27 -0600 Subject: [PATCH 2/3] feat: Add Auto-Translate with Store-compliant toggle - Implemented Auto-Translate feature (disabled by default for Chrome Web Store approval). - Added 'autoTranslate' and 'defaultTranslateLanguage' settings to Options menu. - Refactored language list into new 'DefaultLanguages.mjs' file. - Fixed race condition in OptionsStore to ensure settings load before UI renders. - Updated SubtitlesManager to hide translation UI completely when feature is disabled. --- chrome/player/modules/SubtitleTranslator.mjs | 139 ++++++++++++++++++ chrome/player/options/OptionsStore.mjs | 15 +- .../options/defaults/DefaultLanguages.mjs | 23 +++ .../options/defaults/DefaultOptions.mjs | 2 + chrome/player/options/index.html | 16 ++ chrome/player/options/options.mjs | 47 ++++++ .../player/ui/subtitles/SubtitlesManager.mjs | 83 +++++++---- 7 files changed, 291 insertions(+), 34 deletions(-) create mode 100755 chrome/player/modules/SubtitleTranslator.mjs create mode 100644 chrome/player/options/defaults/DefaultLanguages.mjs diff --git a/chrome/player/modules/SubtitleTranslator.mjs b/chrome/player/modules/SubtitleTranslator.mjs new file mode 100755 index 00000000..45e6598c --- /dev/null +++ b/chrome/player/modules/SubtitleTranslator.mjs @@ -0,0 +1,139 @@ +/** + * SubtitleTranslator.mjs + * "Interactive" Version: Opens the specific API URL to force the CAPTCHA check. + */ +export class SubtitleTranslator { + constructor() { + this.apis = [ + { + name: "gtx", + url: "https://translate.googleapis.com/translate_a/single", + params: { client: "gtx", dt: "t" }, + batchSize: 8, + delay: 3000 + }, + { + name: "clients5", + url: "https://clients5.google.com/translate_a/t", + params: { client: "dict-chrome-ex" }, + batchSize: 12, + delay: 3000 + } + ]; + this.currentApiIndex = 0; + } + + async translateStream(cues, targetLang, onProgress) { + if (!cues || cues.length === 0) throw new Error("No cues found."); + + let api = this.apis[this.currentApiIndex]; + let delimiter = " \n "; + + for (let i = 0; i < cues.length; i += api.batchSize) { + api = this.apis[this.currentApiIndex]; + + const batch = cues.slice(i, i + api.batchSize); + const texts = batch.map(c => c.text.replace(/\r?\n|\r/g, " ").trim()); + + try { + const translatedTexts = await this.fetchWithRetry(texts, targetLang, delimiter, api); + + const segmentCues = []; + batch.forEach((cue, index) => { + if (translatedTexts[index]) { + segmentCues.push({ + startTime: cue.startTime, + endTime: cue.endTime, + text: translatedTexts[index] + }); + } + }); + + if (onProgress) onProgress(segmentCues, null); + await this.delay(api.delay); + + } catch (e) { + console.warn(`[FastStream] API ${api.name} error:`, e); + + // CHECK FOR BAN + if (e.message.includes("429") || e.message.includes("Rate Limit")) { + if (onProgress) onProgress([], "⚠️ Google Ban Detected. Opening Unblocker..."); + + // CONSTRUCT THE EXACT URL THAT IS BLOCKED + // This forces the "Sorry... Unusual Traffic" page to appear + const testUrl = `https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&sl=auto&tl=${targetLang}&q=Hello%20World`; + + // 1. Open the specific API URL + window.open(testUrl, "_blank"); + + // 2. Pause and ask user + const resolved = confirm( + "Google has temporarily blocked the translation signal.\n\n" + + "1. A new tab has opened with a weird code page.\n" + + "2. IF you see a CAPTCHA ('I am not a robot'), solve it.\n" + + "3. IF you just see text code (JSON), close the tab.\n\n" + + "Click OK when you are done to resume." + ); + + if (resolved) { + if (onProgress) onProgress([], "Resuming..."); + // Wait 5 seconds for the unblock to register + await this.delay(5000); + i -= api.batchSize; // Retry batch + continue; + } else { + throw new Error("Translation stopped by user."); + } + } + + // Normal rotation for other errors + this.currentApiIndex = (this.currentApiIndex + 1) % this.apis.length; + api = this.apis[this.currentApiIndex]; + i -= api.batchSize; + await this.delay(2000); + } + } + } + + async fetchWithRetry(texts, targetLang, delimiter, api) { + const combinedText = texts.join(delimiter); + const params = new URLSearchParams({ + ...api.params, + sl: "auto", + tl: targetLang, + q: combinedText + }); + + const response = await fetch(`${api.url}?${params.toString()}`); + + // If 429 or redirected to the "Sorry" page + if (response.status === 429 || response.url.includes("google.com/sorry")) { + throw new Error("429 Rate Limit"); + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + let fullText = ""; + + if (api.name === "clients5") { + if (Array.isArray(data)) { + if (typeof data[0] === 'string') fullText = data[0]; + else data.forEach(item => { if (item && item[0]) fullText += item[0]; }); + } + } else { + if (data && data[0]) { + data[0].forEach(segment => { + if (segment[0]) fullText += segment[0]; + }); + } + } + + return fullText.split(delimiter).map(s => s.trim()); + } + + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} \ No newline at end of file diff --git a/chrome/player/options/OptionsStore.mjs b/chrome/player/options/OptionsStore.mjs index 7ddf6a37..d96a9ed1 100644 --- a/chrome/player/options/OptionsStore.mjs +++ b/chrome/player/options/OptionsStore.mjs @@ -15,11 +15,18 @@ export class OptionsStore { /** * Initialize the store by loading options and wiring external updates. */ - static async init() { - if (this.#initialized) return this.#options; - this.#initialized = true; - this.#options = await Utils.getOptionsFromStorage(); + static async init() { + // 1. If we have options, return them immediately + if (this.#initialized && this.#options) return this.#options; + + // 2. Load the options first + const options = await Utils.getOptionsFromStorage(); + this.#options = options; this.#wireExternalUpdates(); + + // 3. ONLY NOW mark as initialized + this.#initialized = true; + return this.#options; } diff --git a/chrome/player/options/defaults/DefaultLanguages.mjs b/chrome/player/options/defaults/DefaultLanguages.mjs new file mode 100644 index 00000000..c110c460 --- /dev/null +++ b/chrome/player/options/defaults/DefaultLanguages.mjs @@ -0,0 +1,23 @@ +export const DefaultLanguages = { + "af": "Afrikaans", "sq": "Albanian", "am": "Amharic", "ar": "Arabic", "hy": "Armenian", + "az": "Azerbaijani", "eu": "Basque", "be": "Belarusian", "bn": "Bengali", "bs": "Bosnian", + "bg": "Bulgarian", "ca": "Catalan", "ceb": "Cebuano", "ny": "Chichewa", "zh-CN": "Chinese (Simplified)", + "zh-TW": "Chinese (Traditional)", "co": "Corsican", "hr": "Croatian", "cs": "Czech", "da": "Danish", + "nl": "Dutch", "en": "English", "eo": "Esperanto", "et": "Estonian", "tl": "Filipino", + "fi": "Finnish", "fr": "French", "fy": "Frisian", "gl": "Galician", "ka": "Georgian", + "de": "German", "el": "Greek", "gu": "Gujarati", "ht": "Haitian Creole", "ha": "Hausa", + "haw": "Hawaiian", "iw": "Hebrew", "hi": "Hindi", "hmn": "Hmong", "hu": "Hungarian", + "is": "Icelandic", "ig": "Igbo", "id": "Indonesian", "ga": "Irish", "it": "Italian", + "ja": "Japanese", "jw": "Javanese", "kn": "Kannada", "kk": "Kazakh", "km": "Khmer", + "ko": "Korean", "ku": "Kurdish (Kurmanji)", "ky": "Kyrgyz", "lo": "Lao", "la": "Latin", + "lv": "Latvian", "lt": "Lithuanian", "lb": "Luxembourgish", "mk": "Macedonian", "mg": "Malagasy", + "ms": "Malay", "ml": "Malayalam", "mt": "Maltese", "mi": "Maori", "mr": "Marathi", + "mn": "Mongolian", "my": "Myanmar (Burmese)", "ne": "Nepali", "no": "Norwegian", "ps": "Pashto", + "fa": "Persian", "pl": "Polish", "pt": "Portuguese", "pa": "Punjabi", "ro": "Romanian", + "ru": "Russian", "sm": "Samoan", "gd": "Scots Gaelic", "sr": "Serbian", "st": "Sesotho", + "sn": "Shona", "sd": "Sindhi", "si": "Sinhala", "sk": "Slovak", "sl": "Slovenian", + "so": "Somali", "es": "Spanish", "su": "Sundanese", "sw": "Swahili", "sv": "Swedish", + "tg": "Tajik", "ta": "Tamil", "te": "Telugu", "th": "Thai", "tr": "Turkish", + "uk": "Ukrainian", "ur": "Urdu", "uz": "Uzbek", "vi": "Vietnamese", "cy": "Welsh", + "xh": "Xhosa", "yi": "Yiddish", "yo": "Yoruba", "zu": "Zulu" +}; \ No newline at end of file diff --git a/chrome/player/options/defaults/DefaultOptions.mjs b/chrome/player/options/defaults/DefaultOptions.mjs index d2f993a6..416f4f6b 100644 --- a/chrome/player/options/defaults/DefaultOptions.mjs +++ b/chrome/player/options/defaults/DefaultOptions.mjs @@ -47,4 +47,6 @@ export const DefaultOptions = { videoDelay: 0, maximumDownloaders: 6, youtubePlayerID: '', + autoTranslate: false, // Default to off for Store compliance + defaultTranslateLanguage: 'en', // Default language code }; diff --git a/chrome/player/options/index.html b/chrome/player/options/index.html index 6624b66e..9f764c93 100644 --- a/chrome/player/options/index.html +++ b/chrome/player/options/index.html @@ -203,7 +203,23 @@

+ +
+
+
+ +
Enable Auto-Translate
+
+
+
+
+
+
Default Translation Language
+
+
+
+

diff --git a/chrome/player/options/options.mjs b/chrome/player/options/options.mjs index a480213e..677d90f5 100644 --- a/chrome/player/options/options.mjs +++ b/chrome/player/options/options.mjs @@ -17,6 +17,7 @@ import {DaltonizerTypes} from './defaults/DaltonizerTypes.mjs'; import {DefaultToolSettings} from './defaults/ToolSettings.mjs'; import {DefaultQualities} from './defaults/DefaultQualities.mjs'; import {ColorThemes} from './defaults/ColorThemes.mjs'; +import {DefaultLanguages} from './defaults/DefaultLanguages.mjs'; let Options = {}; const analyzeVideos = document.getElementById('analyzevideos'); @@ -53,6 +54,8 @@ const optionsSearchBar = document.getElementById('searchbar'); const optionsResetButton = document.getElementById('resetsearch'); // const ytclient = document.getElementById('ytclient'); const maxdownloaders = document.getElementById('maxdownloaders'); +const autoTranslate = document.getElementById('autotranslate'); +const defaultTranslateLang = document.getElementById('defaulttranslatelang'); autoEnableURLSInput.setAttribute('autocapitalize', 'off'); autoEnableURLSInput.setAttribute('autocomplete', 'off'); autoEnableURLSInput.setAttribute('autocorrect', 'off'); @@ -159,6 +162,14 @@ async function loadOptions(newOptions) { document.getElementById('dev').style.display = ''; } initsearch(); + + autoTranslate.checked = !!Options.autoTranslate; +// Re-run builder to ensure correct selection is shown +buildLanguageMenu(); + +// Disable if feature is off +const langSelect = defaultTranslateLang.querySelector('select'); +if (langSelect) langSelect.disabled = !Options.autoTranslate; } function createSelectMenu(container, options, selected, localPrefix, callback) { @@ -240,6 +251,34 @@ createSelectMenu(qualityMenu, Object.values(DefaultQualities), Options.defaultQu // optionChanged(); // }); +// Custom builder for Language Dropdown +function buildLanguageMenu() { + defaultTranslateLang.replaceChildren(); // Clear existing + const select = document.createElement('select'); + + // Sort languages alphabetically by Name, not Code + const sortedLangs = Object.entries(DefaultLanguages).sort((a, b) => a[1].localeCompare(b[1])); + + for (const [code, name] of sortedLangs) { + const option = document.createElement('option'); + option.value = code; + option.textContent = name; + if (code === Options.defaultTranslateLanguage) { + option.selected = true; + } + select.appendChild(option); + } + + select.addEventListener('change', (e) => { + Options.defaultTranslateLanguage = e.target.value; + optionChanged(); + }); + + defaultTranslateLang.appendChild(select); +} +// Run it immediately +buildLanguageMenu(); + document.querySelectorAll('.option').forEach((option) => { option.addEventListener('click', (e) => { if (e.target.tagName !== 'INPUT') { @@ -675,3 +714,11 @@ if (EnvUtils.isExtension()) { // SPLICER:NO_UPDATE_CHECKER:REMOVE_END } +autoTranslate.addEventListener('change', () => { + Options.autoTranslate = autoTranslate.checked; + + const select = defaultTranslateLang.querySelector('select'); + if (select) select.disabled = !Options.autoTranslate; + + optionChanged(); +}); \ No newline at end of file diff --git a/chrome/player/ui/subtitles/SubtitlesManager.mjs b/chrome/player/ui/subtitles/SubtitlesManager.mjs index cd92ecfa..47d3795a 100644 --- a/chrome/player/ui/subtitles/SubtitlesManager.mjs +++ b/chrome/player/ui/subtitles/SubtitlesManager.mjs @@ -7,6 +7,8 @@ import {RequestUtils} from '../../utils/RequestUtils.mjs'; import {SubtitleUtils} from '../../utils/SubtitleUtils.mjs'; import {Utils} from '../../utils/Utils.mjs'; import {WebUtils} from '../../utils/WebUtils.mjs'; +import {OptionsStore} from '../../options/OptionsStore.mjs'; +import {DefaultLanguages} from '../../options/defaults/DefaultLanguages.mjs'; import {DOMElements} from '../DOMElements.mjs'; import {OpenSubtitlesSearch, OpenSubtitlesSearchEvents} from './OpenSubtitlesSearch.mjs'; import {SubtitlesSettingsManager, SubtitlesSettingsManagerEvents} from './SubtitlesSettingsManager.mjs'; @@ -14,30 +16,6 @@ import {SubtitleSyncer} from './SubtitleSyncer.mjs'; // NOTE: Removed top-level SubtitleTranslator import to prevent player crash if file missing -const LANGUAGES = { - "af": "Afrikaans", "sq": "Albanian", "am": "Amharic", "ar": "Arabic", "hy": "Armenian", - "az": "Azerbaijani", "eu": "Basque", "be": "Belarusian", "bn": "Bengali", "bs": "Bosnian", - "bg": "Bulgarian", "ca": "Catalan", "ceb": "Cebuano", "ny": "Chichewa", "zh-CN": "Chinese (Simplified)", - "zh-TW": "Chinese (Traditional)", "co": "Corsican", "hr": "Croatian", "cs": "Czech", "da": "Danish", - "nl": "Dutch", "en": "English", "eo": "Esperanto", "et": "Estonian", "tl": "Filipino", - "fi": "Finnish", "fr": "French", "fy": "Frisian", "gl": "Galician", "ka": "Georgian", - "de": "German", "el": "Greek", "gu": "Gujarati", "ht": "Haitian Creole", "ha": "Hausa", - "haw": "Hawaiian", "iw": "Hebrew", "hi": "Hindi", "hmn": "Hmong", "hu": "Hungarian", - "is": "Icelandic", "ig": "Igbo", "id": "Indonesian", "ga": "Irish", "it": "Italian", - "ja": "Japanese", "jw": "Javanese", "kn": "Kannada", "kk": "Kazakh", "km": "Khmer", - "ko": "Korean", "ku": "Kurdish (Kurmanji)", "ky": "Kyrgyz", "lo": "Lao", "la": "Latin", - "lv": "Latvian", "lt": "Lithuanian", "lb": "Luxembourgish", "mk": "Macedonian", "mg": "Malagasy", - "ms": "Malay", "ml": "Malayalam", "mt": "Maltese", "mi": "Maori", "mr": "Marathi", - "mn": "Mongolian", "my": "Myanmar (Burmese)", "ne": "Nepali", "no": "Norwegian", "ps": "Pashto", - "fa": "Persian", "pl": "Polish", "pt": "Portuguese", "pa": "Punjabi", "ro": "Romanian", - "ru": "Russian", "sm": "Samoan", "gd": "Scots Gaelic", "sr": "Serbian", "st": "Sesotho", - "sn": "Shona", "sd": "Sindhi", "si": "Sinhala", "sk": "Slovak", "sl": "Slovenian", - "so": "Somali", "es": "Spanish", "su": "Sundanese", "sw": "Swahili", "sv": "Swedish", - "tg": "Tajik", "ta": "Tamil", "te": "Telugu", "th": "Thai", "tr": "Turkish", - "uk": "Ukrainian", "ur": "Urdu", "uz": "Uzbek", "vi": "Vietnamese", "cy": "Welsh", - "xh": "Xhosa", "yi": "Yiddish", "yo": "Yoruba", "zu": "Zulu" -}; - export class SubtitlesManager extends EventEmitter { constructor(client) { super(); @@ -47,6 +25,10 @@ export class SubtitlesManager extends EventEmitter { this.isTestSubtitleActive = false; this.subtitleTrackListElements = []; this.subtitleTrackDisplayElements = []; + + // Initialize the store so we listen for changes from the Options Page! + OptionsStore.init(); + this.settingsManager = new SubtitlesSettingsManager(); this.settingsManager.on(SubtitlesSettingsManagerEvents.SETTINGS_CHANGED, this.onSettingsChanged.bind(this)); this.settingsManager.loadSettings(); @@ -159,13 +141,15 @@ export class SubtitlesManager extends EventEmitter { try { const translateContainer = document.createElement('div'); translateContainer.className = 'subtitle-menu-option'; - translateContainer.style.display = 'flex'; + + // STYLE: Basic container setup translateContainer.style.justifyContent = 'space-between'; translateContainer.style.alignItems = 'center'; translateContainer.style.padding = '8px 8px'; translateContainer.style.cursor = 'default'; translateContainer.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; + // LABEL const translateLabel = document.createElement('span'); translateLabel.textContent = "Trans:"; translateLabel.style.marginRight = '5px'; @@ -173,11 +157,11 @@ export class SubtitlesManager extends EventEmitter { translateLabel.style.color = '#ccc'; translateContainer.appendChild(translateLabel); + // DROPDOWN const langSelect = document.createElement('select'); langSelect.style.flex = '1'; langSelect.style.width = '0'; langSelect.style.marginRight = '5px'; - langSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; langSelect.style.color = 'white'; langSelect.style.border = '1px solid #555'; @@ -186,20 +170,59 @@ export class SubtitlesManager extends EventEmitter { langSelect.style.fontSize = '12px'; langSelect.style.outline = 'none'; - Object.keys(LANGUAGES).forEach(code => { + // Populate Dropdown + Object.keys(DefaultLanguages).forEach(code => { const option = document.createElement('option'); option.value = code; - option.textContent = LANGUAGES[code]; - if (code === 'my') option.selected = true; + option.textContent = DefaultLanguages[code]; option.style.backgroundColor = '#222'; option.style.color = 'white'; langSelect.appendChild(option); }); + + // LOGIC: Handle Visibility & Language Selection + const updateUIFromOptions = (options) => { + // 1. Show/Hide based on setting + translateContainer.style.display = options.autoTranslate ? 'flex' : 'none'; + + // 2. Select the saved language (if valid) + if (options.defaultTranslateLanguage && DefaultLanguages[options.defaultTranslateLanguage]) { + langSelect.value = options.defaultTranslateLanguage; + } else { + langSelect.value = 'en'; // Default fallback + } + }; + + // 1. Initial Render (Might be defaults) + updateUIFromOptions(OptionsStore.get()); + + // 2. FORCE UPDATE after storage is ready (Fixes "Off by default" bug) + OptionsStore.init().then(() => { + const loadedOptions = OptionsStore.get(); + updateUIFromOptions(loadedOptions); + + // CRITICAL FIX: If enabled in saved settings, make sure we really show it + if (loadedOptions.autoTranslate) { + translateContainer.style.display = 'flex'; + } + }); + + // C. Listen for live changes (e.g. from Options Page) + OptionsStore.subscribe((options) => { + updateUIFromOptions(options); + }); + + // D. Save changes when user picks a language (Fixes "Not Saved") + langSelect.addEventListener('change', (e) => { + const newLang = e.target.value; + OptionsStore.set({ defaultTranslateLanguage: newLang }); + }); langSelect.addEventListener('click', (e) => e.stopPropagation()); langSelect.addEventListener('mousedown', (e) => e.stopPropagation()); translateContainer.appendChild(langSelect); + // GO BUTTON const goBtn = document.createElement('button'); goBtn.textContent = "Go"; goBtn.className = "fs-control-button"; @@ -223,7 +246,7 @@ export class SubtitlesManager extends EventEmitter { } const targetLang = langSelect.value; - const targetName = LANGUAGES[targetLang]; + const targetName = DefaultLanguages[targetLang]; AlertPolyfill.toast('info', `Translation Started: ${targetName}`); const sourceCues = this.activeTracks[0].cues; From d1c313a4fb3a662ba8c1d1767be63cda978dbeb0 Mon Sep 17 00:00:00 2001 From: lianzthang Date: Fri, 30 Jan 2026 22:43:16 -0600 Subject: [PATCH 3/3] fix: Use double-newline delimiter to improve Japanese/Asian translations --- chrome/player/modules/SubtitleTranslator.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/chrome/player/modules/SubtitleTranslator.mjs b/chrome/player/modules/SubtitleTranslator.mjs index 45e6598c..67b83f09 100755 --- a/chrome/player/modules/SubtitleTranslator.mjs +++ b/chrome/player/modules/SubtitleTranslator.mjs @@ -27,7 +27,10 @@ export class SubtitleTranslator { if (!cues || cues.length === 0) throw new Error("No cues found."); let api = this.apis[this.currentApiIndex]; - let delimiter = " \n "; + + // FIX: Use double newline. This forces Google to treat cues as separate paragraphs. + // Single newlines cause Japanese/Chinese lines to merge during translation. + let delimiter = "\n\n"; for (let i = 0; i < cues.length; i += api.batchSize) { api = this.apis[this.currentApiIndex]; @@ -40,6 +43,7 @@ export class SubtitleTranslator { const segmentCues = []; batch.forEach((cue, index) => { + // Safety check: ensure we have a translation for this index if (translatedTexts[index]) { segmentCues.push({ startTime: cue.startTime, @@ -60,7 +64,6 @@ export class SubtitleTranslator { if (onProgress) onProgress([], "⚠️ Google Ban Detected. Opening Unblocker..."); // CONSTRUCT THE EXACT URL THAT IS BLOCKED - // This forces the "Sorry... Unusual Traffic" page to appear const testUrl = `https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&sl=auto&tl=${targetLang}&q=Hello%20World`; // 1. Open the specific API URL @@ -130,6 +133,7 @@ export class SubtitleTranslator { } } + // Split by the double newline to get original segments back return fullText.split(delimiter).map(s => s.trim()); }