feat: Separate the video.js components

This commit is contained in:
Yiannis Christodoulou 2025-07-13 03:37:08 +03:00
parent aeb0455fa7
commit 8c6361f17e
7 changed files with 1531 additions and 1553 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
# Video.js Components
This directory contains the organized Video.js components, separated into logical modules for better maintainability and reusability.
## Directory Structure
```
components/
├── controls/ # Control components (buttons, menus, etc.)
│ └── NextVideoButton.js
├── markers/ # Progress bar markers and indicators
│ └── ChapterMarkers.js
├── overlays/ # Overlay components (end screens, popups, etc.)
│ └── EndScreenOverlay.js
├── video-player/ # Main video player component
│ └── VideoJSPlayer.jsx
├── index.js # Main exports file
└── README.md # This file
```
## Components Overview
### VideoJSPlayer (Main Component)
- **Location**: `video-player/VideoJSPlayer.jsx`
- **Purpose**: Main Video.js player component that orchestrates all other components
- **Features**:
- Video.js initialization and configuration
- Event handling and lifecycle management
- Integration with all sub-components
### EndScreenOverlay
- **Location**: `overlays/EndScreenOverlay.js`
- **Purpose**: Displays related videos when the current video ends
- **Features**:
- Grid layout for related videos
- Thumbnail and metadata display
- Click navigation to related videos
### ChapterMarkers
- **Location**: `markers/ChapterMarkers.js`
- **Purpose**: Provides chapter navigation on the progress bar
- **Features**:
- Visual chapter markers on progress bar
- Floating tooltip with chapter information
- Click-to-jump functionality
- Continuous chapter display while hovering
### NextVideoButton
- **Location**: `controls/NextVideoButton.js`
- **Purpose**: Custom control bar button for next video navigation
- **Features**:
- Custom SVG icon
- Accessibility support
- Event triggering for next video functionality
## Usage
### Import Individual Components
```javascript
import EndScreenOverlay from './components/overlays/EndScreenOverlay';
import ChapterMarkers from './components/markers/ChapterMarkers';
import NextVideoButton from './components/controls/NextVideoButton';
```
### Import from Index
```javascript
import {
VideoJSPlayer,
EndScreenOverlay,
ChapterMarkers,
NextVideoButton,
} from './components';
```
### Use Main Component
```javascript
import { VideoJSPlayer } from './components';
function App() {
return <VideoJSPlayer />;
}
```
## Development Guidelines
1. **Separation of Concerns**: Each component should have a single, well-defined responsibility
2. **Video.js Registration**: Each component registers itself with Video.js using `videojs.registerComponent()`
3. **Event Handling**: Use Video.js event system for communication between components
4. **Cleanup**: Implement proper cleanup in `dispose()` methods to prevent memory leaks
5. **Accessibility**: Ensure all components follow accessibility best practices
## Adding New Components
1. Create the component in the appropriate subdirectory
2. Register it with Video.js using `videojs.registerComponent()`
3. Export it from the subdirectory's index file (if needed)
4. Add it to the main `components/index.js` file
5. Update this README with the new component information
## Dependencies
- **video.js**: Core Video.js library
- **React**: For the main VideoJSPlayer component
- **videojs.dom**: For DOM manipulation utilities
- **videojs.getComponent**: For extending Video.js base components

View File

@ -0,0 +1,52 @@
import videojs from 'video.js';
const Button = videojs.getComponent('Button');
// Custom Next Video Button Component using modern Video.js API
class NextVideoButton extends Button {
constructor(player, options) {
super(player, options);
}
createEl() {
const button = super.createEl('button', {
className: 'vjs-next-video-control vjs-control vjs-button',
type: 'button',
title: 'Next Video',
'aria-label': 'Next Video',
});
// Create the icon span using Video.js core icon
const iconSpan = videojs.dom.createEl('span', {
'aria-hidden': 'true',
});
// Create SVG that matches Video.js icon dimensions
iconSpan.innerHTML = `
<svg viewBox="0 0 24 24" width="2em" height="2em" fill="currentColor" style="position: relative; top: 3px; left: 8px; right: 0; bottom: 0; margin: auto; cursor: pointer;">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
`;
// Create control text span
const controlTextSpan = videojs.dom.createEl('span', {
className: 'vjs-control-text',
});
controlTextSpan.textContent = 'Next Video';
// Append both spans to button
button.appendChild(iconSpan);
button.appendChild(controlTextSpan);
return button;
}
handleClick() {
this.player().trigger('nextVideo');
}
}
// Register the component
videojs.registerComponent('NextVideoButton', NextVideoButton);
export default NextVideoButton;

