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://greatest.deepsurf.us/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); 

})();