feat: Major Upgrade to Video.js v8 — Chapters Functionality, Fixes and Improvements

This commit is contained in:
Yiannis Christodoulou
2025-10-20 15:30:00 +03:00
committed by GitHub
parent b39072c8ae
commit a5e6e7b9ca
362 changed files with 62326 additions and 238721 deletions

View File

@@ -0,0 +1,235 @@
export class AutoplayHandler {
constructor(player, mediaData, userPreferences) {
this.player = player;
this.mediaData = mediaData;
this.userPreferences = userPreferences;
this.isFirefox = this.detectFirefox();
}
detectFirefox() {
return (
typeof navigator !== 'undefined' &&
navigator.userAgent &&
navigator.userAgent.toLowerCase().indexOf('firefox') > -1
);
}
hasUserInteracted() {
// Firefox-specific user interaction detection
if (this.isFirefox) {
return (
// Check if user has explicitly interacted
sessionStorage.getItem('userInteracted') === 'true' ||
// Firefox-specific: Check if document has been clicked/touched
sessionStorage.getItem('firefoxUserGesture') === 'true' ||
// More reliable focus check for Firefox
(document.hasFocus() && document.visibilityState === 'visible') ||
// Check if any user event has been registered
this.checkFirefoxUserGesture()
);
}
// Original detection for other browsers
return (
document.hasFocus() ||
document.visibilityState === 'visible' ||
sessionStorage.getItem('userInteracted') === 'true'
);
}
checkFirefoxUserGesture() {
// Firefox requires actual user gesture for autoplay
// This checks if we've detected any user interaction events
try {
const hasGesture = document.createElement('video').play();
return hasGesture && typeof hasGesture.then === 'function';
} catch {
return false;
}
}
async handleAutoplay() {
// Don't attempt autoplay if already playing or loading
if (!this.player.paused() || this.player.seeking()) {
return;
}
// Firefox-specific delay to ensure player is ready
if (this.isFirefox) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Define variables outside try block so they're accessible in catch
const userInteracted = this.hasUserInteracted();
const savedMuteState = this.userPreferences.getPreference('muted');
try {
// Firefox-specific: Always start muted if no user interaction
if (this.isFirefox && !userInteracted) {
this.player.muted(true);
} else if (!this.mediaData.urlMuted && userInteracted && savedMuteState !== true) {
this.player.muted(false);
}
// First attempt: try to play with current mute state
const playPromise = this.player.play();
// Firefox-specific promise handling
if (this.isFirefox && playPromise && typeof playPromise.then === 'function') {
await playPromise;
} else if (playPromise) {
await playPromise;
}
} catch (error) {
// Firefox-specific error handling
if (this.isFirefox) {
await this.handleFirefoxAutoplayError(error, userInteracted, savedMuteState);
} else {
// Fallback to muted autoplay unless user explicitly wants to stay unmuted
if (!this.player.muted()) {
try {
this.player.muted(true);
await this.player.play();
// Only try to restore sound if user hasn't explicitly saved mute=true
if (savedMuteState !== true) {
this.restoreSound(userInteracted);
}
} catch {
// console.error('❌ Even muted autoplay was blocked:', mutedError.message);
}
}
}
}
}
async handleFirefoxAutoplayError(error, userInteracted, savedMuteState) {
// Firefox requires muted autoplay in most cases
if (!this.player.muted()) {
try {
this.player.muted(true);
// Add a small delay for Firefox
await new Promise((resolve) => setTimeout(resolve, 50));
const mutedPlayPromise = this.player.play();
if (mutedPlayPromise && typeof mutedPlayPromise.then === 'function') {
await mutedPlayPromise;
}
// Only try to restore sound if user hasn't explicitly saved mute=true
if (savedMuteState !== true) {
this.restoreSound(userInteracted);
}
} catch {
// Even muted autoplay failed - set up user interaction listeners
this.setupFirefoxInteractionListeners();
}
} else {
// Already muted but still failed - set up interaction listeners
this.setupFirefoxInteractionListeners();
}
}
setupFirefoxInteractionListeners() {
if (!this.isFirefox) return;
const enablePlayback = async () => {
try {
sessionStorage.setItem('firefoxUserGesture', 'true');
sessionStorage.setItem('userInteracted', 'true');
if (this.player && !this.player.isDisposed() && this.player.paused()) {
const playPromise = this.player.play();
if (playPromise && typeof playPromise.then === 'function') {
await playPromise;
}
}
// Remove listeners after successful interaction
document.removeEventListener('click', enablePlayback);
document.removeEventListener('keydown', enablePlayback);
document.removeEventListener('touchstart', enablePlayback);
} catch {
// Interaction still didn't work, keep listeners active
}
};
// Set up interaction listeners for Firefox
document.addEventListener('click', enablePlayback, { once: true });
document.addEventListener('keydown', enablePlayback, { once: true });
document.addEventListener('touchstart', enablePlayback, { once: true });
// Show Firefox-specific notification
if (this.player && !this.player.isDisposed()) {
this.player.trigger('notify', '🦊 Firefox: Click to enable playback');
}
}
restoreSound(userInteracted) {
const restoreSound = () => {
if (this.player && !this.player.isDisposed()) {
this.player.muted(false);
this.player.trigger('notify', '🔊 Sound enabled!');
}
};
// Firefox-specific sound restoration
if (this.isFirefox) {
// Firefox needs more time and user interaction verification
if (userInteracted || sessionStorage.getItem('firefoxUserGesture') === 'true') {
setTimeout(restoreSound, 200); // Longer delay for Firefox
} else {
// Show Firefox-specific notification
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
this.player.trigger('notify', '🦊 Firefox: Click to enable sound');
}
}, 1500); // Longer delay for Firefox notification
// Set up Firefox-specific interaction listeners
const enableSound = () => {
restoreSound();
// Mark Firefox user interaction
sessionStorage.setItem('userInteracted', 'true');
sessionStorage.setItem('firefoxUserGesture', 'true');
// Remove listeners
document.removeEventListener('click', enableSound);
document.removeEventListener('keydown', enableSound);
document.removeEventListener('touchstart', enableSound);
};
document.addEventListener('click', enableSound, { once: true });
document.addEventListener('keydown', enableSound, { once: true });
document.addEventListener('touchstart', enableSound, { once: true });
}
} else {
// Original behavior for other browsers
if (userInteracted) {
setTimeout(restoreSound, 100);
} else {
// Show notification for manual interaction
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
this.player.trigger('notify', '🔇 Click anywhere to enable sound');
}
}, 1000);
// Set up interaction listeners
const enableSound = () => {
restoreSound();
// Mark user interaction for future videos
sessionStorage.setItem('userInteracted', 'true');
// Remove listeners
document.removeEventListener('click', enableSound);
document.removeEventListener('keydown', enableSound);
document.removeEventListener('touchstart', enableSound);
};
document.addEventListener('click', enableSound, { once: true });
document.addEventListener('keydown', enableSound, { once: true });
document.addEventListener('touchstart', enableSound, { once: true });
}
}
}
}

