Speak selected English text, then play Vietnamese translation. Hover HUD to hide.
// ==UserScript==
// @name TTS: EN → VI (Selected Text)
// @description Speak selected English text, then play Vietnamese translation. Hover HUD to hide.
// @version 6.1
// @author Rpyon
// @match *://*/*
// @icon https://uxwing.com/wp-content/themes/uxwing/download/brands-and-social-media/botim-icon.png
// @grant GM_xmlhttpRequest
// @connect translate.googleapis.com
// @connect translate.google.com
// @namespace https://greasyfork.org/users/1441817
// ==/UserScript==
(function () {
'use strict';
// ─────────────────────────────────────────────
// CONFIG — chỉnh tại đây, không cần sửa code
// ─────────────────────────────────────────────
const CONFIG = {
// Các trang bị loại trừ (hostname chứa chuỗi này sẽ bị bỏ qua)
excludedHosts: ['messenger.com'],
// Ngôn ngữ đích để dịch và đọc
targetLang: 'vi',
// Phím tắt: Alt + key
hotkeys: {
toggleMode: 't', // Alt+T: chuyển SELECTED ↔ HOTKEY
readNow: 'Shift', // Shift: đọc selection (chỉ khi HOTKEY mode)
},
// HUD (thanh trạng thái)
hud: {
enabled: true,
fadeDelay: 2000, // ms trước khi HUD mờ đi
hideOnHover: true, // ẩn hoàn toàn khi rê chuột vào
},
// Phát âm tiếng Anh (Web Speech API)
speech: {
lang: 'en-US',
rate: 1.0,
pitch: 1.0,
volume: 1.0,
},
};
// ─────────────────────────────────────────────
// Kiểm tra host bị loại trừ
if (CONFIG.excludedHosts.some(h => location.host.includes(h))) return;
// ─── State ───────────────────────────────────
const _tts_state = {
mode: 'HOTKEY', // 'SELECTED' | 'HOTKEY'
lastText: '',
isEnDone: false,
viAudioBuffer: null,
};
// ─── Audio Context ───────────────────────────
const _tts_AudioCtx = window.AudioContext || window.webkitAudioContext;
const _tts_audioCtx = new _tts_AudioCtx();
function _tts_ensureAudio() {
if (_tts_audioCtx.state === 'suspended') _tts_audioCtx.resume();
}
// ─── HUD ─────────────────────────────────────
const _tts_hud = (() => {
if (!CONFIG.hud.enabled) return { log: () => {} };
// Xoá HUD cũ nếu script bị reload
const existing = document.getElementById('__ttsEnVi_hud');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = '__ttsEnVi_hud';
el.style.cssText = `
position:fixed; bottom:22px; left:83px;
color:#facc15; background:rgba(0,0,0,.55);
padding:3px 9px; font:13px monospace;
border-radius:6px; z-index:2147483647;
pointer-events:auto; user-select:none;
opacity:1; transition:opacity 0.25s ease;
cursor:default;
`;
document.body.appendChild(el);
let _timer = null;
let _hovered = false;
if (CONFIG.hud.hideOnHover) {
el.addEventListener('mouseenter', () => {
_hovered = true;
clearTimeout(_timer);
el.style.opacity = '0';
});
el.addEventListener('mouseleave', () => {
_hovered = false;
el.style.opacity = '1';
});
}
function log(msg, isError = false) {
el.textContent = `TTS [${_tts_state.mode}] │ ${msg}`;
el.style.color = isError ? '#ff5555' : '#facc15';
if (!_hovered) el.style.opacity = '1';
clearTimeout(_timer);
_timer = setTimeout(() => {
if (!_hovered) el.style.opacity = '0.5';
}, CONFIG.hud.fadeDelay);
}
return { log };
})();
// ─── Playback helpers ────────────────────────
function _tts_stopAll() {
speechSynthesis.cancel();
_tts_state.isEnDone = false;
_tts_state.viAudioBuffer = null;
_tts_hud.log('STOPPED');
}
function _tts_playVietnamese() {
if (!_tts_state.isEnDone || !_tts_state.viAudioBuffer) return;
const src = _tts_audioCtx.createBufferSource();
src.buffer = _tts_state.viAudioBuffer;
src.connect(_tts_audioCtx.destination);
src.onended = () => _tts_hud.log('DONE');
src.start(0);
_tts_hud.log('READING VI…');
_tts_state.viAudioBuffer = null;
_tts_state.isEnDone = false;
}
function _tts_speakEnglish(text) {
const utt = new SpeechSynthesisUtterance(text);
utt.lang = CONFIG.speech.lang;
utt.rate = CONFIG.speech.rate;
utt.pitch = CONFIG.speech.pitch;
utt.volume = CONFIG.speech.volume;
utt.onstart = () => _tts_hud.log('READING EN…');
utt.onend = () => { _tts_state.isEnDone = true; _tts_playVietnamese(); };
utt.onerror = (e) => _tts_hud.log(`EN ERR: ${e.error}`, true);
speechSynthesis.speak(utt);
}
// ─── Translation + TTS fetch ─────────────────
function _tts_fetchTranslation(text, onResult) {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${CONFIG.targetLang}&dt=t&q=${encodeURIComponent(text)}`;
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
try {
const translated = JSON.parse(res.responseText)[0][0][0];
onResult(translated);
} catch {
_tts_hud.log('TRANS PARSE ERR', true);
}
},
onerror: () => _tts_hud.log('TRANS NETWORK ERR', true),
});
}
function _tts_fetchViAudio(translatedText) {
const url = `https://translate.google.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(translatedText)}&tl=${CONFIG.targetLang}&client=tw-ob`;
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
onload: async (res) => {
try {
_tts_state.viAudioBuffer = await _tts_audioCtx.decodeAudioData(res.response);
_tts_playVietnamese();
} catch {
_tts_hud.log('AUDIO DECODE ERR', true);
}
},
onerror: () => _tts_hud.log('VI AUDIO ERR', true),
});
}
// ─── Main process ────────────────────────────
function _tts_process(text) {
text = text.trim();
if (!text || text.length < 2 || !/[a-zA-Z]/.test(text)) return;
if (text === _tts_state.lastText) return;
_tts_state.lastText = text;
_tts_ensureAudio();
_tts_stopAll();
_tts_state.lastText = text; // khôi phục sau stopAll
_tts_speakEnglish(text);
_tts_fetchTranslation(text, _tts_fetchViAudio);
}
// ─── Event listeners ─────────────────────────
document.addEventListener('mouseup', () => {
if (_tts_state.mode !== 'SELECTED') return;
const selected = window.getSelection().toString().trim();
if (selected) _tts_process(selected);
});
document.addEventListener('keydown', (e) => {
const key = e.key;
const isToggle = e.altKey && key.toLowerCase() === CONFIG.hotkeys.toggleMode;
const isReadNow = key === CONFIG.hotkeys.readNow && !e.altKey && !e.ctrlKey && !e.metaKey;
if (isToggle) {
e.preventDefault();
_tts_state.mode = _tts_state.mode === 'SELECTED' ? 'HOTKEY' : 'SELECTED';
_tts_hud.log(`MODE → ${_tts_state.mode}`);
return;
}
if (isReadNow && _tts_state.mode === 'HOTKEY') {
const selected = window.getSelection().toString().trim();
if (selected) {
e.preventDefault();
_tts_process(selected);
return;
}
}
// Bất kỳ phím nào khác → dừng phát nếu đang chạy
if (speechSynthesis.speaking || _tts_state.viAudioBuffer || _tts_state.isEnDone) {
_tts_stopAll();
}
});
_tts_hud.log('READY');
})();