fix: Video.js (chaptersData conditions, separate sprite preview, add close icon in modal settings)

This commit is contained in:
Yiannis Christodoulou 2025-09-16 09:58:41 +03:00
parent 0eb9b5ce1b
commit ca830788ca
10 changed files with 344 additions and 816 deletions

View File

@ -539,6 +539,12 @@ white-space: nowrap; text-overflow: ellipsis; height: 28px; overflow: hidden; di
.chapter-close button{ background:transparent; color:#fff; border: 0; width: 40px; height: 40px; padding: 0; display: flex;
align-items: center; justify-content: center; border-radius:8px;}
.chapter-close button:hover{ background:rgba(255,255,255,0.1);}
.settings-header{ display:flex; align-items:center; justify-content:space-between; position:relative;}
.settings-close-btn{ background:transparent; color:#fff; border: 0; width: 32px; height: 32px; padding: 0; display: flex;
align-items: center; justify-content: center; border-radius:6px; cursor:pointer;}
.settings-close-btn:hover{ background:rgba(255,255,255,0.1);}
.playlist-action-menu{ display:none; justify-content:space-between; gap:10px;}
.playlist-action-menu button{ background:transparent; border: 0; width: 40px; height: 40px; padding: 0; display: flex;
align-items: center; justify-content: center; align-items:center; border-radius:100px; }
@ -596,11 +602,16 @@ overflow:visible !important; clip: initial !important;}
.vjs-chapter-floating-tooltip{ text-align:center; width:160px !important; max-width:100% !important ; height:auto;}
.chapter-image-sprite{width: 166px !important; max-width:100% !important; height: 96px; margin:0 auto 10px;
border-radius: 6px; border: 3px solid #FFF; }
.vjs-chapter-floating-tooltip .chapter-title{ font-size:24px; margin:0 0 10px; font-weight:700; text-overflow:ellipsis; white-space:nowrap;
height:30px; line-height:30px; overflow:hidden;}
.vjs-chapter-floating-tooltip .chapter-title{ font-size:16px; margin:0 0 10px; font-weight:700; word-break: break-all; line-height:20px;}
.vjs-chapter-floating-tooltip .position-info,
.vjs-chapter-floating-tooltip .chapter-info{ font-size:15px; display:inline-block; margin:0 0 2px; line-height:normal; vertical-align:top; line-height:20px;}
/* Sprite Preview Tooltip Styles - Match chapter styling */
.vjs-sprite-preview-tooltip{ text-align:center; width:172px !important; max-width:100% !important ; height:auto;}
.vjs-sprite-preview-tooltip .sprite-image-preview{ width: 166px !important; max-width:100% !important; height: 96px; margin:0 auto;
border-radius: 6px; border: 3px solid #FFF; }
@media (pointer: coarse) {
.video-js .vjs-volume-panel div.vjs-volume-control {width: auto; opacity: 1;}

View File

@ -155,7 +155,15 @@ class CustomSettingsMenu extends Component {
(currentQuality ? String(currentQuality) : "Auto");
// Settings menu content - split into separate variables for maintainability
const settingsHeader = `<div class="settings-header">Settings</div>`;
const settingsHeader = `
<div class="settings-header">
<span>Settings</span>
<button class="settings-close-btn" aria-label="Close settings">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.7096 12L20.8596 20.15L20.1496 20.86L11.9996 12.71L3.84965 20.86L3.13965 20.15L11.2896 12L3.14965 3.85001L3.85965 3.14001L11.9996 11.29L20.1496 3.14001L20.8596 3.85001L12.7096 12Z" fill="currentColor"/>
</svg>
</button>
</div>`;
const playbackSpeedSection = `
<div class="settings-item" data-setting="playback-speed">
@ -527,6 +535,29 @@ class CustomSettingsMenu extends Component {
}
setupEventListeners() {
// Close button functionality
const closeButton = this.settingsOverlay.querySelector('.settings-close-btn');
if (closeButton) {
const closeFunction = (e) => {
e.stopPropagation();
this.settingsOverlay.classList.remove("show");
this.settingsOverlay.style.display = "none";
this.speedSubmenu.style.display = "none";
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = "none";
const btnEl = this.settingsButton?.el();
if (btnEl) {
btnEl.classList.remove("settings-clicked");
}
};
closeButton.addEventListener('click', closeFunction);
closeButton.addEventListener('touchend', (e) => {
e.preventDefault();
closeFunction(e);
}, { passive: false });
}
// Settings item clicks
this.settingsOverlay.addEventListener("click", (e) => {
e.stopPropagation();

View File

@ -218,7 +218,7 @@ class ChapterMarkers extends Component {
// Update text content without rebuilding DOM
this.chapterTitle.textContent = currentChapter.text;
this.chapterInfo.textContent = `Chapter: ${startTime} - ${endTime}`;
this.positionInfo.textContent = `Position: ${timeAtPosition}`;
// this.positionInfo.textContent = `Position: ${timeAtPosition}`;
// Update sprite thumbnail
this.updateSpriteThumbnail(currentTime);

View File

@ -0,0 +1,246 @@
import videojs from 'video.js';
const Component = videojs.getComponent('Component');
// Sprite Preview Component for seekbar hover thumbnails (used when no chapters exist)
class SpritePreview extends Component {
constructor(player, options) {
super(player, options);
this.tooltip = null;
this.isHovering = false;
this.previewSprite = options.previewSprite || null;
}
createEl() {
const el = super.createEl('div', {
className: 'vjs-sprite-preview-track',
});
// Initialize tooltip as null - will be created when needed
this.tooltip = null;
return el;
}
setupProgressBarHover() {
const progressControl = this.player().getChild('controlBar').getChild('progressControl');
if (!progressControl) return;
const seekBar = progressControl.getChild('seekBar');
if (!seekBar) return;
const seekBarEl = seekBar.el();
// Only setup if we have sprite data
if (!this.previewSprite || !this.previewSprite.url) {
console.log('No sprite data available for preview:', this.previewSprite);
return;
}
// Ensure tooltip is properly created and add to seekBar if not already added
if (!this.tooltip || !this.tooltip.nodeType) {
// Create tooltip if it's not a proper DOM node
this.tooltip = videojs.dom.createEl('div', {
className: 'vjs-sprite-preview-tooltip',
});
// Style the floating tooltip
Object.assign(this.tooltip.style, {
position: 'absolute',
zIndex: '1000',
bottom: '45px',
transform: 'translateX(-50%)',
display: 'none',
minWidth: '172px', // Accommodate 166px image + 3px border on each side
maxWidth: '172px',
width: '172px',
});
// Create stable DOM structure
this.spriteImage = videojs.dom.createEl('div', {
className: 'sprite-image-preview',
});
Object.assign(this.spriteImage.style, {
display: 'block',
overflow: 'hidden',
});
// Append sprite image to tooltip (no time info)
this.tooltip.appendChild(this.spriteImage);
}
// Add tooltip to seekBar if not already added
if (!seekBarEl.querySelector('.vjs-sprite-preview-tooltip')) {
try {
seekBarEl.appendChild(this.tooltip);
} catch (error) {
console.warn('Could not append sprite preview 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.updateSpriteTooltip(e, seekBarEl, progressControlEl);
};
// Add event listeners to the entire progress control area
progressControlEl.addEventListener('mouseenter', this.handleMouseEnter);
progressControlEl.addEventListener('mouseleave', this.handleMouseLeave);
progressControlEl.addEventListener('mousemove', this.handleMouseMove);
}
updateSpriteTooltip(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;
// Update sprite thumbnail
this.updateSpriteThumbnail(currentTime);
// 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 || 172; // Fallback width matches our fixed 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';
}
updateSpriteThumbnail(currentTime) {
if (!this.previewSprite || !this.previewSprite.url) {
// Hide image if no sprite data available
this.spriteImage.style.display = 'none';
console.log('No sprite data available:', this.previewSprite);
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, 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);
console.log(
`Sprite Preview - Time: ${currentTime}s, Duration: ${this.player().duration()}s, Interval: ${frameInterval}s, Frame: ${frameIndex}/${maxFrames - 1}, Row: ${frameRow}, Col: ${frameCol}, Pos: ${xPos}px ${yPos}px, URL: ${url}`
);
// Apply sprite background
this.spriteImage.style.backgroundImage = `url("${url}")`;
this.spriteImage.style.backgroundPosition = `${xPos}px ${yPos}px`;
this.spriteImage.style.backgroundSize = 'auto';
this.spriteImage.style.backgroundRepeat = 'no-repeat';
// Use CSS-defined dimensions (166x96) to match chapter styling
this.spriteImage.style.width = '166px';
this.spriteImage.style.height = '96px';
// Ensure the image is visible
this.spriteImage.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.spriteImage.style.backgroundPosition = `${xPos}px ${fallbackYPos}px`;
console.log(`Fallback: Using frame 2 instead of frame ${frameIndex} for time ${currentTime}s`);
}
}
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
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 sprite preview component
videojs.registerComponent('SpritePreview', SpritePreview);
export default SpritePreview;

View File

@ -6,6 +6,7 @@ import 'video.js/dist/video-js.css';
import EndScreenOverlay from '../overlays/EndScreenOverlay';
import AutoplayCountdownOverlay from '../overlays/AutoplayCountdownOverlay';
import ChapterMarkers from '../markers/ChapterMarkers';
import SpritePreview from '../markers/SpritePreview';
import NextVideoButton from '../controls/NextVideoButton';
import AutoplayToggleButton from '../controls/AutoplayToggleButton';
import CustomRemainingTime from '../controls/CustomRemainingTime';
@ -634,6 +635,10 @@ function VideoJSPlayer() {
// Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
// CONDITIONAL LOGIC:
// - When chaptersData has content: Uses original ChapterMarkers with sprite preview
// - When chaptersData is empty: Uses separate SpritePreview component
// Toggle between these two lines to test both scenarios:
const chaptersData = mediaData?.data?.chapter_data && mediaData?.data?.chapter_data.length > 0 ? mediaData?.data?.chapter_data : isDevelopment ? [
{ startTime: 0, endTime: 4, text: 'Introduction' },
{ startTime: 5, endTime: 10, text: 'Overview of Marine Life' },
@ -658,7 +663,7 @@ function VideoJSPlayer() {
{ startTime: 1440, endTime: 1520, text: 'Commercial Aquaculture' },
{ startTime: 1520, endTime: 1600, text: 'Ocean Exploration Technology' },
] : [];
// const chaptersData = [];
// const chaptersData = []; // NO CHAPTERS (uses separate SpritePreview)
// Get video data from mediaData
const currentVideo = useMemo(
@ -1531,14 +1536,40 @@ function VideoJSPlayer() {
setupClickableMenus();
}, 1500);
// BEGIN: Add chapter markers to progress control
// BEGIN: Add chapter markers and sprite preview to progress control
if (progressControl && seekBar) {
console.log('Setting up sprite preview and chapter markers...');
console.log('mediaData.previewSprite:', mediaData.previewSprite);
console.log('chaptersData:', chaptersData);
// Check if we have chapters
const hasChapters = chaptersData && chaptersData.length > 0;
if (hasChapters) {
// Use original ChapterMarkers with sprite functionality when chapters exist
console.log('✓ Adding ChapterMarkers component with sprite functionality (chapters exist)');
const chapterMarkers = new ChapterMarkers(playerRef.current, {
previewSprite: mediaData.previewSprite,
});
seekBar.addChild(chapterMarkers);
} else if (mediaData.previewSprite) {
// Use separate SpritePreview component only when no chapters but sprite data exists
console.log('✓ Adding SpritePreview component (no chapters, but sprite data available)');
const spritePreview = new SpritePreview(playerRef.current, {
previewSprite: mediaData.previewSprite,
});
seekBar.addChild(spritePreview);
// Setup sprite preview hover functionality
setTimeout(() => {
console.log('✓ Setting up sprite preview hover functionality');
spritePreview.setupProgressBarHover();
}, 100);
} else {
console.log('✗ No chapters and no sprite data available');
}
// END: Add chapter markers to progress control
}
// END: Add chapter markers and sprite preview to progress control
// BEGIN: Simple button layout fix - use CSS approach
setTimeout(() => {

View File

@ -2108,13 +2108,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz",
"integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==",
"version": "24.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz",
"integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.11.0"
"undici-types": "~7.12.0"
}
},
"node_modules/@types/resolve": {
@ -6352,9 +6352,9 @@
}
},
"node_modules/undici-types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz",
"integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"dev": true,
"license": "MIT"
},

View File

@ -2594,12 +2594,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz",
"integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==",
"version": "24.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz",
"integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.11.0"
"undici-types": "~7.12.0"
}
},
"node_modules/@types/parse-json": {
@ -13493,9 +13493,9 @@
}
},
"node_modules/undici-types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz",
"integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long