VK Video Timeline Timecodes

Добавляет кликабельные маркеры таймкода из комментариев на временную шкалу Видео ВКонтакте и показывает предварительный просмотр времени и описания прямо над временной шкалой при наведении курсора.

// ==UserScript==
// @name         VK Video Timeline Timecodes
// @namespace    http://tampermonkey.net/
// @version      6.0
// @description  Добавляет кликабельные маркеры таймкода из комментариев на временную шкалу Видео ВКонтакте и показывает предварительный просмотр времени и описания прямо над временной шкалой при наведении курсора.
// @author       Gemini & User Feedback
// @match        https://vkvideo.ru/video-*
// @grant        GM_addStyle
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    GM_addStyle(`
        .clickable-timecode { color: #71aaeb; cursor: pointer; text-decoration: none; }
        .clickable-timecode:hover { text-decoration: underline; }

        .timeline-markers-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; margin: 0; padding: 0; z-index: 1; }
        .timeline-marker {
            position: absolute;
            width: 3px; height: 140%; top: -20%;
            background-color: rgba(255, 255, 255, 0.7);
            border-radius: 2px;
            transform: translateX(-50%);
            pointer-events: all;
            cursor: pointer;
            transition: background-color 0.2s ease;
        }
        .timeline-marker:hover { background-color: #0077FF; }

        /* --- НОВЫЕ СТИЛИ ДЛЯ ПОДСКАЗКИ НАД ШКАЛОЙ --- */
        .timeline-hover-tooltip {
            position: absolute;
            bottom: 100%; /* Размещаем над родительским элементом (шкалой) */
            margin-bottom: 8px; /* Небольшой отступ вверх */
            transform: translateX(-50%);
            background-color: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 5px 10px;
            border-radius: 6px;
            font-size: 13px;
            font-family: var(--vkui--font_family_base, sans-serif);
            z-index: 100;
            pointer-events: none;
            white-space: pre-wrap;
            max-width: 350px;
            text-align: center;
            opacity: 0;
            visibility: hidden;
            transition: opacity 0.15s ease-out, visibility 0.15s ease-out;
        }
        .timeline-hover-tooltip--visible {
            opacity: 1;
            visibility: visible;
        }
    `);

    const TIMESTAMP_REGEX = /(\d{1,2}:\d{2}(?::\d{2})?)/g;
    let allFoundTimecodes = [];

    function parseTimeToSeconds(timeStr) {
        const parts = timeStr.split(':').map(Number);
        if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
        if (parts.length === 2) return parts[0] * 60 + parts[1];
        return 0;
    }

    /**
     * Форматирует секунды в строку HH:MM:SS или MM:SS.
     * @param {number} totalSeconds - Время в секундах.
     * @returns {string} - Форматированная строка времени.
     */
    function formatSecondsToTime(totalSeconds) {
        const hours = Math.floor(totalSeconds / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const seconds = Math.floor(totalSeconds % 60);

        const paddedMinutes = String(minutes).padStart(2, '0');
        const paddedSeconds = String(seconds).padStart(2, '0');

        if (hours > 0) {
            return `${String(hours).padStart(2, '0')}:${paddedMinutes}:${paddedSeconds}`;
        }
        return `${paddedMinutes}:${paddedSeconds}`;
    }


    function seekVideo(seconds) {
        const videoElement = document.querySelector('.videoplayer_media video');
        if (videoElement) {
            videoElement.currentTime = seconds;
            if (videoElement.paused) videoElement.play();
        }
    }

    function processComments(commentElements) {
        let newTimecodes = [];
        commentElements.forEach(commentElement => {
            commentElement.dataset.processed = 'true';
            const textContainer = commentElement.querySelector('.vkitComment__formattedText--6F18D');
            if (!textContainer) return;

            const walker = document.createTreeWalker(textContainer, NodeFilter.SHOW_TEXT);
            const textNodes = [];
            while (walker.nextNode()) textNodes.push(walker.currentNode);

            textNodes.forEach(textNode => {
                const text = textNode.nodeValue;
                const lines = text.split('\n');

                lines.forEach(line => {
                    const match = line.match(TIMESTAMP_REGEX);
                    if (match) {
                        const timeStr = match[0];
                        const seconds = parseTimeToSeconds(timeStr);
                        const description = line.replace(timeStr, '').trim();
                        newTimecodes.push({ seconds, timeStr, description: description || `Перейти на ${timeStr}` });
                    }
                });

                const matches = Array.from(text.matchAll(TIMESTAMP_REGEX));
                if (matches.length === 0) return;

                const fragment = document.createDocumentFragment();
                let lastIndex = 0;
                matches.forEach(match => {
                    const timeStr = match[0];
                    const index = match.index;
                    if (index > lastIndex) fragment.appendChild(document.createTextNode(text.substring(lastIndex, index)));
                    const seconds = parseTimeToSeconds(timeStr);
                    const link = document.createElement('a');
                    link.className = 'clickable-timecode';
                    link.textContent = timeStr;
                    link.dataset.seconds = seconds;
                    fragment.appendChild(link);
                    lastIndex = index + timeStr.length;
                });
                if (lastIndex < text.length) fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
                textNode.parentNode.replaceChild(fragment, textNode);
            });

            commentElement.addEventListener('click', (event) => {
                if (event.target.classList.contains('clickable-timecode')) {
                    event.preventDefault();
                    seekVideo(parseFloat(event.target.dataset.seconds));
                }
            });
        });
        return newTimecodes;
    }

    function addMarkersToTimeline(timecodes) {
        const videoElement = document.querySelector('.videoplayer_media video');
        const timelineContainer = document.querySelector('.videoplayer_timeline'); // Родительский контейнер шкалы
        const timelineSlider = document.querySelector('.videoplayer_timeline_slider');

        if (!videoElement || !timelineContainer || !timelineSlider) return;

        // Создаем (или находим) элемент для подсказки над шкалой
        let hoverTooltip = timelineContainer.querySelector('.timeline-hover-tooltip');
        if (!hoverTooltip) {
            hoverTooltip = document.createElement('div');
            hoverTooltip.className = 'timeline-hover-tooltip';
            timelineContainer.appendChild(hoverTooltip);
        }

        const drawMarkers = () => {
            const duration = videoElement.duration;
            if (isNaN(duration) || duration === 0) return;

            let markersContainer = timelineSlider.querySelector('.timeline-markers-container');
            if (markersContainer) markersContainer.remove();

            markersContainer = document.createElement('div');
            markersContainer.className = 'timeline-markers-container';

            timecodes.forEach(({ seconds }) => {
                if (seconds > duration) return;
                const percentage = (seconds / duration) * 100;
                const marker = document.createElement('div');
                marker.className = 'timeline-marker';
                marker.style.left = `${percentage}%`;
                marker.dataset.seconds = seconds;
                markersContainer.appendChild(marker);
            });

            timelineSlider.appendChild(markersContainer);

            // Слушаем движение мыши по всей шкале
            timelineSlider.addEventListener('mousemove', (event) => {
                const rect = timelineSlider.getBoundingClientRect();
                const hoverX = event.clientX - rect.left;
                const hoverPercent = hoverX / rect.width;
                const hoverTime = hoverPercent * duration;

                // Порог чувствительности (например, 1% от длительности видео)
                const threshold = duration * 0.005;
                const closestTimecode = timecodes.find(tc => Math.abs(tc.seconds - hoverTime) < threshold);

                if (closestTimecode) {
                    const timeStr = formatSecondsToTime(closestTimecode.seconds);
                    hoverTooltip.innerHTML = `<strong>${timeStr}</strong><br>${closestTimecode.description}`;
                    hoverTooltip.style.left = `${hoverPercent * 100}%`;
                    hoverTooltip.classList.add('timeline-hover-tooltip--visible');
                } else {
                    hoverTooltip.classList.remove('timeline-hover-tooltip--visible');
                }
            });

            timelineSlider.addEventListener('mouseleave', () => {
                hoverTooltip.classList.remove('timeline-hover-tooltip--visible');
            });

            markersContainer.addEventListener('click', (event) => {
                if (event.target.classList.contains('timeline-marker')) {
                    seekVideo(parseFloat(event.target.dataset.seconds));
                }
            });
        };

        if (videoElement.readyState >= 1) {
            drawMarkers();
        } else {
            videoElement.addEventListener('loadedmetadata', drawMarkers, { once: true });
        }
    }

    function initialize() {
        const unprocessedComments = document.querySelectorAll('[data-testid="comment-text"]:not([data-processed="true"])');
        if (unprocessedComments.length === 0) return;

        const newTimecodes = processComments(unprocessedComments);

        if (newTimecodes.length > 0) {
            allFoundTimecodes.push(...newTimecodes);
            const uniqueSeconds = new Set();
            allFoundTimecodes = allFoundTimecodes.filter(tc => {
                const isDuplicate = uniqueSeconds.has(tc.seconds);
                uniqueSeconds.add(tc.seconds);
                return !isDuplicate;
            });
            allFoundTimecodes.sort((a, b) => a.seconds - b.seconds);
            addMarkersToTimeline(allFoundTimecodes);
        }
    }

    const observer = new MutationObserver(() => {
        const player = document.querySelector('.videoplayer_media video');
        const commentsExist = document.querySelector('[data-testid="comment-text"]');
        if (player && commentsExist) {
            initialize();
        }
    });

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

})();

QingJ © 2025

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