TTS: EN → VI (Selected Text)

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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