mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-09 17:08:58 -05:00
399 lines
15 KiB
JavaScript
399 lines
15 KiB
JavaScript
import videojs from 'video.js';
|
|
import './ChapterMarkers.css';
|
|
|
|
const Component = videojs.getComponent('Component');
|
|
|
|
// Enhanced Chapter Markers Component with continuous chapter display
|
|
class ChapterMarkers extends Component {
|
|
constructor(player, options) {
|
|
super(player, options);
|
|
this.on(player, 'loadedmetadata', this.updateChapterMarkers);
|
|
this.on(player, 'texttrackchange', this.updateChapterMarkers);
|
|
this.chaptersData = [];
|
|
this.tooltip = null;
|
|
this.isHovering = false;
|
|
this.previewSprite = options.previewSprite || null;
|
|
}
|
|
|
|
createEl() {
|
|
const el = super.createEl('div', {
|
|
className: 'vjs-chapter-markers-track',
|
|
});
|
|
|
|
// Initialize tooltip as null - will be created when needed
|
|
this.tooltip = null;
|
|
|
|
return el;
|
|
}
|
|
|
|
updateChapterMarkers() {
|
|
const player = this.player();
|
|
const textTracks = player.textTracks();
|
|
let chaptersTrack = null;
|
|
|
|
// Find the chapters track
|
|
for (let i = 0; i < textTracks.length; i++) {
|
|
if (textTracks[i].kind === 'chapters') {
|
|
chaptersTrack = textTracks[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!chaptersTrack || !chaptersTrack.cues) {
|
|
return;
|
|
}
|
|
|
|
// Store chapters data for tooltip lookup
|
|
this.chaptersData = [];
|
|
for (let i = 0; i < chaptersTrack.cues.length; i++) {
|
|
const cue = chaptersTrack.cues[i];
|
|
this.chaptersData.push({
|
|
startTime: cue.startTime,
|
|
endTime: cue.endTime,
|
|
chapterTitle: cue.text,
|
|
});
|
|
}
|
|
|
|
// Clear existing markers
|
|
this.el().innerHTML = '';
|
|
|
|
const duration = player.duration();
|
|
if (!duration || duration === Infinity) {
|
|
return;
|
|
}
|
|
|
|
// Create markers for each chapter
|
|
for (let i = 0; i < chaptersTrack.cues.length; i++) {
|
|
const cue = chaptersTrack.cues[i];
|
|
const marker = this.createMarker(cue, duration);
|
|
this.el().appendChild(marker);
|
|
}
|
|
|
|
// Setup progress bar hover for continuous chapter display
|
|
this.setupProgressBarHover();
|
|
}
|
|
|
|
setupProgressBarHover() {
|
|
// Check if device is touch-enabled (tablet/mobile)
|
|
const isTouchDevice =
|
|
this.options_.isTouchDevice ||
|
|
'ontouchstart' in window ||
|
|
navigator.maxTouchPoints > 0 ||
|
|
navigator.msMaxTouchPoints > 0;
|
|
|
|
// Skip tooltip setup on touch devices
|
|
if (isTouchDevice) {
|
|
return;
|
|
}
|
|
|
|
const progressControl = this.player().getChild('controlBar').getChild('progressControl');
|
|
if (!progressControl) return;
|
|
|
|
const seekBar = progressControl.getChild('seekBar');
|
|
if (!seekBar) return;
|
|
|
|
const seekBarEl = seekBar.el();
|
|
|
|
// Ensure tooltip is properly created and add to seekBar if not already added
|
|
if (!this.tooltip || !this.tooltip.nodeType) {
|
|
// Recreate tooltip if it's not a proper DOM node
|
|
this.tooltip = videojs.dom.createEl('div', {
|
|
className: 'vjs-chapter-floating-tooltip',
|
|
});
|
|
|
|
// Style the floating tooltip
|
|
Object.assign(this.tooltip.style, {
|
|
position: 'absolute',
|
|
zIndex: '1000',
|
|
bottom: '45px',
|
|
transform: 'translateX(-50%)',
|
|
display: 'none',
|
|
minWidth: '160px',
|
|
maxWidth: '200px',
|
|
width: 'auto',
|
|
});
|
|
|
|
// Create stable DOM structure to avoid trembling
|
|
this.chapterTitle = videojs.dom.createEl('div', {
|
|
className: 'chapter-title',
|
|
});
|
|
// Object.assign(this.chapterTitle.style, {
|
|
// fontWeight: 'bold',
|
|
// marginBottom: '4px',
|
|
// color: '#fff',
|
|
// });
|
|
|
|
this.chapterInfo = videojs.dom.createEl('div', {
|
|
className: 'chapter-info',
|
|
});
|
|
// Object.assign(this.chapterInfo.style, {
|
|
// fontSize: '11px',
|
|
// opacity: '0.8',
|
|
// marginBottom: '2px',
|
|
// });
|
|
|
|
this.positionInfo = videojs.dom.createEl('div', {
|
|
className: 'position-info',
|
|
});
|
|
// Object.assign(this.positionInfo.style, {
|
|
// fontSize: '10px',
|
|
// opacity: '0.6',
|
|
// });
|
|
|
|
this.chapterImage = videojs.dom.createEl('div', {
|
|
className: 'chapter-image-sprite',
|
|
});
|
|
Object.assign(this.chapterImage.style, {
|
|
display: 'block',
|
|
overflow: 'hidden',
|
|
});
|
|
|
|
// Append all elements to tooltip
|
|
this.tooltip.appendChild(this.chapterTitle);
|
|
this.tooltip.appendChild(this.chapterImage);
|
|
this.tooltip.appendChild(this.chapterInfo);
|
|
this.tooltip.appendChild(this.positionInfo);
|
|
}
|
|
|
|
// Add tooltip to seekBar if not already added
|
|
if (!seekBarEl.querySelector('.vjs-chapter-floating-tooltip')) {
|
|
try {
|
|
seekBarEl.appendChild(this.tooltip);
|
|
} catch {
|
|
// console.warn('Could not append chapter tooltip:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Get the progress control element for larger hover area
|
|
const progressControlEl = progressControl.el();
|
|
|
|
// Remove existing listeners to prevent duplicates
|
|
progressControlEl.removeEventListener('mouseenter', this.handleMouseEnter);
|
|
progressControlEl.removeEventListener('mouseleave', this.handleMouseLeave);
|
|
progressControlEl.removeEventListener('mousemove', this.handleMouseMove);
|
|
|
|
// Bind methods to preserve context
|
|
this.handleMouseEnter = () => {
|
|
this.isHovering = true;
|
|
this.tooltip.style.display = 'block';
|
|
};
|
|
|
|
this.handleMouseLeave = () => {
|
|
this.isHovering = false;
|
|
this.tooltip.style.display = 'none';
|
|
};
|
|
|
|
this.handleMouseMove = (e) => {
|
|
if (!this.isHovering) return;
|
|
this.updateChapterTooltip(e, seekBarEl, progressControlEl);
|
|
};
|
|
|
|
// Add event listeners to the entire progress control area (includes gray area above)
|
|
progressControlEl.addEventListener('mouseenter', this.handleMouseEnter);
|
|
progressControlEl.addEventListener('mouseleave', this.handleMouseLeave);
|
|
progressControlEl.addEventListener('mousemove', this.handleMouseMove);
|
|
}
|
|
|
|
updateChapterTooltip(event, seekBarEl, progressControlEl) {
|
|
if (!this.tooltip || !this.isHovering) return;
|
|
|
|
const duration = this.player().duration();
|
|
if (!duration) return;
|
|
|
|
// Calculate time position based on mouse position relative to seekBar
|
|
const seekBarRect = seekBarEl.getBoundingClientRect();
|
|
const progressControlRect = progressControlEl.getBoundingClientRect();
|
|
|
|
// Use seekBar for horizontal calculation but allow vertical tolerance
|
|
const offsetX = event.clientX - seekBarRect.left;
|
|
const percentage = Math.max(0, Math.min(1, offsetX / seekBarRect.width));
|
|
const currentTime = percentage * duration;
|
|
|
|
// Position tooltip relative to progress control area
|
|
const tooltipOffsetX = event.clientX - progressControlRect.left;
|
|
|
|
// Find current chapter
|
|
const currentChapter = this.findChapterAtTime(currentTime);
|
|
|
|
if (currentChapter) {
|
|
// Format time for display
|
|
const formatTime = (seconds) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const startTime = formatTime(currentChapter.startTime);
|
|
const endTime = formatTime(currentChapter.endTime);
|
|
// const timeAtPosition = formatTime(currentTime);
|
|
|
|
// Update text content without rebuilding DOM - truncate if too long
|
|
const truncatedTitle =
|
|
currentChapter.chapterTitle.length > 30
|
|
? currentChapter.chapterTitle.substring(0, 30) + '...'
|
|
: currentChapter.chapterTitle;
|
|
this.chapterTitle.textContent = truncatedTitle;
|
|
this.chapterInfo.textContent = `${startTime} - ${endTime}`;
|
|
// this.positionInfo.textContent = `Position: ${timeAtPosition}`;
|
|
|
|
// Update sprite thumbnail
|
|
this.updateSpriteThumbnail(currentTime);
|
|
this.chapterImage.style.display = 'block';
|
|
} else {
|
|
// const timeAtPosition = this.formatTime(currentTime);
|
|
this.chapterTitle.textContent = '';
|
|
this.chapterInfo.textContent = '';
|
|
// this.positionInfo.textContent = `Position: ${timeAtPosition}`;
|
|
|
|
// Still show sprite thumbnail even when not in a chapter
|
|
this.updateSpriteThumbnail(currentTime);
|
|
this.chapterImage.style.display = 'block';
|
|
}
|
|
|
|
// Position tooltip with smart boundary detection
|
|
// Force tooltip to be visible momentarily to get accurate dimensions
|
|
this.tooltip.style.visibility = 'hidden';
|
|
this.tooltip.style.display = 'block';
|
|
|
|
const tooltipWidth = this.tooltip.offsetWidth || 240; // Fallback width
|
|
const progressControlWidth = progressControlRect.width;
|
|
const halfTooltipWidth = tooltipWidth / 2;
|
|
|
|
// Calculate ideal position (where mouse is)
|
|
let idealLeft = tooltipOffsetX;
|
|
|
|
// Check and adjust boundaries
|
|
if (idealLeft - halfTooltipWidth < 0) {
|
|
// Too far left - align to left edge with small margin
|
|
idealLeft = halfTooltipWidth + 5;
|
|
} else if (idealLeft + halfTooltipWidth > progressControlWidth) {
|
|
// Too far right - align to right edge with small margin
|
|
idealLeft = progressControlWidth - halfTooltipWidth - 5;
|
|
}
|
|
|
|
// Apply position and make visible
|
|
this.tooltip.style.left = `${idealLeft}px`;
|
|
this.tooltip.style.visibility = 'visible';
|
|
this.tooltip.style.display = 'block';
|
|
}
|
|
|
|
findChapterAtTime(time) {
|
|
for (const chapter of this.chaptersData) {
|
|
if (time >= chapter.startTime && time < chapter.endTime) {
|
|
return chapter;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
updateSpriteThumbnail(currentTime) {
|
|
if (!this.previewSprite || !this.previewSprite.url) {
|
|
// Hide image if no sprite data available
|
|
this.chapterImage.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const { url, frame } = this.previewSprite;
|
|
const { width, height } = frame;
|
|
|
|
// Calculate which frame to show based on current time
|
|
// Use sprite interval from frame data, fallback to 10 seconds
|
|
const frameInterval = frame.seconds || 10;
|
|
|
|
// Try to detect total frames based on video duration vs frame interval
|
|
const videoDuration = this.player().duration() || 45; // fallback duration
|
|
const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval);
|
|
const maxFrames = Math.min(calculatedMaxFrames, 6); // Cap at 6 frames to be safe
|
|
|
|
let frameIndex = Math.floor(currentTime / frameInterval);
|
|
|
|
// Clamp frameIndex to available frames to prevent showing empty areas
|
|
frameIndex = Math.min(frameIndex, maxFrames - 1);
|
|
|
|
// Based on the sprite image you shared, it appears to have frames arranged vertically
|
|
// Let's try a vertical layout first (1 column, multiple rows)
|
|
const frameRow = frameIndex; // Each frame is on its own row
|
|
const frameCol = 0; // Always first (and only) column
|
|
|
|
// Calculate background position (negative values to shift the sprite)
|
|
const xPos = -(frameCol * width);
|
|
const yPos = -(frameRow * height);
|
|
|
|
// Apply sprite background
|
|
this.chapterImage.style.backgroundImage = `url("${url}")`;
|
|
this.chapterImage.style.backgroundPosition = `${xPos}px ${yPos}px`;
|
|
this.chapterImage.style.backgroundSize = 'auto';
|
|
this.chapterImage.style.backgroundRepeat = 'no-repeat';
|
|
|
|
// Ensure the image is visible
|
|
this.chapterImage.style.display = 'block';
|
|
|
|
// Fallback: if we're beyond frame 3 (30s+), try showing frame 2 instead (20-30s frame)
|
|
if (frameIndex >= 3 && currentTime > 30) {
|
|
const fallbackYPos = -(2 * height); // Frame 2 (20-30s range)
|
|
this.chapterImage.style.backgroundPosition = `${xPos}px ${fallbackYPos}px`;
|
|
}
|
|
}
|
|
|
|
formatTime(seconds) {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
createMarker(cue, duration) {
|
|
const marker = videojs.dom.createEl('div', {
|
|
className: 'vjs-chapter-marker',
|
|
});
|
|
|
|
// Calculate position as percentage
|
|
const position = (cue.startTime / duration) * 100;
|
|
marker.style.left = position + '%';
|
|
|
|
// Create static tooltip for chapter start points
|
|
const tooltip = videojs.dom.createEl('div', {
|
|
className: 'vjs-chapter-marker-tooltip',
|
|
});
|
|
// Truncate tooltip text if too long
|
|
const truncatedTooltipTitle = cue.text.length > 30 ? cue.text.substring(0, 30) + '...' : cue.text;
|
|
tooltip.textContent = truncatedTooltipTitle;
|
|
marker.appendChild(tooltip);
|
|
|
|
// Add click handler to jump to chapter
|
|
marker.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.player().currentTime(cue.startTime);
|
|
});
|
|
|
|
// Make marker interactive
|
|
marker.style.pointerEvents = 'auto';
|
|
marker.style.cursor = 'pointer';
|
|
|
|
return marker;
|
|
}
|
|
|
|
dispose() {
|
|
// Clean up event listeners
|
|
const progressControl = this.player().getChild('controlBar')?.getChild('progressControl');
|
|
if (progressControl) {
|
|
const progressControlEl = progressControl.el();
|
|
progressControlEl.removeEventListener('mouseenter', this.handleMouseEnter);
|
|
progressControlEl.removeEventListener('mouseleave', this.handleMouseLeave);
|
|
progressControlEl.removeEventListener('mousemove', this.handleMouseMove);
|
|
}
|
|
|
|
// Remove tooltip
|
|
if (this.tooltip && this.tooltip.parentNode) {
|
|
this.tooltip.parentNode.removeChild(this.tooltip);
|
|
}
|
|
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
// Register the chapter markers component
|
|
videojs.registerComponent('ChapterMarkers', ChapterMarkers);
|
|
|
|
export default ChapterMarkers;
|