View File

@ -0,0 +1,5 @@
// Export all Video.js components
export { default as VideoJSPlayer } from './video-player/VideoJSPlayer';
export { default as EndScreenOverlay } from './overlays/EndScreenOverlay';
export { default as ChapterMarkers } from './markers/ChapterMarkers';
export { default as NextVideoButton } from './controls/NextVideoButton';

View File

@ -0,0 +1,293 @@
import videojs from 'video.js';
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;
}
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,
text: 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() {
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',
background: 'rgba(0, 0, 0, 0.9)',
color: 'white',
padding: '8px 12px',
borderRadius: '6px',
fontSize: '12px',
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: '1000',
bottom: '45px',
transform: 'translateX(-50%)',
display: 'none',
maxWidth: '250px',
textAlign: 'center',
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
});
}
// 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);
this.tooltip.innerHTML = `
<div style="font-weight: bold; margin-bottom: 4px; color: #fff;">${currentChapter.text}</div>
<div style="font-size: 11px; opacity: 0.8; margin-bottom: 2px;">Chapter: ${startTime} - ${endTime}</div>
<div style="font-size: 10px; opacity: 0.6;">Position: ${timeAtPosition}</div>
`;
} else {
const timeAtPosition = this.formatTime(currentTime);
this.tooltip.innerHTML = `
<div style="font-weight: bold; margin-bottom: 2px;">No Chapter</div>
<div style="font-size: 10px; opacity: 0.6;">Position: ${timeAtPosition}</div>
`;
}
// Position tooltip relative to progress control container
this.tooltip.style.left = `${tooltipOffsetX}px`;
this.tooltip.style.display = 'block';
}
findChapterAtTime(time) {
for (const chapter of this.chaptersData) {
if (time >= chapter.startTime && time < chapter.endTime) {
return chapter;
}
}
return null;
}
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',
});
tooltip.textContent = cue.text;
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;

View File

@ -0,0 +1,131 @@
import videojs from 'video.js';
const Component = videojs.getComponent('Component');
class EndScreenOverlay extends Component {
constructor(player, options) {
// Store relatedVideos in options before calling super
// so it's available during createEl()
if (options && options.relatedVideos) {
options._relatedVideos = options.relatedVideos;
}
super(player, options);
// Now set the instance property after super() completes
this.relatedVideos =
options && options.relatedVideos ? options.relatedVideos : [];
// console.log(
// 'EndScreenOverlay created with',
// this.relatedVideos.length,
// 'related videos'
// );
}
createEl() {
// Get relatedVideos from options since createEl is called during super()
const relatedVideos =
this.options_ && this.options_._relatedVideos
? this.options_._relatedVideos
: [];
// console.log(
// 'Creating end screen with',
// relatedVideos.length,
// 'related videos'
// );
const overlay = super.createEl('div', {
className: 'vjs-end-screen-overlay',
});
// Create grid container
const grid = videojs.dom.createEl('div', {
className: 'vjs-related-videos-grid',
});
// Create video items
if (
relatedVideos &&
Array.isArray(relatedVideos) &&
relatedVideos.length > 0
) {
relatedVideos.forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
} else {
// Fallback message if no related videos
const noVideos = videojs.dom.createEl('div', {
className: 'vjs-no-related-videos',
});
noVideos.textContent = 'No related videos available';
noVideos.style.color = 'white';
noVideos.style.textAlign = 'center';
grid.appendChild(noVideos);
}
overlay.appendChild(grid);
return overlay;
}
createVideoItem(video) {
const item = videojs.dom.createEl('div', {
className: 'vjs-related-video-item',
});
const thumbnail = videojs.dom.createEl('img', {
className: 'vjs-related-video-thumbnail',
src: video.thumbnail,
alt: video.title,
});
const overlay = videojs.dom.createEl('div', {
className: 'vjs-related-video-overlay',
});
const title = videojs.dom.createEl('div', {
className: 'vjs-related-video-title',
});
title.textContent = video.title;
const author = videojs.dom.createEl('div', {
className: 'vjs-related-video-author',
});
author.textContent = video.author;
const views = videojs.dom.createEl('div', {
className: 'vjs-related-video-views',
});
views.textContent = video.views;
overlay.appendChild(title);
overlay.appendChild(author);
overlay.appendChild(views);
item.appendChild(thumbnail);
item.appendChild(overlay);
// Add click handler
item.addEventListener('click', () => {
window.location.href = `/view?m=${video.id}`;
});
return item;
}
show() {
this.el().style.display = 'flex';
}
hide() {
this.el().style.display = 'none';
}
}
// Register the component
videojs.registerComponent('EndScreenOverlay', EndScreenOverlay);
export default EndScreenOverlay;

