// components/controls/CustomSettingsMenu.js import videojs from 'video.js'; import './CustomSettingsMenu.css'; // import './SettingsButton.css'; import UserPreferences from '../../utils/UserPreferences'; // Get the Component base class from Video.js const Component = videojs.getComponent('Component'); class CustomSettingsMenu extends Component { constructor(player, options) { super(player, options); this.settingsButton = null; this.settingsOverlay = null; this.speedSubmenu = null; this.qualitySubmenu = null; this.subtitlesSubmenu = null; this.userPreferences = options?.userPreferences || new UserPreferences(); this.providedQualities = options?.qualities || null; this.hasSubtitles = options?.hasSubtitles || false; // Touch scroll detection (mobile) this.isTouchScrolling = false; this.touchStartY = 0; this.isMobile = this.detectMobile(); this.isSmallScreen = window.innerWidth <= 480; this.touchThreshold = 150; // ms for tap vs scroll detection // Bind methods this.createSettingsButton = this.createSettingsButton.bind(this); this.createSettingsOverlay = this.createSettingsOverlay.bind(this); this.positionButton = this.positionButton.bind(this); this.toggleSettings = this.toggleSettings.bind(this); this.handleSpeedChange = this.handleSpeedChange.bind(this); this.handleQualityChange = this.handleQualityChange.bind(this); this.getAvailableQualities = this.getAvailableQualities.bind(this); this.createSubtitlesSubmenu = this.createSubtitlesSubmenu.bind(this); this.refreshSubtitlesSubmenu = this.refreshSubtitlesSubmenu.bind(this); this.handleSubtitleChange = this.handleSubtitleChange.bind(this); this.handleClickOutside = this.handleClickOutside.bind(this); this.detectMobile = this.detectMobile.bind(this); this.handleMobileInteraction = this.handleMobileInteraction.bind(this); this.setupResizeListener = this.setupResizeListener.bind(this); // Initialize after player is ready this.player().ready(() => { this.createSettingsButton(); this.createSettingsOverlay(); this.setupEventListeners(); this.restoreSubtitlePreference(); 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(element, callback) { if (!this.isMobile) return; let touchStartTime = 0; let touchMoved = false; let touchStartY = 0; element.addEventListener( 'touchstart', (e) => { touchStartTime = Date.now(); touchMoved = false; touchStartY = e.touches[0].clientY; // Add visual feedback element.style.transform = 'scale(0.98)'; element.style.transition = 'transform 0.1s ease'; // Add haptic feedback if available if (navigator.vibrate) { navigator.vibrate(30); } }, { passive: true } ); element.addEventListener( 'touchmove', (e) => { const touchMoveY = e.touches[0].clientY; const deltaY = Math.abs(touchMoveY - touchStartY); const scrollThreshold = this.isSmallScreen ? 5 : 8; if (deltaY > scrollThreshold) { touchMoved = true; // Remove visual feedback when scrolling element.style.transform = ''; } }, { passive: true } ); element.addEventListener( 'touchend', (e) => { const touchEndTime = Date.now(); const touchDuration = touchEndTime - touchStartTime; // Reset visual feedback element.style.transform = ''; // Only trigger if it's a quick tap (not a scroll) const tapThreshold = this.isSmallScreen ? 120 : this.touchThreshold; if (!touchMoved && touchDuration < tapThreshold) { e.preventDefault(); callback(e); } }, { passive: false } ); element.addEventListener( 'touchcancel', () => { // Reset visual feedback on cancel element.style.transform = ''; }, { passive: true } ); } setupResizeListener() { const handleResize = () => { this.isSmallScreen = window.innerWidth <= 480; }; window.addEventListener('resize', handleResize); window.addEventListener('orientationchange', handleResize); // Store reference for cleanup this.resizeHandler = handleResize; } createSettingsButton() { const controlBar = this.player().getChild('controlBar'); // Do NOT hide default playback rate button to avoid control bar layout shifts // Create settings button this.settingsButton = controlBar.addChild('button', { controlText: 'Settings', className: 'vjs-settings-button vjs-control vjs-button settings-clicked', }); // Style the settings button (gear icon) const settingsButtonEl = this.settingsButton.el(); settingsButtonEl.innerHTML = ` Settings `; // Add tooltip attributes settingsButtonEl.setAttribute('aria-label', 'Settings'); // Position the settings button at the end of the control bar this.positionButton(); // Add touch tooltip support this.addTouchTooltipSupport(settingsButtonEl); // Add mobile touch handling and unified click handling const buttonEl = this.settingsButton.el(); if (buttonEl) { buttonEl.style.pointerEvents = 'auto'; buttonEl.style.cursor = 'pointer'; buttonEl.style.touchAction = 'manipulation'; buttonEl.style.webkitTapHighlightColor = 'transparent'; // Use a more robust approach for mobile touch handling let touchStartTime = 0; let touchStartPos = { x: 0, y: 0 }; let touchHandled = false; // Handle touchstart buttonEl.addEventListener( 'touchstart', (e) => { touchStartTime = Date.now(); touchHandled = false; const touch = e.touches[0]; touchStartPos = { x: touch.clientX, y: touch.clientY }; }, { passive: true } ); // Handle touchend with proper passive handling buttonEl.addEventListener( 'touchend', (e) => { const touchEndTime = Date.now(); const touchDuration = touchEndTime - touchStartTime; // Only handle if it's a quick tap (not a swipe) if (touchDuration < 500) { const touch = e.changedTouches[0]; const touchEndPos = { x: touch.clientX, y: touch.clientY }; const distance = Math.sqrt( Math.pow(touchEndPos.x - touchStartPos.x, 2) + Math.pow(touchEndPos.y - touchStartPos.y, 2) ); // Only trigger if it's a tap (not a swipe) if (distance < 50) { e.preventDefault(); e.stopPropagation(); touchHandled = true; this.toggleSettings(e); } } }, { passive: false } ); // Handle click events (desktop and mobile fallback) buttonEl.addEventListener('click', (e) => { // Only handle click if touch wasn't already handled if (!touchHandled) { e.preventDefault(); e.stopPropagation(); this.toggleSettings(e); } touchHandled = false; // Reset for next interaction }); } } createSettingsOverlay() { const controlBar = this.player().getChild('controlBar'); // Create settings overlay this.settingsOverlay = document.createElement('div'); this.settingsOverlay.className = 'custom-settings-overlay'; // Get current preferences for display const currentPlaybackRate = this.userPreferences.getPreference('playbackRate'); const currentQuality = this.userPreferences.getPreference('quality'); // Find current subtitle selection for label let currentSubtitleLabel = 'Off'; try { const tt = this.player().textTracks(); for (let i = 0; i < tt.length; i++) { const t = tt[i]; if (t.kind === 'subtitles' && t.mode === 'showing') { currentSubtitleLabel = t.label || t.language || 'Captions'; break; } } } catch (e) {} // Format playback rate for display const playbackRateLabel = 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 - split into separate variables for maintainability const settingsHeader = `
Settings
`; const playbackSpeedSection = `
Playback speed ${playbackRateLabel}
`; const qualitySection = `
Quality ${qualityLabelHTML}
`; const subtitlesSection = `
Captions ${currentSubtitleLabel}
`; // Build the complete settings overlay this.settingsOverlay.innerHTML = settingsHeader; this.settingsOverlay.innerHTML += playbackSpeedSection; this.settingsOverlay.innerHTML += qualitySection; // Check if subtitles are available if (this.hasSubtitles) { this.settingsOverlay.innerHTML += subtitlesSection; } // Create speed submenu this.createSpeedSubmenu(); // Create quality submenu this.createQualitySubmenu(qualities, activeQuality?.value); // Create subtitles submenu (YouTube-like) this.createSubtitlesSubmenu(); // Add mobile-specific optimizations if (this.isMobile) { this.settingsOverlay.style.cssText += ` -webkit-overflow-scrolling: touch; scroll-behavior: smooth; overscroll-behavior-y: contain; touch-action: pan-y; `; // Prevent body scroll when overlay is open on mobile this.settingsOverlay.addEventListener( 'touchstart', (e) => { e.stopPropagation(); }, { passive: true } ); } // Add to control bar this.player().el().appendChild(this.settingsOverlay); // Add mobile touch handling to settings items if (this.isMobile) { const settingsItems = this.settingsOverlay.querySelectorAll('.settings-item'); settingsItems.forEach((item) => { this.handleMobileInteraction(item, (e) => { // Handle the same logic as click events if (e.target.closest('[data-setting="playback-speed"]')) { this.speedSubmenu.style.display = 'flex'; this.qualitySubmenu.style.display = 'none'; if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none'; } if (e.target.closest('[data-setting="quality"]')) { this.qualitySubmenu.style.display = 'flex'; this.speedSubmenu.style.display = 'none'; if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none'; } if (e.target.closest('[data-setting="subtitles"]')) { this.refreshSubtitlesSubmenu(); if (this.subtitlesSubmenu) { this.subtitlesSubmenu.style.display = 'flex'; this.speedSubmenu.style.display = 'none'; this.qualitySubmenu.style.display = 'none'; } } }); }); } } createSpeedSubmenu() { const speedOptions = [ { label: '0.25', value: 0.25 }, { label: '0.5', value: 0.5 }, { label: '0.75', value: 0.75 }, { label: 'Normal', value: 1 }, { label: '1.25', value: 1.25 }, { label: '1.5', value: 1.5 }, { label: '1.75', value: 1.75 }, { label: '2', value: 2 }, ]; this.speedSubmenu = document.createElement('div'); this.speedSubmenu.className = 'speed-submenu'; // Get current playback rate for highlighting const currentRate = this.userPreferences.getPreference('playbackRate'); this.speedSubmenu.innerHTML = ` ${speedOptions .map( (option) => `
${option.label} ${option.value === currentRate ? '' : ''}
` ) .join('')} `; this.settingsOverlay.appendChild(this.speedSubmenu); } createQualitySubmenu(qualities, currentValue) { this.qualitySubmenu = document.createElement('div'); this.qualitySubmenu.className = 'quality-submenu'; const header = ` `; const optionsHtml = qualities .map( (q) => `
${q.displayLabel || q.label} ${q.value === currentValue ? '' : ''}
` ) .join(''); this.qualitySubmenu.innerHTML = header + optionsHtml; this.settingsOverlay.appendChild(this.qualitySubmenu); } createSubtitlesSubmenu() { this.subtitlesSubmenu = document.createElement('div'); this.subtitlesSubmenu.className = 'subtitles-submenu'; // Header const header = ` `; this.subtitlesSubmenu.innerHTML = header + ''; this.settingsOverlay.appendChild(this.subtitlesSubmenu); // Populate now and on demand this.refreshSubtitlesSubmenu(); } refreshSubtitlesSubmenu() { if (!this.subtitlesSubmenu) return; const body = this.subtitlesSubmenu.querySelector('.submenu-body'); if (!body) return; const player = this.player(); const tracks = player.textTracks(); // Determine active let activeLang = null; for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (t.kind === 'subtitles' && t.mode === 'showing') { activeLang = t.language; break; } } // Build items: Off + languages const items = []; items.push({ label: 'Off', lang: null }); for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (t.kind === 'subtitles') { items.push({ label: t.label || t.language || `Track ${i}`, lang: t.language, track: t }); } } body.innerHTML = items .map( (it) => `
${it.label} ${it.lang === activeLang ? '' : ''}
` ) .join(''); // Also update the current subtitle display in main settings this.updateCurrentSubtitleDisplay(); } updateCurrentSubtitleDisplay() { try { const player = this.player(); const tracks = player.textTracks(); let currentSubtitleLabel = 'Off'; // Find the active subtitle track for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (t.kind === 'subtitles' && t.mode === 'showing') { currentSubtitleLabel = t.label || t.language || 'Captions'; break; } } const currentSubtitlesDisplay = this.settingsOverlay.querySelector('.current-subtitles'); if (currentSubtitlesDisplay) { currentSubtitlesDisplay.textContent = currentSubtitleLabel; } } catch (error) { console.error('Error updating current subtitle display:', error); } } // Method to periodically check and update subtitle display startSubtitleSync() { // Update immediately this.updateCurrentSubtitleDisplay(); // Listen for real-time subtitle changes this.player().on('texttrackchange', () => { this.updateCurrentSubtitleDisplay(); // Also refresh the subtitle submenu to show correct selection this.refreshSubtitlesSubmenu(); }); // Set up periodic updates every 2 seconds as backup this.subtitleSyncInterval = setInterval(() => { this.updateCurrentSubtitleDisplay(); }, 2000); } // Method to stop subtitle sync stopSubtitleSync() { if (this.subtitleSyncInterval) { clearInterval(this.subtitleSyncInterval); this.subtitleSyncInterval = null; } } // Cleanup method dispose() { this.stopSubtitleSync(); // Remove event listeners document.removeEventListener('click', this.handleClickOutside); // Remove text track change listener if (this.player()) { this.player().off('texttrackchange'); } } 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 - return empty array if no valid sources found return []; } sortAndDecorateQualities(list, desiredOrder) { const orderIndex = (val) => { const i = desiredOrder.indexOf(String(val).toLowerCase()); return i === -1 ? 999 : i; }; // Only include qualities that have actual sources const validQualities = list.filter((q) => q.src); const decorated = validQualities .map((q) => { const val = (q.value || q.label || '').toString().toLowerCase(); const baseLabel = q.label || q.value || ''; const is1080 = val === '1080p'; const displayLabel = is1080 ? `${baseLabel} HD` : baseLabel; return { ...q, value: val, label: baseLabel, displayLabel }; }) .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() { // CSS flexbox order handles positioning - no manual repositioning needed // The settings button will be positioned according to the order property in CSS } setupEventListeners() { // Close button functionality const closeButton = this.settingsOverlay.querySelector('.settings-close-btn'); if (closeButton) { const closeFunction = (e) => { e.stopPropagation(); this.settingsOverlay.classList.remove('show'); this.settingsOverlay.style.display = 'none'; this.speedSubmenu.style.display = 'none'; if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none'; const btnEl = this.settingsButton?.el(); if (btnEl) { btnEl.classList.remove('settings-clicked'); } }; closeButton.addEventListener('click', closeFunction); closeButton.addEventListener( 'touchend', (e) => { e.preventDefault(); closeFunction(e); }, { passive: false } ); } // 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'; } if (e.target.closest('[data-setting="subtitles"]')) { this.refreshSubtitlesSubmenu(); this.subtitlesSubmenu.style.display = 'flex'; this.speedSubmenu.style.display = 'none'; this.qualitySubmenu.style.display = 'none'; } }); // Touch scroll detection for settingsOverlay this.settingsOverlay.addEventListener( 'touchstart', (e) => { this.touchStartY = e.touches[0].clientY; this.isTouchScrolling = false; }, { passive: true } ); this.settingsOverlay.addEventListener( 'touchmove', (e) => { const dy = Math.abs(e.touches[0].clientY - this.touchStartY); if (dy > 10) this.isTouchScrolling = true; }, { passive: true } ); // Mobile touch events for settings items (tap vs scroll) this.settingsOverlay.addEventListener( 'touchend', (e) => { e.stopPropagation(); if (this.isTouchScrolling) { this.isTouchScrolling = false; return; } if (e.target.closest('[data-setting="playback-speed"]')) { e.preventDefault(); this.speedSubmenu.style.display = 'flex'; this.qualitySubmenu.style.display = 'none'; } if (e.target.closest('[data-setting="quality"]')) { e.preventDefault(); this.qualitySubmenu.style.display = 'flex'; this.speedSubmenu.style.display = 'none'; } if (e.target.closest('[data-setting="subtitles"]')) { e.preventDefault(); this.refreshSubtitlesSubmenu(); this.subtitlesSubmenu.style.display = 'flex'; this.speedSubmenu.style.display = 'none'; this.qualitySubmenu.style.display = 'none'; } }, { passive: false } ); // Speed submenu header (back button) const speedHeader = this.speedSubmenu.querySelector('.submenu-header'); speedHeader.addEventListener('click', () => { this.speedSubmenu.style.display = 'none'; }); speedHeader.addEventListener( 'touchend', (e) => { e.preventDefault(); e.stopPropagation(); this.speedSubmenu.style.display = 'none'; }, { passive: false } ); // Quality submenu header (back button) const qualityHeader = this.qualitySubmenu.querySelector('.submenu-header'); qualityHeader.addEventListener('click', () => { this.qualitySubmenu.style.display = 'none'; }); qualityHeader.addEventListener( 'touchend', (e) => { e.preventDefault(); e.stopPropagation(); this.qualitySubmenu.style.display = 'none'; }, { passive: false } ); // Subtitles submenu header (back) const subtitlesHeader = this.subtitlesSubmenu.querySelector('.submenu-header'); subtitlesHeader.addEventListener('click', () => { this.subtitlesSubmenu.style.display = 'none'; }); subtitlesHeader.addEventListener( 'touchend', (e) => { e.preventDefault(); e.stopPropagation(); this.subtitlesSubmenu.style.display = 'none'; }, { passive: false } ); // 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); } }); // Touch scroll detection for speed submenu this.speedSubmenu.addEventListener( 'touchstart', (e) => { this.touchStartY = e.touches[0].clientY; this.isTouchScrolling = false; }, { passive: true } ); this.speedSubmenu.addEventListener( 'touchmove', (e) => { const dy = Math.abs(e.touches[0].clientY - this.touchStartY); if (dy > 10) this.isTouchScrolling = true; }, { passive: true } ); // Mobile touch events for speed options (tap vs scroll) this.speedSubmenu.addEventListener( 'touchend', (e) => { e.stopPropagation(); if (this.isTouchScrolling) { this.isTouchScrolling = false; return; } const speedOption = e.target.closest('.speed-option'); if (speedOption) { e.preventDefault(); const speed = parseFloat(speedOption.dataset.speed); this.handleSpeedChange(speed, speedOption); } }, { passive: false } ); // 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); } }); this.qualitySubmenu.addEventListener( 'touchstart', (e) => { this.touchStartY = e.touches[0].clientY; this.isTouchScrolling = false; }, { passive: true } ); this.qualitySubmenu.addEventListener( 'touchmove', (e) => { const dy = Math.abs(e.touches[0].clientY - this.touchStartY); if (dy > 10) this.isTouchScrolling = true; }, { passive: true } ); // Mobile touch events for quality options (tap vs scroll) this.qualitySubmenu.addEventListener( 'touchend', (e) => { e.stopPropagation(); if (this.isTouchScrolling) { this.isTouchScrolling = false; return; } const qualityOption = e.target.closest('.quality-option'); if (qualityOption) { e.preventDefault(); const value = qualityOption.dataset.quality; this.handleQualityChange(value, qualityOption); } }, { passive: false } ); // Subtitle option clicks this.subtitlesSubmenu.addEventListener('click', (e) => { const opt = e.target.closest('.subtitle-option'); if (opt) { const lang = opt.dataset.lang || null; this.handleSubtitleChange(lang, opt); } }); // Touch scroll detection for subtitles submenu this.subtitlesSubmenu.addEventListener( 'touchstart', (e) => { this.touchStartY = e.touches[0].clientY; this.isTouchScrolling = false; }, { passive: true } ); this.subtitlesSubmenu.addEventListener( 'touchmove', (e) => { const dy = Math.abs(e.touches[0].clientY - this.touchStartY); if (dy > 10) this.isTouchScrolling = true; }, { passive: true } ); // Mobile touch events for subtitle options (tap vs scroll) this.subtitlesSubmenu.addEventListener( 'touchend', (e) => { e.stopPropagation(); if (this.isTouchScrolling) { this.isTouchScrolling = false; return; } const opt = e.target.closest('.subtitle-option'); if (opt) { e.preventDefault(); const lang = opt.dataset.lang || null; this.handleSubtitleChange(lang, opt); } }, { passive: false } ); // 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'; } }); // Start subtitle synchronization this.startSubtitleSync(); } toggleSettings(e) { e.stopPropagation(); const isVisible = this.settingsOverlay.classList.contains('show'); if (isVisible) { this.settingsOverlay.classList.remove('show'); this.settingsOverlay.style.display = 'none'; // Restore body scroll on mobile when closing if (this.isMobile) { document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.width = ''; } } else { this.settingsOverlay.classList.add('show'); this.settingsOverlay.style.display = 'block'; // Add haptic feedback on mobile when opening if (this.isMobile && navigator.vibrate) { navigator.vibrate(30); } // Prevent body scroll on mobile when overlay is open if (this.isMobile) { document.body.style.overflow = 'hidden'; document.body.style.position = 'fixed'; document.body.style.width = '100%'; } } this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none'; const btnEl = this.settingsButton?.el(); if (btnEl) { if (!isVisible) { btnEl.classList.add('settings-clicked'); } else { btnEl.classList.remove('settings-clicked'); } } } // Method to open settings directly to subtitles submenu openSubtitlesMenu() { // First ensure settings overlay is visible this.settingsOverlay.classList.add('show'); this.settingsOverlay.style.display = 'block'; // Hide other submenus and show subtitles submenu this.speedSubmenu.style.display = 'none'; if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; if (this.subtitlesSubmenu) { this.subtitlesSubmenu.style.display = 'flex'; // Refresh the submenu to ensure it's up to date this.refreshSubtitlesSubmenu(); } // Mark settings button as active const btnEl = this.settingsButton?.el(); if (btnEl) { btnEl.classList.add('settings-clicked'); } } // Check if settings menu is open isMenuOpen() { return this.settingsOverlay && this.settingsOverlay.classList.contains('show'); } // Close the settings menu closeMenu() { if (this.settingsOverlay) { this.settingsOverlay.classList.remove('show'); this.settingsOverlay.style.display = 'none'; this.speedSubmenu.style.display = 'none'; if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none'; // Remove active state from settings button const btnEl = this.settingsButton?.el(); if (btnEl) { btnEl.classList.remove('settings-clicked'); } // Restore body scroll on mobile when closing if (this.isMobile) { document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.width = ''; } } } 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', ''); // 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'; } 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', ''); // 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(); // Capture active subtitle language and existing remote tracks let activeSubtitleLang = null; try { const tt = player.textTracks(); for (let i = 0; i < tt.length; i++) { const t = tt[i]; if (t.kind === 'subtitles' && t.mode === 'showing') { activeSubtitleLang = t.language || t.srclang || null; break; } } } catch (e) {} // Persist active subtitle language so it survives reloads if (activeSubtitleLang) { this.userPreferences.setPreference('subtitleLanguage', activeSubtitleLang, true); // Also mark subtitles as enabled so applySubtitlePreference() runs on load this.userPreferences.setPreference('subtitleEnabled', true, true); } // Prefer remoteTextTrackEls (have src reliably) const subtitleTracksInfo = []; try { const els = player.remoteTextTrackEls ? player.remoteTextTrackEls() : []; for (let i = 0; i < els.length; i++) { const el = els[i]; // Only keep subtitle tracks if ((el.kind || '').toLowerCase() === 'subtitles') { subtitleTracksInfo.push({ kind: 'subtitles', src: el.src, srclang: el.srclang || (el.track && el.track.language) || '', label: el.label || (el.track && el.track.label) || '', default: !!el.default, }); } } } catch (e) {} // Fallback: try TextTracks if no elements found and track.src exists if (subtitleTracksInfo.length === 0) { try { const tt = player.textTracks(); for (let i = 0; i < tt.length; i++) { const t = tt[i]; if (t.kind === 'subtitles' && t.src) { subtitleTracksInfo.push({ kind: 'subtitles', src: t.src, srclang: t.language || '', label: t.label || '', default: false, }); } } } catch (e) {} } player.addClass('vjs-changing-resolution'); player.isChangingQuality = true; // prevent seek indicator during quality change player.src({ src: selected.src, type: selected.type || 'video/mp4' }); if (wasPaused) { player.pause(); } const finishRestore = () => { // Re-add remote tracks try { subtitleTracksInfo.forEach((trackInfo) => { if (trackInfo && trackInfo.src) { player.addRemoteTextTrack(trackInfo, false); } }); } catch (e) {} // Restore time and rate try { player.playbackRate(rate); } catch (e) {} try { if (!isNaN(currentTime)) player.currentTime(currentTime); } catch (e) {} // Resume state if (!wasPaused) { player.play().catch(() => {}); } else { player.pause(); } // Restore the previously active subtitle language setTimeout(() => { try { const tt2 = player.textTracks(); let restored = false; for (let i = 0; i < tt2.length; i++) { const t = tt2[i]; if (t.kind === 'subtitles') { const match = activeSubtitleLang && (t.language === activeSubtitleLang || t.srclang === activeSubtitleLang); t.mode = match ? 'showing' : 'disabled'; if (match) restored = true; } } // If nothing restored but a preference exists, try to apply it if (!restored) { const pref = this.userPreferences.getPreference('subtitleLanguage'); if (pref) { for (let i = 0; i < tt2.length; i++) { const t = tt2[i]; if (t.kind === 'subtitles' && (t.language === pref || t.srclang === pref)) { t.mode = 'showing'; break; } } } } } catch (e) {} // Sync UI this.refreshSubtitlesSubmenu(); this.updateCurrentSubtitleDisplay(); player.trigger('texttrackchange'); }, 150); // 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) {} player.removeClass('vjs-changing-resolution'); }; // Wait for metadata/data to be ready, then restore const onLoadedMeta = () => { player.off('loadedmetadata', onLoadedMeta); // Some browsers need loadeddata to have text track list ready player.one('loadeddata', finishRestore); }; player.one('loadedmetadata', onLoadedMeta); } // Close only the quality submenu (keep overlay open) if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; } handleSubtitleChange(lang, optionEl) { const player = this.player(); const tracks = player.textTracks(); // Update tracks for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (t.kind === 'subtitles') { t.mode = lang && t.language === lang ? 'showing' : 'disabled'; } } // Save preference via UserPreferences (force set) this.userPreferences.setPreference('subtitleLanguage', lang || null, true); this.userPreferences.setPreference('subtitleEnabled', !!lang, true); // for iphones // Trigger a custom event to notify other components about subtitle state change const subtitleChangeEvent = new CustomEvent('subtitleStateChanged', { detail: { enabled: !!lang, language: lang }, }); window.dispatchEvent(subtitleChangeEvent); // Update UI selection this.subtitlesSubmenu.querySelectorAll('.subtitle-option').forEach((opt) => { opt.classList.remove('active'); opt.style.background = 'transparent'; const check = opt.querySelector('.checkmark'); if (check) check.remove(); }); optionEl.classList.add('active'); optionEl.style.background = 'rgba(255, 255, 255, 0.1)'; optionEl.insertAdjacentHTML('beforeend', ''); // Update label in main settings const label = lang ? optionEl.querySelector('span')?.textContent || lang : 'Off'; const currentSubtitlesDisplay = this.settingsOverlay.querySelector('.current-subtitles'); if (currentSubtitlesDisplay) currentSubtitlesDisplay.textContent = label; // Close the entire settings overlay after subtitle selection this.settingsOverlay.classList.remove('show'); this.settingsOverlay.style.display = 'none'; this.subtitlesSubmenu.style.display = 'none'; // Remove active state from settings button const btnEl = this.settingsButton?.el(); if (btnEl) { btnEl.classList.remove('settings-clicked'); } } restoreSubtitlePreference() { const savedLanguage = this.userPreferences.getPreference('subtitleLanguage'); if (savedLanguage) { const tryRestore = (attempt = 1) => { try { const player = this.player(); const tracks = player.textTracks(); const saved = String(savedLanguage || '').toLowerCase(); // First disable all subtitle tracks for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (t.kind === 'subtitles') t.mode = 'disabled'; } // Helper for robust language matching (language or srclang; en vs en-US) const matches = (t) => { const tl = String(t.language || t.srclang || '').toLowerCase(); if (!tl || !saved) return false; return tl === saved || tl.startsWith(saved + '-') || saved.startsWith(tl + '-'); }; let restored = false; for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (t.kind === 'subtitles' && matches(t)) { t.mode = 'showing'; restored = true; // Persist enabled flag so iOS applies on next load try { this.userPreferences.setPreference('subtitleEnabled', true, true); } catch (e) {} // Refresh UI this.refreshSubtitlesSubmenu(); this.updateCurrentSubtitleDisplay(); try { player.trigger('texttrackchange'); } catch (e) {} break; } } if (!restored && attempt < 8) { // Retry with incremental delay for iOS where tracks may not be ready const delay = 150 * attempt; setTimeout(() => tryRestore(attempt + 1), delay); } } catch (e) { if (attempt < 8) setTimeout(() => tryRestore(attempt + 1), 150 * attempt); } }; setTimeout(() => tryRestore(1), 300); try { const p = this.player(); const once = (ev) => p.one(ev, () => setTimeout(() => tryRestore(1), 50)); once('loadedmetadata'); once('loadeddata'); once('canplay'); } catch (e) {} } } handleClickOutside(e) { if ( this.settingsOverlay && this.settingsButton && !this.settingsOverlay.contains(e.target) && !this.settingsButton.el().contains(e.target) ) { this.settingsOverlay.classList.remove('show'); this.settingsOverlay.style.display = 'none'; this.speedSubmenu.style.display = 'none'; if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; const btnEl = this.settingsButton?.el(); if (btnEl) { btnEl.classList.remove('settings-clicked'); } } } // Add touch tooltip support for mobile devices addTouchTooltipSupport(button) { // Check if device is touch-enabled const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; // Only add touch tooltip support on actual touch devices if (!isTouchDevice) { return; } let touchStartTime = 0; let tooltipTimeout = null; // Touch start button.addEventListener( 'touchstart', () => { touchStartTime = Date.now(); }, { passive: true } ); // Touch end button.addEventListener( 'touchend', (e) => { const touchDuration = Date.now() - touchStartTime; // Only show tooltip for quick taps (not swipes) if (touchDuration < 300) { // Don't prevent default here as it might interfere with the settings menu // Show tooltip briefly button.classList.add('touch-tooltip-active'); // Clear any existing timeout if (tooltipTimeout) { clearTimeout(tooltipTimeout); } // Hide tooltip after delay tooltipTimeout = setTimeout(() => { button.classList.remove('touch-tooltip-active'); }, 2000); } }, { passive: true } ); } dispose() { // Remove event listeners document.removeEventListener('click', this.handleClickOutside); // Clean up resize listener if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('orientationchange', this.resizeHandler); } // Restore body scroll on mobile when disposing if (this.isMobile) { document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.width = ''; } // Remove DOM elements if (this.settingsOverlay) { this.settingsOverlay.remove(); } super.dispose(); } } // Set component name for Video.js CustomSettingsMenu.prototype.controlText_ = 'Settings Menu'; // Register the component with Video.js videojs.registerComponent('CustomSettingsMenu', CustomSettingsMenu); export default CustomSettingsMenu;