YouTube Language Learner Helper

Flashes larger words from subtitles, quick display of recent subtitles, translation to English and replay slow last 5sec

// ==UserScript==
// @name         YouTube Language Learner Helper
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  Flashes larger words from subtitles, quick display of recent subtitles, translation to English and replay slow last 5sec
// @author       James Stapleton
// @match        https://www.youtube.com/watch*
// @match        https://www.youtube.com/embed/*
// @grant        none
// @license MIT
// ==/UserScript==

/*
Overview

This utility helps with learning a second language while watching YouTube videos in that language.

I’ve been learning Spanish primarily through the Comprehensible Input method, focusing on listening
and watching without English translations or subtitles. For more advanced content — especially podcasts
with few or no visual clues — I often missed parts of what’s said. I found myself pausing, checking subtitles,
sometimes looking up English translations, rewinding, or slowing down the speaker.

This script provides quick, convenient ways to do all of this. It also includes an auto-word flasher that helps
you pick up missed words without switching your brain into full “reading mode,” allowing you to remain
in "listening mode" while still following along.


Usage

[CC] must be available and enabled to work (script will try to enable automatically when loading a new page or video).
Ensure the subtitles are in your target language and not your native language.
Tested fine (in Brave w/Tampermonkey) on main YT site and embedded (dreaming spanish site), including when in full screen mode.
Autogenerated subtitles worked fine too.

Feature 1:  Flash word helper
- It intercepts and hides the main subtitles, instead flashing up the longer words briefly on screen in random positions
- This feature is enabled by default
- Press '=' to toggle on/off

Feature 2: Subtitle quick view
- Press ',' to bring up the most recent subtitles, closes after 2 seconds

Feature 3: Subtitle translation view
- Press '.' to bring up the most recent subtitles translated to English, closes after 2 seconds

Feature 4: Pause and show current subtitles
- Press ';' - remains pause and showing until you press ';' again
- While paused, you may also:
-- Press '.' to toggle between translated and untranslated
-- Press '[' and ']' to scroll back and forward through subtitle history
-- Note, if you resume with [space] or navigate using arrow keys, the translate window will remain, just hit ',' and it will clear in 2 secs

Feature 5: Slow replay with subtitles
- Press '-' key to replay last 5 seconds at 0.75x speed, subtitle text will also display for that duration
- If not far enough back, press '-' again.  (Note slow play and subtitles will only last for 5 seconds from the last jump)

Feature 6: Saved word/phrases list
- When in paused Subtitle view, click on a word to select or hilight a phrase with mouse, then press 's'
- To view saved list, press Ctrl+'s'

*/

