mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-11 14:32:30 -05:00
feat: Major Upgrade to Video.js v8 — Chapters Functionality, Fixes and Improvements
This commit is contained in:
committed by
GitHub
parent
b39072c8ae
commit
a5e6e7b9ca
@@ -0,0 +1,504 @@
|
||||
// components/controls/CustomChaptersOverlay.js
|
||||
import videojs from 'video.js';
|
||||
import './CustomChaptersOverlay.css';
|
||||
|
||||
// Get the Component base class from Video.js
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
class CustomChaptersOverlay extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
|
||||
this.chaptersData = options.chaptersData || [];
|
||||
this.overlay = null;
|
||||
this.chaptersList = null;
|
||||
this.seriesTitle = options.seriesTitle || 'Chapters';
|
||||
this.channelName = options.channelName || '';
|
||||
this.thumbnail = options.thumbnail || '';
|
||||
this.isScrolling = false;
|
||||
this.isMobile = this.detectMobile();
|
||||
this.touchStartTime = 0;
|
||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
|
||||
// Bind methods
|
||||
this.createOverlay = this.createOverlay.bind(this);
|
||||
this.updateCurrentChapter = this.updateCurrentChapter.bind(this);
|
||||
this.toggleOverlay = this.toggleOverlay.bind(this);
|
||||
this.formatTime = this.formatTime.bind(this);
|
||||
this.getChapterTimeRange = this.getChapterTimeRange.bind(this);
|
||||
this.detectMobile = this.detectMobile.bind(this);
|
||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
this.createOverlay();
|
||||
this.setupChaptersButton();
|
||||
this.setupResizeListener();
|
||||
});
|
||||
}
|
||||
|
||||
detectMobile() {
|
||||
return (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 0) ||
|
||||
window.matchMedia('(hover: none) and (pointer: coarse)').matches
|
||||
);
|
||||
}
|
||||
|
||||
handleMobileInteraction(event, chapter, index) {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Add haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
|
||||
// Seek to chapter and close overlay
|
||||
this.player().currentTime(chapter.startTime);
|
||||
this.overlay.style.display = 'none';
|
||||
this.updateActiveItem(index);
|
||||
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
}
|
||||
|
||||
setupResizeListener() {
|
||||
this.handleResize = () => {
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
window.addEventListener('orientationchange', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
// Update small screen detection on resize/orientation change
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const totalSec = Math.max(0, Math.floor(seconds));
|
||||
const hh = Math.floor(totalSec / 3600);
|
||||
const mm = Math.floor((totalSec % 3600) / 60);
|
||||
const ss = totalSec % 60;
|
||||
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
getChapterTimeRange(chapter) {
|
||||
const startTime = this.formatTime(chapter.startTime);
|
||||
const endTime = this.formatTime(chapter.endTime || chapter.startTime);
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
if (!this.chaptersData || this.chaptersData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerEl = this.player().el();
|
||||
|
||||
// Create overlay element
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'custom-chapters-overlay';
|
||||
this.overlay.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
`;
|
||||
|
||||
this.overlay.addEventListener('click', (event) => {
|
||||
if (event.target === this.overlay) {
|
||||
this.closeOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'video-chapter';
|
||||
container.style.cssText = `
|
||||
pointer-events: auto;
|
||||
z-index: 9999999;
|
||||
`;
|
||||
this.overlay.appendChild(container);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'chapter-head';
|
||||
container.appendChild(header);
|
||||
|
||||
const playlistTitle = document.createElement('div');
|
||||
playlistTitle.className = 'playlist-title';
|
||||
header.appendChild(playlistTitle);
|
||||
|
||||
const chapterTitle = document.createElement('div');
|
||||
chapterTitle.className = 'chapter-title';
|
||||
chapterTitle.innerHTML = `
|
||||
<h3><a href="#">${this.seriesTitle}</a></h3>
|
||||
<p><a href="#">${this.channelName}</a> <span>1 / ${this.chaptersData.length}</span></p>
|
||||
`;
|
||||
playlistTitle.appendChild(chapterTitle);
|
||||
|
||||
// Store reference to the current chapter span for dynamic updates
|
||||
this.currentChapterSpan = chapterTitle.querySelector('span');
|
||||
|
||||
const chapterClose = document.createElement('div');
|
||||
chapterClose.className = 'chapter-close';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.setAttribute('aria-label', 'Close chapters');
|
||||
closeBtn.innerHTML = `
|
||||
<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="currentColor"/>
|
||||
</svg>
|
||||
`;
|
||||
closeBtn.onclick = () => {
|
||||
this.overlay.style.display = 'none';
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
};
|
||||
chapterClose.appendChild(closeBtn);
|
||||
playlistTitle.appendChild(chapterClose);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'chapter-body';
|
||||
// Enable smooth touch scrolling on mobile devices
|
||||
body.style.cssText += `
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
`;
|
||||
|
||||
// Add mobile-specific scroll optimization
|
||||
if (this.isMobile) {
|
||||
body.style.cssText += `
|
||||
scroll-snap-type: y proximity;
|
||||
overscroll-behavior-y: contain;
|
||||
`;
|
||||
|
||||
// For very small screens, add momentum scrolling optimization
|
||||
if (this.isSmallScreen) {
|
||||
body.style.cssText += `
|
||||
scroll-padding-top: 5px;
|
||||
scroll-padding-bottom: 5px;
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(body);
|
||||
|
||||
const list = document.createElement('ul');
|
||||
body.appendChild(list);
|
||||
this.chaptersList = list;
|
||||
|
||||
this.chaptersData.forEach((chapter, index) => {
|
||||
const li = document.createElement('li');
|
||||
const item = document.createElement('div');
|
||||
item.className = `playlist-items ${index === 0 ? 'selected' : ''}`;
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = '#';
|
||||
anchor.onclick = (e) => e.preventDefault();
|
||||
|
||||
const drag = document.createElement('div');
|
||||
drag.className = 'playlist-drag-handle';
|
||||
drag.textContent = String(index + 1);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'thumbnail-meta';
|
||||
|
||||
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.chapterTitle;
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'meta-sub';
|
||||
const dynamic = document.createElement('span');
|
||||
dynamic.className = 'meta-dynamic';
|
||||
const chapterTimeRange = this.getChapterTimeRange(chapter);
|
||||
dynamic.textContent = chapterTimeRange;
|
||||
dynamic.setAttribute('data-duration', timeStr);
|
||||
dynamic.setAttribute('data-time-range', chapterTimeRange);
|
||||
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);
|
||||
|
||||
// Enhanced mobile touch handling
|
||||
if (this.isMobile) {
|
||||
let touchStartY = 0;
|
||||
let touchStartTime = 0;
|
||||
let touchMoved = false;
|
||||
|
||||
item.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartTime = Date.now();
|
||||
touchMoved = false;
|
||||
this.isScrolling = false;
|
||||
|
||||
// Add visual feedback
|
||||
item.style.transform = 'scale(0.98)';
|
||||
item.style.transition = 'transform 0.1s ease';
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
const touchMoveY = e.touches[0].clientY;
|
||||
const deltaY = Math.abs(touchMoveY - touchStartY);
|
||||
// Use smaller threshold for very small screens to be more sensitive
|
||||
const scrollThreshold = this.isSmallScreen ? 5 : 8;
|
||||
|
||||
if (deltaY > scrollThreshold) {
|
||||
touchMoved = true;
|
||||
this.isScrolling = true;
|
||||
// Remove visual feedback when scrolling
|
||||
item.style.transform = '';
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchend',
|
||||
(e) => {
|
||||
const touchEndTime = Date.now();
|
||||
const touchDuration = touchEndTime - touchStartTime;
|
||||
|
||||
// Reset visual feedback
|
||||
item.style.transform = '';
|
||||
|
||||
// Only trigger if it's a quick tap (not a scroll)
|
||||
// Use shorter threshold for small screens to feel more responsive
|
||||
const tapThreshold = this.isSmallScreen ? 120 : this.touchThreshold;
|
||||
if (!touchMoved && touchDuration < tapThreshold) {
|
||||
this.handleMobileInteraction(e, chapter, index);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchcancel',
|
||||
() => {
|
||||
// Reset visual feedback on cancel
|
||||
item.style.transform = '';
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
} else {
|
||||
// Desktop click handling
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.player().currentTime(chapter.startTime);
|
||||
this.overlay.style.display = 'none';
|
||||
this.updateActiveItem(index);
|
||||
});
|
||||
}
|
||||
|
||||
anchor.appendChild(drag);
|
||||
anchor.appendChild(meta);
|
||||
anchor.appendChild(action);
|
||||
item.appendChild(anchor);
|
||||
li.appendChild(item);
|
||||
this.chaptersList.appendChild(li);
|
||||
});
|
||||
|
||||
playerEl.appendChild(this.overlay);
|
||||
|
||||
this.player().on('timeupdate', this.updateCurrentChapter);
|
||||
}
|
||||
|
||||
setupChaptersButton() {
|
||||
const chaptersButton = this.player().getChild('controlBar').getChild('chaptersButton');
|
||||
if (chaptersButton) {
|
||||
chaptersButton.off('click');
|
||||
chaptersButton.off('touchstart');
|
||||
|
||||
if (this.isMobile) {
|
||||
// Enhanced mobile button handling
|
||||
chaptersButton.on('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleOverlay();
|
||||
});
|
||||
} else {
|
||||
chaptersButton.on('click', this.toggleOverlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleOverlay() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
const el = this.player().el();
|
||||
const isHidden = this.overlay.style.display === 'none' || !this.overlay.style.display;
|
||||
|
||||
this.overlay.style.display = isHidden ? 'block' : 'none';
|
||||
if (el) el.classList.toggle('chapters-open', isHidden);
|
||||
|
||||
// Add haptic feedback on mobile when opening
|
||||
if (this.isMobile && isHidden && navigator.vibrate) {
|
||||
navigator.vibrate(30);
|
||||
}
|
||||
|
||||
// Prevent body scroll on mobile when overlay is open
|
||||
if (this.isMobile) {
|
||||
if (isHidden) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.player()
|
||||
.el()
|
||||
.querySelectorAll('.vjs-menu')
|
||||
.forEach((m) => {
|
||||
m.classList.remove('vjs-lock-showing');
|
||||
m.style.display = 'none';
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateCurrentChapter() {
|
||||
if (!this.chaptersList || !this.chaptersData) return;
|
||||
|
||||
const currentTime = this.player().currentTime();
|
||||
const chapterItems = this.chaptersList.querySelectorAll('.playlist-items');
|
||||
let currentChapterIndex = -1;
|
||||
|
||||
chapterItems.forEach((item, index) => {
|
||||
const chapter = this.chaptersData[index];
|
||||
const isPlaying =
|
||||
currentTime >= chapter.startTime &&
|
||||
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime);
|
||||
|
||||
const handle = item.querySelector('.playlist-drag-handle');
|
||||
const dynamic = item.querySelector('.meta-dynamic');
|
||||
if (isPlaying) {
|
||||
currentChapterIndex = index;
|
||||
item.classList.add('selected');
|
||||
if (dynamic)
|
||||
dynamic.textContent = dynamic.getAttribute('data-time-range') || this.getChapterTimeRange(chapter);
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
if (dynamic)
|
||||
dynamic.textContent = dynamic.getAttribute('data-time-range') || this.getChapterTimeRange(chapter);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the header chapter number
|
||||
if (this.currentChapterSpan && currentChapterIndex !== -1) {
|
||||
this.currentChapterSpan.textContent = `${currentChapterIndex + 1} / ${this.chaptersData.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveItem(activeIndex) {
|
||||
const items = this.chaptersList.querySelectorAll('.playlist-items');
|
||||
items.forEach((el, idx) => {
|
||||
const dynamic = el.querySelector('.meta-dynamic');
|
||||
if (idx === activeIndex) {
|
||||
el.classList.add('selected');
|
||||
if (dynamic) dynamic.textContent = dynamic.getAttribute('data-duration') || '';
|
||||
} else {
|
||||
el.classList.remove('selected');
|
||||
if (dynamic) {
|
||||
const timeRange = dynamic.getAttribute('data-time-range');
|
||||
if (timeRange) {
|
||||
dynamic.textContent = timeRange;
|
||||
} else {
|
||||
// Fallback: calculate time range from chapters data
|
||||
const chapter = this.chaptersData[idx];
|
||||
if (chapter) {
|
||||
dynamic.textContent = this.getChapterTimeRange(chapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update the header chapter number
|
||||
if (this.currentChapterSpan) {
|
||||
this.currentChapterSpan.textContent = `${activeIndex + 1} / ${this.chaptersData.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
closeOverlay() {
|
||||
if (this.overlay) {
|
||||
this.overlay.style.display = 'none';
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
}
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile when disposing
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
if (this.handleResize) {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('orientationchange', this.handleResize);
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Set component name for Video.js
|
||||
CustomChaptersOverlay.prototype.controlText_ = 'Chapters Overlay';
|
||||
|
||||
// Register the component with Video.js
|
||||
videojs.registerComponent('CustomChaptersOverlay', CustomChaptersOverlay);
|
||||
|
||||
export default CustomChaptersOverlay;
|
||||
Reference in New Issue
Block a user