TTS: EN → VI (Selected Text)

Speak selected English text, then play Vietnamese translation. Hover HUD to hide.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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');
})();