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
@@ -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;