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,10 +1,10 @@
// 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) {
@ -13,7 +13,9 @@ class CustomSettingsMenu extends Component {
this.settingsButton = null; this.settingsButton = null;
this.settingsOverlay = null; this.settingsOverlay = null;
this.speedSubmenu = null; this.speedSubmenu = null;
this.qualitySubmenu = null;
this.userPreferences = options?.userPreferences || new UserPreferences(); 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);
@ -21,6 +23,8 @@ class CustomSettingsMenu extends Component {
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.handleQualityChange = this.handleQualityChange.bind(this);
this.getAvailableQualities = this.getAvailableQualities.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this); this.handleClickOutside = this.handleClickOutside.bind(this);
// Initialize after player is ready // Initialize after player is ready
@ -32,18 +36,14 @@ class CustomSettingsMenu extends Component {
} }
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)
@ -56,63 +56,88 @@ class CustomSettingsMenu extends Component {
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="vjs-icon-placeholder settings-item-svg">
<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>
<span>Playback speed</span></span>
<span class="settings-right">
<span class="current-speed">${playbackRateLabel}</span> <span class="current-speed">${playbackRateLabel}</span>
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
</span>
</div> </div>
<div class="settings-item" data-setting="quality"> <div class="settings-item" data-setting="quality">
<span>Quality</span> <span class="settings-left">
<span class="current-quality">${qualityLabel}</span> <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> </div>
`; `;
// Create speed submenu // Create speed submenu
this.createSpeedSubmenu(); this.createSpeedSubmenu();
// Create quality submenu
this.createQualitySubmenu(qualities, activeQuality?.value);
// Add to control bar // Add to control bar
controlBar.el().appendChild(this.settingsOverlay); controlBar.el().appendChild(this.settingsOverlay);
} }
createSpeedSubmenu() { createSpeedSubmenu() {
const speedOptions = [ const speedOptions = [
{ label: '0.25', value: 0.25 }, { label: "0.25", value: 0.25 },
{ label: '0.5', value: 0.5 }, { label: "0.5", value: 0.5 },
{ label: '0.75', value: 0.75 }, { label: "0.75", value: 0.75 },
{ label: 'Normal', value: 1 }, { label: "Normal", value: 1 },
{ label: '1.25', value: 1.25 }, { label: "1.25", value: 1.25 },
{ label: '1.5', value: 1.5 }, { label: "1.5", value: 1.5 },
{ label: '1.75', value: 1.75 }, { label: "1.75", value: 1.75 },
{ label: '2', value: 2 }, { label: "2", value: 2 },
]; ];
this.speedSubmenu = document.createElement('div'); this.speedSubmenu = document.createElement("div");
this.speedSubmenu.className = 'speed-submenu'; this.speedSubmenu.className = "speed-submenu";
// Get current playback rate for highlighting // Get current playback rate for highlighting
const currentRate = this.userPreferences.getPreference('playbackRate'); const currentRate = this.userPreferences.getPreference("playbackRate");
this.speedSubmenu.innerHTML = ` this.speedSubmenu.innerHTML = `
<div class="submenu-header"> <div class="submenu-header">
@ -122,21 +147,158 @@ class CustomSettingsMenu extends Component {
${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
);
}
try {
const md = typeof window !== "undefined" ? window.MEDIA_DATA : null;
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
}
// Derive from player's current sources
const sources = this.player().currentSources
? this.player().currentSources()
: this.player().currentSrc();
if (Array.isArray(sources) && sources.length > 0) {
const mapped = sources.map((s, idx) => {
const label =
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() { positionButton() {
const controlBar = this.player().getChild('controlBar'); const controlBar = this.player().getChild("controlBar");
const fullscreenToggle = controlBar.getChild('fullscreenToggle'); const fullscreenToggle = controlBar.getChild("fullscreenToggle");
if (this.settingsButton && fullscreenToggle) { if (this.settingsButton && fullscreenToggle) {
// Small delay to ensure all buttons are created // Small delay to ensure all buttons are created
@ -144,59 +306,84 @@ class CustomSettingsMenu extends Component {
const fullscreenIndex = controlBar.children().indexOf(fullscreenToggle); const fullscreenIndex = controlBar.children().indexOf(fullscreenToggle);
controlBar.removeChild(this.settingsButton); controlBar.removeChild(this.settingsButton);
controlBar.addChild(this.settingsButton, {}, fullscreenIndex + 1); controlBar.addChild(this.settingsButton, {}, fullscreenIndex + 1);
console.log('✓ Settings button positioned after fullscreen toggle'); console.log("✓ Settings button positioned after fullscreen toggle");
}, 50); }, 50);
} }
} }
setupEventListeners() { setupEventListeners() {
// Settings item clicks // Settings item clicks
this.settingsOverlay.addEventListener('click', (e) => { this.settingsOverlay.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
if (e.target.closest('[data-setting="playback-speed"]')) { if (e.target.closest('[data-setting="playback-speed"]')) {
this.speedSubmenu.style.display = 'flex'; 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) // Speed submenu header (back button)
this.speedSubmenu.querySelector('.submenu-header').addEventListener('click', () => { this.speedSubmenu
this.speedSubmenu.style.display = 'none'; .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 // Speed option clicks
this.speedSubmenu.addEventListener('click', (e) => { this.speedSubmenu.addEventListener("click", (e) => {
const speedOption = e.target.closest('.speed-option'); const speedOption = e.target.closest(".speed-option");
if (speedOption) { if (speedOption) {
const speed = parseFloat(speedOption.dataset.speed); const speed = parseFloat(speedOption.dataset.speed);
this.handleSpeedChange(speed, speedOption); this.handleSpeedChange(speed, speedOption);
} }
}); });
// Close menu when clicking outside // Quality option clicks
document.addEventListener('click', this.handleClickOutside); this.qualitySubmenu.addEventListener("click", (e) => {
const qualityOption = e.target.closest(".quality-option");
// Add hover effects if (qualityOption) {
this.settingsOverlay.addEventListener('mouseover', (e) => { const value = qualityOption.dataset.quality;
const item = e.target.closest('.settings-item, .speed-option'); this.handleQualityChange(value, qualityOption);
if (item && !item.style.background.includes('0.1')) {
item.style.background = 'rgba(255, 255, 255, 0.05)';
} }
}); });
this.settingsOverlay.addEventListener('mouseout', (e) => { // Close menu when clicking outside
const item = e.target.closest('.settings-item, .speed-option'); document.addEventListener("click", this.handleClickOutside);
if (item && !item.style.background.includes('0.1')) {
item.style.background = 'transparent'; // 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) { toggleSettings(e) {
e.stopPropagation(); e.stopPropagation();
const isVisible = this.settingsOverlay.style.display === 'block'; const isVisible = this.settingsOverlay.style.display === "block";
this.settingsOverlay.style.display = isVisible ? 'none' : 'block'; this.settingsOverlay.style.display = isVisible ? "none" : "block";
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles this.speedSubmenu.style.display = "none"; // Hide submenu when main menu toggles
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
} }
handleSpeedChange(speed, speedOption) { handleSpeedChange(speed, speedOption) {
@ -204,29 +391,145 @@ class CustomSettingsMenu extends Component {
this.player().playbackRate(speed); this.player().playbackRate(speed);
// Save preference // Save preference
this.userPreferences.setPreference('playbackRate', speed); this.userPreferences.setPreference("playbackRate", speed);
// Update UI // Update UI
document.querySelectorAll('.speed-option').forEach((opt) => { this.speedSubmenu.querySelectorAll(".speed-option").forEach((opt) => {
opt.classList.remove('active'); opt.classList.remove("active");
opt.style.background = 'transparent'; opt.style.background = "transparent";
opt.querySelector('span:last-child')?.remove(); const check = opt.querySelector(".checkmark");
if (check) check.remove();
}); });
speedOption.classList.add('active'); speedOption.classList.add("active");
speedOption.style.background = 'rgba(255, 255, 255, 0.1)'; speedOption.style.background = "rgba(255, 255, 255, 0.1)";
speedOption.insertAdjacentHTML('beforeend', '<span>✓</span>'); speedOption.insertAdjacentHTML(
"beforeend",
'<span class="checkmark">✓</span>'
);
// Update main menu display // Update main menu display
const currentSpeedDisplay = this.settingsOverlay.querySelector('.current-speed'); const currentSpeedDisplay =
const speedLabel = speed === 1 ? 'Normal' : `${speed}`; this.settingsOverlay.querySelector(".current-speed");
const speedLabel = speed === 1 ? "Normal" : `${speed}`;
currentSpeedDisplay.textContent = speedLabel; currentSpeedDisplay.textContent = speedLabel;
// Hide menus // Close only the speed submenu (keep overlay open)
this.settingsOverlay.style.display = 'none'; this.speedSubmenu.style.display = "none";
this.speedSubmenu.style.display = 'none';
console.log('Playback speed preference saved:', speed); 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;
}
}
console.log("Switching quality to", selected.label, selected.src);
player.addClass("vjs-changing-resolution");
player.src({ src: selected.src, type: selected.type || "video/mp4" });
const onLoaded = () => {
// Restore time, rate, subtitles
try {
player.playbackRate(rate);
} catch (e) {}
try {
if (!isNaN(currentTime)) player.currentTime(currentTime);
} catch (e) {}
if (!wasPaused) {
player.play().catch(() => {});
}
// 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) { handleClickOutside(e) {
@ -236,14 +539,15 @@ class CustomSettingsMenu extends Component {
!this.settingsOverlay.contains(e.target) && !this.settingsOverlay.contains(e.target) &&
!this.settingsButton.el().contains(e.target) !this.settingsButton.el().contains(e.target)
) { ) {
this.settingsOverlay.style.display = 'none'; this.settingsOverlay.style.display = "none";
this.speedSubmenu.style.display = 'none'; this.speedSubmenu.style.display = "none";
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
} }
} }
dispose() { dispose() {
// Remove event listeners // Remove event listeners
document.removeEventListener('click', this.handleClickOutside); document.removeEventListener("click", this.handleClickOutside);
// Remove DOM elements // Remove DOM elements
if (this.settingsOverlay) { if (this.settingsOverlay) {
@ -255,9 +559,9 @@ class CustomSettingsMenu extends Component {
} }
// 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

@ -564,6 +564,11 @@ function VideoJSPlayer() {
}, },
siteUrl: '', siteUrl: '',
nextLink: 'https://demo.mediacms.io/view?m=YjGJafibO', nextLink: 'https://demo.mediacms.io/view?m=YjGJafibO',
chaptersData: [
{ startTime: 0, endTime: 5, text: 'Start111' },
{ startTime: 5, endTime: 10, text: 'Introduction - EuroHPC' },
{ startTime: 10, endTime: 15, text: 'Planning - EuroHPC' },
],
}, },
[] []
); );
@ -571,11 +576,11 @@ function VideoJSPlayer() {
// Define chapters as JSON object // Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON // Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
const chaptersData = mediaData.chaptersData; const chaptersData = mediaData.chaptersData;
// [ /* [
// { startTime: 0, endTime: 5, text: 'Start111' }, { startTime: 0, endTime: 5, text: 'Start111' },
// { startTime: 5, endTime: 10, text: 'Introduction - EuroHPC' }, { startTime: 5, endTime: 10, text: 'Introduction - EuroHPC' },
// { startTime: 10, endTime: 15, text: 'Planning - EuroHPC' }, { startTime: 10, endTime: 15, text: 'Planning - EuroHPC' },
// ]; ]; */
// Get video data from mediaData // Get video data from mediaData
const currentVideo = useMemo( const currentVideo = useMemo(
@ -603,6 +608,51 @@ function VideoJSPlayer() {
[mediaData] [mediaData]
); );
// Compute available qualities. Prefer JSON (mediaData.data.qualities), otherwise build a full ordered list using the current source.
const availableQualities = useMemo(() => {
const desiredOrder = ['auto', '144p', '240p', '360p', '480p', '720p', '1080p'];
const normalize = (arr) => {
const norm = arr.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',
}));
// ensure all desired present
const have = new Set(norm.map((q) => q.value));
desiredOrder.forEach((v) => {
if (!have.has(v)) {
norm.push({ label: v === 'auto' ? 'Auto' : v, value: v });
}
});
// sort
const idx = (v) => {
const i = desiredOrder.indexOf(String(v).toLowerCase());
return i === -1 ? 999 : i;
};
norm.sort((a, b) => idx(a.value) - idx(b.value));
return norm;
};
const jsonList = mediaData?.data?.qualities;
if (Array.isArray(jsonList) && jsonList.length) {
return normalize(jsonList);
}
// Build from current source
const baseSrc = (currentVideo?.sources && currentVideo.sources[0]?.src) || null;
const type = (currentVideo?.sources && currentVideo.sources[0]?.type) || 'video/mp4';
const buildFromBase = desiredOrder.map((v) => ({
label: v === 'auto' ? 'Auto' : v,
value: v,
src: baseSrc || undefined,
type,
}));
return normalize(buildFromBase);
}, [mediaData, currentVideo]);
// Get related videos from mediaData instead of static data // Get related videos from mediaData instead of static data
const relatedVideos = useMemo(() => { const relatedVideos = useMemo(() => {
if (!mediaData?.data?.related_media) { if (!mediaData?.data?.related_media) {
@ -858,16 +908,16 @@ function VideoJSPlayer() {
// Picture-in-picture toggle button // Picture-in-picture toggle button
pictureInPictureToggle: true, pictureInPictureToggle: true,
// Playback rate menu button // Remove default playback speed dropdown from control bar
playbackRateMenuButton: true, playbackRateMenuButton: false,
// Descriptions button // Descriptions button
descriptionsButton: true, descriptionsButton: true,
// Subtitles button // Subtitles (CC) button should be visible
subtitlesButton: false, subtitlesButton: true,
// Captions button (disabled to avoid duplicate) // Captions button (keep disabled to avoid duplicate with subtitles)
captionsButton: false, captionsButton: false,
// Audio track button // Audio track button
@ -982,6 +1032,7 @@ function VideoJSPlayer() {
// END: Add subtitle tracks // END: Add subtitle tracks
// BEGIN: Chapters Implementation // BEGIN: Chapters Implementation
console.log('chaptersData1', chaptersData);
if (chaptersData && chaptersData.length > 0) { if (chaptersData && chaptersData.length > 0) {
const chaptersTrack = playerRef.current.addTextTrack('chapters', 'Chapters', 'en'); const chaptersTrack = playerRef.current.addTextTrack('chapters', 'Chapters', 'en');
// Add cues to the chapters track // Add cues to the chapters track
@ -1036,8 +1087,8 @@ function VideoJSPlayer() {
userPreferences: userPreferences.current, userPreferences: userPreferences.current,
}); });
// Add it after the play button // Add it after the play button
const playToggleIndex = controlBar.children().indexOf(playToggle); const fullscreenToggleIndex = controlBar.children().indexOf(fullscreenToggle);
controlBar.addChild(autoplayToggleButton, {}, playToggleIndex + 1); controlBar.addChild(autoplayToggleButton, {}, fullscreenToggleIndex - 1);
// Store reference for later use // Store reference for later use
customComponents.current.autoplayToggleButton = autoplayToggleButton; customComponents.current.autoplayToggleButton = autoplayToggleButton;
@ -1204,10 +1255,14 @@ function VideoJSPlayer() {
} }
// END: Move chapters button after fullscreen toggle // END: Move chapters button after fullscreen toggle
console.log('chaptersData', chaptersData);
// BEGIN: Add Chapters Overlay Component // BEGIN: Add Chapters Overlay Component
if (chaptersData && chaptersData.length > 0) { if (chaptersData && chaptersData.length > 0) {
customComponents.current.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, { customComponents.current.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, {
chaptersData: chaptersData, chaptersData: chaptersData,
seriesTitle: mediaData?.data?.title || 'Chapters',
channelName: mediaData?.data?.user || mediaData?.data?.author_name || 'Channel',
thumbnail: mediaData?.data?.thumbnail_url || mediaData?.data?.author_thumbnail || '',
}); });
} else { } else {
console.log('⚠ No chapters data available for overlay'); console.log('⚠ No chapters data available for overlay');
@ -1217,6 +1272,7 @@ function VideoJSPlayer() {
// BEGIN: Add Settings Menu Component // BEGIN: Add Settings Menu Component
customComponents.current.settingsMenu = new CustomSettingsMenu(playerRef.current, { customComponents.current.settingsMenu = new CustomSettingsMenu(playerRef.current, {
userPreferences: userPreferences.current, userPreferences: userPreferences.current,
qualities: availableQualities,
}); });
// END: Add Settings Menu Component // END: Add Settings Menu Component

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>
<div className='video-wrapper'>
<div className='video-box'>
<VideoJS /> <VideoJS />
</div>
{/* <ChapterList /> */}
</div>
</StrictMode> </StrictMode>
); );
} }