TTS: EN → VI (Selected Text)

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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