Files
yt-local/youtube/static/js/storyboard-preview.js

376 lines
11 KiB
JavaScript

/**
* YouTube Storyboard Preview Thumbnails
* Shows preview thumbnails when hovering over the progress bar
* Works with native HTML5 video player
*
* Fetches the proxied WebVTT storyboard from backend and extracts image URLs
*/
(function() {
'use strict';
console.log('Storyboard Preview Thumbnails loaded');
// Storyboard configuration
let storyboardImages = []; // Array of {time, imageUrl, x, y, width, height}
let previewElement = null;
let tooltipElement = null;
let video = null;
let progressBarRect = null;
/**
* Fetch and parse the storyboard VTT file
* The backend generates a VTT with proxied image URLs
*/
function fetchStoryboardVTT(vttUrl) {
return fetch(vttUrl)
.then(response => {
if (!response.ok) throw new Error('Failed to fetch storyboard VTT');
return response.text();
})
.then(vttText => {
console.log('Fetched storyboard VTT, length:', vttText.length);
const lines = vttText.split('\n');
const images = [];
let currentEntry = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Parse timestamp line: 00:00:00.000 --> 00:00:10.000
if (line.includes('-->')) {
const timeMatch = line.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const ms = parseInt(timeMatch[4]);
currentEntry = {
time: hours * 3600 + minutes * 60 + seconds + ms / 1000
};
}
}
// Parse image URL with crop parameters: /url#xywh=x,y,w,h
else if (line.includes('#xywh=') && currentEntry) {
const [urlPart, paramsPart] = line.split('#xywh=');
const [x, y, width, height] = paramsPart.split(',').map(Number);
currentEntry.imageUrl = urlPart;
currentEntry.x = x;
currentEntry.y = y;
currentEntry.width = width;
currentEntry.height = height;
images.push(currentEntry);
currentEntry = null;
}
}
console.log('Parsed', images.length, 'storyboard frames');
return images;
});
}
/**
* Format time as MM:SS or H:MM:SS
*/
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
/**
* Find the closest storyboard frame for a given time
*/
function findFrameAtTime(time) {
if (!storyboardImages.length) return null;
// Binary search for efficiency
let left = 0;
let right = storyboardImages.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const frame = storyboardImages[mid];
if (time >= frame.time && time < (storyboardImages[mid + 1]?.time || Infinity)) {
return frame;
} else if (time < frame.time) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// Return closest frame
return storyboardImages[Math.min(left, storyboardImages.length - 1)];
}
/**
* Detect browser
*/
function getBrowser() {
const ua = navigator.userAgent;
if (ua.indexOf('Firefox') > -1) return 'firefox';
if (ua.indexOf('Chrome') > -1) return 'chrome';
if (ua.indexOf('Safari') > -1) return 'safari';
return 'other';
}
/**
* Detect the progress bar position in native video element
* Different browsers have different control layouts
*/
function detectProgressBar() {
if (!video) return null;
const rect = video.getBoundingClientRect();
const browser = getBrowser();
let progressBarArea;
switch(browser) {
case 'firefox':
// Firefox: La barra de progreso está en la parte inferior pero más delgada
// Normalmente ocupa solo unos 20-25px de altura y está centrada
progressBarArea = {
top: rect.bottom - 30, // Área más pequeña para Firefox
bottom: rect.bottom - 5, // Dejamos espacio para otros controles
left: rect.left + 60, // Firefox tiene botones a la izquierda (play, volumen)
right: rect.right - 10, // Y a la derecha (fullscreen, etc)
height: 25
};
break;
case 'chrome':
default:
// Chrome: La barra de progreso ocupa un área más grande
progressBarArea = {
top: rect.bottom - 50,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
height: 50
};
break;
}
return progressBarArea;
}
/**
* Check if mouse is over the progress bar area
*/
function isOverProgressBar(mouseX, mouseY) {
if (!progressBarRect) return false;
return mouseX >= progressBarRect.left &&
mouseX <= progressBarRect.right &&
mouseY >= progressBarRect.top &&
mouseY <= progressBarRect.bottom;
}
/**
* Initialize preview elements
*/
function initPreviewElements() {
video = document.getElementById('js-video-player');
if (!video) {
console.error('Video element not found');
return;
}
console.log('Video element found, browser:', getBrowser());
// Create preview element
previewElement = document.createElement('div');
previewElement.className = 'storyboard-preview';
previewElement.style.cssText = `
position: fixed;
display: none;
pointer-events: none;
z-index: 10000;
background: #000;
border: 2px solid #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
`;
// Create tooltip element
tooltipElement = document.createElement('div');
tooltipElement.className = 'storyboard-tooltip';
tooltipElement.style.cssText = `
position: absolute;
bottom: -25px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: #fff;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-family: Arial, sans-serif;
white-space: nowrap;
pointer-events: none;
`;
previewElement.appendChild(tooltipElement);
document.body.appendChild(previewElement);
// Update progress bar position on mouse move
video.addEventListener('mousemove', updateProgressBarPosition);
}
/**
* Update progress bar position detection
*/
function updateProgressBarPosition() {
progressBarRect = detectProgressBar();
}
/**
* Handle mouse move - only show preview when over progress bar area
*/
function handleMouseMove(e) {
if (!video || !storyboardImages.length) return;
// Update progress bar position on each move
progressBarRect = detectProgressBar();
// Only show preview if mouse is over the progress bar area
if (!isOverProgressBar(e.clientX, e.clientY)) {
if (previewElement) previewElement.style.display = 'none';
return;
}
// Calculate position within the progress bar
const progressBarWidth = progressBarRect.right - progressBarRect.left;
let xInProgressBar = e.clientX - progressBarRect.left;
// Adjust for Firefox's left offset
const browser = getBrowser();
if (browser === 'firefox') {
// Ajustar el rango para que coincida mejor con la barra real
xInProgressBar = Math.max(0, Math.min(xInProgressBar, progressBarWidth));
}
const percentage = Math.max(0, Math.min(1, xInProgressBar / progressBarWidth));
const time = percentage * video.duration;
const frame = findFrameAtTime(time);
if (!frame) return;
// Preview dimensions
const previewWidth = 160;
const previewHeight = 90;
const offsetFromCursor = 10;
// Position above the cursor
let previewTop = e.clientY - previewHeight - offsetFromCursor;
// If preview would go above the video, position below the cursor
const videoRect = video.getBoundingClientRect();
if (previewTop < videoRect.top) {
previewTop = e.clientY + offsetFromCursor;
}
// Keep preview within horizontal bounds
let left = e.clientX - (previewWidth / 2);
// Ajustes específicos para Firefox
if (browser === 'firefox') {
// En Firefox, la barra no llega hasta los extremos
const minLeft = progressBarRect.left + 10;
const maxLeft = progressBarRect.right - previewWidth - 10;
left = Math.max(minLeft, Math.min(left, maxLeft));
} else {
left = Math.max(videoRect.left, Math.min(left, videoRect.right - previewWidth));
}
// Apply all styles
previewElement.style.cssText = `
display: block;
position: fixed;
left: ${left}px;
top: ${previewTop}px;
width: ${previewWidth}px;
height: ${previewHeight}px;
background-image: url('${frame.imageUrl}');
background-position: -${frame.x}px -${frame.y}px;
background-size: auto;
background-repeat: no-repeat;
border: 2px solid #fff;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 10000;
pointer-events: none;
`;
tooltipElement.textContent = formatTime(time);
}
/**
* Handle mouse leave video
*/
function handleMouseLeave() {
if (previewElement) {
previewElement.style.display = 'none';
}
}
/**
* Initialize storyboard preview
*/
function init() {
console.log('Initializing storyboard preview...');
// Check if storyboard URL is available
if (typeof storyboard_url === 'undefined' || !storyboard_url) {
console.log('No storyboard URL available');
return;
}
console.log('Storyboard URL:', storyboard_url);
// Fetch the proxied VTT file from backend
fetchStoryboardVTT(storyboard_url)
.then(images => {
storyboardImages = images;
console.log('Loaded', images.length, 'storyboard images');
if (images.length === 0) {
console.log('No storyboard images parsed');
return;
}
initPreviewElements();
// Add event listeners to video
video.addEventListener('mousemove', handleMouseMove);
video.addEventListener('mouseleave', handleMouseLeave);
console.log('Storyboard preview initialized for', getBrowser());
})
.catch(err => {
console.error('Failed to load storyboard:', err);
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();