From dc9a5492dbdd8dfb025a71598761f73f1ff69763 Mon Sep 17 00:00:00 2001 From: Yiannis Christodoulou Date: Mon, 14 Jul 2025 03:19:27 +0300 Subject: [PATCH] feat: Create the custom icon and chapters sidebar --- .../controls/CustomChaptersOverlay.js | 201 +++++++++++++++ .../controls/CustomSettingsMenu.css | 115 +++++++++ .../components/controls/CustomSettingsMenu.js | 242 ++++++++++++++++++ .../components/video-player/VideoJSPlayer.jsx | 138 ++++++---- 4 files changed, 641 insertions(+), 55 deletions(-) create mode 100644 frontend-tools/video-js/src/components/controls/CustomChaptersOverlay.js create mode 100644 frontend-tools/video-js/src/components/controls/CustomSettingsMenu.css create mode 100644 frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js diff --git a/frontend-tools/video-js/src/components/controls/CustomChaptersOverlay.js b/frontend-tools/video-js/src/components/controls/CustomChaptersOverlay.js new file mode 100644 index 00000000..f3db81ec --- /dev/null +++ b/frontend-tools/video-js/src/components/controls/CustomChaptersOverlay.js @@ -0,0 +1,201 @@ +// components/controls/CustomChaptersOverlay.js +import videojs from 'video.js'; + +// Get the Component base class from Video.js +const Component = videojs.getComponent('Component'); + +class CustomChaptersOverlay extends Component { + constructor(player, options) { + super(player, options); + + this.chaptersData = options.chaptersData || []; + this.overlay = null; + this.chaptersList = null; + + // Bind methods + this.createOverlay = this.createOverlay.bind(this); + this.updateCurrentChapter = this.updateCurrentChapter.bind(this); + this.toggleOverlay = this.toggleOverlay.bind(this); + + // Initialize after player is ready + this.player().ready(() => { + this.createOverlay(); + this.setupChaptersButton(); + }); + } + + createOverlay() { + if (!this.chaptersData || this.chaptersData.length === 0) { + console.log('⚠ No chapters data available for overlay'); + return; + } + + const playerEl = this.player().el(); + + // Create overlay element + this.overlay = document.createElement('div'); + this.overlay.className = 'custom-chapters-overlay'; + this.overlay.style.cssText = ` + position: absolute; + top: 0; + right: 0; + width: 300px; + height: 100%; + background: linear-gradient(180deg, rgba(20, 20, 30, 0.95) 0%, rgba(40, 40, 50, 0.95) 100%); + color: white; + z-index: 1000; + display: none; + overflow-y: auto; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); + `; + + // Create header + const header = document.createElement('div'); + header.style.cssText = ` + background: rgba(0, 0, 0, 0.8); + padding: 20px; + text-align: center; + font-weight: bold; + font-size: 14px; + letter-spacing: 2px; + border-bottom: 2px solid #4a90e2; + position: sticky; + top: 0; + `; + header.textContent = 'CHAPTERS'; + this.overlay.appendChild(header); + + // Create close button + const closeBtn = document.createElement('div'); + closeBtn.style.cssText = ` + position: absolute; + top: 15px; + right: 15px; + width: 25px; + height: 25px; + background: rgba(0, 0, 0, 0.6); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + z-index: 10; + `; + closeBtn.textContent = '×'; + closeBtn.onclick = () => { + this.overlay.style.display = 'none'; + }; + this.overlay.appendChild(closeBtn); + + // Create chapters list + this.chaptersList = document.createElement('div'); + this.chaptersList.style.cssText = ` + padding: 10px 0; + `; + + // Add chapters from data + this.chaptersData.forEach((chapter) => { + const chapterItem = document.createElement('div'); + chapterItem.style.cssText = ` + padding: 15px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; + transition: background 0.2s ease; + font-size: 14px; + line-height: 1.4; + `; + chapterItem.textContent = chapter.text; + + // Add hover effect + chapterItem.onmouseenter = () => { + chapterItem.style.background = 'rgba(74, 144, 226, 0.2)'; + }; + chapterItem.onmouseleave = () => { + chapterItem.style.background = 'transparent'; + }; + + // Add click handler + chapterItem.onclick = () => { + this.player().currentTime(chapter.startTime); + this.overlay.style.display = 'none'; + + // Update active state + this.chaptersList.querySelectorAll('div').forEach((item) => { + item.style.background = 'transparent'; + item.style.fontWeight = 'normal'; + }); + chapterItem.style.background = 'rgba(74, 144, 226, 0.4)'; + chapterItem.style.fontWeight = 'bold'; + }; + + this.chaptersList.appendChild(chapterItem); + }); + + this.overlay.appendChild(this.chaptersList); + + // Add to player + playerEl.appendChild(this.overlay); + + // Set up time update listener + this.player().on('timeupdate', this.updateCurrentChapter); + + console.log('✓ Custom chapters overlay created'); + } + + setupChaptersButton() { + const chaptersButton = this.player().getChild('controlBar').getChild('chaptersButton'); + if (chaptersButton) { + // Override the click handler + chaptersButton.off('click'); // Remove default handler + chaptersButton.on('click', this.toggleOverlay); + } + } + + toggleOverlay() { + if (!this.overlay) return; + + if (this.overlay.style.display === 'none' || !this.overlay.style.display) { + this.overlay.style.display = 'block'; + } else { + this.overlay.style.display = 'none'; + } + } + + updateCurrentChapter() { + if (!this.chaptersList || !this.chaptersData) return; + + const currentTime = this.player().currentTime(); + const chapterItems = this.chaptersList.querySelectorAll('div'); + + chapterItems.forEach((item, index) => { + const chapter = this.chaptersData[index]; + const isPlaying = + currentTime >= chapter.startTime && + (index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime); + + if (isPlaying) { + item.style.borderLeft = '4px solid #10b981'; + item.style.paddingLeft = '16px'; + } else { + item.style.borderLeft = 'none'; + item.style.paddingLeft = '20px'; + } + }); + } + + dispose() { + if (this.overlay) { + this.overlay.remove(); + } + super.dispose(); + } +} + +// Set component name for Video.js +CustomChaptersOverlay.prototype.controlText_ = 'Chapters Overlay'; + +// Register the component with Video.js +videojs.registerComponent('CustomChaptersOverlay', CustomChaptersOverlay); + +export default CustomChaptersOverlay; diff --git a/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.css b/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.css new file mode 100644 index 00000000..c2ac6d8b --- /dev/null +++ b/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.css @@ -0,0 +1,115 @@ +/* CustomSettingsMenu.css */ + +/* Settings button styling */ +.vjs-settings-button { + width: 3em; + height: 3em; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; +} + +/* Settings button icon styling */ +.vjs-icon-cog1 { + font-size: 30px !important; + position: relative; + top: -8px !important; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + line-height: 1; +} + +/* Settings overlay styling */ +.custom-settings-overlay { + position: absolute; + bottom: 100%; + right: 0; + width: 250px; + background: rgba(28, 28, 28, 0.95); + color: white; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + display: none; + z-index: 1000; + font-size: 14px; + backdrop-filter: blur(10px); +} + +/* Settings header */ +.settings-header { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + font-weight: bold; +} + +/* Settings items */ +.settings-item { + padding: 12px 16px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: background 0.2s ease; +} + +.settings-item:last-child { + border-bottom: none; +} + +.settings-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Speed submenu */ +.speed-submenu { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(28, 28, 28, 0.95); + display: none; + flex-direction: column; +} + +/* Submenu header */ +.submenu-header { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + cursor: pointer; +} + +.submenu-header:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Speed options */ +.speed-option { + padding: 12px 16px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.2s ease; +} + +.speed-option:hover { + background: rgba(255, 255, 255, 0.05); +} + +.speed-option.active { + background: rgba(255, 255, 255, 0.1); +} +.vjs-icon-cog:before { + font-size: 20px !important; + position: relative; + top: -5px !important; +} diff --git a/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js b/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js new file mode 100644 index 00000000..054cd86c --- /dev/null +++ b/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js @@ -0,0 +1,242 @@ +// components/controls/CustomSettingsMenu.js +import videojs from 'video.js'; +import './CustomSettingsMenu.css'; + +// Get the Component base class from Video.js +const Component = videojs.getComponent('Component'); + +class CustomSettingsMenu extends Component { + constructor(player, options) { + super(player, options); + + this.settingsButton = null; + this.settingsOverlay = null; + this.speedSubmenu = null; + + // Bind methods + this.createSettingsButton = this.createSettingsButton.bind(this); + this.createSettingsOverlay = this.createSettingsOverlay.bind(this); + this.positionButton = this.positionButton.bind(this); + this.toggleSettings = this.toggleSettings.bind(this); + this.handleSpeedChange = this.handleSpeedChange.bind(this); + this.handleClickOutside = this.handleClickOutside.bind(this); + + // Initialize after player is ready + this.player().ready(() => { + this.createSettingsButton(); + this.createSettingsOverlay(); + this.setupEventListeners(); + }); + } + + createSettingsButton() { + const controlBar = this.player().getChild('controlBar'); + + // Hide default playback rate button + const playbackRateButton = controlBar.getChild('playbackRateMenuButton'); + if (playbackRateButton) { + playbackRateButton.hide(); + } + + // Create settings button + this.settingsButton = controlBar.addChild('button', { + controlText: 'Settings', + className: 'vjs-settings-button', + }); + + // Style the settings button (gear icon) + const settingsButtonEl = this.settingsButton.el(); + settingsButtonEl.innerHTML = ` + + `; + + // Position the settings button at the end of the control bar + this.positionButton(); + + // Add click handler + this.settingsButton.on('click', this.toggleSettings); + } + + createSettingsOverlay() { + const controlBar = this.player().getChild('controlBar'); + + // Create settings overlay + this.settingsOverlay = document.createElement('div'); + this.settingsOverlay.className = 'custom-settings-overlay'; + + // Settings menu content + this.settingsOverlay.innerHTML = ` +
Settings
+ +
+ Playback speed + Normal +
+ +
+ Quality + Auto +
+ `; + + // Create speed submenu + this.createSpeedSubmenu(); + + // Add to control bar + controlBar.el().appendChild(this.settingsOverlay); + } + + createSpeedSubmenu() { + const speedOptions = [ + { label: '0.25', value: 0.25 }, + { label: '0.5', value: 0.5 }, + { label: '0.75', value: 0.75 }, + { label: 'Normal', value: 1 }, + { label: '1.25', value: 1.25 }, + { label: '1.5', value: 1.5 }, + { label: '1.75', value: 1.75 }, + { label: '2', value: 2 }, + ]; + + this.speedSubmenu = document.createElement('div'); + this.speedSubmenu.className = 'speed-submenu'; + + this.speedSubmenu.innerHTML = ` + + ${speedOptions + .map( + (option) => ` +
+ ${option.label} + ${option.value === 1 ? '' : ''} +
+ ` + ) + .join('')} + `; + + this.settingsOverlay.appendChild(this.speedSubmenu); + } + + positionButton() { + const controlBar = this.player().getChild('controlBar'); + const fullscreenToggle = controlBar.getChild('fullscreenToggle'); + + if (this.settingsButton && fullscreenToggle) { + // Small delay to ensure all buttons are created + setTimeout(() => { + const fullscreenIndex = controlBar.children().indexOf(fullscreenToggle); + controlBar.removeChild(this.settingsButton); + controlBar.addChild(this.settingsButton, {}, fullscreenIndex + 1); + console.log('✓ Settings button positioned after fullscreen toggle'); + }, 50); + } + } + + setupEventListeners() { + // Settings item clicks + this.settingsOverlay.addEventListener('click', (e) => { + e.stopPropagation(); + + if (e.target.closest('[data-setting="playback-speed"]')) { + this.speedSubmenu.style.display = 'flex'; + } + }); + + // Speed submenu header (back button) + this.speedSubmenu.querySelector('.submenu-header').addEventListener('click', () => { + this.speedSubmenu.style.display = 'none'; + }); + + // Speed option clicks + this.speedSubmenu.addEventListener('click', (e) => { + const speedOption = e.target.closest('.speed-option'); + if (speedOption) { + const speed = parseFloat(speedOption.dataset.speed); + this.handleSpeedChange(speed, speedOption); + } + }); + + // Close menu when clicking outside + document.addEventListener('click', this.handleClickOutside); + + // Add hover effects + this.settingsOverlay.addEventListener('mouseover', (e) => { + const item = e.target.closest('.settings-item, .speed-option'); + if (item && !item.style.background.includes('0.1')) { + item.style.background = 'rgba(255, 255, 255, 0.05)'; + } + }); + + this.settingsOverlay.addEventListener('mouseout', (e) => { + const item = e.target.closest('.settings-item, .speed-option'); + if (item && !item.style.background.includes('0.1')) { + item.style.background = 'transparent'; + } + }); + } + + toggleSettings(e) { + e.stopPropagation(); + const isVisible = this.settingsOverlay.style.display === 'block'; + this.settingsOverlay.style.display = isVisible ? 'none' : 'block'; + this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles + } + + handleSpeedChange(speed, speedOption) { + // Update player speed + this.player().playbackRate(speed); + + // Update UI + document.querySelectorAll('.speed-option').forEach((opt) => { + opt.style.background = 'transparent'; + opt.querySelector('span:last-child')?.remove(); + }); + + speedOption.style.background = 'rgba(255, 255, 255, 0.1)'; + speedOption.insertAdjacentHTML('beforeend', ''); + + // Update main menu display + const currentSpeedDisplay = this.settingsOverlay.querySelector('.current-speed'); + currentSpeedDisplay.textContent = speedOption.querySelector('span').textContent; + + // Hide menus + this.settingsOverlay.style.display = 'none'; + this.speedSubmenu.style.display = 'none'; + } + + handleClickOutside(e) { + if ( + this.settingsOverlay && + this.settingsButton && + !this.settingsOverlay.contains(e.target) && + !this.settingsButton.el().contains(e.target) + ) { + this.settingsOverlay.style.display = 'none'; + this.speedSubmenu.style.display = 'none'; + } + } + + dispose() { + // Remove event listeners + document.removeEventListener('click', this.handleClickOutside); + + // Remove DOM elements + if (this.settingsOverlay) { + this.settingsOverlay.remove(); + } + + super.dispose(); + } +} + +// Set component name for Video.js +CustomSettingsMenu.prototype.controlText_ = 'Settings Menu'; + +// Register the component with Video.js +videojs.registerComponent('CustomSettingsMenu', CustomSettingsMenu); + +export default CustomSettingsMenu; diff --git a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx index a416de17..67c214de 100644 --- a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx +++ b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx @@ -7,6 +7,8 @@ import EndScreenOverlay from '../overlays/EndScreenOverlay'; import ChapterMarkers from '../markers/ChapterMarkers'; import NextVideoButton from '../controls/NextVideoButton'; import CustomRemainingTime from '../controls/CustomRemainingTime'; +import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; +import CustomSettingsMenu from '../controls/CustomSettingsMenu'; function VideoJSPlayer() { const videoRef = useRef(null); @@ -366,7 +368,7 @@ function VideoJSPlayer() { descriptionsButton: true, // Subtitles button - subtitlesButton: true, + subtitlesButton: false, // Captions button (disabled to avoid duplicate) captionsButton: false, @@ -421,7 +423,19 @@ function VideoJSPlayer() { }); // Event listeners - playerRef.current.on('ready', () => { + /* playerRef.current.on('ready', () => { + console.log('Video.js player ready'); + }); */ + playerRef.current.ready(() => { + // Get control bar and its children + const controlBar = playerRef.current.getChild('controlBar'); + const playToggle = controlBar.getChild('playToggle'); + const currentTimeDisplay = controlBar.getChild('currentTimeDisplay'); + const progressControl = controlBar.getChild('progressControl'); + const seekBar = progressControl.getChild('seekBar'); + const chaptersButton = controlBar.getChild('chaptersButton'); + const fullscreenToggle = controlBar.getChild('fullscreenToggle'); + // Auto-play video when navigating from next button const urlParams = new URLSearchParams(window.location.search); const hasVideoParam = urlParams.get('m'); @@ -436,8 +450,8 @@ function VideoJSPlayer() { }, 100); } - // Add English subtitle track after player is ready - playerRef.current.addRemoteTextTrack( + // BEGIN: Add subtitle tracks + const subtitleTracks = [ { kind: 'subtitles', src: '/sample-subtitles.vtt', @@ -445,11 +459,6 @@ function VideoJSPlayer() { label: 'English Subtitles', default: false, }, - false - ); - - // Add Greek subtitle track - playerRef.current.addRemoteTextTrack( { kind: 'subtitles', src: '/sample-subtitles-greek.vtt', @@ -457,46 +466,34 @@ function VideoJSPlayer() { 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); + subtitleTracks.forEach((track) => { + playerRef.current.addRemoteTextTrack(track, false); }); + // END: Add subtitle tracks + + // BEGIN: Chapters Implementation + if (chaptersData && chaptersData.length > 0) { + 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); + }); + } + // END: Chapters Implementation // 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(); - } - } + /* setTimeout(() => { + if (chapterMarkers && chapterMarkers.updateChapterMarkers) { + chapterMarkers.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 playToggle = controlBar.getChild('playToggle'); - const currentTimeDisplay = controlBar.getChild('currentTimeDisplay'); + }, 500); */ // BEGIN: Implement custom time display component const customRemainingTime = new CustomRemainingTime(playerRef.current, { @@ -523,7 +520,7 @@ function VideoJSPlayer() { // END: Implement custom next video button // Remove duplicate captions button and move chapters to end - const cleanupControls = () => { + /* const cleanupControls = () => { // Log all current children for debugging const allChildren = controlBar.children(); @@ -561,12 +558,12 @@ function VideoJSPlayer() { console.log('✗ Failed to move chapters button:', e); } } - }; + }; */ // Try multiple times with different delays - setTimeout(cleanupControls, 200); + /* setTimeout(cleanupControls, 200); setTimeout(cleanupControls, 500); - setTimeout(cleanupControls, 1000); + setTimeout(cleanupControls, 1000); */ // Make menus clickable instead of hover-only setTimeout(() => { @@ -628,15 +625,46 @@ function VideoJSPlayer() { 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); + // BEGIN: Add chapter markers to progress control + if (progressControl && seekBar) { + const chapterMarkers = new ChapterMarkers(playerRef.current); + seekBar.addChild(chapterMarkers); + } + // END: Add chapter markers to progress control + + // BEGIN: Move chapters button after fullscreen toggle + if (chaptersButton && fullscreenToggle) { + try { + const fullscreenIndex = controlBar.children().indexOf(fullscreenToggle); + controlBar.addChild(chaptersButton, {}, fullscreenIndex + 1); + console.log('✓ Chapters button moved after fullscreen toggle'); + } catch (e) { + console.log('✗ Failed to move chapters button:', e); } } + // END: Move chapters button after fullscreen toggle + + // Store custom components for potential future use (cleanup, method access, etc.) + const customComponents = {}; + + // BEGIN: Add Chapters Overlay Component + if (chaptersData && chaptersData.length > 0) { + customComponents.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, { + chaptersData: chaptersData, + }); + console.log('✓ Custom chapters overlay component created'); + } else { + console.log('⚠ No chapters data available for overlay'); + } + // END: Add Chapters Overlay Component + + // BEGIN: Add Settings Menu Component + customComponents.settingsMenu = new CustomSettingsMenu(playerRef.current); + console.log('✓ Custom settings menu component created'); + // END: Add Settings Menu Component + + // Store components reference for potential cleanup + console.log('Custom components initialized:', Object.keys(customComponents)); }); // Listen for next video event