YouTube Speed Control (v14)

Modern speed button with per-channel memory. Automatically remembers your preferred speed for each channel.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube Speed Control (v14)
// @namespace    http://tampermonkey.net/
// @author       Solomon
// @license      CC-BY-4.0
// @version      14
// @description  Modern speed button with per-channel memory. Automatically remembers your preferred speed for each channel.
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

/*
 * ═══════════════════════════════════════════════════════════════════════════
 * 📋 CHANGELOG
 * ═══════════════════════════════════════════════════════════════════════════
 *
 * Previous Features (Preserved):
 * ✅ Speed options: 1x, 1.25x, 1.5x, 1.75x, 2x
 * ✅ Remember speed preference
 * ✅ Keyboard shortcuts: [ ] \ P
 * ✅ Double-click to reset to 1x
 * ✅ Modern red button design
 * ✅ Positioned next to channel/like buttons
 *
 * 🆕 NEW in v14:
 * ✨ Per-channel speed memory - automatically remembers speed for each channel
 * ✨ Channel indicator shows when using channel-specific speed
 * ✨ Falls back to default speed for new/unknown channels
 * ✨ Toggle between global and per-channel mode
 *
 * ═══════════════════════════════════════════════════════════════════════════
 */

(function() {
    'use strict';

    // ═══════════════════════════════════════════════════════════════════════════
    // 🔧 CONFIGURATION
    // ═══════════════════════════════════════════════════════════════════════════

    const SPEEDS = [1, 1.25, 1.5, 1.75, 2];
    const STORAGE_KEYS = {
        SAVE_ENABLED: 'yt_speed_save_v14',
        DEFAULT_SPEED: 'yt_speed_default_v14',
        CHANNEL_SPEEDS: 'yt_speed_channels_v14',
        PER_CHANNEL_MODE: 'yt_speed_perchannel_v14'
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // 📊 STATE
    // ═══════════════════════════════════════════════════════════════════════════

    const state = {
        currentSpeed: 1,
        currentChannel: null,
        isOpen: false,
        inserted: false,
        btn: null,
        menu: null
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // 💾 STORAGE FUNCTIONS
    // ═══════════════════════════════════════════════════════════════════════════

    // Initialize defaults
    if (localStorage.getItem(STORAGE_KEYS.SAVE_ENABLED) === null) {
        localStorage.setItem(STORAGE_KEYS.SAVE_ENABLED, 'true');
    }
    if (localStorage.getItem(STORAGE_KEYS.PER_CHANNEL_MODE) === null) {
        localStorage.setItem(STORAGE_KEYS.PER_CHANNEL_MODE, 'true');
    }
    if (localStorage.getItem(STORAGE_KEYS.CHANNEL_SPEEDS) === null) {
        localStorage.setItem(STORAGE_KEYS.CHANNEL_SPEEDS, '{}');
    }

    const isSaveEnabled = () => localStorage.getItem(STORAGE_KEYS.SAVE_ENABLED) === 'true';
    const isPerChannelMode = () => localStorage.getItem(STORAGE_KEYS.PER_CHANNEL_MODE) === 'true';

    const getDefaultSpeed = () => parseFloat(localStorage.getItem(STORAGE_KEYS.DEFAULT_SPEED)) || 1;
    const setDefaultSpeed = (speed) => localStorage.setItem(STORAGE_KEYS.DEFAULT_SPEED, String(speed));

    const getChannelSpeeds = () => {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEYS.CHANNEL_SPEEDS)) || {};
        } catch {
            return {};
        }
    };

    const setChannelSpeed = (channelId, speed) => {
        if (!channelId) return;
        const speeds = getChannelSpeeds();
        speeds[channelId] = speed;
        localStorage.setItem(STORAGE_KEYS.CHANNEL_SPEEDS, JSON.stringify(speeds));
    };

    const getChannelSpeed = (channelId) => {
        if (!channelId) return null;
        const speeds = getChannelSpeeds();
        return speeds[channelId] || null;
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // 🔍 CHANNEL DETECTION
    // ═══════════════════════════════════════════════════════════════════════════

    function getCurrentChannel() {
        // Try multiple methods to get channel identifier

        // Method 1: Channel link in video owner section
        const ownerLink = document.querySelector(
            '#owner a[href*="/@"], ' +
            '#owner a[href*="/channel/"], ' +
            '#owner a[href*="/c/"], ' +
            'ytd-video-owner-renderer a[href*="/@"], ' +
            'ytd-video-owner-renderer a[href*="/channel/"]'
        );

        if (ownerLink) {
            const href = ownerLink.getAttribute('href');
            if (href) {
                // Extract @handle or channel ID
                const handleMatch = href.match(/\/@([^/?]+)/);
                if (handleMatch) return '@' + handleMatch[1];

                const channelMatch = href.match(/\/channel\/([^/?]+)/);
                if (channelMatch) return channelMatch[1];

                const customMatch = href.match(/\/c\/([^/?]+)/);
                if (customMatch) return 'c/' + customMatch[1];
            }
        }

        // Method 2: Channel name element
        const channelName = document.querySelector(
            '#owner #channel-name a, ' +
            '#owner ytd-channel-name a, ' +
            'ytd-video-owner-renderer #channel-name a'
        );

        if (channelName) {
            const href = channelName.getAttribute('href');
            if (href) {
                const handleMatch = href.match(/\/@([^/?]+)/);
                if (handleMatch) return '@' + handleMatch[1];
            }
            // Fallback to channel name text
            const name = channelName.textContent?.trim();
            if (name) return 'name:' + name;
        }

        return null;
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // 🎨 STYLES
    // ═══════════════════════════════════════════════════════════════════════════

    const css = document.createElement('style');
    css.textContent = `
        #ytspeed-wrapper {
            display: inline-flex !important;
            align-items: center !important;
            position: relative !important;
            margin-left: 8px !important;
            margin-right: 8px !important;
            vertical-align: middle !important;
        }

        /* ===== MAIN BUTTON ===== */
        #ytspeed-btn {
            display: inline-flex !important;
            align-items: center !important;
            justify-content: center !important;
            gap: 8px !important;
            height: 36px !important;
            padding: 0 16px !important;
            background: #cc0000 !important;
            color: #ffffff !important;
            border: none !important;
            border-radius: 18px !important;
            font-family: "YouTube Sans", "Roboto", Arial, sans-serif !important;
            font-size: 14px !important;
            font-weight: 600 !important;
            cursor: pointer !important;
            user-select: none !important;
            transition: all 0.2s ease !important;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important;
        }

        #ytspeed-btn:hover {
            background: #aa0000 !important;
            transform: translateY(-1px) !important;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
        }

        #ytspeed-btn:active {
            transform: translateY(0) scale(0.98) !important;
        }

        #ytspeed-btn.open {
            background: #990000 !important;
        }

        /* 🆕 v14: Channel indicator */
        #ytspeed-btn.has-channel {
            background: linear-gradient(135deg, #cc0000 0%, #ff6600 100%) !important;
        }

        /* Icon */
        .ytspeed-icon {
            font-size: 14px !important;
            line-height: 1 !important;
        }

        /* Speed text */
        .ytspeed-text {
            font-size: 14px !important;
            font-weight: 600 !important;
            line-height: 1 !important;
        }

        /* 🆕 v14: Channel badge */
        .ytspeed-channel-badge {
            font-size: 10px !important;
            background: rgba(255,255,255,0.2) !important;
            padding: 2px 6px !important;
            border-radius: 10px !important;
            margin-left: 4px !important;
        }

        /* ===== DROPDOWN MENU ===== */
        #ytspeed-menu {
            position: absolute !important;
            bottom: calc(100% + 10px) !important;
            left: 50% !important;
            transform: translateX(-50%) scale(0.95) !important;
            opacity: 0 !important;
            visibility: hidden !important;
            background: #282828 !important;
            border: 1px solid #404040 !important;
            border-radius: 12px !important;
            padding: 8px !important;
            min-width: 160px !important;
            box-shadow: 0 8px 24px rgba(0,0,0,0.4) !important;
            z-index: 9999 !important;
            font-family: "YouTube Sans", "Roboto", Arial, sans-serif !important;
            transition: all 0.2s ease !important;
        }

        #ytspeed-menu.open {
            opacity: 1 !important;
            visibility: visible !important;
            transform: translateX(-50%) scale(1) !important;
        }

        /* Menu arrow */
        #ytspeed-menu::after {
            content: "" !important;
            position: absolute !important;
            bottom: -8px !important;
            left: 50% !important;
            transform: translateX(-50%) !important;
            border-left: 8px solid transparent !important;
            border-right: 8px solid transparent !important;
            border-top: 8px solid #282828 !important;
        }

        /* ===== SPEED OPTIONS ===== */
        .ytspeed-item {
            padding: 10px 16px !important;
            color: #ffffff !important;
            cursor: pointer !important;
            font-size: 14px !important;
            font-weight: 500 !important;
            text-align: center !important;
            border-radius: 8px !important;
            margin: 2px 0 !important;
            transition: background 0.15s ease !important;
        }

        .ytspeed-item:hover {
            background: #404040 !important;
        }

        .ytspeed-item.active {
            background: #cc0000 !important;
            color: #ffffff !important;
        }

        /* ===== DIVIDER ===== */
        .ytspeed-divider {
            height: 1px !important;
            background: #404040 !important;
            margin: 8px 4px !important;
        }

        /* ===== TOGGLE OPTIONS ===== */
        .ytspeed-toggle-row {
            display: flex !important;
            align-items: center !important;
            justify-content: space-between !important;
            padding: 8px 12px !important;
            color: #aaaaaa !important;
            font-size: 12px !important;
            font-weight: 500 !important;
            cursor: pointer !important;
            border-radius: 8px !important;
            transition: all 0.15s ease !important;
        }

        .ytspeed-toggle-row:hover {
            background: #353535 !important;
            color: #ffffff !important;
        }

        /* Toggle switch */
        .ytspeed-toggle {
            position: relative !important;
            width: 36px !important;
            height: 20px !important;
            background: #555555 !important;
            border-radius: 10px !important;
            cursor: pointer !important;
            transition: background 0.2s ease !important;
        }

        .ytspeed-toggle.on {
            background: #cc0000 !important;
        }

        .ytspeed-toggle::after {
            content: "" !important;
            position: absolute !important;
            top: 2px !important;
            left: 2px !important;
            width: 16px !important;
            height: 16px !important;
            background: #ffffff !important;
            border-radius: 50% !important;
            transition: left 0.2s ease !important;
        }

        .ytspeed-toggle.on::after {
            left: 18px !important;
        }

        /* 🆕 v14: Channel info display */
        .ytspeed-channel-info {
            padding: 8px 12px !important;
            background: #1a1a1a !important;
            border-radius: 8px !important;
            margin-bottom: 8px !important;
            font-size: 11px !important;
            color: #888888 !important;
            text-align: center !important;
            overflow: hidden !important;
            text-overflow: ellipsis !important;
            white-space: nowrap !important;
        }

        .ytspeed-channel-info strong {
            color: #ff6600 !important;
            font-weight: 600 !important;
        }
    `;
    document.head.appendChild(css);

    // ═══════════════════════════════════════════════════════════════════════════
    // 🎛️ UI CREATION
    // ═══════════════════════════════════════════════════════════════════════════

    function createElements() {
        const wrapper = document.createElement('div');
        wrapper.id = 'ytspeed-wrapper';

        // Main button
        state.btn = document.createElement('button');
        state.btn.id = 'ytspeed-btn';
        state.btn.title = 'Playback Speed (Double-click to reset)';

        const icon = document.createElement('span');
        icon.className = 'ytspeed-icon';
        icon.textContent = '⚡';

        const text = document.createElement('span');
        text.className = 'ytspeed-text';
        text.id = 'ytspeed-text';
        text.textContent = state.currentSpeed + 'x';

        state.btn.appendChild(icon);
        state.btn.appendChild(text);

        // Dropdown menu
        state.menu = document.createElement('div');
        state.menu.id = 'ytspeed-menu';

        // 🆕 v14: Channel info display
        const channelInfo = document.createElement('div');
        channelInfo.className = 'ytspeed-channel-info';
        channelInfo.id = 'ytspeed-channel-info';
        channelInfo.innerHTML = 'Channel: <strong>detecting...</strong>';
        state.menu.appendChild(channelInfo);

        // Speed options
        SPEEDS.forEach(speed => {
            const item = document.createElement('div');
            item.className = 'ytspeed-item' + (Math.abs(speed - state.currentSpeed) < 0.01 ? ' active' : '');
            item.setAttribute('data-speed', speed);
            item.textContent = speed + 'x';
            item.onclick = (e) => {
                e.stopPropagation();
                setSpeed(speed);
            };
            state.menu.appendChild(item);
        });

        // Divider
        const divider1 = document.createElement('div');
        divider1.className = 'ytspeed-divider';
        state.menu.appendChild(divider1);

        // 🆕 v14: Per-channel mode toggle
        const perChannelRow = document.createElement('div');
        perChannelRow.className = 'ytspeed-toggle-row';
        perChannelRow.innerHTML = `
            <span>Per-Channel</span>
            <div class="ytspeed-toggle ${isPerChannelMode() ? 'on' : ''}" id="ytspeed-perchannel-toggle"></div>
        `;
        perChannelRow.onclick = (e) => {
            e.stopPropagation();
            const toggle = document.getElementById('ytspeed-perchannel-toggle');
            const newValue = !isPerChannelMode();
            localStorage.setItem(STORAGE_KEYS.PER_CHANNEL_MODE, newValue ? 'true' : 'false');
            toggle.classList.toggle('on', newValue);
            loadSpeedForCurrentChannel();
        };
        state.menu.appendChild(perChannelRow);

        // Remember toggle
        const rememberRow = document.createElement('div');
        rememberRow.className = 'ytspeed-toggle-row';
        rememberRow.innerHTML = `
            <span>Remember</span>
            <div class="ytspeed-toggle ${isSaveEnabled() ? 'on' : ''}" id="ytspeed-remember-toggle"></div>
        `;
        rememberRow.onclick = (e) => {
            e.stopPropagation();
            const toggle = document.getElementById('ytspeed-remember-toggle');
            const newValue = !isSaveEnabled();
            localStorage.setItem(STORAGE_KEYS.SAVE_ENABLED, newValue ? 'true' : 'false');
            toggle.classList.toggle('on', newValue);
            if (newValue) saveCurrentSpeed();
        };
        state.menu.appendChild(rememberRow);

        wrapper.appendChild(state.btn);
        wrapper.appendChild(state.menu);

        // Button click - toggle menu
        state.btn.onclick = (e) => {
            e.stopPropagation();
            state.isOpen = !state.isOpen;
            state.menu.classList.toggle('open', state.isOpen);
            state.btn.classList.toggle('open', state.isOpen);
            updateChannelInfo();
        };

        // Double-click - reset to 1x
        state.btn.ondblclick = (e) => {
            e.stopPropagation();
            setSpeed(1);
        };

        return wrapper;
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // 🔄 SPEED MANAGEMENT
    // ═══════════════════════════════════════════════════════════════════════════

    function setSpeed(speed) {
        state.currentSpeed = speed;

        // Apply to video
        const video = document.querySelector('video');
        if (video) video.playbackRate = speed;

        // Save speed
        if (isSaveEnabled()) {
            saveCurrentSpeed();
        }

        updateUI();
        console.log(`[YouTube Speed v14] ⚡ Speed set to ${speed}x` + 
            (isPerChannelMode() && state.currentChannel ? ` for ${state.currentChannel}` : ' (global)'));
    }

    function saveCurrentSpeed() {
        if (isPerChannelMode() && state.currentChannel) {
            // Save per-channel
            setChannelSpeed(state.currentChannel, state.currentSpeed);
        } else {
            // Save global default
            setDefaultSpeed(state.currentSpeed);
        }
    }

    function loadSpeedForCurrentChannel() {
        // Detect current channel
        state.currentChannel = getCurrentChannel();

        let speed = getDefaultSpeed();

        if (isPerChannelMode() && state.currentChannel) {
            const channelSpeed = getChannelSpeed(state.currentChannel);
            if (channelSpeed !== null) {
                speed = channelSpeed;
                console.log(`[YouTube Speed v14] 📺 Loaded ${speed}x for channel: ${state.currentChannel}`);
            } else {
                console.log(`[YouTube Speed v14] 📺 New channel: ${state.currentChannel}, using default ${speed}x`);
            }
        }

        state.currentSpeed = speed;
        applySpeed();
        updateUI();
    }

    function applySpeed() {
        const video = document.querySelector('video');
        if (video && Math.abs(video.playbackRate - state.currentSpeed) > 0.01) {
            video.playbackRate = state.currentSpeed;
        }
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // 🎨 UI UPDATES
    // ═══════════════════════════════════════════════════════════════════════════

    function updateUI() {
        // Update button text
        const text = document.getElementById('ytspeed-text');
        if (text) text.textContent = state.currentSpeed + 'x';

        // Update button style for channel mode
        if (state.btn) {
            const hasChannelSpeed = isPerChannelMode() && state.currentChannel && getChannelSpeed(state.currentChannel) !== null;
            state.btn.classList.toggle('has-channel', hasChannelSpeed);
        }

        // Update active speed in menu
        if (state.menu) {
            state.menu.querySelectorAll('.ytspeed-item').forEach(item => {
                const speed = parseFloat(item.getAttribute('data-speed'));
                item.classList.toggle('active', Math.abs(speed - state.currentSpeed) < 0.01);
            });
        }

        updateChannelInfo();
    }

    function updateChannelInfo() {
        const channelInfo = document.getElementById('ytspeed-channel-info');
        if (!channelInfo) return;

        state.currentChannel = getCurrentChannel();

        if (state.currentChannel) {
            const displayName = state.currentChannel.startsWith('@') 
                ? state.currentChannel 
                : state.currentChannel.startsWith('name:') 
                    ? state.currentChannel.substring(5) 
                    : state.currentChannel.substring(0, 15) + '...';

            const savedSpeed = getChannelSpeed(state.currentChannel);
            if (savedSpeed !== null && isPerChannelMode()) {
                channelInfo.innerHTML = `📺 <strong>${displayName}</strong> → ${savedSpeed}x`;
            } else {
                channelInfo.innerHTML = `📺 <strong>${displayName}</strong> <span style="color:#666">(new)</span>`;
            }
        } else {
            channelInfo.innerHTML = 'Channel: <strong>detecting...</strong>';
        }
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // 📍 INSERTION
    // ═══════════════════════════════════════════════════════════════════════════

    function insertButton() {
        if (document.getElementById('ytspeed-wrapper')) {
            state.inserted = true;
            state.btn = document.getElementById('ytspeed-btn');
            state.menu = document.getElementById('ytspeed-menu');
            return;
        }

        const targets = [
            '#owner',
            '#top-row #owner',
            'ytd-watch-metadata #owner',
            '#above-the-fold #owner'
        ];

        let owner = null;
        for (const sel of targets) {
            owner = document.querySelector(sel);
            if (owner) break;
        }

        if (!owner) return;

        const wrapper = createElements();

        if (owner.nextSibling) {
            owner.parentNode.insertBefore(wrapper, owner.nextSibling);
        } else {
            owner.parentNode.appendChild(wrapper);
        }

        state.inserted = true;
        console.log('[YouTube Speed v14] ⚡ Button inserted!');
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // ⌨️ KEYBOARD SHORTCUTS
    // ═══════════════════════════════════════════════════════════════════════════

    document.addEventListener('keydown', (e) => {
        if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;

        if (e.key === '[') setSpeed(Math.max(0.25, state.currentSpeed - 0.25));
        if (e.key === ']') setSpeed(Math.min(4, state.currentSpeed + 0.25));
        if (e.key === '\\') setSpeed(1);
        if (e.key === 'p' || e.key === 'P') setSpeed(1.25);
    });

    // ═══════════════════════════════════════════════════════════════════════════
    // 🖱️ CLOSE MENU ON OUTSIDE CLICK
    // ═══════════════════════════════════════════════════════════════════════════

    document.addEventListener('click', (e) => {
        if (state.isOpen && state.btn && state.menu && 
            e.target !== state.btn && !state.btn.contains(e.target) && !state.menu.contains(e.target)) {
            state.isOpen = false;
            state.menu.classList.remove('open');
            state.btn.classList.remove('open');
        }
    });

    // ═══════════════════════════════════════════════════════════════════════════
    // 🚀 INITIALIZATION
    // ═══════════════════════════════════════════════════════════════════════════

    function init() {
        insertButton();
        loadSpeedForCurrentChannel();
    }

    init();

    // Watch for page changes
    const observer = new MutationObserver(() => {
        if (!document.getElementById('ytspeed-wrapper')) state.inserted = false;
        if (!state.inserted) init();
    });
    observer.observe(document.body, { childList: true, subtree: true });

    // YouTube SPA navigation
    document.addEventListener('yt-navigate-finish', () => {
        state.inserted = false;
        state.currentChannel = null;
        setTimeout(init, 500);
    });

    // Keep speed applied
    setInterval(applySpeed, 1000);

    // Periodically check for channel changes (for playlists, autoplay)
    setInterval(() => {
        const newChannel = getCurrentChannel();
        if (newChannel && newChannel !== state.currentChannel) {
            console.log(`[YouTube Speed v14] 📺 Channel changed: ${state.currentChannel} → ${newChannel}`);
            loadSpeedForCurrentChannel();
        }
    }, 2000);

})();