YouTube Customizable Subtitles / Youtube Subtitulos Inteligentes

Customizable YouTube subtitles with automatically generated outline for perfect readability / Subtítulos personalizables con contorno generado automáticamente para mejor legibilidad

// ==UserScript==
// @name         YouTube Customizable Subtitles / Youtube Subtitulos Inteligentes
// @namespace    https://greatest.deepsurf.us/es/scripts/504151-youtube-customizable-subtitles-youtube-subtitulos-personalizables
// @version      3.0
// @description  Customizable YouTube subtitles with automatically generated outline for perfect readability / Subtítulos personalizables con contorno generado automáticamente para mejor legibilidad
// @author       Eterve Nallo - Diam
// @license      MIT
// @match        *://*.youtube.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const fullscreenScale = 1.25;
    let isFullscreen = false;

    const languages = {
        en: { configTitle: 'Subtitle Settings', outlineColor: 'Outline Color:', outlineWidth: 'Outline Width:', textColor: 'Text Color:', saveButton: 'Save', closeButton: 'X', languageButton: 'Language', languageLabel: 'Choose Language', menuCommand: 'Show/Hide Subtitle Settings' },
        es: { configTitle: 'Configuración de Subtítulos', outlineColor: 'Color del Contorno:', outlineWidth: 'Grosor del Contorno:', textColor: 'Color del Texto:', saveButton: 'Guardar', closeButton: 'X', languageButton: 'Idioma', languageLabel: 'Elegir Idioma', menuCommand: 'Mostrar/Ocultar Configuración de Subtítulos' },
        ja: { configTitle: '字幕設定', outlineColor: 'アウトラインカラー:', outlineWidth: 'アウトラインの幅:', textColor: '文字色:', saveButton: '保存', closeButton: 'X', languageButton: '言語', languageLabel: '言語を選択', menuCommand: '字幕設定を表示/非表示' },
        ru: { configTitle: 'Настройки субтитров', outlineColor: 'Цвет контура:', outlineWidth: 'Ширина контура:', textColor: 'Цвет текста:', saveButton: 'Сохранить', closeButton: 'X', languageButton: 'Язык', languageLabel: 'Выберите язык', menuCommand: 'Показать/Скрыть настройки субтитров' },
        ko: { configTitle: '자막 설정', outlineColor: '윤곽선 색상:', outlineWidth: '윤곽선 너비:', textColor: '글자 색상:', saveButton: '저장', closeButton: 'X', languageButton: '언어', languageLabel: '언어 선택', menuCommand: '자막 설정 표시/숨기기' },
        zh: { configTitle: '字幕设置', outlineColor: '轮廓颜色:', outlineWidth: '轮廓宽度:', textColor: '文字颜色:', saveButton: '保存', closeButton: 'X', languageButton: '语言', languageLabel: '选择语言', menuCommand: '显示/隐藏字幕设置' }
    };

    const defaultConfig = { outlineColor: 'black', outlineWidth: 1, textColor: 'white', showPanel: false, language: 'en' };

    function loadConfig() {
        const savedConfig = GM_getValue('ytSubtitleConfig');
        return savedConfig ? JSON.parse(savedConfig) : defaultConfig;
    }

    function saveConfig(config) {
        GM_setValue('ytSubtitleConfig', JSON.stringify(config));
    }

    const config = loadConfig();
    const lang = languages[config.language] || languages.en;

    // Función que genera automáticamente text-shadow
    function generateShadows(outlineWidth, steps, color) {
        const shadows = [];
        for (let x = -steps; x <= steps; x++) {
            for (let y = -steps; y <= steps; y++) {
                if (x !== 0 || y !== 0) { // evitar sombra central
                    shadows.push(`${x * outlineWidth}px ${y * outlineWidth}px 0 ${color}`);
                }
            }
        }
        return shadows.join(',\n');
    }

    function applySubtitleStyles() {
        const effectiveWidth = isFullscreen ? config.outlineWidth * fullscreenScale : config.outlineWidth;
        const steps = isFullscreen ? 3 : 2; // más pasos en fullscreen para contorno más grueso
        const shadows = generateShadows(effectiveWidth, steps, config.outlineColor);

        GM_addStyle(`
            .ytp-caption-segment {
                color: ${config.textColor} !important;
                text-shadow: ${shadows};
                background: transparent !important;
                font-weight: bold;
                font-size: calc(16px + 1vw);
            }

            .caption-window {
                background-color: transparent !important;
            }
        `);
    }

    function checkFullscreen() {
        const newState = !!(document.fullscreenElement || document.webkitFullscreenElement);
        if (newState !== isFullscreen) {
            isFullscreen = newState;
            applySubtitleStyles();
        }
    }

    document.addEventListener('fullscreenchange', checkFullscreen);
    document.addEventListener('webkitfullscreenchange', checkFullscreen);

    function createConfigPanel() {
        const videoPlayer = document.querySelector('.html5-video-player');
        if (!videoPlayer) return;

        const panel = document.createElement('div');
        panel.id = 'subtitleConfigPanel';
        panel.style.position = 'absolute';
        panel.style.top = '10px';
        panel.style.right = '10px';
        panel.style.padding = '10px';
        panel.style.backgroundColor = 'rgba(0,0,0,0.8)';
        panel.style.color = 'white';
        panel.style.borderRadius = '5px';
        panel.style.zIndex = '9999';
        panel.style.display = 'block';

        const title = document.createElement('h4');
        title.textContent = lang.configTitle;
        panel.appendChild(title);

        const outlineLabel = document.createElement('label');
        outlineLabel.textContent = lang.outlineColor + ' ';
        const outlineInput = document.createElement('input');
        outlineInput.type = 'color';
        outlineInput.value = config.outlineColor;
        outlineLabel.appendChild(outlineInput);
        panel.appendChild(outlineLabel);
        panel.appendChild(document.createElement('br'));

        const widthLabel = document.createElement('label');
        widthLabel.textContent = lang.outlineWidth;
        const widthControl = document.createElement('div');
        widthControl.style.display = 'flex';
        widthControl.style.alignItems = 'center';

        const widthSlider = document.createElement('input');
        widthSlider.type = 'range';
        widthSlider.min = '0.5';
        widthSlider.max = '1.5';  // máximo ajustado a tu grosor ideal
        widthSlider.step = '0.1'; // para poder elegir 1.5 exactamente
        widthSlider.value = config.outlineWidth;
        widthSlider.style.width = '150px';

        const widthDisplay = document.createElement('div');
        widthDisplay.style.marginLeft = '10px';
        widthDisplay.style.fontSize = '16px';
        widthDisplay.style.width = '35px';
        widthDisplay.style.textAlign = 'center';
        widthDisplay.textContent = config.outlineWidth;

        widthControl.appendChild(widthSlider);
        widthControl.appendChild(widthDisplay);
        widthLabel.appendChild(widthControl);
        panel.appendChild(widthLabel);
        panel.appendChild(document.createElement('br'));

        const textLabel = document.createElement('label');
        textLabel.textContent = lang.textColor + ' ';
        const textInput = document.createElement('input');
        textInput.type = 'color';
        textInput.value = config.textColor;
        textLabel.appendChild(textInput);
        panel.appendChild(textLabel);
        panel.appendChild(document.createElement('br'));

        const saveBtn = document.createElement('button');
        saveBtn.textContent = lang.saveButton;
        panel.appendChild(saveBtn);

        const closeBtn = document.createElement('button');
        closeBtn.style.marginTop = '10px';
        closeBtn.textContent = lang.closeButton;
        panel.appendChild(closeBtn);

        const langBtn = document.createElement('button');
        langBtn.style.display = 'block';
        langBtn.style.marginTop = '10px';
        langBtn.textContent = lang.languageButton;
        panel.appendChild(langBtn);

        videoPlayer.appendChild(panel);

        widthSlider.addEventListener('input', (e) => {
            const newOutlineWidth = parseFloat(e.target.value);
            widthDisplay.textContent = newOutlineWidth;
            config.outlineWidth = newOutlineWidth;
            applySubtitleStyles();
        });

        saveBtn.addEventListener('click', () => {
            config.outlineColor = outlineInput.value;
            config.textColor = textInput.value;
            applySubtitleStyles();
            saveConfig(config);
        });

        closeBtn.addEventListener('click', () => {
            panel.remove();
            config.showPanel = false;
            saveConfig(config);
        });

        langBtn.addEventListener('click', () => {
            toggleLanguageMenu(panel);
        });
    }

    function toggleLanguageMenu(panel) {
        let languageMenu = panel.querySelector('#languageMenu');
        if (languageMenu) { languageMenu.remove(); return; }

        languageMenu = document.createElement('div');
        languageMenu.id = 'languageMenu';
        languageMenu.style.position = 'absolute';
        languageMenu.style.top = '50px';
        languageMenu.style.left = '10px';
        languageMenu.style.padding = '10px';
        languageMenu.style.backgroundColor = 'rgba(0,0,0,0.8)';
        languageMenu.style.color = 'white';
        languageMenu.style.borderRadius = '5px';
        languageMenu.style.zIndex = '9999';

        const title = document.createElement('h4');
        title.textContent = lang.languageLabel;
        languageMenu.appendChild(title);

        const langs = ['en','es','ja','ru','ko','zh'];
        const labels = { en:'English', es:'Español', ja:'日本語', ru:'Русский', ko:'한국어', zh:'中文' };
        langs.forEach(code => {
            const btn = document.createElement('button');
            btn.textContent = labels[code];
            btn.style.display = 'block';
            btn.style.marginTop = '5px';
            btn.addEventListener('click', () => { changeLanguage(code); });
            languageMenu.appendChild(btn);
        });

        const closeBtn = document.createElement('button');
        closeBtn.textContent = lang.closeButton;
        closeBtn.style.marginTop = '10px';
        closeBtn.addEventListener('click', () => { languageMenu.remove(); });
        languageMenu.appendChild(closeBtn);

        panel.appendChild(languageMenu);
    }

    function changeLanguage(newLanguage) {
        config.language = newLanguage;
        saveConfig(config);
        location.reload();
    }

    function toggleConfigPanel() {
        const videoPlayer = document.querySelector('.html5-video-player');
        if (!videoPlayer) return;

        const panel = document.getElementById('subtitleConfigPanel');
        if (panel) {
            panel.remove();
            config.showPanel = false;
        } else {
            createConfigPanel();
            config.showPanel = true;
        }

        saveConfig(config);
    }

    GM_registerMenuCommand(lang.menuCommand, toggleConfigPanel);

    applySubtitleStyles();
    if (config.showPanel) createConfigPanel();

})();