controller-helpers-commands.js
1 import { getAudioStateManager } from '../audio/audio-state-manager.js'; 2 import { createLogger } from '../../utils/debug/logger.js'; 3 4 const logger = createLogger('VideoCommands'); 5 6 export function enqueueCommand(vcInstance, commandFunction, options = {}) { 7 const { description = 'Unknown command', unmuteBeforeExecution = false } = options; 8 9 return new Promise((resolve, reject) => { 10 if (!vcInstance || !vcInstance.videoElement) { 11 reject(new Error('VideoController instance is invalid or cleaned up.')); 12 return; 13 } 14 15 const wrappedCommand = () => { 16 if (unmuteBeforeExecution && vcInstance.videoElement.muted && vcInstance.hasUserInteracted) { 17 const audioStateManager = getAudioStateManager(); 18 19 if (!audioStateManager.videoElement) { 20 audioStateManager.initialize(vcInstance.videoElement); 21 } 22 23 audioStateManager.ensureUnmuted(vcInstance.hasUserInteracted); 24 } 25 return commandFunction(); 26 }; 27 28 vcInstance.commandQueue.push({ 29 description, 30 execute: wrappedCommand, 31 reject, 32 resolve, 33 }); 34 35 setTimeout(() => { 36 if (vcInstance && !vcInstance.isProcessingCommandQueue) { 37 _processCommandQueue(vcInstance); 38 } 39 }, 0); 40 }); 41 } 42 43 export async function _processCommandQueue(vcInstance) { 44 if (vcInstance.isProcessingCommandQueue || !vcInstance || !vcInstance.videoElement) { 45 return; 46 } 47 vcInstance.isProcessingCommandQueue = true; 48 while (vcInstance.commandQueue.length > 0) { 49 if (!vcInstance || !vcInstance.videoElement) { 50 logger.warn('VideoController invalid during queue processing. Aborting.'); 51 52 vcInstance.commandQueue = []; 53 break; 54 } 55 56 const { description, execute, reject, resolve } = vcInstance.commandQueue.shift(); 57 const commandDesc = description || 'Unknown command'; 58 59 try { 60 const result = await execute(); 61 62 resolve(result); 63 } catch (error) { 64 logger.warn(`Command failed: ${commandDesc}`, error); 65 66 reject(error); 67 } 68 } 69 vcInstance.isProcessingCommandQueue = false; 70 } 71 72 function validateVideoController(vcInstance) { 73 if (!vcInstance) { 74 throw new Error('VideoController instance is null'); 75 } 76 77 if (!vcInstance.videoElement) { 78 logger.warn('Play command failed: Video element is null', { 79 currentCid: vcInstance.currentCid || 'none', 80 hasController: Boolean(vcInstance), 81 isProcessingCommandQueue: vcInstance.isProcessingCommandQueue, 82 queueLength: vcInstance.commandQueue?.length || 0, 83 }); 84 throw new Error('Video element not available'); 85 } 86 87 if (!vcInstance.currentCid) { 88 throw new Error('Cannot play: No video source loaded (missing CID)'); 89 } 90 } 91 92 export async function forceMetadataLoading(vcInstance, timeoutMs = 5000) { 93 if (!vcInstance || !vcInstance.videoElement) { 94 logger.warn('Cannot force metadata loading: Video element not available'); 95 return false; 96 } 97 98 const videoElement = vcInstance.videoElement; 99 100 if (videoElement.readyState >= HTMLMediaElement.HAVE_METADATA) { 101 logger.debug('Metadata already loaded, no need to force loading'); 102 return true; 103 } 104 105 logger.info('Forcing metadata loading with timeout:', timeoutMs); 106 107 return new Promise(resolve => { 108 const timeoutId = setTimeout(() => { 109 logger.warn(`Metadata loading timed out after ${timeoutMs}ms`); 110 cleanup(); 111 resolve(false); 112 }, timeoutMs); 113 114 const loadedMetadataHandler = () => { 115 logger.info('Metadata loaded successfully'); 116 cleanup(); 117 resolve(true); 118 }; 119 120 const errorHandler = error => { 121 logger.error('Error during metadata loading:', error); 122 cleanup(); 123 resolve(false); 124 }; 125 126 const cleanup = () => { 127 videoElement.removeEventListener('loadedmetadata', loadedMetadataHandler); 128 videoElement.removeEventListener('error', errorHandler); 129 clearTimeout(timeoutId); 130 }; 131 132 videoElement.addEventListener('loadedmetadata', loadedMetadataHandler); 133 videoElement.addEventListener('error', errorHandler); 134 135 if (videoElement.readyState === 0) { 136 try { 137 videoElement.load(); 138 } catch (e) { 139 logger.warn('Error calling load() to force metadata loading:', e); 140 } 141 } 142 }); 143 } 144 145 async function prepareVideoForPlayback(vcInstance) { 146 if (!vcInstance.videoElement.paused) { 147 return false; 148 } 149 150 if (vcInstance.videoElement.ended) { 151 vcInstance.videoElement.currentTime = 0; 152 } 153 154 if (vcInstance.videoElement.readyState < HTMLMediaElement.HAVE_METADATA) { 155 logger.info('Waiting for metadata to load before playback'); 156 await forceMetadataLoading(vcInstance, 5000); 157 } 158 159 return true; 160 } 161 162 function handleAudioState(vcInstance) { 163 if (!vcInstance.hasUserInteracted) { 164 if (!vcInstance.videoElement.muted) { 165 vcInstance.videoElement.muted = true; 166 } 167 } else { 168 const audioStateManager = getAudioStateManager(); 169 170 if (!audioStateManager.videoElement) { 171 audioStateManager.initialize(vcInstance.videoElement); 172 } 173 174 audioStateManager.ensureUnmuted(vcInstance.hasUserInteracted); 175 } 176 } 177 178 async function attemptPlayErrorRecovery(vcInstance, error) { 179 if (error.name === 'NotAllowedError') { 180 return; 181 } 182 183 logger.warn(`Play command failed: ${error.name} - ${error.message}`); 184 185 if (!vcInstance.videoElement?.src) { 186 return; 187 } 188 189 try { 190 logger.info('Attempting to reload video after play error...'); 191 const { currentTime } = vcInstance.videoElement; 192 193 vcInstance.videoElement.load(); 194 vcInstance.videoElement.currentTime = currentTime; 195 196 await vcInstance.videoElement.play(); 197 } catch (retryError) { 198 logger.warn('Failed to play after reload:', retryError); 199 } 200 } 201 202 async function _playInternal(vcInstance) { 203 validateVideoController(vcInstance); 204 205 const shouldContinue = await prepareVideoForPlayback(vcInstance); 206 if (!shouldContinue) { 207 return; 208 } 209 210 vcInstance.playbackState.isPlaying = true; 211 vcInstance._hasAttemptedPlay = true; 212 213 handleAudioState(vcInstance); 214 215 try { 216 await vcInstance.videoElement.play(); 217 } catch (error) { 218 vcInstance.playbackState.isPlaying = !vcInstance.videoElement.paused; 219 vcInstance._emitCustomEvent('playbackError', { error }); 220 221 await attemptPlayErrorRecovery(vcInstance, error).catch(recoveryError => { 222 logger.warn('Error during play recovery attempt:', recoveryError); 223 }); 224 } 225 } 226 227 function _pauseInternal(vcInstance) { 228 if (!vcInstance.videoElement) { 229 return Promise.reject('Video element not available'); 230 } 231 if (vcInstance.videoElement.paused) { 232 return Promise.resolve(); 233 } 234 try { 235 vcInstance.videoElement.pause(); 236 237 return Promise.resolve(); 238 } catch (error) { 239 return Promise.reject(error); 240 } 241 } 242 243 export function playCommand(vcInstance) { 244 return enqueueCommand(vcInstance, () => _playInternal(vcInstance), { 245 description: 'Play Command', 246 unmuteBeforeExecution: true, 247 }); 248 } 249 250 export function pauseCommand(vcInstance) { 251 return enqueueCommand(vcInstance, () => _pauseInternal(vcInstance), { 252 description: 'Pause Command', 253 }); 254 } 255 256 export function seekToCommand(vcInstance, timeSeconds) { 257 if (!Number.isFinite(timeSeconds)) { 258 return Promise.reject(new Error(`Invalid seek target time: ${timeSeconds}`)); 259 } 260 261 if (!vcInstance || !vcInstance.videoElement) { 262 return Promise.reject(new Error('Cannot seek: Video element not available')); 263 } 264 265 const validatedTime = Math.max(0, timeSeconds); 266 267 return enqueueCommand( 268 vcInstance, 269 () => { 270 return new Promise((resolve, reject) => { 271 try { 272 _performSimpleSeek(vcInstance, validatedTime, resolve, reject); 273 } catch (error) { 274 reject(error); 275 } 276 }); 277 }, 278 { 279 description: `Seek To ${validatedTime.toFixed(2)}s Command`, 280 unmuteBeforeExecution: false, 281 }, 282 ); 283 } 284 285 async function _performSimpleSeek(vcInstance, targetTime, resolve, reject) { 286 try { 287 if (vcInstance.videoElement.readyState < HTMLMediaElement.HAVE_METADATA) { 288 logger.info('Waiting for metadata to load before seeking'); 289 await forceMetadataLoading(vcInstance, 5000); 290 } 291 292 const duration = vcInstance.videoElement.duration; 293 const clampedTime = 294 Number.isFinite(duration) && duration > 0 ? Math.min(targetTime, duration) : targetTime; 295 296 vcInstance.playbackState.isSeeking = true; 297 298 vcInstance.videoElement.currentTime = clampedTime; 299 300 vcInstance.playbackState.currentTimeSeconds = clampedTime; 301 vcInstance.playbackState.isSeeking = false; 302 303 resolve(); 304 } catch (error) { 305 logger.error(`Simple seek error: ${error.message}`); 306 vcInstance.playbackState.isSeeking = false; 307 reject(error); 308 } 309 } 310 311 export async function seekByCommand(vcInstance, deltaSeconds) { 312 if (!Number.isFinite(deltaSeconds)) { 313 return Promise.reject(new Error(`Invalid seek delta time: ${deltaSeconds}`)); 314 } 315 316 if (!vcInstance || !vcInstance.videoElement) { 317 return Promise.reject(new Error('Cannot seek: Video element not available')); 318 } 319 320 const currentTime = vcInstance.videoElement.currentTime; 321 322 const targetTime = Number.isFinite(currentTime) 323 ? Math.max(0, currentTime + deltaSeconds) 324 : Math.max(0, deltaSeconds); 325 326 return seekToCommand(vcInstance, targetTime); 327 }