VK Video Timeline Timecodes

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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
    });

})();