From 8c6361f17e50c7c12ddda478aa885ae0acd68606 Mon Sep 17 00:00:00 2001 From: Yiannis Christodoulou Date: Sun, 13 Jul 2025 03:37:08 +0300 Subject: [PATCH] feat: Separate the video.js components --- frontend-tools/video-js/src/VideoJS.jsx | 1556 +---------------- .../video-js/src/components/README.md | 112 ++ .../components/controls/NextVideoButton.js | 52 + .../video-js/src/components/index.js | 5 + .../src/components/markers/ChapterMarkers.js | 293 ++++ .../components/overlays/EndScreenOverlay.js | 131 ++ .../components/video-player/VideoJSPlayer.jsx | 935 ++++++++++ 7 files changed, 1531 insertions(+), 1553 deletions(-) create mode 100644 frontend-tools/video-js/src/components/README.md create mode 100644 frontend-tools/video-js/src/components/controls/NextVideoButton.js create mode 100644 frontend-tools/video-js/src/components/index.js create mode 100644 frontend-tools/video-js/src/components/markers/ChapterMarkers.js create mode 100644 frontend-tools/video-js/src/components/overlays/EndScreenOverlay.js create mode 100644 frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx diff --git a/frontend-tools/video-js/src/VideoJS.jsx b/frontend-tools/video-js/src/VideoJS.jsx index 97814232..8f462ea3 100644 --- a/frontend-tools/video-js/src/VideoJS.jsx +++ b/frontend-tools/video-js/src/VideoJS.jsx @@ -1,1558 +1,8 @@ -import React, { useEffect, useRef, useState, useMemo } from 'react'; -import videojs from 'video.js'; - -// import './assets/css/mediacms-player.css'; -// import './assets/css/VideoPlayer.scss'; -import 'video.js/dist/video-js.css'; - -// Define EndScreenOverlay outside the component to avoid re-definition -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 once -videojs.registerComponent('EndScreenOverlay', EndScreenOverlay); - -// 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 = ` -
${currentChapter.text}
-
Chapter: ${startTime} - ${endTime}
-
Position: ${timeAtPosition}
- `; - } else { - const timeAtPosition = this.formatTime(currentTime); - this.tooltip.innerHTML = ` -
No Chapter
-
Position: ${timeAtPosition}
- `; - } - - // 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); +import React from 'react'; +import { VideoJSPlayer } from './components'; function VideoJS() { - const videoRef = useRef(null); - const playerRef = useRef(null); // Track the player instance - // const [chapters] = useState([]); // Track chapters for display - - // 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', - }, - ]); - - // Custom Next Video Button Component using modern Video.js API - const Button = videojs.getComponent('Button'); - - 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 = ` - - - - `; - - // 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); - - // 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