Bilibili时间标记跳转

在B站视频播放时标记时间点并能快速跳转

// ==UserScript==
// @name         Bilibili时间标记跳转
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  在B站视频播放时标记时间点并能快速跳转
// @author       洪小帅
// @match        *://www.bilibili.com/video/*
// @match        *://www.bilibili.com/bangumi/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    let markedTimes = [];  // 存储标记的时间点
    let isInitialized = false;
    let isDragging = false;
    let dragOffset = { x: 0, y: 0 };

    // 默认设置
    const defaultSettings = {
        markHotkey: 'm',
        jumpHotkey: 'n',
        maxTimePoints: 15,
        panelPosition: { x: 50, y: 120 },
        clearOnRefresh: true
    };

    // 获取设置
    let settings = GM_getValue('biliTimeMarkerSettings', defaultSettings);

    // 创建控制面板
    const createControlPanel = () => {
        const panel = document.createElement('div');
        panel.className = 'bili-time-marker-panel';
        panel.style.cssText = `
            position: fixed;
            top: ${settings.panelPosition.y}px;
            right: ${settings.panelPosition.x}px;
            z-index: 999999;
            background-color: rgba(0, 0, 0, 0.8);
            border-radius: 8px;
            padding: 12px;
            color: white;
            font-family: -apple-system,BlinkMacSystemFont,Helvetica Neue,Helvetica,Arial,PingFang SC,Hiragino Sans GB,Microsoft YaHei,sans-serif;
            min-width: 200px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.2);
            user-select: none;
        `;

        // 添加拖动条
        const dragBar = document.createElement('div');
        dragBar.style.cssText = `
            padding: 4px;
            margin: -12px -12px 8px -12px;
            cursor: move;
            background-color: #00a1d6;
            border-radius: 8px 8px 0 0;
            display: flex;
            justify-content: space-between;
            align-items: center;
        `;

        const title = document.createElement('span');
        title.textContent = '403专用时间标记器';
        title.style.marginLeft = '8px';

        const settingsButton = document.createElement('button');
        settingsButton.innerHTML = '⚙️';
        settingsButton.style.cssText = `
            background: none;
            border: none;
            color: white;
            cursor: pointer;
            padding: 0 8px;
            font-size: 16px;
        `;
        settingsButton.onclick = () => {
            document.body.appendChild(createOverlay());
            showSettings();
        };

        dragBar.appendChild(title);
        dragBar.appendChild(settingsButton);

        // 添加拖动功能
        dragBar.addEventListener('mousedown', (e) => {
            isDragging = true;
            const rect = panel.getBoundingClientRect();
            dragOffset.x = e.clientX - rect.left;
            dragOffset.y = e.clientY - rect.top;

            panel.style.transition = 'none';
            document.addEventListener('mousemove', handleDrag);
            document.addEventListener('mouseup', () => {
                isDragging = false;
                document.removeEventListener('mousemove', handleDrag);
                // 保存位置
                const rect = panel.getBoundingClientRect();
                settings.panelPosition = {
                    x: window.innerWidth - rect.right,
                    y: rect.top
                };
                GM_setValue('biliTimeMarkerSettings', settings);
            });
        });

        panel.appendChild(dragBar);

        // 创建按钮容器
        const buttonContainer = document.createElement('div');
        buttonContainer.style.cssText = `
            display: flex;
            gap: 8px;
            margin-bottom: 12px;
        `;

        const markButton = createButton(`标记 [${settings.markHotkey}]`, '#00a1d6');
        const clearButton = createButton('清除标记', '#fb7299');

        markButton.onclick = addTimePoint;
        clearButton.onclick = () => {
            markedTimes = markedTimes.filter(t => t.pinned);
            updateTimesList();
            // 清除时立即保存到存储
            GM_setValue('biliTimeMarkerPoints', markedTimes);
        };

        buttonContainer.appendChild(markButton);
        buttonContainer.appendChild(clearButton);
        panel.appendChild(buttonContainer);

        // 创建时间点列表容器
        const timesList = document.createElement('div');
        timesList.className = 'bili-time-marker-list';
        timesList.style.cssText = `
            display: flex;
            flex-direction: column;
            gap: 6px;
            max-height: 300px;
            overflow-y: auto;
            padding-right: 4px;
        `;

        // 添加滚动条样式
        GM_addStyle(`
            .bili-time-marker-list::-webkit-scrollbar {
                width: 4px;
            }
            .bili-time-marker-list::-webkit-scrollbar-track {
                background: rgba(255, 255, 255, 0.1);
                border-radius: 2px;
            }
            .bili-time-marker-list::-webkit-scrollbar-thumb {
                background: rgba(255, 255, 255, 0.3);
                border-radius: 2px;
            }
            .bili-time-marker-list::-webkit-scrollbar-thumb:hover {
                background: rgba(255, 255, 255, 0.5);
            }
        `);

        panel.appendChild(timesList);

        return panel;
    };

    // 处理拖动
    const handleDrag = (e) => {
        if (!isDragging) return;
        const panel = document.querySelector('.bili-time-marker-panel');
        if (!panel) return;

        const x = e.clientX - dragOffset.x;
        const y = e.clientY - dragOffset.y;

        // 确保面板不会拖出屏幕
        const maxX = window.innerWidth - panel.offsetWidth;
        const maxY = window.innerHeight - panel.offsetHeight;

        panel.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
        panel.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
        panel.style.right = 'auto';
    };

    // 添加时间点
    const addTimePoint = () => {
        const video = document.querySelector('video');
        if (!video) return;

        const currentTime = video.currentTime;
        const timeString = formatTime(currentTime);

        markedTimes.unshift({
            time: currentTime,
            label: timeString,
            pinned: false
        });

        // 如果超出限制且有未固定的时间点,删除最早的未固定时间点
        if (markedTimes.length > settings.maxTimePoints) {
            const unpinnedIndex = markedTimes.findIndex(t => !t.pinned);
            if (unpinnedIndex !== -1) {
                markedTimes.splice(unpinnedIndex, 1);
            }
        }

        updateTimesList();
    };

    // 创建时间点按钮
    const createTimeButton = (timeData, index) => {
        const container = document.createElement('div');
        container.style.cssText = `
            display: flex;
            align-items: center;
            gap: 4px;
            background-color: rgba(255, 255, 255, 0.1);
            border-radius: 4px;
            padding: 6px;
            transition: background-color 0.2s;
        `;

        const button = document.createElement('button');
        button.style.cssText = `
            flex: 1;
            background: none;
            border: none;
            color: white;
            cursor: pointer;
            font-size: 12px;
            text-align: left;
            padding: 0;
        `;
        button.textContent = `⏱ ${timeData.label}`;

        const pinButton = document.createElement('button');
        pinButton.innerHTML = timeData.pinned ? '📌' : '📍';
        pinButton.style.cssText = `
            background: none;
            border: none;
            color: white;
            cursor: pointer;
            padding: 0 4px;
            opacity: ${timeData.pinned ? 1 : 0.5};
        `;

        pinButton.onclick = (e) => {
            e.stopPropagation();
            timeData.pinned = !timeData.pinned;
            updateTimesList();
        };

        container.onmouseover = () => container.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
        container.onmouseout = () => container.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
        container.onclick = () => {
            const video = document.querySelector('video');
            if (video) {
                video.currentTime = timeData.time;
            }
        };

        container.appendChild(button);
        container.appendChild(pinButton);
        return container;
    };

    // 显示设置面板
    const showSettings = () => {
        const settingsPanel = document.createElement('div');
        settingsPanel.className = 'bili-time-marker-settings';
        settingsPanel.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: rgba(255, 255, 255, 0.95);
            padding: 20px;
            border-radius: 8px;
            z-index: 1000000;
            min-width: 300px;
            color: #333;
            box-shadow: 0 2px 10px rgba(0,0,0,0.2);
        `;

        settingsPanel.innerHTML = `
            <h3 style="margin-top: 0; color: #00a1d6;">设置</h3>
            <div style="margin-bottom: 12px;">
                <label>标记快捷键: <input type="text" id="markHotkey" value="${settings.markHotkey}" style="width: 50px; padding: 4px; border: 1px solid #ccc; border-radius: 4px; color: #333;"></label>
            </div>
            <div style="margin-bottom: 12px;">
                <label>跳转快捷键: <input type="text" id="jumpHotkey" value="${settings.jumpHotkey}" style="width: 50px; padding: 4px; border: 1px solid #ccc; border-radius: 4px; color: #333;"></label>
            </div>
            <div style="margin-bottom: 12px;">
                <label>最大时间点数量: <input type="number" id="maxTimePoints" value="${settings.maxTimePoints}" min="1" max="50" style="width: 60px; padding: 4px; border: 1px solid #ccc; border-radius: 4px; color: #333;"></label>
            </div>
            <div style="margin-bottom: 12px;">
                <label style="display: flex; align-items: center; gap: 8px;">
                    <input type="checkbox" id="clearOnRefresh" ${settings.clearOnRefresh ? 'checked' : ''} style="width: 16px; height: 16px;">
                    <span>刷新页面时清除标记点</span>
                </label>
            </div>
            <div style="text-align: right;">
                <button id="saveSettings" style="padding: 6px 12px; background: #00a1d6; border: none; border-radius: 4px; color: white; cursor: pointer;">保存</button>
            </div>
        `;

        document.body.appendChild(settingsPanel);

        document.getElementById('saveSettings').onclick = () => {
            settings.markHotkey = document.getElementById('markHotkey').value;
            settings.jumpHotkey = document.getElementById('jumpHotkey').value;
            settings.maxTimePoints = parseInt(document.getElementById('maxTimePoints').value);
            settings.clearOnRefresh = document.getElementById('clearOnRefresh').checked;
            GM_setValue('biliTimeMarkerSettings', settings);
            settingsPanel.remove();
            document.querySelector('.bili-time-marker-overlay')?.remove();
            init();
        };
    };

    // 添加快捷键支持
    document.addEventListener('keydown', (e) => {
        if (e.target.tagName === 'INPUT') return;

        if (e.key === settings.markHotkey) {
            addTimePoint();
        } else if (e.key === settings.jumpHotkey && markedTimes.length > 0) {
            const video = document.querySelector('video');
            if (video) {
                video.currentTime = markedTimes[0].time;
            }
        }
    });

    // 格式化时间
    const formatTime = (seconds) => {
        const h = Math.floor(seconds / 3600);
        const m = Math.floor((seconds % 3600) / 60);
        const s = Math.floor(seconds % 60);
        const ms = Math.floor((seconds % 1) * 1000);

        const parts = [];

        if (h > 0) {
            parts.push(String(h).padStart(2, '0'));
        }
        parts.push(String(m).padStart(2, '0'));
        parts.push(String(s).padStart(2, '0'));

        return parts.join(':') + `.${String(ms).padStart(3, '0')}`;
    };

    // 创建按钮的辅助函数
    const createButton = (text, color) => {
        const button = document.createElement('button');
        button.textContent = text;
        button.style.cssText = `
            flex: 1;
            padding: 6px 12px;
            background-color: ${color};
            border: none;
            border-radius: 4px;
            color: white;
            cursor: pointer;
            font-size: 12px;
            transition: opacity 0.2s;
        `;
        button.onmouseover = () => button.style.opacity = '0.8';
        button.onmouseout = () => button.style.opacity = '1';
        return button;
    };

    // 更新时间点列表
    const updateTimesList = () => {
        const timesList = document.querySelector('.bili-time-marker-list');
        if (!timesList) return;

        timesList.innerHTML = '';
        if (markedTimes.length === 0) {
            const emptyText = document.createElement('div');
            emptyText.style.cssText = `
                text-align: center;
                color: #999;
                font-size: 12px;
                padding: 8px;
            `;
            emptyText.textContent = '暂无标记时间点';
            timesList.appendChild(emptyText);
            return;
        }

        // 保存时间点到本地存储
        GM_setValue('biliTimeMarkerPoints', markedTimes);

        // 创建时间点列表
        markedTimes.forEach((timeData, index) => {
            timesList.appendChild(createTimeButton(timeData, index));
        });
    };

    // 从本地存储加载时间点
    const loadTimePoints = () => {
        if (settings.clearOnRefresh) {
            markedTimes = [];
            GM_setValue('biliTimeMarkerPoints', []); // 确保存储也被清除
            updateTimesList();
            return;
        }

        const savedPoints = GM_getValue('biliTimeMarkerPoints', []);
        if (Array.isArray(savedPoints) && savedPoints.length > 0) {
            markedTimes = savedPoints;
            updateTimesList();
        }
    };

    // 添加设置面板的遮罩层
    const createOverlay = () => {
        const overlay = document.createElement('div');
        overlay.className = 'bili-time-marker-overlay';
        overlay.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: rgba(0, 0, 0, 0.5);
            z-index: 999999;
        `;
        overlay.onclick = (e) => {
            if (e.target === overlay) {
                overlay.remove();
                document.querySelector('.bili-time-marker-settings')?.remove();
            }
        };
        return overlay;
    };

    // 初始化
    const init = () => {
        if (isInitialized) return;

        const video = document.querySelector('video');
        if (!video) {
            setTimeout(init, 1000);
            return;
        }

        const existingPanel = document.querySelector('.bili-time-marker-panel');
        if (existingPanel) {
            existingPanel.remove();
        }

        document.body.appendChild(createControlPanel());
        loadTimePoints();
        updateTimesList();
        isInitialized = true;
        console.log('时间标记面板已添加');
    };

    // 监听视频加载
    const observer = new MutationObserver((mutations, obs) => {
        if (document.querySelector('video')) {
            init();
            obs.disconnect();
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    // 立即尝试初始化
    init();

    // 添加页面卸载时的清理函数
    window.addEventListener('beforeunload', () => {
        if (settings.clearOnRefresh) {
            GM_setValue('biliTimeMarkerPoints', []);
        }
    });
})();