From d70b71228a6356970d1052ef388b1741abaaa3d7 Mon Sep 17 00:00:00 2001 From: Yiannis Christodoulou Date: Tue, 22 Jul 2025 01:09:55 +0300 Subject: [PATCH] feat: Custom Seek Indicator Component for showing visual feedback during arrow key seeking --- frontend-tools/video-js/src/VideoJS.css | 121 ++++++++++++ .../src/components/controls/SeekIndicator.js | 179 ++++++++++++++++++ .../components/video-player/VideoJSPlayer.jsx | 117 +++++++++++- 3 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 frontend-tools/video-js/src/components/controls/SeekIndicator.js diff --git a/frontend-tools/video-js/src/VideoJS.css b/frontend-tools/video-js/src/VideoJS.css index 3f7ff884..64654740 100644 --- a/frontend-tools/video-js/src/VideoJS.css +++ b/frontend-tools/video-js/src/VideoJS.css @@ -515,3 +515,124 @@ .video-js .vjs-control-bar .vjs-control { /* Natural flex flow */ } + +/* Seek Indicator Styles */ +.vjs-seek-indicator { + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + z-index: 9999 !important; + pointer-events: none !important; + display: none !important; + align-items: center !important; + justify-content: center !important; + opacity: 0 !important; + visibility: hidden !important; + transition: opacity 0.2s ease-in-out !important; +} + +.vjs-seek-indicator-content { + background: transparent !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; +} + +.vjs-seek-indicator-icon { + position: relative !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin-bottom: 4px !important; +} + +.seek-icon-container { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + animation: seekPulse 0.3s ease-out !important; +} + +/* YouTube-style seek indicator */ +.youtube-seek-container { + display: flex !important; + align-items: center !important; + justify-content: center !important; + animation: youtubeSeekPulse 0.3s ease-out !important; +} + +.youtube-seek-circle { + width: 80px !important; + height: 80px !important; + border-radius: 50% !important; + -webkit-border-radius: 50% !important; + -moz-border-radius: 50% !important; + background: rgba(0, 0, 0, 0.8) !important; + backdrop-filter: blur(10px) !important; + -webkit-backdrop-filter: blur(10px) !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3) !important; + border: 1px solid rgba(255, 255, 255, 0.15) !important; + box-sizing: border-box !important; + overflow: hidden !important; +} + +.youtube-seek-icon { + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin-bottom: 4px !important; +} + +.youtube-seek-icon svg { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)) !important; +} + +.youtube-seek-time { + color: white !important; + font-size: 10px !important; + font-weight: 500 !important; + text-align: center !important; + line-height: 1.2 !important; + opacity: 0.9 !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; +} + +@keyframes youtubeSeekPulse { + 0% { + transform: scale(0.7); + opacity: 0.5; + } + 50% { + transform: scale(1.05); + opacity: 0.9; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.seek-seconds { + color: white !important; + font-size: 16px !important; + font-weight: bold !important; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7) !important; + line-height: 1 !important; +} + +/* Remove old animation - replaced with youtubeSeekPulse */ + +.vjs-seek-indicator-text { + color: white !important; + font-size: 16px !important; + font-weight: 500 !important; + text-align: center !important; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8) !important; +} diff --git a/frontend-tools/video-js/src/components/controls/SeekIndicator.js b/frontend-tools/video-js/src/components/controls/SeekIndicator.js new file mode 100644 index 00000000..2ea64454 --- /dev/null +++ b/frontend-tools/video-js/src/components/controls/SeekIndicator.js @@ -0,0 +1,179 @@ +import videojs from 'video.js'; + +const Component = videojs.getComponent('Component'); + +// Custom Seek Indicator Component for showing visual feedback during arrow key seeking +class SeekIndicator extends Component { + constructor(player, options) { + super(player, options); + this.seekAmount = options.seekAmount || 5; // Default seek amount in seconds + this.showTimeout = null; + } + + createEl() { + const el = super.createEl('div', { + className: 'vjs-seek-indicator', + }); + + // Create the indicator content + el.innerHTML = ` +
+
+
+
+ `; + + // Initially hide the indicator completely + el.style.display = 'none'; + el.style.opacity = '0'; + el.style.visibility = 'hidden'; + + return el; + } + + /** + * Show seek indicator with direction and amount + * @param {string} direction - 'forward' or 'backward' + * @param {number} seconds - Number of seconds to seek + */ + show(direction, seconds = this.seekAmount) { + const el = this.el(); + const iconEl = el.querySelector('.vjs-seek-indicator-icon'); + const textEl = el.querySelector('.vjs-seek-indicator-text'); + + // Clear any existing timeout + if (this.showTimeout) { + clearTimeout(this.showTimeout); + } + + // Set content based on direction - YouTube-style circular design + if (direction === 'forward') { + iconEl.innerHTML = ` +
+
+
+ + + + +
+
${seconds} seconds
+
+
+ `; + } else { + iconEl.innerHTML = ` +
+
+
+ + + + +
+
${seconds} seconds
+
+
+ `; + } + + // Clear any text content in the text element + textEl.textContent = ''; + + // Force show the element with YouTube-style positioning + el.style.cssText = ` + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + z-index: 99999 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + visibility: visible !important; + opacity: 1 !important; + pointer-events: none !important; + width: auto !important; + height: auto !important; + `; + + // Auto-hide after 1 second + this.showTimeout = setTimeout(() => { + this.hide(); + }, 1000); + } + + /** + * Hide the seek indicator + */ + hide() { + const el = this.el(); + el.style.opacity = '0'; + + setTimeout(() => { + el.style.display = 'none'; + el.style.visibility = 'hidden'; + }, 200); // Wait for fade out animation + } + + /** + * Clean up when component is disposed + */ + dispose() { + if (this.showTimeout) { + clearTimeout(this.showTimeout); + } + super.dispose(); + } +} + +// Register the component with Video.js +videojs.registerComponent('SeekIndicator', SeekIndicator); + +export default SeekIndicator; 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 a5499350..373d81dd 100644 --- a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx +++ b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx @@ -9,12 +9,14 @@ import NextVideoButton from '../controls/NextVideoButton'; import CustomRemainingTime from '../controls/CustomRemainingTime'; import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; import CustomSettingsMenu from '../controls/CustomSettingsMenu'; +import SeekIndicator from '../controls/SeekIndicator'; import UserPreferences from '../../utils/UserPreferences'; function VideoJSPlayer() { const videoRef = useRef(null); const playerRef = useRef(null); // Track the player instance const userPreferences = useRef(new UserPreferences()); // User preferences instance + const customComponents = useRef({}); // Store custom components for cleanup // Safely access window.MEDIA_DATA with fallback using useMemo const mediaData = useMemo( @@ -809,6 +811,15 @@ function VideoJSPlayer() { playPauseKey: function (event) { return event.which === 75 || event.which === 32; // 'k' or Space }, + + // Custom seek functions for arrow keys + seekForwardKey: function (event) { + return event.which === 39; // Right arrow key + }, + + seekBackwardKey: function (event) { + return event.which === 37; // Left arrow key + }, }, }, @@ -1206,12 +1217,9 @@ function VideoJSPlayer() { } // 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, { + customComponents.current.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, { chaptersData: chaptersData, }); console.log('✓ Custom chapters overlay component created'); @@ -1221,19 +1229,111 @@ function VideoJSPlayer() { // END: Add Chapters Overlay Component // BEGIN: Add Settings Menu Component - customComponents.settingsMenu = new CustomSettingsMenu(playerRef.current, { + customComponents.current.settingsMenu = new CustomSettingsMenu(playerRef.current, { userPreferences: userPreferences.current, }); console.log('✓ Custom settings menu component created'); // END: Add Settings Menu Component + // BEGIN: Add Seek Indicator Component + customComponents.current.seekIndicator = new SeekIndicator(playerRef.current, { + seekAmount: 5, // 5 seconds seek amount + }); + // Add the component but ensure it's hidden initially + playerRef.current.addChild(customComponents.current.seekIndicator); + + // Log the element to verify it exists + console.log('✓ Custom seek indicator component created'); + console.log('Seek indicator element:', customComponents.current.seekIndicator.el()); + console.log('Player element:', playerRef.current.el()); + + customComponents.current.seekIndicator.hide(); // Explicitly hide on creation + console.log('✓ Seek indicator hidden after creation'); + // END: Add Seek Indicator Component + // Store components reference for potential cleanup - console.log('Custom components initialized:', Object.keys(customComponents)); + console.log('Custom components initialized:', Object.keys(customComponents.current)); + + // BEGIN: Add custom arrow key seek functionality + const handleKeyDown = (event) => { + // Only handle if the player has focus or no input elements are focused + const activeElement = document.activeElement; + const isInputFocused = + activeElement && + (activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.contentEditable === 'true'); + + if (isInputFocused) { + return; // Don't interfere with input fields + } + + const seekAmount = 5; // 5 seconds + + if (event.key === 'ArrowRight' || event.keyCode === 39) { + event.preventDefault(); + const currentTime = playerRef.current.currentTime(); + const duration = playerRef.current.duration(); + const newTime = Math.min(currentTime + seekAmount, duration); + + playerRef.current.currentTime(newTime); + customComponents.current.seekIndicator.show('forward', seekAmount); + } else if (event.key === 'ArrowLeft' || event.keyCode === 37) { + event.preventDefault(); + const currentTime = playerRef.current.currentTime(); + const newTime = Math.max(currentTime - seekAmount, 0); + + playerRef.current.currentTime(newTime); + customComponents.current.seekIndicator.show('backward', seekAmount); + } + }; + + // Add keyboard event listener to the document + document.addEventListener('keydown', handleKeyDown); + + // Store cleanup function + customComponents.current.cleanupKeyboardHandler = () => { + document.removeEventListener('keydown', handleKeyDown); + }; + + console.log('✓ Arrow key seek functionality enabled'); + // END: Add custom arrow key seek functionality // Log current user preferences console.log('Current user preferences:', userPreferences.current.getPreferences()); // Add debugging methods to window for testing + window.debugSeek = { + testForward: () => { + console.log('🧪 Testing seek indicator forward'); + customComponents.current.seekIndicator.show('forward', 5); + }, + testBackward: () => { + console.log('🧪 Testing seek indicator backward'); + customComponents.current.seekIndicator.show('backward', 5); + }, + testHide: () => { + console.log('🧪 Testing seek indicator hide'); + customComponents.current.seekIndicator.hide(); + }, + getElement: () => { + return customComponents.current.seekIndicator.el(); + }, + getStyles: () => { + const el = customComponents.current.seekIndicator.el(); + return { + display: el.style.display, + visibility: el.style.visibility, + opacity: el.style.opacity, + position: el.style.position, + zIndex: el.style.zIndex, + top: el.style.top, + left: el.style.left, + cssText: el.style.cssText, + }; + }, + }; + window.debugSubtitles = { showTracks: () => { const textTracks = playerRef.current.textTracks(); @@ -1555,6 +1655,11 @@ function VideoJSPlayer() { // Cleanup function return () => { + // Clean up keyboard event listener if it exists + if (customComponents.current && customComponents.current.cleanupKeyboardHandler) { + customComponents.current.cleanupKeyboardHandler(); + } + if (playerRef.current && !playerRef.current.isDisposed()) { playerRef.current.dispose(); playerRef.current = null;