View File

@@ -0,0 +1,234 @@
import EndScreenOverlay from '../components/overlays/EndScreenOverlay';
import AutoplayCountdownOverlay from '../components/overlays/AutoplayCountdownOverlay';
export class EndScreenHandler {
constructor(player, options) {
this.player = player;
this.options = options;
this.endScreen = null;
this.autoplayCountdown = null;
this.setupEndScreenHandling();
}
setupEndScreenHandling() {
// Handle video ended event
this.player.on('ended', () => {
this.handleVideoEnded();
});
// Hide end screen and autoplay countdown when user wants to replay
const hideEndScreenAndStopCountdown = () => {
if (this.endScreen) {
this.endScreen.hide();
}
if (this.autoplayCountdown) {
this.autoplayCountdown.stopCountdown();
}
// Reset control bar to normal auto-hide behavior
this.resetControlBarBehavior();
};
this.player.on('play', hideEndScreenAndStopCountdown);
this.player.on('seeking', hideEndScreenAndStopCountdown);
// Reset control bar when playing after ended state
this.player.on('playing', () => {
// Only reset if we're coming from ended state (time near 0)
if (this.player.currentTime() < 1) {
setTimeout(() => {
this.player.userActive(false);
}, 1000); // Hide controls after 1 second
}
});
}
// New method to reset control bar to default behavior
resetControlBarBehavior() {
const controlBar = this.player.getChild('controlBar');
if (controlBar && controlBar.el()) {
// Remove the forced visible styles
controlBar.el().style.opacity = '';
controlBar.el().style.pointerEvents = '';
// Let video.js handle the control bar visibility normally
// Force the player to be inactive after a short delay
setTimeout(() => {
if (!this.player.paused() && !this.player.ended()) {
this.player.userActive(false);
}
}, 500);
}
}
handleVideoEnded() {
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options;
// For embed players, show big play button when video ends
if (isEmbedPlayer) {
const bigPlayButton = this.player.getChild('bigPlayButton');
if (bigPlayButton) {
bigPlayButton.show();
}
}
// Keep controls active after video ends
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
const playerEl = this.player.el();
if (playerEl) {
// Hide poster image when end screen is shown - multiple approaches
const posterImage = this.player.getChild('posterImage');
if (posterImage) {
posterImage.hide();
posterImage.el().style.display = 'none';
posterImage.el().style.visibility = 'hidden';
posterImage.el().style.opacity = '0';
}
// Hide all poster elements directly
const posterElements = playerEl.querySelectorAll('.vjs-poster');
posterElements.forEach((posterEl) => {
posterEl.style.display = 'none';
posterEl.style.visibility = 'hidden';
posterEl.style.opacity = '0';
});
// Set player background to dark to match end screen
playerEl.style.backgroundColor = '#000';
// Keep video element visible but ensure it doesn't show poster
const videoEl = playerEl.querySelector('video');
if (videoEl) {
// Remove poster attribute from video element
videoEl.removeAttribute('poster');
videoEl.style.backgroundColor = '#000';
}
// Keep the visual ended state but ensure controls work
const controlBar = this.player.getChild('controlBar');
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents = 'auto';
// Style progress bar to match dark end screen background
const progressControl = controlBar.getChild('progressControl');
if (progressControl) {
progressControl.show();
}
}
}
}
}, 50);
// Check if autoplay is enabled and there's a next video
const isAutoplayEnabled = userPreferences.getAutoplayPreference();
const hasNextVideo = mediaData.nextLink !== null;
if (!isEmbedPlayer && isAutoplayEnabled && hasNextVideo) {
// If it's a playlist, skip countdown and play directly
if (currentVideo.isPlayList) {
this.cleanupOverlays();
goToNextVideo();
} else {
this.showAutoplayCountdown(relatedVideos, goToNextVideo);
}
} else {
// Autoplay disabled or no next video - show regular end screen
this.showEndScreen(relatedVideos);
}
}
showAutoplayCountdown(relatedVideos, goToNextVideo) {
// Get next video data for countdown display - find the next video in related videos
let nextVideoData = {
title: 'Next Video',
author: '',
duration: 0,
thumbnail: '',
};
// Try to find the next video by URL matching or just use the first related video
if (relatedVideos.length > 0) {
const nextVideo = relatedVideos[0];
nextVideoData = {
title: nextVideo.title || 'Next Video',
author: nextVideo.author || '',
duration: nextVideo.duration || 0,
thumbnail: nextVideo.thumbnail || '',
};
}
// Clean up any existing overlays
this.cleanupOverlays();
// Show autoplay countdown immediately!
this.autoplayCountdown = new AutoplayCountdownOverlay(this.player, {
nextVideoData: nextVideoData,
countdownSeconds: 5,
onPlayNext: () => {
// Reset control bar when auto-playing next video
this.resetControlBarBehavior();
goToNextVideo();
},
onCancel: () => {
// Hide countdown and show end screen instead
if (this.autoplayCountdown) {
this.player.removeChild(this.autoplayCountdown);
this.autoplayCountdown = null;
}
this.showEndScreen(relatedVideos);
},
});
this.player.addChild(this.autoplayCountdown);
// Start countdown immediately without any delay
setTimeout(() => {
if (this.autoplayCountdown && !this.autoplayCountdown.isDisposed()) {
this.autoplayCountdown.startCountdown();
}
}, 0);
}
showEndScreen(relatedVideos) {
// Prevent creating multiple end screens
if (this.endScreen) {
this.player.removeChild(this.endScreen);
this.endScreen = null;
}
// Show end screen with related videos
this.endScreen = new EndScreenOverlay(this.player, {
relatedVideos: relatedVideos,
});
// Store the data directly on the component as backup and update it
this.endScreen.relatedVideos = relatedVideos;
if (this.endScreen.setRelatedVideos) {
this.endScreen.setRelatedVideos(relatedVideos);
}
this.player.addChild(this.endScreen);
this.endScreen.show();
}
cleanupOverlays() {
// Clean up any existing overlays
if (this.endScreen) {
this.player.removeChild(this.endScreen);
this.endScreen = null;
}
if (this.autoplayCountdown) {
this.player.removeChild(this.autoplayCountdown);
this.autoplayCountdown = null;
}
}
cleanup() {
this.cleanupOverlays();
// Reset control bar on cleanup
this.resetControlBarBehavior();
}
}

