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

})();