Добавляет кликабельные маркеры таймкода из комментариев на временную шкалу Видео ВКонтакте и показывает предварительный просмотр времени и описания прямо над временной шкалой при наведении курсора.
// ==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或关注我们的公众号极客氢云获取最新地址