View File

@@ -0,0 +1,183 @@
/**
* KeyboardHandler - Utility for handling video player keyboard controls
*
* Provides comprehensive keyboard event handling for video players including:
* - Space bar for play/pause
* - Arrow keys for seeking
* - Input field detection to avoid conflicts
*/
class KeyboardHandler {
constructor(playerRef, customComponents = null, options = {}) {
this.playerRef = playerRef;
this.customComponents = customComponents;
this.options = {
seekAmount: 5, // Default seek amount in seconds
...options,
};
this.eventHandler = null;
this.isActive = false;
}
/**
* Check if an input element is currently focused
* @returns {boolean} True if an input element has focus
*/
isInputFocused() {
const activeElement = document.activeElement;
return (
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.contentEditable === 'true')
);
}
/**
* Handle space key for play/pause functionality
* @param {KeyboardEvent} event - The keyboard event
*/
handleSpaceKey(event) {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault();
if (this.playerRef.current) {
if (this.playerRef.current.paused()) {
this.playerRef.current.play();
} else {
this.playerRef.current.pause();
}
}
return true;
}
return false;
}
/**
* Handle arrow keys for seeking functionality
* @param {KeyboardEvent} event - The keyboard event
*/
handleArrowKeys(event) {
const { seekAmount } = this.options;
if (event.key === 'ArrowRight' || event.keyCode === 39) {
event.preventDefault();
this.seekForward(seekAmount);
return true;
} else if (event.key === 'ArrowLeft' || event.keyCode === 37) {
event.preventDefault();
this.seekBackward(seekAmount);
return true;
}
return false;
}
/**
* Seek forward by specified amount
* @param {number} amount - Seconds to seek forward
*/
seekForward(amount) {
if (!this.playerRef.current) return;
const currentTime = this.playerRef.current.currentTime();
const duration = this.playerRef.current.duration();
const newTime = Math.min(currentTime + amount, duration);
this.playerRef.current.currentTime(newTime);
// Show seek indicator if available
if (this.customComponents?.current?.seekIndicator) {
this.customComponents.current.seekIndicator.show('forward', amount);
}
}
/**
* Seek backward by specified amount
* @param {number} amount - Seconds to seek backward
*/
seekBackward(amount) {
if (!this.playerRef.current) return;
const currentTime = this.playerRef.current.currentTime();
const newTime = Math.max(currentTime - amount, 0);
this.playerRef.current.currentTime(newTime);
// Show seek indicator if available
if (this.customComponents?.current?.seekIndicator) {
this.customComponents.current.seekIndicator.show('backward', amount);
}
}
/**
* Main keyboard event handler
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeyboardEvent = (event) => {
// Only handle if no input elements are focused
if (this.isInputFocused()) {
return; // Don't interfere with input fields
}
// Handle space key for play/pause
if (this.handleSpaceKey(event)) {
return;
}
// Handle arrow keys for seeking
if (this.handleArrowKeys(event)) {
return;
}
};
/**
* Initialize keyboard event handling
*/
init() {
if (this.isActive) {
console.warn('KeyboardHandler is already active');
return;
}
// Add keyboard event listener to the document
document.addEventListener('keydown', this.handleKeyboardEvent);
this.isActive = true;
}
/**
* Clean up keyboard event handling
*/
destroy() {
if (!this.isActive) {
return;
}
document.removeEventListener('keydown', this.handleKeyboardEvent);
this.isActive = false;
}
/**
* Update options
* @param {Object} newOptions - New options to merge
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
}
/**
* Update player reference
* @param {Object} newPlayerRef - New player reference
*/
updatePlayerRef(newPlayerRef) {
this.playerRef = newPlayerRef;
}
/**
* Update custom components reference
* @param {Object} newCustomComponents - New custom components reference
*/
updateCustomComponents(newCustomComponents) {
this.customComponents = newCustomComponents;
}
}
export default KeyboardHandler;

