Softsub Translator

A Google Translate-focused subtitle translator. Settings are configured exclusively via the Tampermonkey extension menu.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           Softsub Translator
// @name:en        Softsub Translator
// @name:tr        Softsub Altyazı Çevirici
// @namespace      https://greasyfork.org/en/users/1500762-kerimdemirkaynak
// @version        2.2
// @description    A Google Translate-focused subtitle translator. Settings are configured exclusively via the Tampermonkey extension menu.
// @description:en A Google Translate-focused subtitle translator. Settings are configured exclusively via the Tampermonkey extension menu.
// @description:tr Google Çeviri odaklı altyazı çevirici. Ayarlar sadece Tampermonkey uzantı menüsünden yapılır.
// @author         Kerim Demirkaynak
// @license        MIT License
// @icon           https://aegisub.org/favicon-32x32.png
// @match          *://*/*
// @grant          GM_xmlhttpRequest
// @grant          GM_setValue
// @grant          GM_getValue
// @grant          GM_addValueChangeListener
// @grant          GM_registerMenuCommand
// @grant          GM_addStyle
// @connect        translate.googleapis.com
// ==/UserScript==

(function() {
    'use strict';

    // Tarayıcı dilini algıla
    const browserLang = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
    const defaultUiLang = browserLang.startsWith('tr') ? 'tr' : 'en';

    // ==========================================
    // MULTI-LANGUAGE / ÇOKLU DİL SİSTEMİ
    // ==========================================
    const i18n = {
        en: {
            settingsMenu: "⚙️ Softsub Settings",
            settingsTitle: "Softsub Settings",
            uiLang: "UI Language",
            sourceLang: "Original Subtitle Language",
            targetLang: "Target Subtitle Language",
            auto: "Auto Detect",
            save: "Save",
            startTrans: "Start",
            stopTrans: "Stop",
            ghostScan: "👻 Scan",
            signalSent: "⏳ Sent...",
            ghostStart: "Fooling system...<br>Scraping (0 / {duration}s)",
            ghostProgress: "Fooling system...<br>Scraping ({fakeTime} / {duration}s)<br>Caught: {size}",
            ghostDone: "👻 Scan complete! Caught {size} lines.<br>Translating...",
            translating: "Translating: %{percent}",
            allCached: "✅ All translations cached!",
        },
        tr: {
            settingsMenu: "⚙️ Softsub Ayarları",
            settingsTitle: "Softsub Ayarları",
            uiLang: "Arayüz Dili",
            sourceLang: "Orijinal Altyazı Dili",
            targetLang: "Hedef Altyazı Dili",
            auto: "Otomatik Algıla",
            save: "Kaydet",
            startTrans: "Başlat",
            stopTrans: "Durdur",
            ghostScan: "👻 Tara",
            signalSent: "⏳ İletildi...",
            ghostStart: "Sistem kandırılıyor...<br>Kazınıyor (0 / {duration}s)",
            ghostProgress: "Sistem kandırılıyor...<br>Kazınıyor ({fakeTime} / {duration}s)<br>Yakalanan: {size}",
            ghostDone: "👻 Tarama bitti! {size} satır yakalandı.<br>Çevriliyor...",
            translating: "Çeviriliyor: %{percent}",
            allCached: "✅ Tüm çeviriler önbellekte hazır!",
        }
    };

    const CONFIG = {
        uiLanguage: GM_getValue('uiLanguage', defaultUiLang),
        sourceLanguage: GM_getValue('sourceLanguage', 'auto'),
        targetLanguage: GM_getValue('targetLanguage', 'tr')
    };

    function t(key, params = {}) {
        let text = i18n[CONFIG.uiLanguage][key] || i18n['en'][key] || key;
        for (const [k, v] of Object.entries(params)) {
            text = text.replace(`{${k}}`, v);
        }
        return text;
    }

    const STRICT_SELECTORS = [
        '.jw-text-track-cue',
        '.vjs-text-track-display > div > div',
        '.plyr__caption',
        '.art-subtitle-control',
        '.libass-captions span',
        '.shaka-text-wrapper span'
    ];

    let translationCache = new Map(); 
    let reverseCache = new Set();     
    let isTranslating = GM_getValue('isTranslatingState', false); 
    const isTopWindow = window.top === window.self;
    let observer = null;
    let lastOriginalText = "";

    GM_addStyle(`
        #st-ghost-notif { 
            position: fixed; top: 20px; left: 50%; transform: translateX(-50%); 
            background: #000; color: #00ff00; border: 1px solid #00ff00; 
            padding: 8px 15px; border-radius: 5px; z-index: 2147483647; 
            font-family: sans-serif; font-size: 14px; font-weight: bold; box-shadow: 0 4px 10px rgba(0,0,0,0.5); 
            display: none; text-align: center;
        }
    `);

    const notifElement = document.createElement('div');
    if (isTopWindow) {
        notifElement.id = 'st-ghost-notif';
        document.documentElement.appendChild(notifElement);
    }

    function showNotifLocal(text, isWarning = false) {
        if (!isTopWindow) return;
        notifElement.innerHTML = text;
        notifElement.style.color = isWarning ? '#ffaa00' : '#00ff00';
        notifElement.style.borderColor = isWarning ? '#ffaa00' : '#00ff00';
        notifElement.style.display = 'block';
    }

    function hideNotifLocal(delay = 0) {
        if (!isTopWindow) return;
        setTimeout(() => { notifElement.style.display = 'none'; }, delay);
    }

    function sendLog(msg, isWarning = false, autoHideDelay = 0) {
        if (isTopWindow) {
            showNotifLocal(msg, isWarning);
            if (autoHideDelay > 0) hideNotifLocal(autoHideDelay);
        } else {
            GM_setValue('ghostLog', { msg, isWarning, time: Date.now(), hideDelay: autoHideDelay });
        }
    }

    GM_addValueChangeListener('ghostLog', function(name, old_value, new_value) {
        if (isTopWindow && new_value) {
            showNotifLocal(new_value.msg, new_value.isWarning);
            if (new_value.hideDelay > 0) hideNotifLocal(new_value.hideDelay);
        }
    });

    // ==========================================
    // AYARLAR VE BUTONLAR / SETTINGS AND BUTTONS
    // ==========================================

    let toggleBtn, ghostBtn;
    let uiTimeout = null;

    if (isTopWindow) {
        // Tampermonkey uzantı menüsüne özel seçenek ekler (Video izlenen sitede eklentiye tıklayınca görünür)
        GM_registerMenuCommand(t('settingsMenu'), openSettings);
        
        toggleBtn = document.createElement('button');
        toggleBtn.innerText = isTranslating ? t('stopTrans') : t('startTrans');
        toggleBtn.style.cssText = `position: fixed; bottom: 20px; right: 20px; z-index: 2147483647; background: ${isTranslating ? '#8b0000' : '#000'}; color: #fff; border: 1px solid #444; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-family: sans-serif; opacity: 0.8; transition: 0.3s; display: none;`;
        
        toggleBtn.onclick = () => {
            GM_setValue('isTranslatingState', !GM_getValue('isTranslatingState', false)); 
        };
        document.body.appendChild(toggleBtn);

        ghostBtn = document.createElement('button');
        ghostBtn.innerText = t('ghostScan');
        ghostBtn.style.cssText = `position: fixed; bottom: 65px; right: 20px; z-index: 2147483647; background: #4b0082; color: #fff; border: 1px solid #8a2be2; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-family: sans-serif; opacity: 0.9; transition: 0.3s; display: none; box-shadow: 0 0 10px #8a2be2;`;
        
        ghostBtn.onclick = () => {
            ghostBtn.innerText = t('signalSent');
            GM_setValue('ghostTrigger', Date.now()); 
            setTimeout(() => { ghostBtn.innerText = t('ghostScan'); }, 5000);
        };
        document.body.appendChild(ghostBtn);

        GM_addValueChangeListener('st_video_alive', () => showUIElements());
    }

    function showUIElements() {
        if (!isTopWindow) return;
        
        toggleBtn.style.display = 'block';
        if (isTranslating) {
            ghostBtn.style.display = 'block';
        }
        
        clearTimeout(uiTimeout);
        uiTimeout = setTimeout(() => {
            toggleBtn.style.display = 'none';
            ghostBtn.style.display = 'none';
        }, 3500); // Tıklamazsan 3.5 saniyede ekrandan tamamen kaybolurlar
    }

    setInterval(() => {
        if (document.querySelector('video')) {
            if (isTopWindow) {
                showUIElements();
            } else {
                GM_setValue('st_video_alive', Date.now()); 
            }
        }
    }, 1500);

    function openSettings() {
        if (document.getElementById('st-settings')) return;
        const overlay = document.createElement('div');
        overlay.id = 'st-settings';
        overlay.innerHTML = `
            <div style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #000; color: #fff; padding: 20px; border: 1px solid #333; border-radius: 8px; z-index: 2147483647; font-family: sans-serif; width: 80%; max-width: 320px; box-shadow: 0 4px 15px rgba(0,0,0,0.9);">
                <h3 style="margin-top:0; border-bottom:1px solid #444; padding-bottom:10px;">${t('settingsTitle')}</h3>
                
                <label style="font-size: 13px; color: #aaa; display:block; margin-bottom:5px;">${t('sourceLang')}</label>
                <select id="st-sourcelang" style="width: 100%; padding: 8px; margin-bottom: 15px; background: #111; color: #fff; border: 1px solid #555; border-radius:4px;">
                    <option value="auto" ${CONFIG.sourceLanguage === 'auto' ? 'selected' : ''}>${t('auto')} / Auto</option>
                    <option value="en" ${CONFIG.sourceLanguage === 'en' ? 'selected' : ''}>İngilizce / English</option>
                    <option value="tr" ${CONFIG.sourceLanguage === 'tr' ? 'selected' : ''}>Türkçe / Turkish</option>
                    <option value="ko" ${CONFIG.sourceLanguage === 'ko' ? 'selected' : ''}>Korece / Korean</option>
                    <option value="ja" ${CONFIG.sourceLanguage === 'ja' ? 'selected' : ''}>Japonca / Japanese</option>
                    <option value="es" ${CONFIG.sourceLanguage === 'es' ? 'selected' : ''}>İspanyolca / Spanish</option>
                </select>

                <label style="font-size: 13px; color: #aaa; display:block; margin-bottom:5px;">${t('targetLang')}</label>
                <select id="st-targetlang" style="width: 100%; padding: 8px; margin-bottom: 15px; background: #111; color: #fff; border: 1px solid #555; border-radius:4px;">
                    <option value="tr" ${CONFIG.targetLanguage === 'tr' ? 'selected' : ''}>Türkçe / Turkish</option>
                    <option value="en" ${CONFIG.targetLanguage === 'en' ? 'selected' : ''}>İngilizce / English</option>
                    <option value="es" ${CONFIG.targetLanguage === 'es' ? 'selected' : ''}>İspanyolca / Spanish</option>
                    <option value="de" ${CONFIG.targetLanguage === 'de' ? 'selected' : ''}>Almanca / German</option>
                    <option value="fr" ${CONFIG.targetLanguage === 'fr' ? 'selected' : ''}>Fransızca / French</option>
                </select>

                <label style="font-size: 13px; color: #aaa; display:block; margin-bottom:5px;">${t('uiLang')}</label>
                <select id="st-uilang" style="width: 100%; padding: 8px; margin-bottom: 25px; background: #111; color: #fff; border: 1px solid #555; border-radius:4px;">
                    <option value="en" ${CONFIG.uiLanguage === 'en' ? 'selected' : ''}>English</option>
                    <option value="tr" ${CONFIG.uiLanguage === 'tr' ? 'selected' : ''}>Türkçe</option>
                </select>
                
                <div style="display:flex; gap:10px;">
                    <button id="st-cancel" style="flex:1; padding: 10px; background: #444; color: #fff; border: none; cursor: pointer; border-radius: 4px; font-weight: bold;">X</button>
                    <button id="st-save" style="flex:3; padding: 10px; background: #007bff; color: #fff; border: none; cursor: pointer; border-radius: 4px; font-weight: bold;">${t('save')}</button>
                </div>
            </div>
        `;
        document.body.appendChild(overlay);
        
        document.getElementById('st-cancel').onclick = () => overlay.remove();

        document.getElementById('st-save').onclick = () => {
            const newUiLang = document.getElementById('st-uilang').value;
            GM_setValue('uiLanguage', newUiLang);
            GM_setValue('sourceLanguage', document.getElementById('st-sourcelang').value);
            GM_setValue('targetLanguage', document.getElementById('st-targetlang').value);
            overlay.remove();
            
            CONFIG.uiLanguage = newUiLang;
            CONFIG.sourceLanguage = document.getElementById('st-sourcelang').value;
            CONFIG.targetLanguage = document.getElementById('st-targetlang').value;
            
            if(toggleBtn) toggleBtn.innerText = isTranslating ? t('stopTrans') : t('startTrans');
            if(ghostBtn) ghostBtn.innerText = t('ghostScan');
        };
    }

    GM_addValueChangeListener('isTranslatingState', function(name, old_value, new_value) {
        isTranslating = new_value;
        if (isTopWindow && toggleBtn) {
            toggleBtn.innerText = isTranslating ? t('stopTrans') : t('startTrans');
            toggleBtn.style.background = isTranslating ? '#8b0000' : '#000';
            
            if (toggleBtn.style.display !== 'none') {
                ghostBtn.style.display = isTranslating ? 'block' : 'none';
            }
        }
        
        if (isTranslating) {
            startObserver();
        } else {
            stopObserver();
            restoreOriginalSubtitles(); 
        }
    });

    // ==========================================
    // HAYALET TARAMA / GHOST SCRUBBING
    // ==========================================

    GM_addValueChangeListener('ghostTrigger', function() {
        if (document.querySelector('video')) startGhostScrubbing();
    });
    
    async function startGhostScrubbing() {
        const video = document.querySelector('video');
        if (!video || isNaN(video.duration) || video.duration === 0) return;

        sendLog(t('ghostStart', { duration: Math.floor(video.duration) }));

        const wasPaused = video.paused;
        const wasMuted = video.muted;
        
        if (wasPaused) {
            video.muted = true;
            try { await video.play(); } catch (e) { console.warn("Otoplay engeli / Autoplay blocked"); }
        }

        const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime');
        let fakeTime = 0;
        let scrapedTexts = new Set();

        Object.defineProperty(video, 'currentTime', {
            get: function() { return fakeTime; },
            configurable: true
        });

        for (fakeTime = 0; fakeTime < video.duration; fakeTime += 0.5) {
            video.dispatchEvent(new Event('timeupdate'));
            await new Promise(r => setTimeout(r, 5)); 

            for (let selector of STRICT_SELECTORS) {
                document.querySelectorAll(selector).forEach(el => {
                    const text = el.textContent.trim();
                    const cleanText = text.replace(/<[^>]*>?/gm, ''); 
                    if (cleanText && cleanText.length > 1 && !reverseCache.has(cleanText)) {
                        scrapedTexts.add(cleanText);
                    }
                });
            }

            if (fakeTime % 10 === 0) {
                sendLog(t('ghostProgress', { fakeTime: Math.floor(fakeTime), duration: Math.floor(video.duration), size: scrapedTexts.size }));
            }
        }

        Object.defineProperty(video, 'currentTime', originalDescriptor);
        
        if (wasPaused) {
            video.pause();
        }
        video.muted = wasMuted; 
        
        sendLog(t('ghostDone', { size: scrapedTexts.size }));

        const linesToTranslate = Array.from(scrapedTexts);
        if (linesToTranslate.length > 0) {
            const chunks = chunkArray(linesToTranslate, 10);
            for (let i=0; i<chunks.length; i++) {
                try {
                    const translatedChunk = await translateArrayBulk(chunks[i]);
                    chunks[i].forEach((originalText, index) => {
                        if (translatedChunk[index]) {
                            translationCache.set(originalText, translatedChunk[index]);
                            reverseCache.add(translatedChunk[index]);
                        }
                    });
                } catch (e) {
                    console.error("Ghost translation error:", e);
                }
                sendLog(t('translating', { percent: Math.floor(((i+1)/chunks.length)*100) }));
            }
        }

        sendLog(t('allCached'), false, 4000);
    }

    function chunkArray(array, size) {
        const result = [];
        for (let i = 0; i < array.length; i += size) { result.push(array.slice(i, i + size)); }
        return result;
    }

    // ==========================================
    // DOM GÖZLEMCİSİ / DOM OBSERVER
    // ==========================================

    async function translateAndUpdateNative(element, text) {
        if (!text || text.length < 2) return;

        element.style.opacity = '0';
        element.dataset.stLocked = 'true'; 

        try {
            let translated = await translateSingleGoogle(text);
            
            translationCache.set(text, translated);
            reverseCache.add(translated); 
            
            element.textContent = translated;
            element.style.opacity = '1';
            element.dataset.stLocked = 'false';
        } catch (error) {
            element.style.opacity = '1';
            element.dataset.stLocked = 'false';
        }
    }

    function startObserver() {
        if (!document.querySelector('video') && !isTopWindow) return; 
        if (observer) return;
        
        observer = new MutationObserver(() => {
            for (let selector of STRICT_SELECTORS) {
                document.querySelectorAll(selector).forEach(subElement => {
                    const currentText = subElement.textContent.trim();
                    const cleanText = currentText.replace(/<[^>]*>?/gm, '').trim(); 
                    if (!cleanText) return;
                    
                    if (reverseCache.has(cleanText)) {
                        subElement.style.opacity = '1'; 
                        return; 
                    }
                    
                    if (translationCache.has(cleanText)) {
                        subElement.textContent = translationCache.get(cleanText);
                        subElement.style.opacity = '1';
                        return;
                    }
                    
                    if (cleanText !== lastOriginalText && subElement.dataset.stLocked !== 'true') {
                        lastOriginalText = cleanText;
                        translateAndUpdateNative(subElement, cleanText);
                    }
                });
            }
        });
        observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
    }

    function stopObserver() {
        if (observer) { 
            observer.disconnect(); 
            observer = null; 
        }
        lastOriginalText = "";
    }

    function restoreOriginalSubtitles() {
        for (let selector of STRICT_SELECTORS) {
            document.querySelectorAll(selector).forEach(el => {
                el.style.opacity = '1';
                el.dataset.stLocked = 'false';
            });
        }
    }

    // --- API İstekleri / API Requests ---
    function translateSingleGoogle(text) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${CONFIG.sourceLanguage}&tl=${CONFIG.targetLanguage}&dt=t&q=${encodeURIComponent(text)}`,
                onload: (res) => {
                    try {
                        let data = JSON.parse(res.responseText), finalStr = "";
                        for (let i = 0; i < data[0].length; i++) finalStr += data[0][i][0];
                        resolve(finalStr);
                    } catch (e) { resolve(text); }
                }
            });
        });
    }

    function translateArrayBulk(texts) {
        return new Promise((resolve, reject) => {
            const joinedText = texts.join(' @@@ ');
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${CONFIG.sourceLanguage}&tl=${CONFIG.targetLanguage}&dt=t&q=${encodeURIComponent(joinedText)}`,
                onload: (res) => {
                    try {
                        let data = JSON.parse(res.responseText), finalStr = "";
                        for (let i = 0; i < data[0].length; i++) finalStr += data[0][i][0];
                        resolve(finalStr.split(/@@@/g).map(t => t.trim()));
                    } catch (e) { reject(e); }
                }
            });
        });
    }

    if (isTranslating) setTimeout(startObserver, 2000); 

})();