controller-helpers-events.js
1 import recoveryCoordinator from './recovery-coordinator.js'; 2 import * as videoRouter from './router.js'; 3 import { VideoEventData, VideoEvents } from './video-events.js'; 4 import { createLogger } from '../../utils/debug/logger.js'; 5 6 const logger = createLogger('VideoEvents'); 7 8 async function getModuleInstance(modulePath, globalGetter, useDefault = false) { 9 const globalInstance = globalGetter?.(); 10 if (globalInstance) { 11 return globalInstance; 12 } 13 14 const importedModule = await import(modulePath); 15 return useDefault ? importedModule.default : importedModule; 16 } 17 18 export function _attachVideoEventListeners(vcInstance) { 19 if (!vcInstance.videoElement) { 20 return; 21 } 22 _removeAllEventListeners(vcInstance); 23 24 const handlers = { 25 canplay: _handleCanPlay.bind(null, vcInstance), 26 durationchange: _handleDurationChange.bind(null, vcInstance), 27 ended: _handleEnded.bind(null, vcInstance), 28 error: _handleError.bind(null, vcInstance), 29 loadedmetadata: _handleMetadataLoaded.bind(null, vcInstance), 30 pause: _handlePause.bind(null, vcInstance), 31 play: _handlePlay.bind(null, vcInstance), 32 playing: _handlePlaying.bind(null, vcInstance), 33 seeked: _handleSeekingEnd.bind(null, vcInstance), 34 seeking: _handleSeekingStart.bind(null, vcInstance), 35 stalled: _handleStalled.bind(null, vcInstance), 36 timeupdate: vcInstance.timeUpdateThrottler, 37 volumechange: _handleVolumeChange.bind(null, vcInstance), 38 waiting: _handleWaiting.bind(null, vcInstance), 39 }; 40 41 for (const [eventName, handler] of Object.entries(handlers)) { 42 try { 43 vcInstance.videoElement.addEventListener(eventName, handler); 44 vcInstance.registeredEventListeners.set(handler, { eventName, handler }); 45 } catch (error) { 46 console.warn(`Failed to attach listener for event "${eventName}":`, error); 47 } 48 } 49 50 try { 51 const corsErrorHandler = event => { 52 const mediaError = vcInstance.videoElement.error; 53 if (mediaError) { 54 if (mediaError.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) { 55 console.warn('Media error detected, possibly CORS related:', mediaError.message); 56 57 const currentCid = vcInstance.currentCid; 58 if (currentCid) { 59 getModuleInstance('./sources.js', () => window.getVideoSourcesInstance?.()) 60 .then(videoSources => { 61 const videoInfo = videoSources 62 .getSourceList() 63 .find(source => source.cid === currentCid); 64 if (videoInfo?.isLivestream) { 65 console.info('Attempting to recover from possible CORS error for livestream'); 66 vcInstance.refreshVideoSources(currentCid, true).catch(err => { 67 console.error('Error during CORS error recovery:', err); 68 }); 69 } 70 }) 71 .catch(err => { 72 console.error('Error importing video sources during CORS error handling:', err); 73 }); 74 } 75 } 76 } 77 }; 78 79 vcInstance.videoElement.addEventListener('error', corsErrorHandler); 80 vcInstance.registeredEventListeners.set(corsErrorHandler, { 81 eventName: 'error', 82 handler: corsErrorHandler, 83 }); 84 } catch (error) { 85 console.warn('Failed to attach CORS error handler:', error); 86 } 87 } 88 89 export function _removeAllEventListeners(vcInstance) { 90 if (!vcInstance.videoElement) { 91 return; 92 } 93 94 if (vcInstance.registeredEventListeners.size > 0) { 95 for (const [handler, listenerInfo] of vcInstance.registeredEventListeners.entries()) { 96 try { 97 vcInstance.videoElement.removeEventListener(listenerInfo.eventName, handler); 98 } catch (error) { 99 console.warn(`Failed to remove listener for event "${listenerInfo.eventName}":`, error); 100 } 101 } 102 vcInstance.registeredEventListeners.clear(); 103 } 104 } 105 106 export function _emitCustomEvent(vcInstance, eventName, detail = {}) { 107 if (!vcInstance.videoElement) { 108 return; 109 } 110 111 try { 112 const eventData = { 113 ...detail, 114 timestamp: Date.now(), 115 }; 116 117 const customEvent = new CustomEvent(`video:${eventName}`, { 118 bubbles: true, 119 composed: true, 120 detail: eventData, 121 }); 122 vcInstance.videoElement.dispatchEvent(customEvent); 123 124 vcInstance.events.publish(`video:${eventName}`, eventData); 125 } catch (error) { 126 console.warn(`Failed to emit custom event "video:${eventName}":`, error); 127 } 128 } 129 130 export function _handlePlay(vcInstance) { 131 if (!vcInstance || !vcInstance.videoElement) { 132 return; 133 } 134 135 if (!vcInstance.playbackState.isPlaying) { 136 vcInstance.playbackState.isPlaying = true; 137 138 vcInstance.events.publish( 139 VideoEvents.PLAYBACK_STARTED, 140 VideoEventData.createPlaybackStartedData(), 141 ); 142 } 143 vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime; 144 } 145 146 export function _handlePause(vcInstance) { 147 if (!vcInstance || !vcInstance.videoElement) { 148 return; 149 } 150 151 vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime; 152 153 if (vcInstance.playbackState.isPlaying) { 154 vcInstance.playbackState.isPlaying = false; 155 156 vcInstance.events.publish( 157 VideoEvents.PLAYBACK_PAUSED, 158 VideoEventData.createPlaybackPausedData(), 159 ); 160 } 161 } 162 163 export function _handleTimeUpdateInternal(vcInstance) { 164 if (!vcInstance || !vcInstance.videoElement) { 165 return; 166 } 167 vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime; 168 169 const eventData = VideoEventData.createTimeUpdateData( 170 vcInstance.playbackState.currentTimeSeconds, 171 ); 172 173 vcInstance.events.publish(VideoEvents.TIME_UPDATE, eventData); 174 } 175 176 export function _handleDurationChange(vcInstance) { 177 if (!vcInstance || !vcInstance.videoElement) { 178 return; 179 } 180 181 const newDuration = vcInstance.videoElement.duration; 182 const oldDuration = vcInstance.playbackState.durationSeconds; 183 184 if ( 185 Number.isFinite(newDuration) && 186 newDuration > 0 && 187 (newDuration !== oldDuration || !Number.isFinite(oldDuration)) 188 ) { 189 vcInstance.playbackState.durationSeconds = newDuration; 190 191 const eventData = VideoEventData.createDurationChangeData(newDuration); 192 193 vcInstance.events.publish(VideoEvents.DURATION_CHANGE, eventData); 194 } else if (newDuration !== oldDuration && (!Number.isFinite(newDuration) || newDuration <= 0)) { 195 vcInstance.playbackState.durationSeconds = Number.NaN; 196 197 const eventData = VideoEventData.createDurationChangeData(Number.NaN); 198 199 vcInstance.events.publish(VideoEvents.DURATION_CHANGE, eventData); 200 } 201 } 202 203 function isValidEndedEvent(videoEl) { 204 const { currentTime, duration, readyState, networkState } = videoEl; 205 206 return ( 207 videoEl.ended && 208 Number.isFinite(duration) && 209 duration > 1 && 210 Math.abs(currentTime - duration) < 0.5 && 211 readyState >= 2 && 212 networkState !== 2 213 ); 214 } 215 216 function handleValidEndedEvent(vcInstance, videoEl) { 217 const { currentTime, duration, readyState, networkState } = videoEl; 218 219 vcInstance.playbackState.isPlaying = false; 220 vcInstance.playbackState.currentTimeSeconds = 221 Number.isFinite(duration) && duration > 0 ? duration : 0; 222 } 223 224 function updateProvidersHealth(videoEl) { 225 const currentSources = [...videoEl.querySelectorAll('source')]; 226 const currentProviders = currentSources.map(el => el.dataset.provider).filter(Boolean); 227 228 for (const provider of currentProviders) { 229 if (provider) { 230 videoRouter.updateProviderHealth(provider, false); 231 } 232 } 233 } 234 235 function handleUnmuting(vcInstance) { 236 if (!vcInstance.hasUserInteracted) { 237 return; 238 } 239 240 vcInstance._ensureVideoUnmuted(); 241 } 242 243 async function handleFalseEndedEvent(vcInstance) { 244 if (!vcInstance.currentCid || !vcInstance.videoElement) { 245 console.warn('Cannot recover from false ended event: Missing CID or video element'); 246 return; 247 } 248 249 const cidStr = vcInstance.currentCid.slice(0, 8); 250 251 const eventData = VideoEventData.createRecoveryData(vcInstance.currentCid, 'false_ended', false); 252 253 vcInstance.events.publish(VideoEvents.RECOVERY_STARTED, eventData); 254 255 try { 256 const success = await recoveryCoordinator.recoverFromFalseEnded( 257 vcInstance, 258 vcInstance.currentCid, 259 ); 260 261 if (success) { 262 handleUnmuting(vcInstance); 263 } else { 264 console.warn( 265 `Recovery coordinator failed to recover from false ended event for CID ${cidStr}`, 266 ); 267 268 const currentTime = vcInstance.videoElement.currentTime || 0; 269 270 updateProvidersHealth(vcInstance.videoElement); 271 272 await vcInstance.refreshVideoSources(vcInstance.currentCid, true, []); 273 274 vcInstance.videoElement.currentTime = currentTime; 275 276 handleUnmuting(vcInstance); 277 278 vcInstance.videoElement.play().catch(playError => { 279 console.warn('Failed to resume playback after refreshing sources:', playError); 280 }); 281 } 282 } catch (error) { 283 console.error(`Error during false ended recovery for CID ${cidStr}:`, error); 284 } 285 } 286 287 export async function _handleEnded(vcInstance) { 288 if (!vcInstance || !vcInstance.videoElement) { 289 return; 290 } 291 292 const videoEl = vcInstance.videoElement; 293 const { currentTime, duration, readyState, networkState } = videoEl; 294 295 if (isValidEndedEvent(videoEl)) { 296 handleValidEndedEvent(vcInstance, videoEl); 297 } else { 298 console.warn( 299 `Suspicious 'ended' event ignored in controller (Time: ${currentTime.toFixed(1)}/${duration.toFixed(1)}, ReadyState: ${readyState}, NetworkState: ${networkState})`, 300 ); 301 302 vcInstance.playbackState.isPlaying = false; 303 304 if (networkState === 2 || networkState === 3) { 305 try { 306 await handleFalseEndedEvent(vcInstance); 307 } catch (error) { 308 console.warn('Error while attempting to handle false ended error:', error); 309 } 310 } 311 } 312 } 313 314 export function _handleVolumeChange(vcInstance) { 315 if (!vcInstance || !vcInstance.videoElement) { 316 return; 317 } 318 319 const eventData = VideoEventData.createVolumeChangeData( 320 vcInstance.videoElement.muted, 321 vcInstance.videoElement.volume, 322 ); 323 324 vcInstance.events.publish(VideoEvents.VOLUME_CHANGE, eventData); 325 } 326 327 export function _handleError(vcInstance) { 328 if (!vcInstance || !vcInstance.videoElement) { 329 return; 330 } 331 332 const mediaError = vcInstance.videoElement.error; 333 334 const eventData = VideoEventData.createErrorData(mediaError); 335 336 vcInstance.events.publish(VideoEvents.PLAYBACK_ERROR, eventData); 337 338 vcInstance.playbackState.isPlaying = false; 339 } 340 341 export function handleServiceWorkerError(vcInstance, statusCode) { 342 if (!vcInstance || !vcInstance.videoElement) { 343 return; 344 } 345 346 if (vcInstance.currentCid) { 347 console.warn(`HTTP error ${statusCode} for CID ${vcInstance.currentCid?.slice(0, 8) ?? 'N/A'}`); 348 349 const httpError = new Error(`HTTP error ${statusCode}`); 350 httpError.code = statusCode; 351 httpError.name = 'HttpError'; 352 } 353 } 354 355 export function _handleSeekingStart(vcInstance) { 356 if (!vcInstance || !vcInstance.videoElement) { 357 return; 358 } 359 360 if (!vcInstance.playbackState.isSeeking) { 361 vcInstance.playbackState.isSeeking = true; 362 } 363 } 364 365 export function _handleSeekingEnd(vcInstance) { 366 if (!vcInstance || !vcInstance.videoElement) { 367 return; 368 } 369 370 if (vcInstance.playbackState.isSeeking) { 371 vcInstance.playbackState.isSeeking = false; 372 vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime; 373 374 vcInstance.playbackState.isPlaying = !vcInstance.videoElement.paused; 375 } 376 } 377 378 export function _handleMetadataLoaded(vcInstance) { 379 if (!vcInstance || !vcInstance.videoElement) { 380 return; 381 } 382 383 _handleDurationChange(vcInstance); 384 385 if (!Number.isFinite(vcInstance.videoElement.duration) || vcInstance.videoElement.duration <= 0) { 386 setTimeout(() => { 387 if (vcInstance.videoElement) { 388 _handleDurationChange(vcInstance); 389 } 390 }, 500); 391 setTimeout(() => { 392 if (vcInstance.videoElement) { 393 _handleDurationChange(vcInstance); 394 } 395 }, 1500); 396 } 397 } 398 399 export function _handleWaiting(vcInstance) { 400 if (!vcInstance || !vcInstance.videoElement) { 401 return; 402 } 403 404 const { videoElement, currentCid } = vcInstance; 405 const cidStr = currentCid?.slice(0, 8) || 'unknown'; 406 407 vcInstance.playbackState.isBuffering = true; 408 409 const eventData = VideoEventData.createBufferingStartedData(currentCid); 410 411 vcInstance.events.publish(VideoEvents.BUFFERING_STARTED, eventData); 412 413 vcInstance._startStallDetectionTimeout(); 414 } 415 416 export function _handleStalled(vcInstance) { 417 if (!vcInstance || !vcInstance.videoElement) { 418 return; 419 } 420 421 const { videoElement, currentCid } = vcInstance; 422 const cidStr = currentCid?.slice(0, 8) || 'unknown'; 423 424 logger.warn(`Video stalled event detected for CID ${cidStr}`); 425 426 const videoEl = vcInstance.videoElement; 427 const diagnosticInfo = { 428 readyState: videoEl.readyState, 429 networkState: videoEl.networkState, 430 paused: videoEl.paused, 431 currentTime: videoEl.currentTime, 432 duration: videoEl.duration, 433 buffered: _getBufferedRangesInfo(videoEl.buffered), 434 currentSrc: videoEl.currentSrc, 435 error: videoEl.error 436 ? { 437 code: videoEl.error.code, 438 message: videoEl.error.message, 439 } 440 : null, 441 sources: Array.from(videoEl.querySelectorAll('source')).map(src => ({ 442 src: src.src, 443 type: src.type, 444 provider: src.dataset.provider, 445 })), 446 }; 447 448 logger.info(`Stall diagnostic info for CID ${cidStr}:`, diagnosticInfo); 449 450 vcInstance.playbackState.isBuffering = true; 451 452 const eventData = VideoEventData.createBufferingStartedData(currentCid); 453 454 vcInstance.events.publish(VideoEvents.BUFFERING_STARTED, eventData); 455 456 vcInstance._startStallDetectionTimeout(); 457 } 458 459 function _getBufferedRangesInfo(buffered) { 460 if (!buffered) { 461 return []; 462 } 463 464 const ranges = []; 465 for (let i = 0; i < buffered.length; i++) { 466 ranges.push({ 467 start: buffered.start(i), 468 end: buffered.end(i), 469 }); 470 } 471 return ranges; 472 } 473 474 export function _handleCanPlay(vcInstance) { 475 if (!vcInstance || !vcInstance.videoElement) { 476 return; 477 } 478 479 if (vcInstance.playbackState.isBuffering) { 480 vcInstance.playbackState.isBuffering = false; 481 482 vcInstance._clearStallDetectionTimeout(); 483 484 const eventData = VideoEventData.createBufferingEndedData(vcInstance.currentCid); 485 486 vcInstance.events.publish(VideoEvents.BUFFERING_ENDED, eventData); 487 } 488 } 489 490 export function _handlePlaying(vcInstance) { 491 if (!vcInstance || !vcInstance.videoElement) { 492 return; 493 } 494 495 const wasBuffering = vcInstance.playbackState.isBuffering; 496 497 vcInstance.playbackState.isPlaying = true; 498 vcInstance.playbackState.isBuffering = false; 499 500 vcInstance._clearStallDetectionTimeout(); 501 502 if (wasBuffering) { 503 const bufferingEndedData = VideoEventData.createBufferingEndedData(vcInstance.currentCid); 504 vcInstance.events.publish(VideoEvents.BUFFERING_ENDED, bufferingEndedData); 505 } 506 507 const playingData = VideoEventData.createPlayingData(vcInstance.videoElement.currentTime); 508 vcInstance.events.publish(VideoEvents.PLAYING, playingData); 509 }