View File

@@ -0,0 +1,65 @@
export class OrientationHandler {
constructor(player, isTouchDevice) {
this.player = player;
this.isTouchDevice = isTouchDevice;
this.orientationChangeHandler = null;
this.screenOrientationHandler = null;
}
setupOrientationHandling() {
// Only apply to mobile/touch devices
if (!this.isTouchDevice) {
return;
}
// Modern approach using Screen Orientation API
if (screen.orientation) {
this.screenOrientationHandler = () => {
const type = screen.orientation.type;
if (type.includes('landscape')) {
// Device rotated to landscape - enter fullscreen
if (!this.player.isFullscreen()) {
this.player.requestFullscreen();
}
} else if (type.includes('portrait')) {
// Device rotated to portrait - exit fullscreen
if (this.player.isFullscreen()) {
this.player.exitFullscreen();
}
}
};
screen.orientation.addEventListener('change', this.screenOrientationHandler);
}
// Fallback for older iOS devices
else {
this.orientationChangeHandler = () => {
// window.orientation: 0 = portrait, 90/-90 = landscape
const isLandscape = Math.abs(window.orientation) === 90;
// Small delay to ensure orientation change is complete
setTimeout(() => {
if (isLandscape && !this.player.isFullscreen()) {
this.player.requestFullscreen();
} else if (!isLandscape && this.player.isFullscreen()) {
this.player.exitFullscreen();
}
}, 100);
};
window.addEventListener('orientationchange', this.orientationChangeHandler);
}
}
cleanup() {
// Remove event listeners
if (this.screenOrientationHandler && screen.orientation) {
screen.orientation.removeEventListener('change', this.screenOrientationHandler);
}
if (this.orientationChangeHandler) {
window.removeEventListener('orientationchange', this.orientationChangeHandler);
}
}
}

View File

