feat: Custom Seek Indicator Component for showing visual feedback during arrow key seeking

This commit is contained in:
Yiannis Christodoulou 2025-07-22 01:09:55 +03:00
parent 20e6a38fc8
commit d70b71228a
3 changed files with 411 additions and 6 deletions

View File

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

View File

@ -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 = `
<div class="vjs-seek-indicator-content">
<div class="vjs-seek-indicator-icon"></div>
<div class="vjs-seek-indicator-text"></div>
</div>
`;
// 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 = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
-webkit-border-radius: 50%;
-moz-border-radius: 50%;
">
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 4px;">
<svg viewBox="0 0 24 24" width="24" height="24" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M8 5v14l11-7z"/>
<path d="M13 5v14l11-7z" opacity="0.6"/>
</svg>
</div>
<div style="
color: white;
font-size: 10px;
font-weight: 500;
text-align: center;
line-height: 1.2;
opacity: 0.9;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
">${seconds} seconds</div>
</div>
</div>
`;
} else {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
-webkit-border-radius: 50%;
-moz-border-radius: 50%;
">
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 4px;">
<svg viewBox="0 0 24 24" width="24" height="24" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M16 19V5l-11 7z"/>
<path d="M11 19V5L0 12z" opacity="0.6"/>
</svg>
</div>
<div style="
color: white;
font-size: 10px;
font-weight: 500;
text-align: center;
line-height: 1.2;
opacity: 0.9;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
">${seconds} seconds</div>
</div>
</div>
`;
}
// 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;

View File

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