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 = `
+
+ `;
+ } else {
+ iconEl.innerHTML = `
+
+ `;
+ }
+
+ // 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;