feat: Focus the player element so keyboard controls work

This commit is contained in:
Yiannis Christodoulou 2025-09-19 09:17:03 +03:00
parent 738d0d9e00
commit e3291a5d75
2 changed files with 1048 additions and 973 deletions

View File

@ -770,6 +770,18 @@ button {
padding: 0;
height: 100% !important;
border-radius: 12px;
outline: none; /* Remove default browser focus outline */
}
/* Custom focus styles for video player */
.video-js:focus {
box-shadow: 0 0 0 3px rgba(25, 153, 50, 0.5);
border-radius: 12px;
}
/* Ensure video player is focusable */
.video-js[tabindex] {
outline: none;
}
/* Fullscreen video-js player styles for embedded video player */

View File

@ -1,10 +1,10 @@
// components/controls/CustomSettingsMenu.js
import videojs from "video.js";
import "./CustomSettingsMenu.css";
import UserPreferences from "../../utils/UserPreferences";
import videojs from 'video.js';
import './CustomSettingsMenu.css';
import UserPreferences from '../../utils/UserPreferences';
// Get the Component base class from Video.js
const Component = videojs.getComponent("Component");
const Component = videojs.getComponent('Component');
class CustomSettingsMenu extends Component {
constructor(player, options) {
@ -45,14 +45,14 @@ class CustomSettingsMenu extends Component {
}
createSettingsButton() {
const controlBar = this.player().getChild("controlBar");
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 settings-clicked",
this.settingsButton = controlBar.addChild('button', {
controlText: 'Settings',
className: 'vjs-settings-button settings-clicked',
});
// Style the settings button (gear icon)
@ -79,15 +79,21 @@ class CustomSettingsMenu extends Component {
let touchHandled = false;
// Handle touchstart
buttonEl.addEventListener('touchstart', (e) => {
buttonEl.addEventListener(
'touchstart',
(e) => {
touchStartTime = Date.now();
touchHandled = false;
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
}, { passive: true });
},
{ passive: true }
);
// Handle touchend with proper passive handling
buttonEl.addEventListener('touchend', (e) => {
buttonEl.addEventListener(
'touchend',
(e) => {
const touchEndTime = Date.now();
const touchDuration = touchEndTime - touchStartTime;
@ -96,8 +102,7 @@ class CustomSettingsMenu extends Component {
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)
Math.pow(touchEndPos.x - touchStartPos.x, 2) + Math.pow(touchEndPos.y - touchStartPos.y, 2)
);
// Only trigger if it's a tap (not a swipe)
@ -108,7 +113,9 @@ class CustomSettingsMenu extends Component {
this.toggleSettings(e);
}
}
}, { passive: false });
},
{ passive: false }
);
// Handle click events (desktop and mobile fallback)
buttonEl.addEventListener('click', (e) => {
@ -124,39 +131,34 @@ class CustomSettingsMenu extends Component {
}
createSettingsOverlay() {
const controlBar = this.player().getChild("controlBar");
const controlBar = this.player().getChild('controlBar');
// Create settings overlay
this.settingsOverlay = document.createElement("div");
this.settingsOverlay.className = "custom-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");
const currentPlaybackRate = this.userPreferences.getPreference('playbackRate');
const currentQuality = this.userPreferences.getPreference('quality');
// Find current subtitle selection for label
let currentSubtitleLabel = "Off";
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 || "Subtitles";
if (t.kind === 'subtitles' && t.mode === 'showing') {
currentSubtitleLabel = t.label || t.language || 'Subtitles';
break;
}
}
} catch (e) {}
// Format playback rate for display
const playbackRateLabel =
currentPlaybackRate === 1 ? "Normal" : `${currentPlaybackRate}`;
const playbackRateLabel = currentPlaybackRate === 1 ? 'Normal' : `${currentPlaybackRate}`;
const qualities = this.getAvailableQualities();
const activeQuality =
qualities.find((q) => q.value === currentQuality) || qualities[0];
const activeQuality = qualities.find((q) => q.value === currentQuality) || qualities[0];
const qualityLabelHTML =
activeQuality?.displayLabel ||
activeQuality?.label ||
(currentQuality ? String(currentQuality) : "Auto");
activeQuality?.displayLabel || activeQuality?.label || (currentQuality ? String(currentQuality) : 'Auto');
// Settings menu content - split into separate variables for maintainability
const settingsHeader = `
@ -233,21 +235,21 @@ class CustomSettingsMenu extends Component {
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 },
{ 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";
this.speedSubmenu = document.createElement('div');
this.speedSubmenu.className = 'speed-submenu';
// Get current playback rate for highlighting
const currentRate = this.userPreferences.getPreference("playbackRate");
const currentRate = this.userPreferences.getPreference('playbackRate');
this.speedSubmenu.innerHTML = `
<div class="submenu-header">
@ -257,21 +259,21 @@ class CustomSettingsMenu extends Component {
${speedOptions
.map(
(option) => `
<div class="speed-option ${option.value === currentRate ? "active" : ""}" data-speed="${option.value}">
<div class="speed-option ${option.value === currentRate ? 'active' : ''}" data-speed="${option.value}">
<span>${option.label}</span>
${option.value === currentRate ? '<span class="checkmark">✓</span>' : ""}
${option.value === currentRate ? '<span class="checkmark">✓</span>' : ''}
</div>
`
)
.join("")}
.join('')}
`;
this.settingsOverlay.appendChild(this.speedSubmenu);
}
createQualitySubmenu(qualities, currentValue) {
this.qualitySubmenu = document.createElement("div");
this.qualitySubmenu.className = "quality-submenu";
this.qualitySubmenu = document.createElement('div');
this.qualitySubmenu.className = 'quality-submenu';
const header = `
<div class="submenu-header">
@ -283,21 +285,21 @@ class CustomSettingsMenu extends Component {
const optionsHtml = qualities
.map(
(q) => `
<div class="quality-option ${q.value === currentValue ? "active" : ""}" data-quality="${q.value}">
<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>' : ""}
${q.value === currentValue ? '<span class="checkmark">✓</span>' : ''}
</div>
`
)
.join("");
.join('');
this.qualitySubmenu.innerHTML = header + optionsHtml;
this.settingsOverlay.appendChild(this.qualitySubmenu);
}
createSubtitlesSubmenu() {
this.subtitlesSubmenu = document.createElement("div");
this.subtitlesSubmenu.className = "subtitles-submenu";
this.subtitlesSubmenu = document.createElement('div');
this.subtitlesSubmenu.className = 'subtitles-submenu';
// Header
const header = `
@ -341,12 +343,16 @@ class CustomSettingsMenu extends Component {
}
}
body.innerHTML = items.map((it) => `
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('');
`
)
.join('');
// Also update the current subtitle display in main settings
this.updateCurrentSubtitleDisplay();
@ -356,14 +362,14 @@ class CustomSettingsMenu extends Component {
try {
const player = this.player();
const tracks = player.textTracks();
let currentSubtitleLabel = "Off";
let currentSubtitleLabel = 'Off';
let activeTrack = null;
// 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 || "Subtitles";
currentSubtitleLabel = t.label || t.language || 'Subtitles';
activeTrack = t;
break;
}
@ -378,11 +384,11 @@ class CustomSettingsMenu extends Component {
if (oldValue !== currentSubtitleLabel) {
console.log(`Updated current subtitle display: "${oldValue}" → "${currentSubtitleLabel}"`);
if (activeTrack) {
console.log(`Active track details: language="${activeTrack.language}", label="${activeTrack.label}", mode="${activeTrack.mode}"`);
console.log(
`Active track details: language="${activeTrack.language}", label="${activeTrack.label}", mode="${activeTrack.mode}"`
);
}
}
} else {
console.warn('Could not find .current-subtitles element in settings overlay');
}
} catch (error) {
console.error('Error updating current subtitle display:', error);
@ -420,7 +426,7 @@ class CustomSettingsMenu extends Component {
dispose() {
this.stopSubtitleSync();
// Remove event listeners
document.removeEventListener("click", this.handleClickOutside);
document.removeEventListener('click', this.handleClickOutside);
// Remove text track change listener
if (this.player()) {
this.player().off('texttrackchange');
@ -429,36 +435,22 @@ class CustomSettingsMenu extends Component {
getAvailableQualities() {
// Priority: provided options -> MEDIA_DATA JSON -> player sources -> default
const desiredOrder = [
"auto",
"144p",
"240p",
"360p",
"480p",
"720p",
"1080p",
];
const desiredOrder = ['auto', '144p', '240p', '360p', '480p', '720p', '1080p'];
if (
Array.isArray(this.providedQualities) &&
this.providedQualities.length
) {
return this.sortAndDecorateQualities(
this.providedQualities,
desiredOrder
);
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 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(),
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",
type: q.type || 'video/mp4',
}));
return this.sortAndDecorateQualities(normalized, desiredOrder);
}
@ -467,18 +459,13 @@ class CustomSettingsMenu extends Component {
}
// Derive from player's current sources
const sources = this.player().currentSources
? this.player().currentSources()
: this.player().currentSrc();
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}`);
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 { label, value, src: s.src, type: s.type || 'video/mp4' };
});
return this.sortAndDecorateQualities(mapped, desiredOrder);
}
@ -494,16 +481,14 @@ class CustomSettingsMenu extends Component {
};
// Only include qualities that have actual sources
const validQualities = list.filter(q => q.src);
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;
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));
@ -514,9 +499,7 @@ class CustomSettingsMenu extends Component {
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
);
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`;
@ -524,8 +507,8 @@ class CustomSettingsMenu extends Component {
}
positionButton() {
const controlBar = this.player().getChild("controlBar");
const fullscreenToggle = controlBar.getChild("fullscreenToggle");
const controlBar = this.player().getChild('controlBar');
const fullscreenToggle = controlBar.getChild('fullscreenToggle');
if (this.settingsButton && fullscreenToggle) {
// Small delay to ensure all buttons are created
@ -533,7 +516,7 @@ class CustomSettingsMenu extends Component {
const fullscreenIndex = controlBar.children().indexOf(fullscreenToggle);
controlBar.removeChild(this.settingsButton);
controlBar.addChild(this.settingsButton, {}, fullscreenIndex + 1);
console.log("✓ Settings button positioned after fullscreen toggle");
console.log('✓ Settings button positioned after fullscreen toggle');
}, 50);
}
}
@ -544,117 +527,148 @@ class CustomSettingsMenu extends Component {
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";
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");
btnEl.classList.remove('settings-clicked');
}
};
closeButton.addEventListener('click', closeFunction);
closeButton.addEventListener('touchend', (e) => {
closeButton.addEventListener(
'touchend',
(e) => {
e.preventDefault();
closeFunction(e);
}, { passive: false });
},
{ passive: false }
);
}
// Settings item clicks
this.settingsOverlay.addEventListener("click", (e) => {
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";
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";
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";
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.settingsOverlay.addEventListener(
'touchstart',
(e) => {
this.touchStartY = e.touches[0].clientY;
this.isTouchScrolling = false;
}, { passive: true });
this.settingsOverlay.addEventListener('touchmove', (e) => {
},
{ 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 });
},
{ passive: true }
);
// Mobile touch events for settings items (tap vs scroll)
this.settingsOverlay.addEventListener("touchend", (e) => {
this.settingsOverlay.addEventListener(
'touchend',
(e) => {
e.stopPropagation();
if (this.isTouchScrolling) { this.isTouchScrolling = false; return; }
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";
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";
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";
this.subtitlesSubmenu.style.display = 'flex';
this.speedSubmenu.style.display = 'none';
this.qualitySubmenu.style.display = 'none';
}
}, { passive: false });
},
{ passive: false }
);
// Speed submenu header (back button)
const speedHeader = this.speedSubmenu.querySelector(".submenu-header");
speedHeader.addEventListener("click", () => {
this.speedSubmenu.style.display = "none";
const speedHeader = this.speedSubmenu.querySelector('.submenu-header');
speedHeader.addEventListener('click', () => {
this.speedSubmenu.style.display = 'none';
});
speedHeader.addEventListener("touchend", (e) => {
speedHeader.addEventListener(
'touchend',
(e) => {
e.preventDefault();
e.stopPropagation();
this.speedSubmenu.style.display = "none";
}, { passive: false });
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";
const qualityHeader = this.qualitySubmenu.querySelector('.submenu-header');
qualityHeader.addEventListener('click', () => {
this.qualitySubmenu.style.display = 'none';
});
qualityHeader.addEventListener("touchend", (e) => {
qualityHeader.addEventListener(
'touchend',
(e) => {
e.preventDefault();
e.stopPropagation();
this.qualitySubmenu.style.display = "none";
}, { passive: false });
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";
const subtitlesHeader = this.subtitlesSubmenu.querySelector('.submenu-header');
subtitlesHeader.addEventListener('click', () => {
this.subtitlesSubmenu.style.display = 'none';
});
subtitlesHeader.addEventListener("touchend", (e) => {
subtitlesHeader.addEventListener(
'touchend',
(e) => {
e.preventDefault();
e.stopPropagation();
this.subtitlesSubmenu.style.display = "none";
}, { passive: false });
this.subtitlesSubmenu.style.display = 'none';
},
{ passive: false }
);
// Speed option clicks
this.speedSubmenu.addEventListener("click", (e) => {
const speedOption = e.target.closest(".speed-option");
this.speedSubmenu.addEventListener('click', (e) => {
const speedOption = e.target.closest('.speed-option');
if (speedOption) {
const speed = parseFloat(speedOption.dataset.speed);
this.handleSpeedChange(speed, speedOption);
@ -662,54 +676,84 @@ class CustomSettingsMenu extends Component {
});
// Touch scroll detection for speed submenu
this.speedSubmenu.addEventListener('touchstart', (e) => {
this.speedSubmenu.addEventListener(
'touchstart',
(e) => {
this.touchStartY = e.touches[0].clientY;
this.isTouchScrolling = false;
}, { passive: true });
this.speedSubmenu.addEventListener('touchmove', (e) => {
},
{ 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 });
},
{ passive: true }
);
// Mobile touch events for speed options (tap vs scroll)
this.speedSubmenu.addEventListener("touchend", (e) => {
this.speedSubmenu.addEventListener(
'touchend',
(e) => {
e.stopPropagation();
if (this.isTouchScrolling) { this.isTouchScrolling = false; return; }
const speedOption = e.target.closest(".speed-option");
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 });
},
{ passive: false }
);
// Quality option clicks
this.qualitySubmenu.addEventListener("click", (e) => {
const qualityOption = e.target.closest(".quality-option");
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.qualitySubmenu.addEventListener(
'touchstart',
(e) => {
this.touchStartY = e.touches[0].clientY;
this.isTouchScrolling = false;
}, { passive: true });
this.qualitySubmenu.addEventListener('touchmove', (e) => {
},
{ 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 });
},
{ passive: true }
);
// Mobile touch events for quality options (tap vs scroll)
this.qualitySubmenu.addEventListener("touchend", (e) => {
this.qualitySubmenu.addEventListener(
'touchend',
(e) => {
e.stopPropagation();
if (this.isTouchScrolling) { this.isTouchScrolling = false; return; }
const qualityOption = e.target.closest(".quality-option");
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 });
},
{ passive: false }
);
// Subtitle option clicks
this.subtitlesSubmenu.addEventListener('click', (e) => {
@ -721,41 +765,56 @@ class CustomSettingsMenu extends Component {
});
// Touch scroll detection for subtitles submenu
this.subtitlesSubmenu.addEventListener('touchstart', (e) => {
this.subtitlesSubmenu.addEventListener(
'touchstart',
(e) => {
this.touchStartY = e.touches[0].clientY;
this.isTouchScrolling = false;
}, { passive: true });
this.subtitlesSubmenu.addEventListener('touchmove', (e) => {
},
{ 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 });
},
{ passive: true }
);
// Mobile touch events for subtitle options (tap vs scroll)
this.subtitlesSubmenu.addEventListener('touchend', (e) => {
this.subtitlesSubmenu.addEventListener(
'touchend',
(e) => {
e.stopPropagation();
if (this.isTouchScrolling) { this.isTouchScrolling = false; return; }
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 });
},
{ passive: false }
);
// Close menu when clicking outside
document.addEventListener("click", this.handleClickOutside);
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('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";
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';
}
});
@ -765,24 +824,24 @@ class CustomSettingsMenu extends Component {
toggleSettings(e) {
e.stopPropagation();
const isVisible = this.settingsOverlay.classList.contains("show");
const isVisible = this.settingsOverlay.classList.contains('show');
if (isVisible) {
this.settingsOverlay.classList.remove("show");
this.settingsOverlay.style.display = "none";
this.settingsOverlay.classList.remove('show');
this.settingsOverlay.style.display = 'none';
} else {
this.settingsOverlay.classList.add("show");
this.settingsOverlay.style.display = "block";
this.settingsOverlay.classList.add('show');
this.settingsOverlay.style.display = 'block';
}
this.speedSubmenu.style.display = "none"; // Hide submenu when main menu toggles
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
const btnEl = this.settingsButton?.el();
if (btnEl) {
if (!isVisible) {
btnEl.classList.add("settings-clicked");
btnEl.classList.add('settings-clicked');
} else {
btnEl.classList.remove("settings-clicked");
btnEl.classList.remove('settings-clicked');
}
}
}
@ -792,33 +851,29 @@ class CustomSettingsMenu extends Component {
this.player().playbackRate(speed);
// Save preference
this.userPreferences.setPreference("playbackRate", speed);
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");
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>'
);
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}`;
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";
this.speedSubmenu.style.display = 'none';
console.log("Playback speed preference saved:", speed);
console.log('Playback speed preference saved:', speed);
}
handleQualityChange(value, qualityOption) {
@ -829,25 +884,20 @@ class CustomSettingsMenu extends Component {
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");
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>'
);
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);
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) {
@ -889,7 +939,7 @@ class CustomSettingsMenu extends Component {
src: el.src,
srclang: el.srclang || (el.track && el.track.language) || '',
label: el.label || (el.track && el.track.label) || '',
default: !!el.default
default: !!el.default,
});
}
}
@ -907,7 +957,7 @@ class CustomSettingsMenu extends Component {
src: t.src,
srclang: t.language || '',
label: t.label || '',
default: false
default: false,
});
}
}
@ -933,8 +983,12 @@ class CustomSettingsMenu extends Component {
} catch (e) {}
// Restore time and rate
try { player.playbackRate(rate); } catch (e) {}
try { if (!isNaN(currentTime)) player.currentTime(currentTime); } catch (e) {}
try {
player.playbackRate(rate);
} catch (e) {}
try {
if (!isNaN(currentTime)) player.currentTime(currentTime);
} catch (e) {}
// Resume state
if (!wasPaused) {
@ -951,7 +1005,9 @@ class CustomSettingsMenu extends Component {
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);
const match =
activeSubtitleLang &&
(t.language === activeSubtitleLang || t.srclang === activeSubtitleLang);
t.mode = match ? 'showing' : 'disabled';
if (match) restored = true;
}
@ -985,7 +1041,10 @@ class CustomSettingsMenu extends Component {
if (btn) {
if (typeof btn.show === 'function') btn.show();
const el = btn.el && btn.el();
if (el) { el.style.display = ''; el.style.visibility = ''; }
if (el) {
el.style.display = '';
el.style.visibility = '';
}
}
}
} catch (e) {}
@ -1003,9 +1062,9 @@ class CustomSettingsMenu extends Component {
}
// Close only the quality submenu (keep overlay open)
if (this.qualitySubmenu) this.qualitySubmenu.style.display = "none";
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
console.log("Quality preference saved:", value);
console.log('Quality preference saved:', value);
}
handleSubtitleChange(lang, optionEl) {
@ -1036,7 +1095,7 @@ class CustomSettingsMenu extends Component {
optionEl.insertAdjacentHTML('beforeend', '<span class="checkmark">✓</span>');
// Update label in main settings
const label = lang ? (optionEl.querySelector('span')?.textContent || lang) : 'Off';
const label = lang ? optionEl.querySelector('span')?.textContent || lang : 'Off';
const currentSubtitlesDisplay = this.settingsOverlay.querySelector('.current-subtitles');
if (currentSubtitlesDisplay) currentSubtitlesDisplay.textContent = label;
@ -1071,11 +1130,15 @@ class CustomSettingsMenu extends Component {
t.mode = 'showing';
restored = true;
// Persist enabled flag so iOS applies on next load
try { this.userPreferences.setPreference('subtitleEnabled', true, true); } catch (e) { }
try {
this.userPreferences.setPreference('subtitleEnabled', true, true);
} catch (e) {}
// Refresh UI
this.refreshSubtitlesSubmenu();
this.updateCurrentSubtitleDisplay();
try { player.trigger('texttrackchange'); } catch (e) { }
try {
player.trigger('texttrackchange');
} catch (e) {}
break;
}
}
@ -1107,20 +1170,20 @@ class CustomSettingsMenu extends Component {
!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";
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");
btnEl.classList.remove('settings-clicked');
}
}
}
dispose() {
// Remove event listeners
document.removeEventListener("click", this.handleClickOutside);
document.removeEventListener('click', this.handleClickOutside);
// Remove DOM elements
if (this.settingsOverlay) {
@ -1132,9 +1195,9 @@ class CustomSettingsMenu extends Component {
}
// Set component name for Video.js
CustomSettingsMenu.prototype.controlText_ = "Settings Menu";
CustomSettingsMenu.prototype.controlText_ = 'Settings Menu';
// Register the component with Video.js
videojs.registerComponent("CustomSettingsMenu", CustomSettingsMenu);
videojs.registerComponent('CustomSettingsMenu', CustomSettingsMenu);
export default CustomSettingsMenu;