// 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; this.scrollY = 0; // Track scroll position before locking // 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); this.lockBodyScroll = this.lockBodyScroll.bind(this); this.unlockBodyScroll = this.unlockBodyScroll.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'); // Restore body scroll on mobile when closing this.unlockBodyScroll(); } 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 = `

${this.seriesTitle}

${this.channelName} 1 / ${this.chaptersData.length}

`; 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 = ` `; closeBtn.onclick = () => { this.overlay.style.display = 'none'; const el = this.player().el(); if (el) el.classList.remove('chapters-open'); // Restore body scroll on mobile when closing this.unlockBodyScroll(); }; 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 = ` `; 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); } } } lockBodyScroll() { if (!this.isMobile) return; // Save current scroll position this.scrollY = window.scrollY || window.pageYOffset; // Lock body scroll with proper iOS handling document.body.style.overflow = 'hidden'; document.body.style.position = 'fixed'; document.body.style.top = `-${this.scrollY}px`; document.body.style.left = '0'; document.body.style.right = '0'; document.body.style.width = '100%'; } unlockBodyScroll() { if (!this.isMobile) return; // Restore body scroll const scrollY = this.scrollY; document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.top = ''; document.body.style.left = ''; document.body.style.right = ''; document.body.style.width = ''; // Restore scroll position window.scrollTo(0, scrollY); } 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); } // Lock/unlock body scroll on mobile when overlay opens/closes if (isHidden) { this.lockBodyScroll(); } else { this.unlockBodyScroll(); } try { this.player() .el() .querySelectorAll('.vjs-menu') .forEach((m) => { m.classList.remove('vjs-lock-showing'); m.style.display = 'none'; }); } catch { // Ignore errors when closing menus } } 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 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 this.unlockBodyScroll(); } } 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 this.unlockBodyScroll(); // 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;