@@ -0,0 +1,198 @@
/**
* PlaybackEventHandler - Utility for handling video player playback events
*
* Provides comprehensive playback event handling for video players including:
* - Play event handling with seek indicators and embed player visibility
* - Pause event handling with poster management
* - Quality change detection to prevent unnecessary indicators
*/
class PlaybackEventHandler {
constructor(playerRef, customComponents = null, options = {}) {
this.playerRef = playerRef;
this.customComponents = customComponents;
this.options = {
isEmbedPlayer: false,
showSeekIndicators: true,
...options,
};
this.eventHandlers = {};
this.isActive = false;
}
/**
* Handle play event
* Shows play indicator and manages embed player visibility
*/
handlePlayEvent = () => {
const player = this.playerRef.current;
if (!player) return;
// Only show play indicator if not changing quality and indicators are enabled
if (
!player.isChangingQuality &&
this.options.showSeekIndicators &&
this.customComponents?.current?.seekIndicator
) {
this.customComponents.current.seekIndicator.show('play');
}
// For embed players, ensure video becomes visible when playing
if (this.options.isEmbedPlayer) {
this.handleEmbedPlayerVisibility('play');
}
};
/**
* Handle pause event
* Shows pause indicator and manages embed player poster visibility
*/
handlePauseEvent = () => {
const player = this.playerRef.current;
if (!player) return;
// Only show pause indicator if not changing quality and indicators are enabled
if (
!player.isChangingQuality &&
this.options.showSeekIndicators &&
this.customComponents?.current?.seekIndicator
) {
this.customComponents.current.seekIndicator.show('pause');
}
// For embed players, show poster when paused at beginning
if (this.options.isEmbedPlayer && player.currentTime() === 0) {
this.handleEmbedPlayerVisibility('pause');
}
};
/**
* Handle embed player visibility for play/pause states
* @param {string} action - 'play' or 'pause'
*/
handleEmbedPlayerVisibility(action) {
const player = this.playerRef.current;
if (!player) return;
const playerEl = player.el();
const videoEl = playerEl.querySelector('video');
const posterEl = playerEl.querySelector('.vjs-poster');
const bigPlayButton = player.getChild('bigPlayButton');
if (action === 'play') {
// Make video visible and hide poster
if (videoEl) {
videoEl.style.opacity = '1';
}
if (posterEl) {
posterEl.style.opacity = '0';
}
// Hide big play button when video starts playing
if (bigPlayButton) {
bigPlayButton.hide();
}
} else if (action === 'pause') {
// Hide video and show poster
if (videoEl) {
videoEl.style.opacity = '0';
}
if (posterEl) {
posterEl.style.opacity = '1';
}
// Show big play button when paused at beginning
if (bigPlayButton) {
bigPlayButton.show();
}
}
}
/**
* Initialize playback event handling
*/
init() {
if (this.isActive) {
console.warn('PlaybackEventHandler is already active');
return;
}
const player = this.playerRef.current;
if (!player) {
console.error('Player reference is not available');
return;
}
// Add event listeners
player.on('play', this.handlePlayEvent);
player.on('pause', this.handlePauseEvent);
// Store event handlers for cleanup
this.eventHandlers = {
play: this.handlePlayEvent,
pause: this.handlePauseEvent,
};
this.isActive = true;
}
/**
* Clean up playback event handling
*/
destroy() {
if (!this.isActive) {
return;
}
const player = this.playerRef.current;
if (player && this.eventHandlers) {
// Remove event listeners
Object.entries(this.eventHandlers).forEach(([event, handler]) => {
player.off(event, handler);
});
}
this.eventHandlers = {};
this.isActive = false;
}
/**
* Update options
* @param {Object} newOptions - New options to merge
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
}
/**
* Update player reference
* @param {Object} newPlayerRef - New player reference
*/
updatePlayerRef(newPlayerRef) {
this.playerRef = newPlayerRef;
}
/**
* Update custom components reference
* @param {Object} newCustomComponents - New custom components reference
*/
updateCustomComponents(newCustomComponents) {
this.customComponents = newCustomComponents;
}
/**
* Enable or disable seek indicators
* @param {boolean} enabled - Whether to show seek indicators
*/
setSeekIndicatorsEnabled(enabled) {
this.options.showSeekIndicators = enabled;
}
/**
* Set embed player mode
* @param {boolean} isEmbed - Whether this is an embed player
*/
setEmbedPlayerMode(isEmbed) {
this.options.isEmbedPlayer = isEmbed;
}
}
export default PlaybackEventHandler;

View File

