VK Video Timeline Timecodes

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
    });

})();