(function() {
    'use strict';
    let subtitlesRaw = ""; // store the continuously building subtitle text
    let subtitlesRawPrior = "";
    let extractedOffsets = []; // track offsets for arrow navigation (sentence index)
    let currentOffset = 0;
    let overlay;
    let flashTimeout;
    let isTranslated = 0;
    let flashWordsOn = 1;

    /*
      Appends an element to the fullscreen video container if it exists,
      otherwise to the standard video player container, or fallback to body.
      @param {HTMLElement} el - The element to append
    */
    function appendToVideoContainer(el) {
        const fullscreenElement = document.fullscreenElement;
        const container = fullscreenElement || document.querySelector('.html5-video-player');
        if (container) {
            container.appendChild(el);
        } else {
            document.body.appendChild(el);
        }
    }

    // Create overlay div
    function createOverlay() {
        overlay = document.createElement('div');
        overlay.style.position = 'absolute';
        overlay.style.top = '20%';
        overlay.style.left = '50%';
        overlay.style.transform = 'translateX(-50%)';
        overlay.style.padding = '10px 20px';
        overlay.style.background = 'rgba(0,0,0,0.7)';
        overlay.style.color = 'white';
        overlay.style.fontSize = '2vw';
        overlay.style.fontFamily = 'sans-serif';
        overlay.style.borderRadius = '10px';
        overlay.style.display = 'none';
        overlay.style.zIndex = '99999';
        appendToVideoContainer(overlay);
    }

    function showOverlay(text) {
        overlay.innerText = text;
        overlay.style.display = 'block';
    }

    function hideOverlay() {
        isTranslated = 0; // Reset so always default shows back
        overlay.style.display = 'none';
    }

    // Intercept subtitles and hide YouTube's
    function interceptSubtitles() {
        function tryHook() {
            const container = document.querySelector('.ytp-caption-window-container');
            if (!container) {
                setTimeout(tryHook, 1000);
                return;
            }

            const subtitleObserver = new MutationObserver(() => {
                const spans = container.querySelectorAll('.ytp-caption-segment');
                if (spans.length > 0) {
                    const text = Array.from(spans).map(s => s.innerText).join(' ').trim();
                    if (text) {
                        subtitlesRawPrior = subtitlesRaw;
                        subtitlesRaw = text.trim(); // store full growing subtitle text
                        // build sentence offsets for navigation
                        extractedOffsets = text.match(/[^.!?]+[.!?]*/g) || [];
                        currentOffset = extractedOffsets.length - 1; // point at last sentence

                        // Exclude cases where latest is equal or subset of prior (e.g. YT adding Auto-generated subtitle note to end)
                        flashKeywords(getNewSubtitleText(subtitlesRawPrior, subtitlesRaw));
                    }
                    spans.forEach(s => {
                        s.style.display = 'none'
                    });
                }
            });

            subtitleObserver.observe(container, { childList: true, subtree: true });
            console.log("✅ Subtitle observer hooked");
        }

        tryHook();
    }
/*
    let debugContainer = document.createElement('div');
    debugContainer.id = 'subtitleDebug';
    debugContainer.style.position = 'fixed';
    debugContainer.style.bottom = '10px';
    debugContainer.style.right = '10px';
    debugContainer.style.maxHeight = '200px';
    debugContainer.style.overflowY = 'auto';
    debugContainer.style.background = 'rgba(0,0,0,0.8)';
    debugContainer.style.color = '#fff';
    debugContainer.style.fontSize = '12px';
    debugContainer.style.padding = '5px';
    debugContainer.style.zIndex = 9999;
    document.body.appendChild(debugContainer);

    // Debug function
    function debugSubtitle(msg) {
        const line = document.createElement('div');
        line.textContent = msg;
        debugContainer.appendChild(line);
        // optional: auto-scroll to bottom
        debugContainer.scrollTop = debugContainer.scrollHeight;
    }
*/
    function getNewSubtitleText(prior, current) {
        if (!prior) return current;

        let newText = current;
        if (current.startsWith(prior)) {
            newText = current.slice(prior.length).trim()
        }
        return newText;
    }

    // Extract last N sentences capped at maxWords
    function extractSentences(offset = currentOffset, maxWords = 20, maxSentences = 3) {
        if (!extractedOffsets.length) return "";
        let sentences = [];
        let i = offset;
        while (i >= 0 && sentences.length < maxSentences) {
            sentences.unshift(extractedOffsets[i].trim());
            i--;
        }
        let text = sentences.join(' ');
        let words = text.split(/\s+/);
        if (words.length > maxWords) {
            text = words.slice(-maxWords).join(' ');
        }
        return text;
    }

    // Translation using LibreTranslate public endpoint
    async function translateText(text, target = 'en') {
        try {
            const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${target}&dt=t&q=${encodeURIComponent(text)}`;
            const res = await fetch(url);
            if (!res.ok) throw new Error(`HTTP status: ${res.status}`);
            const data = await res.json();
            if (Array.isArray(data) && data[0] && Array.isArray(data[0][0])) {
                return data[0].map(m => m[0]).join(' ');
            }
            throw new Error('Unexpected response format');
        } catch (err) {
            console.error('Translation failed:', err);
            return '[Translation error]';
        }
    }

    const keywordFlashState = {
        activeFlashes: [],
        maxFlashes: 5,
        delay: 500 // ms between flashes
    };

    // Utility: pick random positions on screen
    function randomPosition() {
        const x = Math.random() * 80 + 10; // 10% to 90%
        const y = Math.random() * 80 + 10;
        return { x, y };
    }

    let lastExtractedWords = [];

    // Function to extract keywords from a text
    function extractKeywords(text, maxWords = 5) {
        // Simple noun-ish extraction: take words longer than 3 letters
        // Could be replaced with more advanced NLP
        const words = text.match(/\p{L}{4,}(?:['’-]\p{L}+)*/gu) || [];
        const uniqueWords = words.filter(word => !lastExtractedWords.includes(word));
        lastExtractedWords = words;

        return uniqueWords.slice(0, maxWords);
    }

    // Flash keywords
    function flashKeywords(text) {
        if(flashWordsOn == 0) {
            return;
        }
        const keywords = extractKeywords(text, keywordFlashState.maxFlashes);
        keywords.forEach((word, i) => {
            setTimeout(async () => {
                const div = document.createElement('div');
                div.style.position = 'absolute';
                div.style.left = randomPosition().x + '%';
                div.style.top = randomPosition().y + '%';
                div.style.background = 'rgba(0,0,0,0.7)';
                div.style.color = 'red';
                div.style.padding = '4px 8px';
                div.style.borderRadius = '4px';
                div.style.zIndex = 9999;
                div.style.fontSize = '3vw';
                div.style.display = 'block';
                div.innerText = word;

                appendToVideoContainer(div);

                keywordFlashState.activeFlashes.push(div);

                // Hide after 1 second
                setTimeout(() => {
                    div.remove();
                    keywordFlashState.activeFlashes = keywordFlashState.activeFlashes.filter(d => d !== div);
                }, 1000);

            }, i * keywordFlashState.delay);
        });
    }

    // Replay last X seconds in slow motion, keeping subtitles visible
    function slowReplay(video, backSeconds = 5, speed = 0.75, durationMultiplier = 1.25) {
        if (!video) return;

        // Clear any previous timeout
        if (window.replayTimeout) clearTimeout(window.replayTimeout);

        // Skip back
        video.currentTime = Math.max(0, video.currentTime - backSeconds);

        // Slow playback
        video.playbackRate = speed;

        // Show current subtitle snippet
        showOverlay(extractSentences());

        // Schedule restoring normal speed & hiding overlay
        const replayDuration = backSeconds * durationMultiplier * 1000;
        window.replayTimeout = setTimeout(() => {
            video.playbackRate = 1.0;
            hideOverlay();
        }, replayDuration);
    }

    let savedPhrases = [];

    if (localStorage.getItem('YTLangLearnPhrases')) {
        savedPhrases = JSON.parse(localStorage.getItem('YTLangLearnPhrases'));
    }

    async function saveSelection() {
        const selection = window.getSelection().toString().trim();
        if (!selection) return; // nothing selected

        const dictPhrase = selection + " => " + await translateText(selection);

        if (!savedPhrases.includes(dictPhrase)) {
            savedPhrases.push(dictPhrase);
            localStorage.setItem('YTLangLearnPhrases', JSON.stringify(savedPhrases));
            console.log('Saved phrases:', savedPhrases);
        }
    }

    function showSavedPhrases() {
        const video = document.querySelector('video');
        if (video && !video.paused) video.pause();

        // Remove old popup if it exists
        let oldPopup = document.getElementById('savedPhrasesPopup');
        if (oldPopup) oldPopup.remove();

        const popup = document.createElement('div');
        popup.id = 'savedPhrasesPopup';
        popup.style.position = 'fixed';
        popup.style.top = '10px';
        popup.style.right = '10px';
        popup.style.width = '300px';
        popup.style.height = '400px';
        popup.style.backgroundColor = 'rgba(0,0,0,0.85)';
        popup.style.color = 'white';
        popup.style.padding = '10px';
        popup.style.overflowY = 'auto';
        popup.style.zIndex = 9999;
        popup.style.borderRadius = '8px';
        popup.style.fontFamily = 'sans-serif';
        popup.style.fontSize = '14px';

        // Button container
        const btnContainer = document.createElement('div');
        btnContainer.style.display = 'flex';
        btnContainer.style.justifyContent = 'space-between';
        btnContainer.style.marginBottom = '10px';

        // Close button
        const closeBtn = document.createElement('button');
        closeBtn.textContent = '✖';
        closeBtn.style.cursor = 'pointer';
        closeBtn.onclick = () => popup.remove();
        btnContainer.appendChild(closeBtn);

        // Clear button
        const clearBtn = document.createElement('button');
        clearBtn.textContent = 'Clear List';
        clearBtn.style.cursor = 'pointer';
        clearBtn.onclick = () => {
            if (savedPhrases.length === 0) {
                alert('No saved phrases to clear.');
                return;
            }
            const confirmed = confirm('Are you sure you want to clear all saved phrases?');
            if (confirmed) {
                savedPhrases = [];
                localStorage.removeItem('savedPhrases');
                alert('Saved phrases cleared.');
                showSavedPhrases(); // refresh popup
            }
        };
        btnContainer.appendChild(clearBtn);

        popup.appendChild(btnContainer);

        // Display saved phrases in reverse order
        const reversed = [...savedPhrases].reverse();
        reversed.forEach(phrase => {
            const p = document.createElement('div');
            p.textContent = phrase;
            p.style.marginBottom = '5px';
            popup.appendChild(p);
        });

        appendToVideoContainer(popup);
    }

    // Keyboard controls
    function setupKeyHandlers() {
        document.addEventListener('keydown', async (e) => {
            const video = document.querySelector('video');
            if (!video) return;

            // ; = pause/resume
            if (e.code === 'Semicolon') {
                e.preventDefault();
                if (video.paused) {
                    video.play();
                    hideOverlay();
                } else {
                    video.pause();
                    showOverlay(extractSentences());
                }
            }

            // [ = previous sentence
            else if (video.paused && e.key === '[') {
                e.preventDefault();
                if (currentOffset > 0) {
                    currentOffset--;
                    showOverlay(extractSentences());
                }
            }

            // ] = next sentence
            else if (video.paused && e.key === ']') {
                e.preventDefault();
                if (currentOffset < extractedOffsets.length - 1) {
                    currentOffset++;
                    showOverlay(extractSentences());
                }
            }

            // . = translate last extracted snippet
            else if (e.key === '.') {
                e.preventDefault();
                if (extractedOffsets.length > 0) {
                    const snippet = extractSentences();
                    var text;
                    if (isTranslated == 0) {
                        isTranslated = 1;
                        text = await translateText(snippet);
                    } else {
                        isTranslated = 0;
                        text = snippet;
                    }
                    if (video.paused) {
                        showOverlay(text);
                    } else {
                        showOverlay(text);
                        clearTimeout(flashTimeout);
                        flashTimeout = setTimeout(hideOverlay, 2000);
                    }
                }
            }

            // , = flash current snippet
            else if (e.key === ',') {
                e.preventDefault();
                if (extractedOffsets.length > 0) {
                    showOverlay(extractSentences());
                    clearTimeout(flashTimeout);
                    flashTimeout = setTimeout(hideOverlay, 2000);
                }
            }

            // - = rewind slower replay
            else if (e.key === '-') {
                e.preventDefault();
                slowReplay(video, 5, 0.75, 1.5);
            }
            // = toggle flashWords on /off
            else if (e.key === '=') {
                e.preventDefault();
                flashWordsOn++;
                if(flashWordsOn == 2) flashWordsOn = 0;
            }
            // 's' - saved phrases handling
            else if (e.key === 's') {
                e.preventDefault();
                if (e.ctrlKey) {
                    showSavedPhrases();
                }
                else {
                    await saveSelection();
                }
            }
        });
    }

    function enableSubtitles() {
        const ccButton = document.querySelector('.ytp-subtitles-button');
        if (ccButton && ccButton.getAttribute('aria-pressed') === 'false') {
            ccButton.click();
            console.log("Subtitles turned ON");
            return true;
        }
        return false;
    }

    // Keep trying until successful, give up after 10 seconds
    let subtitlePressRetry = 10;
    let subtitleInterval = setInterval(() => {
        if (enableSubtitles() || (--subtitlePressRetry == 0)) {
            clearInterval(subtitleInterval);
        }
    }, 1000);

    const video = document.querySelector('video');
    video.addEventListener('play', () => {
        setTimeout(enableSubtitles, 500); // slight delay helps
    });

    function init() {
        createOverlay();
        interceptSubtitles();
        setupKeyHandlers();
    }

    // Watch for SPA navigation
    const pageObserver = new MutationObserver(() => {
        if (document.querySelector('video') && !overlay) init();
    });
    pageObserver.observe(document.body, { childList: true, subtree: true });
})();