mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-06 07:28:53 -05:00
1479 lines
57 KiB
JavaScript
1479 lines
57 KiB
JavaScript
// 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 = `
|
|
<span class="vjs-icon-placeholder vjs-icon-cog"></span>
|
|
<span class="vjs-control-text">Settings</span>
|
|
`;
|
|
|
|
// 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 = `
|
|
<div class="settings-header">
|
|
<span>Settings</span>
|
|
<button class="settings-close-btn" aria-label="Close settings">
|
|
<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>
|
|
</button>
|
|
</div>`;
|
|
|
|
const playbackSpeedSection = `
|
|
<div class="settings-item" data-setting="playback-speed">
|
|
<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="vjs-icon-placeholder vjs-icon-navigate-next"></span>
|
|
</span>
|
|
</div>`;
|
|
|
|
const qualitySection = `
|
|
<div class="settings-item" data-setting="quality">
|
|
<span class="settings-left">
|
|
<span class="vjs-icon-placeholder settings-item-svg">
|
|
<svg height="24" viewBox="0 0 24 24" width="24"><path d="M15,17h6v1h-6V17z M11,17H3v1h8v2h1v-2v-1v-2h-1V17z M14,8h1V6V5V3h-1v2H3v1h11V8z M18,5v1h3V5H18z M6,14h1v-2v-1V9H6v2H3v1 h3V14z M10,12h11v-1H10V12z" fill="white"></path></svg>
|
|
</span>
|
|
<span>Quality</span></span>
|
|
<span class="settings-right">
|
|
<span class="current-quality">${qualityLabelHTML}</span>
|
|
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
|
|
</span>
|
|
</div>`;
|
|
|
|
const subtitlesSection = `
|
|
<div class="settings-item" data-setting="subtitles">
|
|
<span class="settings-left">
|
|
<span class="vjs-icon-placeholder settings-item-svg">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 4H5C3.9 4 3 4.9 3 6V18C3 19.1 3.9 20 5 20H19C20.1 20 21 19.1 21 18V6C21 4.9 20.1 4 19 4ZM11 17H5V15H11V17ZM19 13H5V11H19V13ZM19 9H5V7H19V9Z" fill="white"/></svg>
|
|
</span>
|
|
<span>Captions</span></span>
|
|
<span class="settings-right">
|
|
<span class="current-subtitles">${currentSubtitleLabel}</span>
|
|
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
|
|
</span>
|
|
</div>`;
|
|
|
|
// 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 = `
|
|
<div class="submenu-header">
|
|
<span style="margin-right: 8px;">←</span>
|
|
<span>Playback speed</span>
|
|
</div>
|
|
${speedOptions
|
|
.map(
|
|
(option) => `
|
|
<div class="speed-option ${option.value === currentRate ? 'active' : ''}" data-speed="${option.value}">
|
|
<span>${option.label}</span>
|
|
${option.value === currentRate ? '<span class="checkmark">✓</span>' : ''}
|
|
</div>
|
|
`
|
|
)
|
|
.join('')}
|
|
`;
|
|
|
|
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);
|
|
}
|
|
|
|
createSubtitlesSubmenu() {
|
|
this.subtitlesSubmenu = document.createElement('div');
|
|
this.subtitlesSubmenu.className = 'subtitles-submenu';
|
|
|
|
// Header
|
|
const header = `
|
|
<div class="submenu-header">
|
|
<span style="margin-right: 8px;">←</span>
|
|
<span>Captions</span>
|
|
</div>
|
|
`;
|
|
|
|
this.subtitlesSubmenu.innerHTML = header + '<div class="submenu-body"></div>';
|
|
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) => `
|
|
<div class="subtitle-option ${it.lang === activeLang ? 'active' : ''}" data-lang="${it.lang || ''}">
|
|
<span>${it.label}</span>
|
|
${it.lang === activeLang ? '<span class="checkmark">✓</span>' : ''}
|
|
</div>
|
|
`
|
|
)
|
|
.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} <sup class="hd-badge">HD</sup>` : 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', '<span class="checkmark">✓</span>');
|
|
|
|
// Update main menu display
|
|
const currentSpeedDisplay = this.settingsOverlay.querySelector('.current-speed');
|
|
const speedLabel = speed === 1 ? 'Normal' : `${speed}`;
|
|
currentSpeedDisplay.textContent = speedLabel;
|
|
|
|
// Close only the speed submenu (keep overlay open)
|
|
this.speedSubmenu.style.display = 'none';
|
|
}
|
|
|
|
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();
|
|
|
|
// 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', '<span class="checkmark">✓</span>');
|
|
|
|
// 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;
|