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.91
// @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 a subset of the words briefly on screen in random positions
- This feature is enabled by default to show all words 4 characters of greater
- Press '=' to choose different word selection or disable

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, or hit space or click on the window
- If the video is already paused (e.g. space/clicked on window pause), you can press ';' to show the subtitles and it will stay on screen
- While paused, you may also:
-- Press '.' to toggle between translated and untranslated
-- Press '[' and ']' to scroll back and forward through subtitle history

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 and a word is selected or hilighted, press 's'
- To view saved list, press Ctrl+'s'

Feature 7: Quick translate on text highlight / selection
- When in paused Subtitle view, you can double click a word or select a phrase with the mouse
- A translation of the selected word or phrase will automatically popup over the selection and hide after a second

*/


// ==UserScript==
// @name         YouTube Language Learner Helper
// @namespace    http://tampermonkey.net/
// @version      1.0
// @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==

// ==UserScript==
// @name         YouTube Language Learner Helper
// @namespace    http://tampermonkey.net/
// @version      1.0
// @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==

(function() {
    'use strict';
    let subtitlesRaw = "";
    let subtitlesRawPrior = "";
    let subtitlesHistory = "";
    let extractedOffsets = [];
    let currentOffset = 0;
    let overlay;
    let flashTimeout;
    let isTranslated = 0;

    // Default flash configuration
    let flashConfig = {
        minLength: 4,
        maxLength: Infinity,
        fraction: 1.0,
        color: 'red',
        enabled: true
    };

    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);
        }
    }

    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';
        overlay.style.userSelect = "text";
        overlay.style.pointerEvents = "auto";
        appendToVideoContainer(overlay);
    }

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

    function hideOverlay() {
        isTranslated = 0;
        overlay.style.display = 'none';
    }

    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();

                        let newText = subtitlesRaw;
                        if (subtitlesRawPrior && newText.startsWith(subtitlesRawPrior)) {
                            newText = newText.slice(subtitlesRawPrior.length).trim();
                        }
                        subtitlesHistory += " " + newText;
                        subtitlesHistory = subtitlesHistory.slice(-500);

                        extractedOffsets = subtitlesHistory.match(/[^.!?]+[.!?]*/g) || [];
                        if (extractedOffsets.length === 1 && subtitlesHistory.length >= 20) {
                            const words = subtitlesHistory.trim().split(/\s+/);
                            const chunkSize = 10;
                            extractedOffsets = [];
                            for (let i = 0; i < words.length; i += chunkSize) {
                                extractedOffsets.push(words.slice(i, i + chunkSize).join(" "));
                            }
                        }
                        currentOffset = extractedOffsets.length - 1;

                        flashKeywords(newText);
                    }
                    spans.forEach(s => s.style.display = 'none');
                }
            });

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

    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;
    }

    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]';
        }
    }

    function randomPosition() {
        const x = Math.random() * 80 + 10;
        const y = Math.random() * 80 + 10;
        return { x, y };
    }

	function extractKeywords(text, maxWords = 5) {
		const words = text.match(/\p{L}{1,}(?:['’-]\p{L}+)*/gu) || [];

		// Filter by length rules
		const filtered = words.filter(w =>
			w.length >= flashConfig.minLength &&
			w.length <= flashConfig.maxLength
		);

		// Apply fraction filter (random sampling)
		let sampled = filtered;
		if (flashConfig.fraction < 1.0) {
			sampled = sampled.filter(() => Math.random() < flashConfig.fraction);
		}

		// If too many words, prefer the longest ones
		if (sampled.length > maxWords) {
			// Create array with index to preserve order later
			const indexed = sampled.map((w, i) => ({ w, i }));
			// Sort by length (descending), keep top N
			const top = indexed.sort((a, b) => b.w.length - a.w.length)
							   .slice(0, maxWords);
			// Restore original order
			top.sort((a, b) => a.i - b.i);
			return top.map(obj => obj.w);
		}

		return sampled;
	}

    function flashKeywords(text) {
        if (!flashConfig.enabled) return;

        const keywords = extractKeywords(text, 5);
        keywords.forEach((word, i) => {
            setTimeout(() => {
                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 = flashConfig.color;
                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);

                setTimeout(() => div.remove(), 1000);
            }, i * 500);
        });
    }

    function showFlashMenu() {
        let old = document.getElementById('flashMenu');
        if (old) { old.remove(); return; }

        const menu = document.createElement('div');
        menu.id = 'flashMenu';
        menu.style.position = 'fixed';
        menu.style.top = '10px';
        menu.style.right = '10px';
        menu.style.background = 'rgba(0,0,0,0.9)';
        menu.style.color = 'white';
        menu.style.padding = '10px';
        menu.style.borderRadius = '8px';
        menu.style.zIndex = '999999';
        menu.style.fontSize = '14px';
        menu.style.cursor = 'pointer';

        const options = [
            { text: "OFF", min:0, max:0, frac:0, color:'gray', enabled:false },
			{ text: "Flash all ≥4 chars",  min:4, max:Infinity, frac:1.0, color:'red', enabled:true },
			{ text: "Flash all ≥5 chars",  min:5, max:Infinity, frac:1.0, color:'green', enabled:true },
			{ text: "Flash all ≥6 chars",  min:6, max:Infinity, frac:1.0, color:'orange', enabled:true },
            { text: "Flash all ≤5 chars",  min:1, max:5, frac:1.0, color:'blue', enabled:true },
            { text: "Flash 50% ≥4 chars", min:4, max:Infinity, frac:0.5, color:'pink', enabled:true },
            { text: "Flash 50% all words", min:1, max:Infinity, frac:0.5, color:'cyan', enabled:true },
            { text: "Flash 30% all words", min:1, max:Infinity, frac:0.3, color:'yellow', enabled:true },
            { text: "Flash 20% of words >= 3 chars", min:3, max:Infinity, frac:0.2, color:'purple', enabled:true },
        ];

        options.forEach(opt => {
            const item = document.createElement('div');
            item.textContent = opt.text;
            item.style.margin = '4px 0';
            item.style.color = opt.color;
            item.onclick = () => {
                flashConfig = {
                    minLength: opt.min,
                    maxLength: opt.max,
                    fraction: opt.frac,
                    color: opt.color,
                    enabled: opt.enabled
                };
                menu.remove();
            };
            menu.appendChild(item);
        });

        appendToVideoContainer(menu);
    }

    function slowReplay(video, backSeconds = 5, speed = 0.75, durationMultiplier = 1.25) {
        if (!video) return;
        if (window.replayTimeout) clearTimeout(window.replayTimeout);
        video.currentTime = Math.max(0, video.currentTime - backSeconds);
        video.playbackRate = speed;
        showOverlay(extractSentences());
        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;
        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();
        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';

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

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

        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();
            }
        };
        btnContainer.appendChild(clearBtn);
        popup.appendChild(btnContainer);

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

        appendToVideoContainer(popup);
    }

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

            if (e.code === 'Semicolon') {
                e.preventDefault();
                if (video.paused) {
                    video.play();
                    hideOverlay();
                } else {
                    video.pause();
                    showOverlay(extractSentences());
                }
            }
            else if (video.paused && e.key === '[') {
                e.preventDefault();
                if (currentOffset > 0) {
                    currentOffset--;
                    showOverlay(extractSentences());
                }
            }
            else if (video.paused && e.key === ']') {
                e.preventDefault();
                if (currentOffset < extractedOffsets.length - 1) {
                    currentOffset++;
                    showOverlay(extractSentences());
                }
            }
            else if (e.key === '.') {
                e.preventDefault();
                if (extractedOffsets.length > 0) {
                    const snippet = extractSentences();
                    let 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);
                    }
                }
            }
            else if (e.key === ',') {
                e.preventDefault();
                if (extractedOffsets.length > 0) {
                    let text = extractSentences();
                    if (video.paused) {
                        showOverlay(text);
                    } else {
                        showOverlay(text);
                        clearTimeout(flashTimeout);
                        flashTimeout = setTimeout(hideOverlay, 2000);
                    }
                }
            }
            else if (e.key === '-') {
                e.preventDefault();
                slowReplay(video, 5, 0.75, 1.5);
            }
            else if (e.key === '=') {
                e.preventDefault();
                showFlashMenu();
            }
            else if (e.key === 's') {
                e.preventDefault();
                if (e.ctrlKey) {
                    showSavedPhrases();
                } else {
                    await saveSelection();
                }
            }
        }, true);
    }

    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;
    }

    let subtitlePressRetry = 10;
    let subtitleInterval = setInterval(() => {
        if (enableSubtitles() || (--subtitlePressRetry == 0)) {
            clearInterval(subtitleInterval);
        }
    }, 1000);

    const video = document.querySelector('video');
    if (video) {
        video.addEventListener('play', () => {
            setTimeout(enableSubtitles, 500);
        });
    }

    function showQuickTranslatePopup(text, x, y) {
        let popupQ = document.createElement("div");
        popupQ.style.position = "absolute";
        popupQ.style.background = "rgba(0,50,0,0.85)";
        popupQ.style.color = "green";
        popupQ.style.padding = "6px 10px";
        popupQ.style.borderRadius = "8px";
        popupQ.style.fontSize = '2vw';
        popupQ.style.fontFamily = 'sans-serif';
        popupQ.style.maxWidth = "200px";
        popupQ.style.zIndex = "999999";
        popupQ.style.display = "none";
        appendToVideoContainer(popupQ);

        popupQ.innerText = text;
        popupQ.style.left = x + "px";
        popupQ.style.top = y + "px";
        popupQ.style.display = "block";
        setTimeout(() => popupQ.remove(), 1500);
    }

    function setupWordsSelectHandler() {
        document.addEventListener("mouseup", async function(e) {
            let selection = window.getSelection();
            let selectedText = selection.toString().trim();
            if (selectedText) {
                const range = selection.getRangeAt(0);
                const rects = range.getClientRects();
                const rect = rects[0];
                let translation = await translateText(selectedText);
                showQuickTranslatePopup(translation, rect.left, rect.top);
            }
        });
    }

    function setupVideoPlayEventHandler() {
        const video = document.querySelector('video');
        if (!video) return;
        video.addEventListener('play', () => {
            hideOverlay();
        });
    }

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

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