Greasy Fork is available in English.

TTS: EN → VI (Selected Text)

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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');
})();