mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-26 05:12:31 -05:00
feat: Major Upgrade to Video.js v8 — Chapters Functionality, Fixes and Improvements
This commit is contained in:
committed by
GitHub
parent
b39072c8ae
commit
a5e6e7b9ca
235
frontend-tools/video-js/src/utils/AutoplayHandler.js
Normal file
235
frontend-tools/video-js/src/utils/AutoplayHandler.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
234
frontend-tools/video-js/src/utils/EndScreenHandler.js
Normal file
234
frontend-tools/video-js/src/utils/EndScreenHandler.js
Normal 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();
|
||||
}
|
||||
}
|
||||
183
frontend-tools/video-js/src/utils/KeyboardHandler.js
Normal file
183
frontend-tools/video-js/src/utils/KeyboardHandler.js
Normal 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;
|
||||
65
frontend-tools/video-js/src/utils/OrientationHandler.js
Normal file
65
frontend-tools/video-js/src/utils/OrientationHandler.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
198
frontend-tools/video-js/src/utils/PlaybackEventHandler.js
Normal file
198
frontend-tools/video-js/src/utils/PlaybackEventHandler.js
Normal 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;
|
||||
611
frontend-tools/video-js/src/utils/UserPreferences.js
Normal file
611
frontend-tools/video-js/src/utils/UserPreferences.js
Normal 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;
|
||||
Reference in New Issue
Block a user