View File

@ -0,0 +1,935 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
// Import the separated components
import EndScreenOverlay from '../overlays/EndScreenOverlay';
import ChapterMarkers from '../markers/ChapterMarkers';
import NextVideoButton from '../controls/NextVideoButton';
function VideoJSPlayer() {
const videoRef = useRef(null);
const playerRef = useRef(null); // Track the player instance
// Safely access window.MEDIA_DATA with fallback using useMemo
const mediaData = useMemo(
() =>
typeof window !== 'undefined' && window.MEDIA_DATA
? window.MEDIA_DATA
: {
data: {},
siteUrl: '',
},
[]
);
console.log('window.MEDIA_DATA hasNextLink', mediaData.hasNextLink);
// Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
const chaptersData = [
{ startTime: 0, endTime: 5, text: 'Start111' },
{ startTime: 5, endTime: 10, text: 'Introduction - EuroHPC' },
{ startTime: 10, endTime: 15, text: 'Planning - EuroHPC' },
{ startTime: 15, endTime: 20, text: 'Parcel Discounts - EuroHPC' },
{ startTime: 20, endTime: 25, text: 'Class Studies - EuroHPC' },
{ startTime: 25, endTime: 30, text: 'Sustainability - EuroHPC' },
{ startTime: 30, endTime: 35, text: 'Funding and Finance - EuroHPC' },
{ startTime: 35, endTime: 40, text: 'Virtual HPC Academy - EuroHPC' },
{ startTime: 40, endTime: 45, text: 'Wrapping up - EuroHPC' },
];
// Get video data from mediaData
const currentVideo = useMemo(
() => ({
id: mediaData.data?.friendly_token || 'default-video',
title: mediaData.data?.title || 'Video',
poster: mediaData.siteUrl + mediaData.data?.poster_url || '',
sources: mediaData.data?.original_media_url
? [
{
src:
mediaData.siteUrl +
mediaData.data.original_media_url,
type: 'video/mp4',
},
]
: [
{
src: 'https://vjs.zencdn.net/v/oceans.mp4',
type: 'video/mp4',
},
],
}),
[mediaData]
);
// Mock related videos data (would come from API)
const [relatedVideos] = useState([
{
id: 'Otbc37Yj4',
title: 'Amazing Ocean Depths',
author: 'Marine Explorer',
views: '2.1M views',
thumbnail: 'https://picsum.photos/320/180?random=1',
category: 'nature',
},
{
id: 'Kt9m2Pv8x',
title: 'Deep Sea Creatures',
author: 'Aquatic Life',
views: '854K views',
thumbnail: 'https://picsum.photos/320/180?random=2',
category: 'nature',
},
{
id: 'Ln5q8Bw3r',
title: 'Coral Reef Paradise',
author: 'Ocean Films',
views: '1.7M views',
thumbnail: 'https://picsum.photos/320/180?random=3',
category: 'nature',
},
{
id: 'Mz4x7Cy9p',
title: 'Underwater Adventure',
author: 'Sea Documentaries',
views: '3.2M views',
thumbnail: 'https://picsum.photos/320/180?random=4',
category: 'nature',
},
{
id: 'Nx8v2Fk6w',
title: 'Marine Wildlife',
author: 'Nature Plus',
views: '967K views',
thumbnail: 'https://picsum.photos/320/180?random=5',
category: 'nature',
},
{
id: 'Py7t4Mn1q',
title: 'Ocean Mysteries',
author: 'Discovery Zone',
views: '1.4M views',
thumbnail: 'https://picsum.photos/320/180?random=6',
category: 'nature',
},
{
id: 'Qw5e8Rt2n',
title: 'Whales and Dolphins',
author: 'Ocean Planet',
views: '2.8M views',
thumbnail: 'https://picsum.photos/320/180?random=7',
category: 'nature',
},
{
id: 'Uv3k9Lp7m',
title: 'Tropical Fish Paradise',
author: 'Aquatic World',
views: '1.2M views',
thumbnail: 'https://picsum.photos/320/180?random=8',
category: 'nature',
},
{
id: 'Zx6c4Mn8b',
title: 'Deep Ocean Exploration',
author: 'Marine Science',
views: '3.7M views',
thumbnail: 'https://picsum.photos/320/180?random=9',
category: 'nature',
},
]);
// Function to navigate to next video (disabled for single video)
const goToNextVideo = () => {
// console.log('Next video functionality disabled for single video mode');
};
useEffect(() => {
// Only initialize if we don't already have a player and element exists
if (videoRef.current && !playerRef.current) {
// Check if element is already a Video.js player
if (videoRef.current.player) {
// console.log('Video.js already initialized on this element');
return;
}
const timer = setTimeout(() => {
// Double-check that we still don't have a player and element exists
if (
!playerRef.current &&
videoRef.current &&
!videoRef.current.player
) {
playerRef.current = videojs(videoRef.current, {
// ===== STANDARD <video> ELEMENT OPTIONS =====
// Controls whether player has user-interactive controls
controls: true,
// Player dimensions - removed for responsive design
// Autoplay behavior: false, true, 'muted', 'play', 'any'
autoplay: true,
// Start video over when it ends
loop: false,
// Start video muted
muted: false,
// Poster image URL displayed before video starts
poster: currentVideo.poster,
// Preload behavior: 'auto', 'metadata', 'none'
preload: 'auto',
// Video sources from current video
sources: currentVideo.sources,
// ===== VIDEO.JS-SPECIFIC OPTIONS =====
// Aspect ratio for fluid mode (e.g., '16:9', '4:3')
aspectRatio: '16:9',
// Hide all components except control bar for audio-only mode
audioOnlyMode: false,
// Display poster persistently for audio poster mode
audioPosterMode: false,
// Prevent autoSetup for elements with data-setup attribute
autoSetup: undefined,
// Custom breakpoints for responsive design
breakpoints: {
tiny: 210,
xsmall: 320,
small: 425,
medium: 768,
large: 1440,
xlarge: 2560,
huge: 2561,
},
// Disable picture-in-picture mode
disablePictureInPicture: false,
// Enable document picture-in-picture API
enableDocumentPictureInPicture: false,
// Enable smooth seeking experience
enableSmoothSeeking: false,
// Use experimental SVG icons instead of font icons
experimentalSvgIcons: false,
// Make player scale to fit container
fluid: true,
// Fullscreen options
fullscreen: {
options: {
navigationUI: 'hide',
},
},
// Player element ID
id: undefined,
// Milliseconds of inactivity before user considered inactive (0 = never)
inactivityTimeout: 2000,
// Language code for player (e.g., 'en', 'es', 'fr')
language: 'en',
// Custom language definitions
languages: {},
// Enable live UI with progress bar and live edge button
liveui: false,
// Live tracker options
liveTracker: {
trackingThreshold: 20, // Seconds threshold for showing live UI
liveTolerance: 15, // Seconds tolerance for being "live"
},
// Force native controls for touch devices
nativeControlsForTouch: false,
// Normalize autoplay behavior
normalizeAutoplay: false,
// Custom message when media cannot be played
notSupportedMessage: undefined,
// Prevent title attributes on UI elements for better accessibility
noUITitleAttributes: false,
// Array of playback speed options (e.g., [0.5, 1, 1.5, 2])
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
// Prefer non-fullscreen playback on mobile
playsinline: true,
// Plugin initialization options
plugins: {},
// Control poster image display
posterImage: true,
// Prefer full window over fullscreen on some devices
preferFullWindow: false,
// Enable responsive player based on breakpoints
responsive: true,
// Restore element when player is disposed
restoreEl: false,
// Suppress "not supported" error until user interaction
suppressNotSupportedError: false,
// Allow techs to override poster
techCanOverridePoster: false,
// Order of preferred playback technologies
techOrder: ['html5'],
// User interaction options
userActions: {
// Enable/disable or customize click behavior
click: true,
// Enable/disable or customize double-click behavior (fullscreen toggle)
doubleClick: true,
// Hotkey configuration
hotkeys: {
// Function to override fullscreen key (default: 'f')
fullscreenKey: function (event) {
return event.which === 70; // 'f' key
},
// Function to override mute key (default: 'm')
muteKey: function (event) {
return event.which === 77; // 'm' key
},
// Function to override play/pause key (default: 'k' and Space)
playPauseKey: function (event) {
return (
event.which === 75 || event.which === 32
); // 'k' or Space
},
},
},
// URL to vtt.js for WebVTT support
'vtt.js': undefined,
// Spatial navigation for smart TV/remote control navigation
spatialNavigation: {
enabled: false,
horizontalSeek: false,
},
// ===== CONTROL BAR OPTIONS =====
controlBar: {
progressControl: {
seekBar: {
timeTooltip: {
// Customize TimeTooltip behavior
displayNegative: false, // Don't show negative time
},
},
},
// Remaining time display configuration
remainingTimeDisplay: {
displayNegative: true,
},
// Volume panel configuration
volumePanel: {
inline: true, // Display volume control inline
vertical: false, // Use horizontal volume slider
},
// Fullscreen toggle button
fullscreenToggle: true,
// Picture-in-picture toggle button
pictureInPictureToggle: true,
// Playback rate menu button
playbackRateMenuButton: true,
// Descriptions button
descriptionsButton: true,
// Subtitles button
subtitlesButton: true,
// Captions button (disabled to avoid duplicate)
captionsButton: false,
// Audio track button
audioTrackButton: true,
// Live display
liveDisplay: true,
// Seek to live button
seekToLive: true,
// Custom control spacer
customControlSpacer: true,
// Chapters menu button (moved after subtitles/captions)
chaptersButton: true,
},
// ===== HTML5 TECH OPTIONS =====
html5: {
// Force native controls for touch devices
nativeControlsForTouch: false,
// Use native audio tracks instead of emulated
nativeAudioTracks: true,
// Use native text tracks instead of emulated
nativeTextTracks: true,
// Use native video tracks instead of emulated
nativeVideoTracks: true,
// Preload text tracks
preloadTextTracks: true,
},
// ===== COMPONENT CONFIGURATION =====
children: [
'mediaLoader',
'posterImage',
'textTrackDisplay',
'loadingSpinner',
'bigPlayButton',
'liveTracker',
'controlBar',
'errorDisplay',
'textTrackSettings',
'resizeManager',
],
});
// Event listeners
playerRef.current.on('ready', () => {
// Auto-play video when navigating from next button
const urlParams = new URLSearchParams(
window.location.search
);
const hasVideoParam = urlParams.get('m');
if (hasVideoParam) {
// Small delay to ensure everything is loaded
setTimeout(() => {
if (
playerRef.current &&
!playerRef.current.isDisposed()
) {
playerRef.current.play().catch((error) => {
console.log(
'Autoplay was prevented:',
error
);
});
}
}, 100);
}
// Add English subtitle track after player is ready
playerRef.current.addRemoteTextTrack(
{
kind: 'subtitles',
src: '/sample-subtitles.vtt',
srclang: 'en',
label: 'English Subtitles',
default: false,
},
false
);
// Add Greek subtitle track
playerRef.current.addRemoteTextTrack(
{
kind: 'subtitles',
src: '/sample-subtitles-greek.vtt',
srclang: 'el',
label: 'Greek Subtitles (Ελληνικά)',
default: false,
},
false
);
// Create a text track for chapters programmatically
const chaptersTrack = playerRef.current.addTextTrack(
'chapters',
'Chapters',
'en'
);
// Add cues to the chapters track
chaptersData.forEach((chapter) => {
const cue = new (window.VTTCue ||
window.TextTrackCue)(
chapter.startTime,
chapter.endTime,
chapter.text
);
chaptersTrack.addCue(cue);
});
// Force chapter markers update after chapters are loaded
setTimeout(() => {
const progressControl = playerRef.current
.getChild('controlBar')
.getChild('progressControl');
if (progressControl) {
const seekBar =
progressControl.getChild('seekBar');
if (seekBar) {
const markers =
seekBar.getChild('ChapterMarkers');
if (
markers &&
markers.updateChapterMarkers
) {
markers.updateChapterMarkers();
}
}
}
}, 500);
console.log(
'Subtitles loaded but disabled by default - use CC button to enable'
);
});
// Add Next Video button to control bar and reorder chapters button
playerRef.current.ready(() => {
const controlBar =
playerRef.current.getChild('controlBar');
const nextVideoButton = new NextVideoButton(
playerRef.current
);
// Insert after play button
const playToggle = controlBar.getChild('playToggle');
const playToggleIndex = controlBar
.children()
.indexOf(playToggle);
controlBar.addChild(
nextVideoButton,
{},
playToggleIndex + 1
);
// Remove duplicate captions button and move chapters to end
const cleanupControls = () => {
// Log all current children for debugging
const allChildren = controlBar.children();
// Try to find and remove captions/subs-caps button (but keep subtitles)
const possibleCaptionButtons = [
'captionsButton',
'subsCapsButton',
];
possibleCaptionButtons.forEach((buttonName) => {
const button = controlBar.getChild(buttonName);
if (button) {
try {
controlBar.removeChild(button);
console.log(`✓ Removed ${buttonName}`);
} catch (e) {
console.log(
`✗ Failed to remove ${buttonName}:`,
e
);
}
}
});
// Alternative: hide buttons we can't remove
allChildren.forEach((child, index) => {
const name = (
child.name_ ||
child.constructor.name ||
''
).toLowerCase();
if (
name.includes('caption') &&
!name.includes('subtitle')
) {
child.hide();
console.log(
`✓ Hidden button at index ${index}: ${name}`
);
}
});
// Move chapters button to the very end
const chaptersButton =
controlBar.getChild('chaptersButton');
if (chaptersButton) {
try {
controlBar.removeChild(chaptersButton);
controlBar.addChild(chaptersButton);
console.log(
'✓ Chapters button moved to last position'
);
} catch (e) {
console.log(
'✗ Failed to move chapters button:',
e
);
}
}
};
// Try multiple times with different delays
setTimeout(cleanupControls, 200);
setTimeout(cleanupControls, 500);
setTimeout(cleanupControls, 1000);
// Make menus clickable instead of hover-only
setTimeout(() => {
const setupClickableMenus = () => {
// Find all menu buttons (chapters, subtitles, etc.)
const menuButtons = [
'chaptersButton',
'subtitlesButton',
'playbackRateMenuButton',
];
menuButtons.forEach((buttonName) => {
const button =
controlBar.getChild(buttonName);
if (button && button.menuButton_) {
// Override the menu button behavior
const menuButton = button.menuButton_;
// Disable hover events
menuButton.off('mouseenter');
menuButton.off('mouseleave');
// Add click-to-toggle behavior
menuButton.on('click', function () {
if (
this.menu.hasClass(
'vjs-lock-showing'
)
) {
this.menu.removeClass(
'vjs-lock-showing'
);
this.menu.hide();
} else {
this.menu.addClass(
'vjs-lock-showing'
);
this.menu.show();
}
});
console.log(
`✓ Made ${buttonName} clickable`
);
} else if (button) {
// For buttons without menuButton_ property
const buttonEl = button.el();
if (buttonEl) {
// Add click handler to show/hide menu
buttonEl.addEventListener(
'click',
function (e) {
e.preventDefault();
e.stopPropagation();
const menu =
buttonEl.querySelector(
'.vjs-menu'
);
if (menu) {
if (
menu.style
.display ===
'block'
) {
menu.style.display =
'none';
} else {
// Hide other menus first
document
.querySelectorAll(
'.vjs-menu'
)
.forEach(
(m) => {
if (
m !==
menu
)
m.style.display =
'none';
}
);
menu.style.display =
'block';
}
}
}
);
console.log(
`✓ Added click handler to ${buttonName}`
);
}
}
});
};
setupClickableMenus();
}, 1500);
// Add chapter markers to progress control
const progressControl =
controlBar.getChild('progressControl');
if (progressControl) {
const progressHolder =
progressControl.getChild('seekBar');
if (progressHolder) {
const chapterMarkers = new ChapterMarkers(
playerRef.current
);
progressHolder.addChild(chapterMarkers);
}
}
});
// Listen for next video event
playerRef.current.on('nextVideo', () => {
console.log('Next video requested');
goToNextVideo();
});
playerRef.current.on('play', () => {
console.log('Video started playing');
});
playerRef.current.on('pause', () => {
console.log('Video paused');
});
// Store reference to end screen for cleanup
let endScreen = null;
playerRef.current.on('ended', () => {
console.log('Video ended');
console.log('Available relatedVideos:', relatedVideos);
// Keep controls active after video ends
setTimeout(() => {
if (
playerRef.current &&
!playerRef.current.isDisposed()
) {
// Remove vjs-ended class if it disables controls
const playerEl = playerRef.current.el();
if (playerEl) {
// Keep the visual ended state but ensure controls work
const controlBar =
playerRef.current.getChild(
'controlBar'
);
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents =
'auto';
}
}
}
}, 50);
// Prevent creating multiple end screens
if (endScreen) {
console.log(
'End screen already exists, removing previous one'
);
playerRef.current.removeChild(endScreen);
endScreen = null;
}
// Show end screen with related videos
endScreen = new EndScreenOverlay(playerRef.current, {
relatedVideos: relatedVideos,
});
// Also store the data directly on the component as backup
endScreen.relatedVideos = relatedVideos;
playerRef.current.addChild(endScreen);
endScreen.show();
});
// Hide end screen when user wants to replay
playerRef.current.on('play', () => {
if (endScreen) {
endScreen.hide();
}
});
// Hide end screen when user seeks
playerRef.current.on('seeking', () => {
if (endScreen) {
endScreen.hide();
}
});
// Handle replay button functionality
playerRef.current.on('replay', () => {
if (endScreen) {
endScreen.hide();
}
playerRef.current.currentTime(0);
playerRef.current.play();
});
playerRef.current.on('error', (error) => {
console.error('Video.js error:', error);
});
playerRef.current.on('fullscreenchange', () => {
console.log(
'Fullscreen changed:',
playerRef.current.isFullscreen()
);
});
playerRef.current.on('volumechange', () => {
console.log(
'Volume changed:',
playerRef.current.volume(),
'Muted:',
playerRef.current.muted()
);
});
playerRef.current.on('ratechange', () => {
console.log(
'Playback rate changed:',
playerRef.current.playbackRate()
);
});
playerRef.current.on('texttrackchange', () => {
console.log('Text track changed');
const textTracks = playerRef.current.textTracks();
for (let i = 0; i < textTracks.length; i++) {
console.log(
'Track',
i,
':',
textTracks[i].kind,
textTracks[i].label,
'Mode:',
textTracks[i].mode
);
}
});
// Focus the player element so keyboard controls work
// This ensures spacebar can pause/play the video
playerRef.current.ready(() => {
// Focus the player element
if (playerRef.current.el()) {
playerRef.current.el().focus();
console.log(
'Video player focused for keyboard controls'
);
}
// Start playing the video immediately if autoplay is enabled
if (playerRef.current.autoplay()) {
playerRef.current.play().catch((error) => {
console.log(
'Autoplay prevented by browser:',
error
);
// If autoplay fails, we can still focus the element
// so the user can manually start and use keyboard controls
});
}
});
}
}, 0);
return () => {
clearTimeout(timer);
};
}
// Cleanup function
return () => {
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
}
};
}, []);
// Additional effect to ensure video gets focus for keyboard controls
useEffect(() => {
const focusVideo = () => {
if (playerRef.current && playerRef.current.el()) {
playerRef.current.el().focus();
console.log('Video element focused for keyboard controls');
}
};
// Focus when the page becomes visible or gains focus
const handleVisibilityChange = () => {
if (!document.hidden) {
setTimeout(focusVideo, 100);
}
};
const handleWindowFocus = () => {
setTimeout(focusVideo, 100);
};
// Add event listeners
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleWindowFocus);
// Initial focus attempt
setTimeout(focusVideo, 500);
return () => {
document.removeEventListener(
'visibilitychange',
handleVisibilityChange
);
window.removeEventListener('focus', handleWindowFocus);
};
}, []);
return (
<video
ref={videoRef}
className='video-js vjs-default-skin'
tabIndex='0'
/>
);
}
export default VideoJSPlayer;