fix: Video.js improve styling of Toolbar icons

- chapter popup
- settings popup
- autoplay
-align the icons properly in the bar
This commit is contained in:
Yiannis Christodoulou 2025-09-10 13:53:37 +03:00
parent d88f4a27cc
commit 3accbd29ce
8 changed files with 1559 additions and 850 deletions

View File

@ -71,10 +71,10 @@
transition: all 0.2s ease !important; transition: all 0.2s ease !important;
} }
.vjs-autoplay-toggle:hover { /* .vjs-autoplay-toggle:hover {
color: #ff4444 !important; color: #ff4444 !important;
transform: scale(1.1) !important; transform: scale(1.1) !important;
} } */
.vjs-autoplay-toggle .vjs-autoplay-icon { .vjs-autoplay-toggle .vjs-autoplay-icon {
width: 1.2em; width: 1.2em;
@ -360,6 +360,17 @@
visibility: visible !important; visibility: visible !important;
} }
/* Hide menus/tooltips when chapters overlay is open */
.video-js.chapters-open .vjs-menu,
.video-js.chapters-open .vjs-menu.vjs-lock-showing,
.video-js.chapters-open .vjs-hover-display,
.video-js.chapters-open .vjs-time-tooltip,
.video-js.chapters-open .vjs-progress-holder .vjs-mouse-display {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.video-container { .video-container {
@ -681,3 +692,55 @@
text-align: center !important; text-align: center !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8) !important; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8) !important;
} }
button{cursor: pointer;}
body{ font-family: Arial, Helvetica, sans-serif; box-sizing:border-box;}
.video-wrapper{ position:relative; font-family:Arial; height:calc(100vh - 16px);}
.video-box{ height:100%;}
.video-wrapper .video-box .video-js{ padding:0; height:100% !important;}
.video-chapter{ position:absolute; top:10px; width:min(360px, calc(100% - 20px)); border:1px solid rgba(255, 255, 255, 0.12); border-radius:12px;
height:calc(100% - 80px); background:rgba(18, 18, 18, 0.96); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow:hidden;
box-shadow: 0 12px 30px rgba(0,0,0,0.45); right:10px;}
.chapter-head{ padding:12px 8px 10px 16px; position:sticky; top:0; left:0; background:linear-gradient(180deg, rgba(28,28,28,0.95), rgba(18,18,18,0.95));
border-bottom:1px solid rgba(255,255,255,0.08); z-index:2; }
.playlist-title{ display:flex; align-items:center; gap:10px; }
.chapter-title{ width: auto; flex: 1; min-width: 0; }
.chapter-title h3{ margin:0; padding:0;}
.chapter-title h3 a{color:#fff; font-size: 18px; line-height: 26px; font-weight: 700; text-decoration: none;
white-space: nowrap; text-overflow: ellipsis; height: 28px; overflow: hidden; display: block;}
.chapter-title p{ margin:4px 0 0; padding:0; color:#bdbdbd; font-size:12px; font-weight:400; line-height:15px;}
.chapter-title p a{ color:#bdbdbd; font-size:12px; font-weight:400; line-height:15px; text-decoration: none;}
.chapter-close{ width:40px; margin-left:auto; display:flex; align-items:center; justify-content:flex-end;}
.chapter-close button{ background:transparent; color:#fff; border: 0; width: 40px; height: 40px; padding: 0; display: flex;
align-items: center; justify-content: center; border-radius:8px;}
.chapter-close button:hover{ background:rgba(255,255,255,0.1);}
.playlist-action-menu{ display:none; justify-content:space-between; gap:10px;}
.playlist-action-menu button{ background:transparent; border: 0; width: 40px; height: 40px; padding: 0; display: flex;
align-items: center; justify-content: center; align-items:center; border-radius:100px; }
.playlist-action-menu button:hover{ background:rgba(0, 0, 0, 0.1);}
.start-action{ display:flex;}
.chapter-body{ height:calc(100% - 59px); overflow:auto; }
.chapter-body ul{ margin:0; padding:0;}
.playlist-items a{ padding:12px; display:flex; align-items:center; text-decoration:none; gap:12px; width:100%; box-sizing:border-box;}
.playlist-items a:hover{background:rgba(255,255,255,0.06);}
.playlist-items.selected a{ background:rgba(255,255,255,0.14);}
.playlist-drag-handle{ width:24px; display:flex; justify-content:center; color:#e0e0e0; font-size:12px;}
.thumbnail-meta{ flex:1; min-width:0; padding:0;}
.thumbnail-meta h4{ margin:0 2px 4px 0; font-size:14px; line-height:20px; font-weight:600; overflow:hidden; text-overflow:ellipsis;
color:#fff; white-space:normal; max-height:40px; -webkit-line-clamp: 2; line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical;}
.thumbnail-meta .meta-sub{ display:flex; gap:8px; align-items:center; }
.thumbnail-meta .meta-sub .meta-dynamic{ color:#bdbdbd; font-size:12px; line-height:18px; }
.thumbnail-action button{ border:0; background:transparent; color:#fff; opacity:0;}
.playlist-items a:hover .thumbnail-action button{ opacity:1;}
/* custom scrollbar for chapter list */
.chapter-body::-webkit-scrollbar{ width:10px; }
.chapter-body::-webkit-scrollbar-thumb{ background:rgba(255,255,255,0.18); border-radius:8px; }
.chapter-body::-webkit-scrollbar-track{ background:transparent; }
.video-box .video-js .vjs-control-bar .vjs-spacer-control{ margin-left:auto;}
.video-js .vjs-control-bar .settings-item-svg{ display:flex;}
.video-js .vjs-control-bar .settings-item-svg svg{width:auto !important; height: auto !important; transform:inherit !important;}

View File

@ -0,0 +1,156 @@
import React from 'react';
function ChapterList() {
const playlistData = [
{
id: 1,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: true
},
{
id: 2,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
},
{
id: 3,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
},
{
id: 4,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
},
{
id: 5,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
},
{
id: 6,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
}
,
{
id: 7,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
}
,
{
id: 8,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
}
,
{
id: 9,
title: "Class 12 Chapter 1 || Electric Charges and Fields 01 || Quantisation and Conservation of Charge",
channel: "Physics Wallah - Alakh Pandey",
duration: "40:13",
thumbnail: "https://i.ytimg.com/vi/m5VbK66a254/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLCt2rMJW2jZAYkcDi9wLQOGVkLSTw",
selected: false
}
];
return (
<div className='video-chapter'>
<div className='chapter-head'>
<div className='playlist-title'>
<div className='chapter-title'>
<h3><a href=''>12 chapter 1 II Electri charges and Fields JEE MAINS/NEET</a></h3>
<p><a href=''>Physics Wallah - Alakh Pandey</a> <span>1 / 17</span></p>
</div>
<div className='chapter-close'>
<button>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.7096 12L20.8596 20.15L20.1496 20.86L11.9996 12.71L3.84965 20.86L3.13965 20.15L11.2896 12L3.14965 3.85001L3.85965 3.14001L11.9996 11.29L20.1496 3.14001L20.8596 3.85001L12.7096 12Z" fill="black"/>
</svg>
</button>
</div>
</div>
<div className='playlist-action-menu'>
<div className='start-action'>
<button>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.0002 13H22.0002V18L3.93023 18.03L6.55023 20.65L5.84023 21.36L1.99023 17.5L5.84023 13.65L6.55023 14.36L3.88023 17.03L21.0002 17V13ZM3.00023 7.00002L20.1202 6.97002L17.4502 9.64002L18.1602 10.35L22.0102 6.50002L18.1602 2.65002L17.4502 3.36002L20.0702 5.98002L2.00023 6.00002V11H3.00023V7.00002Z" fill="black"/>
</svg>
</button>
<button>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.15 13.65L22 17.5L18.15 21.35L17.44 20.64L20.09 18H19C16.16 18 13.47 16.77 11.61 14.62L12.37 13.97C14.03 15.89 16.45 17 19 17H20.09L17.44 14.35L18.15 13.65ZM19 7.00003H20.09L17.44 9.65003L18.15 10.36L22 6.51003L18.15 2.66003L17.44 3.37003L20.09 6.00003H19C15.42 6.00003 12.14 7.95003 10.43 11.09L9.7 12.43C8.16 15.25 5.21 17 2 17V18C5.58 18 8.86 16.05 10.57 12.91L11.3 11.57C12.84 8.75003 15.79 7.00003 19 7.00003ZM8.59 9.98003L9.34 9.32003C7.49 7.21003 4.81 6.00003 2 6.00003V7.00003C4.52 7.00003 6.92 8.09003 8.59 9.98003Z" fill="black"/>
</svg>
</button>
</div>
<div className='end-action'>
<button>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 16.5C12.83 16.5 13.5 17.17 13.5 18C13.5 18.83 12.83 19.5 12 19.5C11.17 19.5 10.5 18.83 10.5 18C10.5 17.17 11.17 16.5 12 16.5ZM10.5 12C10.5 12.83 11.17 13.5 12 13.5C12.83 13.5 13.5 12.83 13.5 12C13.5 11.17 12.83 10.5 12 10.5C11.17 10.5 10.5 11.17 10.5 12ZM10.5 6C10.5 6.83 11.17 7.5 12 7.5C12.83 7.5 13.5 6.83 13.5 6C13.5 5.17 12.83 4.5 12 4.5C11.17 4.5 10.5 5.17 10.5 6Z" fill="black"/>
</svg>
</button>
</div>
</div>
</div>
<div className="chapter-body">
<ul>
{playlistData.map((item) => (
<li key={item.id}>
<div className={`playlist-items ${item.selected ? 'selected' : ''}`}>
<a href="#">
<div className="playlist-drag-handle">{item.selected ? '▶' : item.id}</div>
<div className="thumbnail-container">
<img src={item.thumbnail} alt={item.title} />
<span>{item.duration}</span>
</div>
<div className="thumbnail-meta">
<h4>{item.title}</h4>
<span>{item.channel}</span>
</div>
<div className="thumbnail-action">
<button>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M12 16.5C12.83 16.5 13.5 17.17 13.5 18C13.5 18.83 12.83 19.5 12 19.5C11.17 19.5 10.5 18.83 10.5 18C10.5 17.17 11.17 16.5 12 16.5ZM10.5 12C10.5 12.83 11.17 13.5 12 13.5C12.83 13.5 13.5 12.83 13.5 12C13.5 11.17 12.83 10.5 12 10.5C11.17 10.5 10.5 11.17 10.5 12ZM10.5 6C10.5 6.83 11.17 7.5 12 7.5C12.83 7.5 13.5 6.83 13.5 6C13.5 5.17 12.83 4.5 12 4.5C11.17 4.5 10.5 5.17 10.5 6Z" fill="black"/>
</svg>
</button>
</div>
</a>
</div>
</li>
))}
</ul>
</div>
</div>
);
}
export default ChapterList;

View File

@ -62,7 +62,11 @@ class AutoplayToggleButton extends Button {
updateIcon() { updateIcon() {
if (this.isAutoplayEnabled) { if (this.isAutoplayEnabled) {
// Simple text icon for now // Simple text icon for now
this.iconSpan.innerHTML = `<span style="font-size: 1.2em; color: #ff4444;">●</span>`; this.iconSpan.innerHTML = `<span style=" transform: inherit !important; margin: 20px 0 0; font-size: 1.2em; color: #ff4444;"><svg width="198" height="100" viewBox="0 0 198 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="18" y="18" width="180" height="64" rx="32" fill="white"/>
<rect width="100" height="100" rx="50" fill="white"/>
<path d="M53.5714 75V25H75V75H53.5714ZM25 75V25H46.4286V75H25ZM60.7143 67.8571H67.8571V32.1429H60.7143V67.8571ZM32.1429 67.8571H39.2857V32.1429H32.1429V67.8571Z" fill="#1C1B1F"/>
</svg></span>`;
// Only update element properties if element exists // Only update element properties if element exists
if (this.el()) { if (this.el()) {
this.el().title = 'Autoplay is on'; this.el().title = 'Autoplay is on';
@ -74,7 +78,12 @@ class AutoplayToggleButton extends Button {
} }
} else { } else {
// Simple text icon for now // Simple text icon for now
this.iconSpan.innerHTML = `<span style="font-size: 1.2em; color: #ccc;">○</span>`; this.iconSpan.innerHTML = `<span style="transform: inherit !important; margin: 20px 0 0; font-size: 1.2em; color: #ccc;">
<svg width="198" height="100" viewBox="0 0 198 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="18" width="180" height="64" rx="32" fill="white"/>
<rect x="98" width="100" height="100" rx="50" fill="white"/>
<path d="M129 75L168 50L129 25V75ZM136.091 61.9643V38.0357L154.705 50L136.091 61.9643Z" fill="#1C1B1F"/>
</svg></span>`;
// Only update element properties if element exists // Only update element properties if element exists
if (this.el()) { if (this.el()) {
this.el().title = 'Autoplay is off'; this.el().title = 'Autoplay is off';

View File

@ -11,6 +11,9 @@ class CustomChaptersOverlay extends Component {
this.chaptersData = options.chaptersData || []; this.chaptersData = options.chaptersData || [];
this.overlay = null; this.overlay = null;
this.chaptersList = null; this.chaptersList = null;
this.seriesTitle = options.seriesTitle || 'Chapters';
this.channelName = options.channelName || '';
this.thumbnail = options.thumbnail || '';
// Bind methods // Bind methods
this.createOverlay = this.createOverlay.bind(this); this.createOverlay = this.createOverlay.bind(this);
@ -39,101 +42,124 @@ class CustomChaptersOverlay extends Component {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
width: 300px; width: 100%;
height: 100%; 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; z-index: 1000;
display: none; display: none;
overflow-y: auto; pointer-events: none; /* allow clicks only on inner panel */
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.35);
`; `;
// Build container
const container = document.createElement('div');
container.className = 'video-chapter';
container.style.pointerEvents = 'auto';
this.overlay.appendChild(container);
// Create header // Create header
const header = document.createElement('div'); const header = document.createElement('div');
header.style.cssText = ` header.className = 'chapter-head';
background: rgba(0, 0, 0, 0.8); container.appendChild(header);
padding: 20px;
text-align: center; const playlistTitle = document.createElement('div');
font-weight: bold; playlistTitle.className = 'playlist-title';
font-size: 14px; header.appendChild(playlistTitle);
letter-spacing: 2px;
border-bottom: 2px solid #4a90e2; const chapterTitle = document.createElement('div');
position: sticky; chapterTitle.className = 'chapter-title';
top: 0; chapterTitle.innerHTML = `
<h3><a href="#">${this.seriesTitle}</a></h3>
<p><a href="#">${this.channelName}</a> <span>1 / ${this.chaptersData.length}</span></p>
`; `;
header.textContent = 'CHAPTERS'; playlistTitle.appendChild(chapterTitle);
this.overlay.appendChild(header);
// Create close button // Create close button
const closeBtn = document.createElement('div'); const chapterClose = document.createElement('div');
closeBtn.style.cssText = ` chapterClose.className = 'chapter-close';
position: absolute; const closeBtn = document.createElement('button');
top: 15px; closeBtn.setAttribute('aria-label', 'Close chapters');
right: 15px; closeBtn.innerHTML = `
width: 25px; <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
height: 25px; <path d="M12.7096 12L20.8596 20.15L20.1496 20.86L11.9996 12.71L3.84965 20.86L3.13965 20.15L11.2896 12L3.14965 3.85001L3.85965 3.14001L11.9996 11.29L20.1496 3.14001L20.8596 3.85001L12.7096 12Z" fill="currentColor"/>
background: rgba(0, 0, 0, 0.6); </svg>
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
z-index: 10;
`; `;
closeBtn.textContent = '×';
closeBtn.onclick = () => { closeBtn.onclick = () => {
this.overlay.style.display = 'none'; this.overlay.style.display = 'none';
const el = this.player().el();
if (el) el.classList.remove('chapters-open');
}; };
this.overlay.appendChild(closeBtn); chapterClose.appendChild(closeBtn);
playlistTitle.appendChild(chapterClose);
// Create chapters list // Create chapters list
this.chaptersList = document.createElement('div'); const body = document.createElement('div');
this.chaptersList.style.cssText = ` body.className = 'chapter-body';
padding: 10px 0; container.appendChild(body);
`;
const list = document.createElement('ul');
body.appendChild(list);
this.chaptersList = list;
// Add chapters from data // Add chapters from data
this.chaptersData.forEach((chapter) => { this.chaptersData.forEach((chapter, index) => {
const chapterItem = document.createElement('div'); const li = document.createElement('li');
chapterItem.style.cssText = ` const item = document.createElement('div');
padding: 15px 20px; item.className = `playlist-items ${index === 0 ? 'selected' : ''}`;
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 const anchor = document.createElement('a');
chapterItem.onmouseenter = () => { anchor.href = '#';
chapterItem.style.background = 'rgba(74, 144, 226, 0.2)'; anchor.onclick = (e) => e.preventDefault();
};
chapterItem.onmouseleave = () => {
chapterItem.style.background = 'transparent';
};
// Add click handler const drag = document.createElement('div');
chapterItem.onclick = () => { drag.className = 'playlist-drag-handle';
drag.textContent = index === 0 ? '▶' : String(index + 1);
const meta = document.createElement('div');
meta.className = 'thumbnail-meta';
// compute duration
const totalSec = Math.max(0, Math.floor((chapter.endTime || chapter.startTime) - chapter.startTime));
const hh = Math.floor(totalSec / 3600);
const mm = Math.floor((totalSec % 3600) / 60);
const ss = totalSec % 60;
const timeStr = hh > 0
? `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
: `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
const titleEl = document.createElement('h4');
titleEl.textContent = chapter.text;
const sub = document.createElement('div');
sub.className = 'meta-sub';
const dynamic = document.createElement('span');
dynamic.className = 'meta-dynamic';
dynamic.textContent = this.channelName;
dynamic.setAttribute('data-duration', timeStr);
sub.appendChild(dynamic);
meta.appendChild(titleEl);
meta.appendChild(sub);
const action = document.createElement('div');
action.className = 'thumbnail-action';
const btn = document.createElement('button');
btn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 16.5C12.83 16.5 13.5 17.17 13.5 18C13.5 18.83 12.83 19.5 12 19.5C11.17 19.5 10.5 18.83 10.5 18C10.5 17.17 11.17 16.5 12 16.5ZM10.5 12C10.5 12.83 11.17 13.5 12 13.5C12.83 13.5 13.5 12.83 13.5 12C13.5 11.17 12.83 10.5 12 10.5C11.17 10.5 10.5 11.17 10.5 12ZM10.5 6C10.5 6.83 11.17 7.5 12 7.5C12.83 7.5 13.5 6.83 13.5 6C13.5 5.17 12.83 4.5 12 4.5C11.17 4.5 10.5 5.17 10.5 6Z" fill="currentColor"/>
</svg>`;
action.appendChild(btn);
// Click to seek
item.onclick = () => {
this.player().currentTime(chapter.startTime); this.player().currentTime(chapter.startTime);
this.overlay.style.display = 'none'; this.overlay.style.display = 'none';
this.updateActiveItem(index);
// 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); anchor.appendChild(drag);
anchor.appendChild(meta);
anchor.appendChild(action);
item.appendChild(anchor);
li.appendChild(item);
this.chaptersList.appendChild(li);
}); });
this.overlay.appendChild(this.chaptersList);
// Add to player // Add to player
playerEl.appendChild(this.overlay); playerEl.appendChild(this.overlay);
@ -155,10 +181,20 @@ class CustomChaptersOverlay extends Component {
toggleOverlay() { toggleOverlay() {
if (!this.overlay) return; if (!this.overlay) return;
const el = this.player().el();
if (this.overlay.style.display === 'none' || !this.overlay.style.display) { if (this.overlay.style.display === 'none' || !this.overlay.style.display) {
this.overlay.style.display = 'block'; this.overlay.style.display = 'block';
if (el) el.classList.add('chapters-open');
// hide any open menus
try {
this.player().el().querySelectorAll('.vjs-menu').forEach((m) => {
m.classList.remove('vjs-lock-showing');
m.style.display = 'none';
});
} catch (e) {}
} else { } else {
this.overlay.style.display = 'none'; this.overlay.style.display = 'none';
if (el) el.classList.remove('chapters-open');
} }
} }
@ -166,7 +202,7 @@ class CustomChaptersOverlay extends Component {
if (!this.chaptersList || !this.chaptersData) return; if (!this.chaptersList || !this.chaptersData) return;
const currentTime = this.player().currentTime(); const currentTime = this.player().currentTime();
const chapterItems = this.chaptersList.querySelectorAll('div'); const chapterItems = this.chaptersList.querySelectorAll('.playlist-items');
chapterItems.forEach((item, index) => { chapterItems.forEach((item, index) => {
const chapter = this.chaptersData[index]; const chapter = this.chaptersData[index];
@ -174,12 +210,33 @@ class CustomChaptersOverlay extends Component {
currentTime >= chapter.startTime && currentTime >= chapter.startTime &&
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime); (index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime);
const handle = item.querySelector('.playlist-drag-handle');
const dynamic = item.querySelector('.meta-dynamic');
if (isPlaying) { if (isPlaying) {
item.style.borderLeft = '4px solid #10b981'; item.classList.add('selected');
item.style.paddingLeft = '16px'; if (handle) handle.textContent = '▶';
if (dynamic) dynamic.textContent = dynamic.getAttribute('data-duration') || '';
} else { } else {
item.style.borderLeft = 'none'; item.classList.remove('selected');
item.style.paddingLeft = '20px'; if (handle) handle.textContent = String(index + 1);
if (dynamic) dynamic.textContent = this.channelName;
}
});
}
updateActiveItem(activeIndex) {
const items = this.chaptersList.querySelectorAll('.playlist-items');
items.forEach((el, idx) => {
const handle = el.querySelector('.playlist-drag-handle');
const dynamic = el.querySelector('.meta-dynamic');
if (idx === activeIndex) {
el.classList.add('selected');
if (handle) handle.textContent = '▶';
if (dynamic) dynamic.textContent = dynamic.getAttribute('data-duration') || '';
} else {
el.classList.remove('selected');
if (handle) handle.textContent = String(idx + 1);
if (dynamic) dynamic.textContent = this.channelName;
} }
}); });
} }
@ -188,6 +245,8 @@ class CustomChaptersOverlay extends Component {
if (this.overlay) { if (this.overlay) {
this.overlay.remove(); this.overlay.remove();
} }
const el = this.player().el();
if (el) el.classList.remove('chapters-open');
super.dispose(); super.dispose();
} }
} }

View File

@ -31,7 +31,7 @@
bottom: 60px; bottom: 60px;
right: 20px; right: 20px;
width: 250px; width: 250px;
height: 600px; height: 400px;
background: rgba(28, 28, 28, 0.95); background: rgba(28, 28, 28, 0.95);
color: white; color: white;
border-radius: 7px; border-radius: 7px;
@ -59,6 +59,8 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.custom-settings-overlay .settings-left span.vjs-icon-placeholder {transform: inherit !important;}
.settings-item:last-child { .settings-item:last-child {
border-bottom: none; border-bottom: none;
@ -80,6 +82,18 @@
flex-direction: column; flex-direction: column;
} }
/* Quality submenu mirrors speed submenu */
.quality-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 */
.submenu-header { .submenu-header {
padding: 12px 16px; padding: 12px 16px;
@ -110,8 +124,50 @@
.speed-option.active { .speed-option.active {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
/* Quality option styling */
.quality-option {
padding: 12px 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s ease;
}
.quality-option:hover {
background: rgba(255, 255, 255, 0.05);
}
.quality-option.active {
background: rgba(255, 255, 255, 0.1);
}
/* Settings row left/right layout like YouTube */
.settings-left {
display: inline-flex;
align-items: center;
gap: 8px;
}
.settings-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.vjs-icon-cog:before { .vjs-icon-cog:before {
font-size: 20px !important; font-size: 20px !important;
position: relative; position: relative;
top: -5px !important; top: -5px !important;
} }
/* HD superscript badge for 1080p */
sup.hd-badge {
font-size: 10px;
line-height: 1;
margin-left: 6px;
background: #e53935;
color: #fff;
padding: 1px 4px;
border-radius: 3px;
}

View File

@ -1,263 +1,567 @@
// components/controls/CustomSettingsMenu.js // components/controls/CustomSettingsMenu.js
import videojs from 'video.js'; import videojs from "video.js";
import './CustomSettingsMenu.css'; import "./CustomSettingsMenu.css";
import UserPreferences from '../../utils/UserPreferences'; import UserPreferences from "../../utils/UserPreferences";
// Get the Component base class from Video.js // Get the Component base class from Video.js
const Component = videojs.getComponent('Component'); const Component = videojs.getComponent("Component");
class CustomSettingsMenu extends Component { class CustomSettingsMenu extends Component {
constructor(player, options) { constructor(player, options) {
super(player, options); super(player, options);
this.settingsButton = null; this.settingsButton = null;
this.settingsOverlay = null; this.settingsOverlay = null;
this.speedSubmenu = null; this.speedSubmenu = null;
this.userPreferences = options?.userPreferences || new UserPreferences(); this.qualitySubmenu = null;
this.userPreferences = options?.userPreferences || new UserPreferences();
this.providedQualities = options?.qualities || null;
// Bind methods // Bind methods
this.createSettingsButton = this.createSettingsButton.bind(this); this.createSettingsButton = this.createSettingsButton.bind(this);
this.createSettingsOverlay = this.createSettingsOverlay.bind(this); this.createSettingsOverlay = this.createSettingsOverlay.bind(this);
this.positionButton = this.positionButton.bind(this); this.positionButton = this.positionButton.bind(this);
this.toggleSettings = this.toggleSettings.bind(this); this.toggleSettings = this.toggleSettings.bind(this);
this.handleSpeedChange = this.handleSpeedChange.bind(this); this.handleSpeedChange = this.handleSpeedChange.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this); this.handleQualityChange = this.handleQualityChange.bind(this);
this.getAvailableQualities = this.getAvailableQualities.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this);
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
this.createSettingsButton(); this.createSettingsButton();
this.createSettingsOverlay(); this.createSettingsOverlay();
this.setupEventListeners(); this.setupEventListeners();
}); });
} }
createSettingsButton() { createSettingsButton() {
const controlBar = this.player().getChild('controlBar'); const controlBar = this.player().getChild("controlBar");
// Hide default playback rate button // Do NOT hide default playback rate button to avoid control bar layout shifts
const playbackRateButton = controlBar.getChild('playbackRateMenuButton');
if (playbackRateButton) {
playbackRateButton.hide();
}
// Create settings button // Create settings button
this.settingsButton = controlBar.addChild('button', { this.settingsButton = controlBar.addChild("button", {
controlText: 'Settings', controlText: "Settings",
className: 'vjs-settings-button', className: "vjs-settings-button",
}); });
// Style the settings button (gear icon) // Style the settings button (gear icon)
const settingsButtonEl = this.settingsButton.el(); const settingsButtonEl = this.settingsButton.el();
settingsButtonEl.innerHTML = ` settingsButtonEl.innerHTML = `
<span class="vjs-icon-cog"></span> <span class="vjs-icon-cog"></span>
`; `;
// Position the settings button at the end of the control bar // Position the settings button at the end of the control bar
this.positionButton(); this.positionButton();
// Add click handler // Add click handler
this.settingsButton.on('click', this.toggleSettings); this.settingsButton.on("click", this.toggleSettings);
} }
createSettingsOverlay() { createSettingsOverlay() {
const controlBar = this.player().getChild('controlBar'); const controlBar = this.player().getChild("controlBar");
// Create settings overlay // Create settings overlay
this.settingsOverlay = document.createElement('div'); this.settingsOverlay = document.createElement("div");
this.settingsOverlay.className = 'custom-settings-overlay'; this.settingsOverlay.className = "custom-settings-overlay";
// Get current preferences for display // Get current preferences for display
const currentPlaybackRate = this.userPreferences.getPreference('playbackRate'); const currentPlaybackRate =
const currentQuality = this.userPreferences.getPreference('quality'); this.userPreferences.getPreference("playbackRate");
const currentQuality = this.userPreferences.getPreference("quality");
// Format playback rate for display // Format playback rate for display
const playbackRateLabel = currentPlaybackRate === 1 ? 'Normal' : `${currentPlaybackRate}`; const playbackRateLabel =
const qualityLabel = currentQuality.charAt(0).toUpperCase() + currentQuality.slice(1); currentPlaybackRate === 1 ? "Normal" : `${currentPlaybackRate}`;
const qualities = this.getAvailableQualities();
const activeQuality =
qualities.find((q) => q.value === currentQuality) || qualities[0];
const qualityLabelHTML =
activeQuality?.displayLabel ||
activeQuality?.label ||
(currentQuality ? String(currentQuality) : "Auto");
// Settings menu content // Settings menu content
this.settingsOverlay.innerHTML = ` this.settingsOverlay.innerHTML = `
<div class="settings-header">Settings</div> <div class="settings-header">Settings</div>
<div class="settings-item" data-setting="playback-speed"> <div class="settings-item" data-setting="playback-speed">
<span>Playback speed</span> <span class="settings-left">
<span class="current-speed">${playbackRateLabel}</span> <span class="vjs-icon-placeholder settings-item-svg">
</div> <svg height="24" viewBox="0 0 24 24" width="24"><path d="M10,8v8l6-4L10,8L10,8z M6.3,5L5.7,4.2C7.2,3,9,2.2,11,2l0.1,1C9.3,3.2,7.7,3.9,6.3,5z M5,6.3L4.2,5.7C3,7.2,2.2,9,2,11 l1,.1C3.2,9.3,3.9,7.7,5,6.3z M5,17.7c-1.1-1.4-1.8-3.1-2-4.8L2,13c0.2,2,1,3.8,2.2,5.4L5,17.7z M11.1,21c-1.8-0.2-3.4-0.9-4.8-2 l-0.6,.8C7.2,21,9,21.8,11,22L11.1,21z M22,12c0-5.2-3.9-9.4-9-10l-0.1,1c4.6,.5,8.1,4.3,8.1,9s-3.5,8.5-8.1,9l0.1,1 C18.2,21.5,22,17.2,22,12z" fill="white"></path></svg>
</span>
<div class="settings-item" data-setting="quality"> <span>Playback speed</span></span>
<span>Quality</span> <span class="settings-right">
<span class="current-quality">${qualityLabel}</span> <span class="current-speed">${playbackRateLabel}</span>
</div> <span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
`; </span>
</div>
<div class="settings-item" data-setting="quality">
<span class="settings-left">
<span class="vjs-icon-placeholder settings-item-svg">
<svg height="24" viewBox="0 0 24 24" width="24"><path d="M15,17h6v1h-6V17z M11,17H3v1h8v2h1v-2v-1v-2h-1V17z M14,8h1V6V5V3h-1v2H3v1h11V8z M18,5v1h3V5H18z M6,14h1v-2v-1V9H6v2H3v1 h3V14z M10,12h11v-1H10V12z" fill="white"></path></svg>
</span>
<span>Quality</span></span>
<span class="settings-right">
<span class="current-quality">${qualityLabelHTML}</span>
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
</span>
</div>
`;
// Create speed submenu // Create speed submenu
this.createSpeedSubmenu(); this.createSpeedSubmenu();
// Add to control bar // Create quality submenu
controlBar.el().appendChild(this.settingsOverlay); this.createQualitySubmenu(qualities, activeQuality?.value);
}
createSpeedSubmenu() { // Add to control bar
const speedOptions = [ controlBar.el().appendChild(this.settingsOverlay);
{ 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'); createSpeedSubmenu() {
this.speedSubmenu.className = 'speed-submenu'; 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 },
];
// Get current playback rate for highlighting this.speedSubmenu = document.createElement("div");
const currentRate = this.userPreferences.getPreference('playbackRate'); this.speedSubmenu.className = "speed-submenu";
this.speedSubmenu.innerHTML = ` // Get current playback rate for highlighting
const currentRate = this.userPreferences.getPreference("playbackRate");
this.speedSubmenu.innerHTML = `
<div class="submenu-header"> <div class="submenu-header">
<span style="margin-right: 8px;"></span> <span style="margin-right: 8px;"></span>
<span>Playback speed</span> <span>Playback speed</span>
</div> </div>
${speedOptions ${speedOptions
.map( .map(
(option) => ` (option) => `
<div class="speed-option ${option.value === currentRate ? 'active' : ''}" data-speed="${option.value}"> <div class="speed-option ${option.value === currentRate ? "active" : ""}" data-speed="${option.value}">
<span>${option.label}</span> <span>${option.label}</span>
${option.value === currentRate ? '<span>✓</span>' : ''} ${option.value === currentRate ? '<span class="checkmark">✓</span>' : ""}
</div> </div>
` `
) )
.join('')} .join("")}
`; `;
this.settingsOverlay.appendChild(this.speedSubmenu); this.settingsOverlay.appendChild(this.speedSubmenu);
}
createQualitySubmenu(qualities, currentValue) {
this.qualitySubmenu = document.createElement("div");
this.qualitySubmenu.className = "quality-submenu";
const header = `
<div class="submenu-header">
<span style="margin-right: 8px;"></span>
<span>Quality</span>
</div>
`;
const optionsHtml = qualities
.map(
(q) => `
<div class="quality-option ${q.value === currentValue ? "active" : ""}" data-quality="${q.value}">
<span class="quality-label">${q.displayLabel || q.label}</span>
${q.value === currentValue ? '<span class="checkmark">✓</span>' : ""}
</div>
`
)
.join("");
this.qualitySubmenu.innerHTML = header + optionsHtml;
this.settingsOverlay.appendChild(this.qualitySubmenu);
}
getAvailableQualities() {
// Priority: provided options -> MEDIA_DATA JSON -> player sources -> default
const desiredOrder = [
"auto",
"144p",
"240p",
"360p",
"480p",
"720p",
"1080p",
];
if (
Array.isArray(this.providedQualities) &&
this.providedQualities.length
) {
return this.sortAndDecorateQualities(
this.providedQualities,
desiredOrder
);
} }
positionButton() { try {
const controlBar = this.player().getChild('controlBar'); const md = typeof window !== "undefined" ? window.MEDIA_DATA : null;
const fullscreenToggle = controlBar.getChild('fullscreenToggle'); const jsonQualities = md?.data?.qualities;
if (Array.isArray(jsonQualities) && jsonQualities.length) {
// Expected format: [{label: '1080p', value: '1080p', src: '...'}]
const normalized = jsonQualities.map((q) => ({
label: q.label || q.value || "Auto",
value: (q.value || q.label || "auto").toString().toLowerCase(),
src: q.src || q.url || q.href,
type: q.type || "video/mp4",
}));
return this.sortAndDecorateQualities(normalized, desiredOrder);
}
} catch (e) {
// ignore
}
if (this.settingsButton && fullscreenToggle) { // Derive from player's current sources
// Small delay to ensure all buttons are created const sources = this.player().currentSources
setTimeout(() => { ? this.player().currentSources()
const fullscreenIndex = controlBar.children().indexOf(fullscreenToggle); : this.player().currentSrc();
controlBar.removeChild(this.settingsButton); if (Array.isArray(sources) && sources.length > 0) {
controlBar.addChild(this.settingsButton, {}, fullscreenIndex + 1); const mapped = sources.map((s, idx) => {
console.log('✓ Settings button positioned after fullscreen toggle'); const label =
}, 50); s.label ||
s.res ||
this.inferLabelFromSrc(s.src) ||
(idx === 0 ? "Auto" : `Source ${idx + 1}`);
const value = String(label).toLowerCase();
return { label, value, src: s.src, type: s.type || "video/mp4" };
});
return this.sortAndDecorateQualities(mapped, desiredOrder);
}
// Default fallback
// Build full ordered list without src so UI is consistent; switching will require src in JSON
const fallback = desiredOrder.map((v) => ({
label: v === "auto" ? "Auto" : v,
value: v,
}));
return this.sortAndDecorateQualities(fallback, desiredOrder);
}
sortAndDecorateQualities(list, desiredOrder) {
const orderIndex = (val) => {
const i = desiredOrder.indexOf(String(val).toLowerCase());
return i === -1 ? 999 : i;
};
const decorated = list
.map((q) => {
const val = (q.value || q.label || "").toString().toLowerCase();
const baseLabel = q.label || q.value || "";
const is1080 = val === "1080p";
const displayLabel = is1080
? `${baseLabel} <sup class="hd-badge">HD</sup>`
: baseLabel;
return { ...q, value: val, label: baseLabel, displayLabel };
})
.sort((a, b) => orderIndex(a.value) - orderIndex(b.value));
// Ensure all desired labels appear at least once (even if not provided), for consistent menu
const have = new Set(decorated.map((q) => q.value));
desiredOrder.forEach((val) => {
if (!have.has(val)) {
const baseLabel = val === "auto" ? "Auto" : val;
const displayLabel =
val === "1080p"
? `${baseLabel} <sup class="hd-badge">HD</sup>`
: baseLabel;
decorated.push({ label: baseLabel, value: val, displayLabel });
}
});
// Re-sort after pushing missing
decorated.sort((a, b) => orderIndex(a.value) - orderIndex(b.value));
return decorated;
}
inferLabelFromSrc(src) {
if (!src) return null;
// Try to detect typical resolution markers in file name or query string
const match = /(?:_|\.|\/)\D*(1440p|1080p|720p|480p|360p|240p|144p)/i.exec(
src
);
if (match && match[1]) return match[1].toUpperCase();
const m2 = /(\b\d{3,4})p\b/i.exec(src);
if (m2 && m2[1]) return `${m2[1]}p`;
return null;
}
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";
this.qualitySubmenu.style.display = "none";
}
if (e.target.closest('[data-setting="quality"]')) {
this.qualitySubmenu.style.display = "flex";
this.speedSubmenu.style.display = "none";
}
});
// Speed submenu header (back button)
this.speedSubmenu
.querySelector(".submenu-header")
.addEventListener("click", () => {
this.speedSubmenu.style.display = "none";
});
// Quality submenu header (back button)
this.qualitySubmenu
.querySelector(".submenu-header")
.addEventListener("click", () => {
this.qualitySubmenu.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);
}
});
// Quality option clicks
this.qualitySubmenu.addEventListener("click", (e) => {
const qualityOption = e.target.closest(".quality-option");
if (qualityOption) {
const value = qualityOption.dataset.quality;
this.handleQualityChange(value, qualityOption);
}
});
// 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
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
}
handleSpeedChange(speed, speedOption) {
// Update player speed
this.player().playbackRate(speed);
// Save preference
this.userPreferences.setPreference("playbackRate", speed);
// Update UI
this.speedSubmenu.querySelectorAll(".speed-option").forEach((opt) => {
opt.classList.remove("active");
opt.style.background = "transparent";
const check = opt.querySelector(".checkmark");
if (check) check.remove();
});
speedOption.classList.add("active");
speedOption.style.background = "rgba(255, 255, 255, 0.1)";
speedOption.insertAdjacentHTML(
"beforeend",
'<span class="checkmark">✓</span>'
);
// Update main menu display
const currentSpeedDisplay =
this.settingsOverlay.querySelector(".current-speed");
const speedLabel = speed === 1 ? "Normal" : `${speed}`;
currentSpeedDisplay.textContent = speedLabel;
// Close only the speed submenu (keep overlay open)
this.speedSubmenu.style.display = "none";
console.log("Playback speed preference saved:", speed);
}
handleQualityChange(value, qualityOption) {
const qualities = this.getAvailableQualities();
const selected = qualities.find((q) => String(q.value) === String(value));
// Save preference
this.userPreferences.setQualityPreference(value);
// Update UI
this.qualitySubmenu.querySelectorAll(".quality-option").forEach((opt) => {
opt.classList.remove("active");
opt.style.background = "transparent";
const check = opt.querySelector(".checkmark");
if (check) check.remove();
});
qualityOption.classList.add("active");
qualityOption.style.background = "rgba(255, 255, 255, 0.1)";
qualityOption.insertAdjacentHTML(
"beforeend",
'<span class="checkmark">✓</span>'
);
// Update main menu display
const currentQualityDisplay =
this.settingsOverlay.querySelector(".current-quality");
currentQualityDisplay.innerHTML =
selected?.displayLabel || selected?.label || String(value);
// Perform source switch if we have src defined
if (selected?.src) {
const player = this.player();
const wasPaused = player.paused();
const currentTime = player.currentTime();
const rate = player.playbackRate();
// Try to preserve active subtitle track
const textTracks = player.textTracks();
let activeSubtitleLang = null;
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === "subtitles" && track.mode === "showing") {
activeSubtitleLang = track.language;
break;
} }
} }
setupEventListeners() { console.log("Switching quality to", selected.label, selected.src);
// Settings item clicks
this.settingsOverlay.addEventListener('click', (e) => {
e.stopPropagation();
if (e.target.closest('[data-setting="playback-speed"]')) { player.addClass("vjs-changing-resolution");
this.speedSubmenu.style.display = 'flex'; player.src({ src: selected.src, type: selected.type || "video/mp4" });
}
});
// Speed submenu header (back button) const onLoaded = () => {
this.speedSubmenu.querySelector('.submenu-header').addEventListener('click', () => { // Restore time, rate, subtitles
this.speedSubmenu.style.display = 'none'; try {
}); player.playbackRate(rate);
} catch (e) {}
// Speed option clicks try {
this.speedSubmenu.addEventListener('click', (e) => { if (!isNaN(currentTime)) player.currentTime(currentTime);
const speedOption = e.target.closest('.speed-option'); } catch (e) {}
if (speedOption) { if (!wasPaused) {
const speed = parseFloat(speedOption.dataset.speed); player.play().catch(() => {});
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);
// Save preference
this.userPreferences.setPreference('playbackRate', speed);
// Update UI
document.querySelectorAll('.speed-option').forEach((opt) => {
opt.classList.remove('active');
opt.style.background = 'transparent';
opt.querySelector('span:last-child')?.remove();
});
speedOption.classList.add('active');
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');
const speedLabel = speed === 1 ? 'Normal' : `${speed}`;
currentSpeedDisplay.textContent = speedLabel;
// Hide menus
this.settingsOverlay.style.display = 'none';
this.speedSubmenu.style.display = 'none';
console.log('Playback speed preference saved:', speed);
}
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(); // Restore subtitles
if (activeSubtitleLang) {
const tt = player.textTracks();
for (let i = 0; i < tt.length; i++) {
const t = tt[i];
if (t.kind === "subtitles") {
t.mode =
t.language === activeSubtitleLang ? "showing" : "disabled";
}
}
}
// Ensure Subtitles (CC) button remains visible after source switch
try {
const controlBar = player.getChild("controlBar");
const names = [
"subtitlesButton",
"textTrackButton",
"subsCapsButton",
];
for (const n of names) {
const btn = controlBar && controlBar.getChild(n);
if (btn) {
if (typeof btn.show === "function") btn.show();
const el = btn.el && btn.el();
if (el) {
el.style.display = "";
el.style.visibility = "";
}
}
}
} catch (e) {
// noop
}
player.removeClass("vjs-changing-resolution");
player.off("loadedmetadata", onLoaded);
};
player.on("loadedmetadata", onLoaded);
} }
// Close overlay to avoid covering the CC button
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
this.settingsOverlay.style.display = "none";
console.log("Quality preference saved:", value);
}
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";
if (this.qualitySubmenu) this.qualitySubmenu.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 // Set component name for Video.js
CustomSettingsMenu.prototype.controlText_ = 'Settings Menu'; CustomSettingsMenu.prototype.controlText_ = "Settings Menu";
// Register the component with Video.js // Register the component with Video.js
videojs.registerComponent('CustomSettingsMenu', CustomSettingsMenu); videojs.registerComponent("CustomSettingsMenu", CustomSettingsMenu);
export default CustomSettingsMenu; export default CustomSettingsMenu;

View File

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import './VideoJS.css'; import './VideoJS.css';
import VideoJS from './VideoJS.jsx'; import VideoJS from './VideoJS.jsx';
// import ChapterList from './components/chapter/ChapterList.jsx';
// Mount the components when the DOM is ready // Mount the components when the DOM is ready
const mountComponents = () => { const mountComponents = () => {
@ -11,7 +12,12 @@ const mountComponents = () => {
const root = createRoot(rootContainer); const root = createRoot(rootContainer);
root.render( root.render(
<StrictMode> <StrictMode>
<VideoJS /> <div className='video-wrapper'>
<div className='video-box'>
<VideoJS />
</div>
{/* <ChapterList /> */}
</div>
</StrictMode> </StrictMode>
); );
} }