@@ -0,0 +1,611 @@
// utils/UserPreferences.js
class UserPreferences {
constructor() {
this.storageKey = 'videojs_user_preferences';
this.isRestoringSubtitles = false; // Flag to prevent interference during restoration
this.subtitleAutoSaveDisabled = false; // Emergency flag to completely disable subtitle auto-save
this.defaultPreferences = {
volume: 1.0, // 100%
playbackRate: 1.0, // Normal speed
quality: 'auto', // Auto quality
subtitleLanguage: null, // No subtitles by default
subtitleEnabled: false, // Subtitles off by default
muted: false,
autoplay: true, // Autoplay disabled by default
};
}
/**
* Get all user preferences from localStorage
* @returns {Object} User preferences object
*/
getPreferences() {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const parsed = JSON.parse(stored);
// Merge with defaults to ensure all properties exist
return { ...this.defaultPreferences, ...parsed };
}
} catch (error) {
console.warn('Error reading user preferences from localStorage:', error);
}
return { ...this.defaultPreferences };
}
/**
* Save user preferences to localStorage
* @param {Object} preferences - Preferences object to save
*/
savePreferences(preferences) {
try {
const currentPrefs = this.getPreferences();
const updatedPrefs = { ...currentPrefs, ...preferences };
localStorage.setItem(this.storageKey, JSON.stringify(updatedPrefs));
} catch (error) {
console.warn('Error saving user preferences to localStorage:', error);
}
}
/**
* Get specific preference value
* @param {string} key - Preference key
* @returns {*} Preference value
*/
getPreference(key) {
const prefs = this.getPreferences();
return prefs[key];
}
/**
* Set specific preference value
* @param {string} key - Preference key
* @param {*} value - Preference value
* @param {boolean} forceSet - Force set even if auto-save is disabled
*/
setPreference(key, value, forceSet = false) {
// Add special logging for subtitle language changes
if (key === 'subtitleLanguage') {
// Block subtitle language changes during restoration, but allow forced sets
if (this.isRestoringSubtitles) {
return; // Don't save during restoration
}
// Allow forced sets even if auto-save is disabled (for direct user clicks)
if (this.subtitleAutoSaveDisabled && !forceSet) {
return; // Don't save if disabled unless forced
}
console.trace('Subtitle preference change stack trace');
}
this.savePreferences({ [key]: value });
}
/**
* Reset all preferences to defaults
*/
resetPreferences() {
try {
localStorage.removeItem(this.storageKey);
} catch (error) {
console.warn('Error resetting user preferences:', error);
}
}
/**
* Apply preferences to a Video.js player instance
* @param {Object} player - Video.js player instance
*/
applyToPlayer(player) {
const prefs = this.getPreferences();
// DISABLE subtitle auto-save completely during initial load
this.subtitleAutoSaveDisabled = true;
// Re-enable after 3 seconds to ensure everything has settled
setTimeout(() => {
this.subtitleAutoSaveDisabled = false;
}, 3000);
// Apply volume and mute state
if (typeof prefs.volume === 'number' && prefs.volume >= 0 && prefs.volume <= 1) {
player.volume(prefs.volume);
}
if (typeof prefs.muted === 'boolean') {
player.muted(prefs.muted);
}
// Apply playback rate
if (typeof prefs.playbackRate === 'number' && prefs.playbackRate > 0) {
player.playbackRate(prefs.playbackRate);
}
}
/**
* Set up event listeners to automatically save preferences when they change
* @param {Object} player - Video.js player instance
*/
setupAutoSave(player) {
// Save volume changes
player.on('volumechange', () => {
this.setPreference('volume', player.volume());
this.setPreference('muted', player.muted());
});
// Save playback rate changes
player.on('ratechange', () => {
this.setPreference('playbackRate', player.playbackRate());
});
// Save subtitle language changes
player.on('texttrackchange', () => {
// Skip saving if we're currently restoring subtitles
if (this.isRestoringSubtitles) {
return;
}
// Small delay to ensure the change has been processed
setTimeout(() => {
const textTracks = player.textTracks();
let activeLanguage = null;
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === 'subtitles' && track.mode === 'showing') {
activeLanguage = track.language;
break;
}
}
this.setPreference('subtitleLanguage', activeLanguage);
}, 100);
});
// Also hook into subtitle menu clicks directly
this.setupSubtitleMenuListeners(player);
}
/**
* Set up listeners on subtitle menu items
* @param {Object} player - Video.js player instance
*/
setupSubtitleMenuListeners(player) {
// Wait for the control bar to be ready
setTimeout(() => {
const controlBar = player.getChild('controlBar');
// Check all possible subtitle button names
const possibleNames = ['subtitlesButton', 'captionsButton', 'subsCapsButton', 'textTrackButton'];
let subtitlesButton = null;
for (const name of possibleNames) {
const button = controlBar.getChild(name);
if (button) {
subtitlesButton = button;
break;
}
}
// Also try to find by scanning all children
if (!subtitlesButton) {
const children = controlBar.children();
children.forEach((child) => {
const name = child.name_ || child.constructor.name || 'Unknown';
if (
name.toLowerCase().includes('subtitle') ||
name.toLowerCase().includes('caption') ||
name.toLowerCase().includes('text')
) {
subtitlesButton = child;
}
});
}
if (subtitlesButton) {
// Wait a bit more for the menu to be created
setTimeout(() => {
this.attachMenuItemListeners(player, subtitlesButton);
}, 500);
// Also try with longer delays
setTimeout(() => {
this.attachMenuItemListeners(player, subtitlesButton);
}, 2000);
} else {
// Try alternative approach - listen to DOM changes
this.setupDOMBasedListeners(player);
}
}, 1000);
}
/**
* Set up DOM-based listeners as fallback
* @param {Object} player - Video.js player instance
*/
setupDOMBasedListeners(player) {
// Wait for DOM to be ready
setTimeout(() => {
const playerEl = player.el();
if (playerEl) {
// Listen for clicks on subtitle menu items
playerEl.addEventListener('click', (event) => {
const target = event.target;
// Check if clicked element is a subtitle menu item
if (
target.closest('.vjs-subtitles-menu-item') ||
target.closest('.vjs-captions-menu-item') ||
(target.closest('.vjs-menu-item') && target.textContent.toLowerCase().includes('subtitle'))
) {
// Extract language from the clicked item
setTimeout(() => {
this.detectActiveSubtitleFromDOM(player, true); // Force set for user clicks
}, 200);
}
// Also handle "captions off" clicks
if (target.closest('.vjs-menu-item') && target.textContent.toLowerCase().includes('off')) {
setTimeout(() => {
this.setPreference('subtitleLanguage', null, true); // Force set for user clicks
}, 200);
}
});
}
}, 1500);
}
/**
* Detect active subtitle from DOM and text tracks
* @param {Object} player - Video.js player instance
* @param {boolean} forceSet - Force set even if auto-save is disabled
*/
detectActiveSubtitleFromDOM(player, forceSet = false) {
// Skip saving if we're currently restoring subtitles
if (this.isRestoringSubtitles) {
return;
}
const textTracks = player.textTracks();
let activeLanguage = null;
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === 'subtitles' && track.mode === 'showing') {
activeLanguage = track.language;
break;
}
}
this.setPreference('subtitleLanguage', activeLanguage, forceSet);
}
/**
* Attach click listeners to subtitle menu items
* @param {Object} player - Video.js player instance
* @param {Object} subtitlesButton - The subtitles button component
*/
attachMenuItemListeners(player, subtitlesButton) {
try {
const menu = subtitlesButton.menu;
if (menu && menu.children_) {
menu.children_.forEach((menuItem) => {
if (menuItem.track) {
const track = menuItem.track;
// Override the handleClick method
const originalHandleClick = menuItem.handleClick.bind(menuItem);
menuItem.handleClick = () => {
// Call original click handler
originalHandleClick();
// Save the preference after a short delay
setTimeout(() => {
if (track.mode === 'showing') {
this.setPreference('subtitleLanguage', track.language, true); // Force set for user clicks
} else {
this.setPreference('subtitleLanguage', null, true); // Force set for user clicks
}
}, 100);
};
} else if (menuItem.label && menuItem.label.toLowerCase().includes('off')) {
// Handle "captions off" option
const originalHandleClick = menuItem.handleClick.bind(menuItem);
menuItem.handleClick = () => {
originalHandleClick();
setTimeout(() => {
this.setPreference('subtitleLanguage', null, true); // Force set for user clicks
}, 100);
};
}
});
}
} catch (error) {
console.error('Error setting up subtitle menu listeners:', error);
}
}
/**
* Apply saved subtitle language preference
* @param {Object} player - Video.js player instance
*/
applySubtitlePreference(player) {
const savedLanguage = this.getPreference('subtitleLanguage');
const enabled = this.getPreference('subtitleEnabled');
if (savedLanguage) {
// Set flag to prevent auto-save during restoration
this.isRestoringSubtitles = true;
// Multiple attempts with increasing delays to ensure text tracks are loaded
// Mobile devices need more time and attempts
const maxAttempts = 10; // Increased from 5 for mobile compatibility
const attemptToApplySubtitles = (attempt = 1) => {
const textTracks = player.textTracks();
// Check if we have any subtitle tracks loaded yet
let hasSubtitleTracks = false;
for (let i = 0; i < textTracks.length; i++) {
if (textTracks[i].kind === 'subtitles') {
hasSubtitleTracks = true;
break;
}
}
// If no subtitle tracks found yet and we have attempts left, retry with longer delay
if (!hasSubtitleTracks && attempt < maxAttempts) {
// Use exponential backoff: 100ms, 200ms, 400ms, 800ms, etc.
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
return;
}
// First, disable all subtitle tracks
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === 'subtitles') {
track.mode = 'disabled';
}
}
// Helper to match language robustly (handles en vs en-US, srclang fallback)
const matchesLang = (track, target) => {
const tl = String(track.language || track.srclang || '').toLowerCase();
const sl = String(target || '').toLowerCase();
if (!tl || !sl) return false;
return tl === sl || tl.startsWith(sl + '-') || sl.startsWith(tl + '-');
};
// Then enable the saved language
let found = false;
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === 'subtitles' && matchesLang(track, savedLanguage)) {
track.mode = 'showing';
found = true;
// Also update the menu UI to reflect the selection
this.updateSubtitleMenuUI(player, track);
// Update subtitle button visual state immediately
this.updateSubtitleButtonVisualState(player, true);
// Ensure enabled flips to true after successful restore
this.setPreference('subtitleEnabled', true, true);
break;
}
}
// Fallback: if not found but enabled is true, enable the first available subtitles track
if (!found && enabled) {
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === 'subtitles') {
track.mode = 'showing';
// Save back the language we actually enabled for future precise matches
const langToSave = track.language || track.srclang || null;
if (langToSave) this.setPreference('subtitleLanguage', langToSave, true);
// Ensure enabled flips to true after successful restore
this.setPreference('subtitleEnabled', true, true);
this.updateSubtitleMenuUI(player, track);
this.updateSubtitleButtonVisualState(player, true);
found = true;
break;
}
}
}
// Clear the restoration flag after a longer delay to ensure all events have settled
setTimeout(() => {
this.isRestoringSubtitles = false;
}, 600);
// If not found and we haven't tried too many times, try again with longer delay
if (!found && attempt < maxAttempts) {
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
} else if (!found) {
// Only log warning if we had subtitle tracks but couldn't match the language
if (hasSubtitleTracks) {
console.warn('Could not find subtitle track for language:', savedLanguage);
}
// Clear flag even if not found
this.isRestoringSubtitles = false;
}
};
// Start attempting to apply subtitles immediately
attemptToApplySubtitles();
// Also attempt when tracks are added/changed (iOS timing)
try {
const vEl =
(player.tech_ && player.tech_.el_) ||
(player.el && player.el().querySelector && player.el().querySelector('video'));
const ttList = vEl && vEl.textTracks;
if (ttList && typeof ttList.addEventListener === 'function') {
const onAddTrack = () => setTimeout(() => attemptToApplySubtitles(1), 50);
const onChange = () => setTimeout(() => attemptToApplySubtitles(1), 50);
ttList.addEventListener('addtrack', onAddTrack, { once: true });
ttList.addEventListener('change', onChange, { once: true });
}
} catch {
// Silently ignore errors accessing native text track list
}
} else {
// Ensure subtitles are off on load when not enabled
try {
const textTracks = player.textTracks();
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track.kind === 'subtitles') {
track.mode = 'disabled';
}
}
// Update subtitle button visual state to show disabled
this.updateSubtitleButtonVisualState(player, false);
// Update custom settings menu to show "Off" as selected
this.updateCustomSettingsMenuUI(player);
} catch (e) {
console.error('Error applying subtitle preference:', e);
}
}
}
/**
* Update subtitle button visual state (red underline)
* @param {Object} player - Video.js player instance
* @param {boolean} enabled - Whether subtitles are enabled
*/
updateSubtitleButtonVisualState(player, enabled) {
try {
const controlBar = player.getChild('controlBar');
const subtitlesButton = controlBar.getChild('subtitlesButton');
if (subtitlesButton && subtitlesButton.el()) {
const buttonEl = subtitlesButton.el();
if (enabled) {
buttonEl.classList.add('vjs-subs-active');
} else {
buttonEl.classList.remove('vjs-subs-active');
}
}
} catch (error) {
console.error('Error updating subtitle button visual state:', error);
}
}
/**
* Update subtitle menu UI to reflect the active track
* @param {Object} player - Video.js player instance
* @param {Object} activeTrack - The active text track
*/
updateSubtitleMenuUI(player, activeTrack) {
try {
const controlBar = player.getChild('controlBar');
const subtitlesButton = controlBar.getChild('subtitlesButton');
if (subtitlesButton && subtitlesButton.menu) {
const menu = subtitlesButton.menu;
// Update menu items to reflect selection
menu.children_.forEach((menuItem) => {
if (menuItem.track) {
if (menuItem.track === activeTrack) {
menuItem.selected(true);
} else {
menuItem.selected(false);
}
} else if (menuItem.label && menuItem.label.toLowerCase().includes('off')) {
menuItem.selected(false);
}
});
}
// Also update the custom settings menu if it exists
this.updateCustomSettingsMenuUI(player);
} catch (error) {
console.error('Error updating subtitle menu UI:', error);
}
}
/**
* Update custom settings menu UI to reflect the current subtitle state
* @param {Object} player - Video.js player instance
*/
updateCustomSettingsMenuUI(player) {
const attemptUpdate = (attempt = 1) => {
try {
// Find the custom settings menu component
const controlBar = player.getChild('controlBar');
const customSettingsMenu = controlBar.getChild('CustomSettingsMenu');
if (customSettingsMenu && customSettingsMenu.refreshSubtitlesSubmenu) {
customSettingsMenu.refreshSubtitlesSubmenu();
} else if (attempt < 5) {
// Retry after a short delay if menu not found
setTimeout(() => attemptUpdate(attempt + 1), attempt * 200);
}
} catch (error) {
console.error('Error updating custom settings menu UI:', error);
}
};
// Start the update attempt
attemptUpdate();
}
/**
* Get quality preference for settings menu
* @returns {string} Quality preference
*/
getQualityPreference() {
return this.getPreference('quality');
}
/**
* Set quality preference from settings menu
* @param {string} quality - Quality setting
*/
setQualityPreference(quality) {
this.setPreference('quality', quality);
}
/**
* Force save subtitle language preference (bypasses all protection)
* @param {string} language - Subtitle language code
*/
forceSetSubtitleLanguage(language) {
const currentPrefs = this.getPreferences();
const updatedPrefs = { ...currentPrefs, subtitleLanguage: language };
try {
localStorage.setItem(this.storageKey, JSON.stringify(updatedPrefs));
} catch (error) {
console.error('❌ Error force saving subtitle language:', error);
}
}
/**
* Get autoplay preference
* @returns {boolean} Autoplay preference
*/
getAutoplayPreference() {
return this.getPreference('autoplay');
}
/**
* Set autoplay preference
* @param {boolean} autoplay - Autoplay setting
*/
setAutoplayPreference(autoplay) {
this.setPreference('autoplay', autoplay);
}
}
export default UserPreferences;