A Google Translate-focused subtitle translator. Settings are configured exclusively via the Tampermonkey extension menu.
// ==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);
})();