TikTok Playback Speed Controller

Adds a persistent playback speed controller to the TikTok website. The selected speed is saved and applied to all videos automatically.

// ==UserScript==
// @name         TikTok Playback Speed Controller
// @name:zh-CN   抖音/TikTok 播放速度控制器
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Adds a persistent playback speed controller to the TikTok website. The selected speed is saved and applied to all videos automatically.
// @description:zh-CN 在 TikTok 网站上添加一个持久的播放速度控制器。所选速度将被保存并自动应用于所有视频。
// @author       Gemini
// @match        https://www.tiktok.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 配置项 ---
    const SPEED_OPTIONS = [1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; // 可用的速度选项
    const DEFAULT_SPEED = 1.0; // 默认速度

    // --- 脚本核心逻辑 ---

    // 1. 获取已保存的速度或使用默认值
    let currentSpeed = GM_getValue('tiktok-playback-speed', DEFAULT_SPEED);

    /**
     * 将指定速度应用到页面上所有 <video> 元素
     * @param {number} speed - 播放速度
     */
    function applySpeedToVideos(speed) {
        const videoElements = document.querySelectorAll('video');
        videoElements.forEach(video => {
            if (video.playbackRate !== speed) {
                video.playbackRate = speed;
            }
        });
    }

    /**
     * 创建并注入UI控件
     */
    function createControllerUI() {
        // --- 创建面板容器 ---
        const panel = document.createElement('div');
        panel.id = 'tiktok-speed-controller-panel';
        panel.innerHTML = `<div id="tsc-header">播放速度</div>`;

        // --- 创建速度按钮 ---
        const buttonsContainer = document.createElement('div');
        buttonsContainer.id = 'tsc-buttons';
        panel.appendChild(buttonsContainer);

        SPEED_OPTIONS.forEach(speed => {
            const button = document.createElement('button');
            button.innerText = `${speed}x`;
            button.dataset.speed = speed;
            if (speed === currentSpeed) {
                button.classList.add('active');
            }
            button.addEventListener('click', handleSpeedChange);
            buttonsContainer.appendChild(button);
        });

        document.body.appendChild(panel);
        injectStyles();
        makeDraggable(panel);
    }

    /**
     * 处理速度变更事件
     * @param {Event} event - 点击事件对象
     */
    function handleSpeedChange(event) {
        const newSpeed = parseFloat(event.target.dataset.speed);
        if (currentSpeed !== newSpeed) {
            currentSpeed = newSpeed;
            GM_setValue('tiktok-playback-speed', newSpeed); // 保存新速度
            applySpeedToVideos(newSpeed);
            updateActiveButton(newSpeed);
        }
    }

    /**
     * 更新UI,高亮当前激活的速度按钮
     * @param {number} activeSpeed - 当前激活的速度
     */
    function updateActiveButton(activeSpeed) {
        const buttons = document.querySelectorAll('#tsc-buttons button');
        buttons.forEach(btn => {
            if (parseFloat(btn.dataset.speed) === activeSpeed) {
                btn.classList.add('active');
            } else {
                btn.classList.remove('active');
            }
        });
    }

    /**
     * 注入CSS样式
     */
    function injectStyles() {
        const styles = `
            #tiktok-speed-controller-panel {
                position: fixed;
                top: 100px;
                right: 20px;
                background-color: rgba(22, 24, 35, 0.85);
                color: white;
                border-radius: 8px;
                padding: 10px;
                z-index: 99999;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
                backdrop-filter: blur(5px);
                border: 1px solid rgba(255, 255, 255, 0.1);
            }
            #tsc-header {
                font-weight: 600;
                text-align: center;
                margin-bottom: 8px;
                cursor: move; /* 添加拖动光标 */
                user-select: none;
            }
            #tsc-buttons {
                display: grid;
                grid-template-columns: 1fr 1fr 1fr;
                gap: 6px;
            }
            #tsc-buttons button {
                background-color: rgba(255, 255, 255, 0.1);
                color: white;
                border: 1px solid rgba(255, 255, 255, 0.2);
                border-radius: 4px;
                padding: 5px 8px;
                cursor: pointer;
                transition: background-color 0.2s, transform 0.1s;
                font-size: 12px;
            }
            #tsc-buttons button:hover {
                background-color: rgba(255, 255, 255, 0.2);
            }
            #tsc-buttons button:active {
                transform: scale(0.95);
            }
            #tsc-buttons button.active {
                background-color: #fe2c55; /* TikTok 主题色 */
                border-color: #fe2c55;
                font-weight: bold;
            }
        `;
        const styleSheet = document.createElement("style");
        styleSheet.type = "text/css";
        styleSheet.innerText = styles;
        document.head.appendChild(styleSheet);
    }

    /**
     * 使元素可拖动 (已优化)
     * @param {HTMLElement} element - 需要拖动的面板元素
     */
    function makeDraggable(element) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        const header = document.getElementById('tsc-header');

        if (header) {
            header.onmousedown = dragMouseDown;
        }

        function dragMouseDown(e) {
            e.preventDefault();

            // --- 优化开始 ---
            // 在开始拖动前,获取当前元素的精确位置,并将定位方式从 'right' 切换到 'left'
            // 这样可以避免在拖动过程中 'left' 和 'right' 属性发生冲突
            const rect = element.getBoundingClientRect();
            element.style.top = rect.top + 'px';
            element.style.left = rect.left + 'px';
            element.style.right = 'auto';
            // --- 优化结束 ---

            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e.preventDefault();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            // 因为现在 top 和 left 属性已被明确设置,所以可以安全地进行计算
            element.style.top = (element.offsetTop - pos2) + "px";
            element.style.left = (element.offsetLeft - pos1) + "px";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
        }
    }

    // --- 启动与监听 ---

    // 使用 MutationObserver 监听新视频的加载
    const observer = new MutationObserver((mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                 // 当有新节点添加时,延迟一小段时间后应用速度,确保video元素已准备好
                setTimeout(() => applySpeedToVideos(currentSpeed), 200);
                return;
            }
        }
    });

    // 等待页面加载完成后开始执行
    window.addEventListener('load', () => {
        createControllerUI();
        applySpeedToVideos(currentSpeed); // 对初始视频应用速度

        // 监视整个文档的变化,以捕获动态加载的视频
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }, false);

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址