feat: Create the custom icon and chapters sidebar

This commit is contained in:
Yiannis Christodoulou 2025-07-14 03:19:27 +03:00
parent b3ab626c91
commit dc9a5492db
4 changed files with 641 additions and 55 deletions

View File

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

View File

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

View File

@ -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 = `
<span class="vjs-icon-cog"></span>
`;
// 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 = `
<div class="settings-header">Settings</div>
<div class="settings-item" data-setting="playback-speed">
<span>Playback speed</span>
<span class="current-speed">Normal</span>
</div>
<div class="settings-item" data-setting="quality">
<span>Quality</span>
<span class="current-quality">Auto</span>
</div>
`;
// 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 = `
<div class="submenu-header">
<span style="margin-right: 8px;"></span>
<span>Playback speed</span>
</div>
${speedOptions
.map(
(option) => `
<div class="speed-option ${option.value === 1 ? 'active' : ''}" data-speed="${option.value}">
<span>${option.label}</span>
${option.value === 1 ? '<span>✓</span>' : ''}
</div>
`
)
.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', '<span>✓</span>');
// 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;

View File

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