controller.js
1 import AppConstants from '../../config/constants.js'; 2 import { 3 createEventEmitter, 4 createLocalEventSubscriptionManager, 5 } from '../../utils/event/index.js'; 6 import * as providerMetrics from '../metrics/provider-metrics.js'; 7 import { throttle } from '../../utils/timing/index.js'; 8 import * as CommandHelpers from './controller-helpers-commands.js'; 9 import * as EventHelpers from './controller-helpers-events.js'; 10 import recoveryCoordinator from './recovery-coordinator.js'; 11 import * as videoRouter from './router.js'; 12 import * as videoSources from './sources.js'; 13 import { VideoEvents, VideoEventData } from './video-events.js'; 14 import { getAudioStateManager } from '../audio/audio-state-manager.js'; 15 import * as providerMapping from '../../utils/video/provider-mapping.js'; 16 17 async function getModuleInstance(modulePath, globalGetter, useDefault = false) { 18 const globalInstance = globalGetter?.(); 19 if (globalInstance) { 20 return globalInstance; 21 } 22 23 const importedModule = await import(modulePath); 24 return useDefault ? importedModule.default : importedModule; 25 } 26 27 class VideoController { 28 constructor(videoElement) { 29 if (!videoElement || !(videoElement instanceof HTMLVideoElement)) { 30 throw new Error('VideoController requires a valid HTMLVideoElement instance.'); 31 } 32 33 this.events = createEventEmitter(); 34 35 this.subscriptions = createLocalEventSubscriptionManager(); 36 37 this.videoElement = videoElement; 38 this.currentCid = null; 39 this.hasUserInteracted = false; 40 41 this.videoSwitcher = null; 42 43 this.commandQueue = []; 44 this.isProcessingCommandQueue = false; 45 this.playbackState = this._createDefaultPlaybackState(); 46 this.registeredEventListeners = new Map(); 47 48 this._stallDetectionTimeoutId = null; 49 50 this._previousLoadTimes = []; 51 this._maxPreviousLoadTimes = 5; 52 53 this.timeUpdateThrottler = throttle( 54 EventHelpers._handleTimeUpdateInternal.bind(this, this), 55 AppConstants.UI.PROGRESS_UPDATE_INTERVAL_MS, 56 ); 57 58 this._initializeVideoElement(); 59 EventHelpers._attachVideoEventListeners(this); 60 this._updateInternalPlaybackState(); 61 this._subscribeToAppEvents(); 62 } 63 64 getEventEmitter() { 65 return this.events; 66 } 67 68 setVideoSwitcher(videoSwitcher) { 69 if (videoSwitcher && typeof videoSwitcher.currentVideoIndex === 'number') { 70 this.videoSwitcher = videoSwitcher; 71 console.info('VideoSwitcher instance set for VideoController'); 72 73 recoveryCoordinator.setVideoSwitcher(videoSwitcher); 74 } else { 75 console.warn('Invalid VideoSwitcher instance provided to VideoController'); 76 } 77 } 78 79 subscribeToRouterEvents(routerEvents) { 80 if (!routerEvents) { 81 console.warn( 82 'Router events not available, video controller will not respond to shuffle target events', 83 ); 84 return; 85 } 86 87 (() => { 88 this.subscriptions.subscribe( 89 routerEvents, 90 VideoEvents.SHUFFLE_TARGET_SELECTED, 91 data => { 92 console.info('VideoController: Received shuffle target event', data); 93 }, 94 this, 95 ); 96 })(); 97 } 98 99 _ensureVideoUnmuted() { 100 if (!this.videoElement) { 101 console.warn('Cannot unmute: video element is not available'); 102 return; 103 } 104 105 if (!this.hasUserInteracted) { 106 console.warn('Cannot unmute: no user interaction detected'); 107 return; 108 } 109 110 const audioStateManager = getAudioStateManager(); 111 112 if (!audioStateManager.videoElement) { 113 audioStateManager.initialize(this.videoElement); 114 } 115 116 audioStateManager.ensureUnmuted(this.hasUserInteracted); 117 } 118 119 _subscribeToAppEvents() { 120 getModuleInstance('../user-state-events.js', () => null).then( 121 ({ UserStateEvents, getUserStateEventEmitter }) => { 122 const userStateEventEmitter = getUserStateEventEmitter(); 123 124 if (userStateEventEmitter) { 125 this.subscriptions.subscribe( 126 userStateEventEmitter, 127 UserStateEvents.USER_INTERACTION_STATE_CHANGED, 128 data => { 129 console.info('VideoController: Received user interaction event'); 130 this.hasUserInteracted = true; 131 this._ensureVideoUnmuted(); 132 }, 133 this, 134 ); 135 } else { 136 console.error( 137 'User state event emitter not available, user interaction events will not work properly', 138 ); 139 } 140 }, 141 ); 142 } 143 144 _createDefaultPlaybackState() { 145 return { 146 currentTimeSeconds: 0, 147 durationSeconds: Number.NaN, 148 isPlaying: false, 149 isSeeking: false, 150 isBuffering: false, 151 }; 152 } 153 154 _initializeVideoElement() { 155 getModuleInstance('../user-state-events.js', () => null).then(({ getHasUserInteracted }) => { 156 const globalUserInteracted = getHasUserInteracted(); 157 if (globalUserInteracted && !this.hasUserInteracted) { 158 console.info('Syncing user interaction state from global state'); 159 this.hasUserInteracted = true; 160 } 161 }); 162 163 const shouldMute = !this.hasUserInteracted; 164 Object.assign(this.videoElement, { 165 autoplay: false, 166 controls: false, 167 crossOrigin: 'anonymous', 168 muted: shouldMute, 169 playsInline: true, 170 preload: 'none', 171 }); 172 173 if (shouldMute) { 174 console.info('Video initialized as muted (no user interaction yet)'); 175 } else { 176 console.info('Video initialized as unmuted (user has already interacted)'); 177 } 178 } 179 180 _updateInternalPlaybackState() { 181 const video = this.videoElement; 182 if (!video) { 183 return; 184 } 185 const wasBuffering = this.playbackState?.isBuffering || false; 186 this.playbackState = { 187 currentTimeSeconds: video.currentTime ?? 0, 188 durationSeconds: Number.isFinite(video.duration) ? video.duration : Number.NaN, 189 isPlaying: !video.paused, 190 isSeeking: video.seeking, 191 isBuffering: wasBuffering, 192 }; 193 } 194 195 _resetInternalPlaybackState() { 196 this.playbackState = this._createDefaultPlaybackState(); 197 this.isProcessingCommandQueue = false; 198 } 199 200 _emitCustomEvent(eventName, detail = {}) { 201 if (!this.videoElement) { 202 return; 203 } 204 EventHelpers._emitCustomEvent(this, eventName, detail); 205 } 206 207 getBufferedTimeRanges() { 208 const buffered = this.videoElement?.buffered; 209 const ranges = []; 210 if (!buffered) { 211 return ranges; 212 } 213 try { 214 for (let i = 0; i < buffered.length; i++) { 215 const start = buffered.start(i); 216 const end = buffered.end(i); 217 if (Number.isFinite(start) && Number.isFinite(end)) { 218 ranges.push({ end, start }); 219 } 220 } 221 } catch (error) { 222 console.warn('Error accessing buffered time ranges:', error); 223 return []; 224 } 225 return ranges; 226 } 227 228 isTimeBuffered(timeSeconds, marginSeconds = 0.1) { 229 if (!Number.isFinite(timeSeconds)) { 230 return false; 231 } 232 233 const bufferedRanges = this.getBufferedTimeRanges(); 234 if (bufferedRanges.length === 0) { 235 return false; 236 } 237 238 return bufferedRanges.some( 239 range => 240 timeSeconds >= range.start - marginSeconds && timeSeconds <= range.end + marginSeconds, 241 ); 242 } 243 244 findNearestBufferedRange(timeSeconds) { 245 if (!Number.isFinite(timeSeconds)) { 246 return null; 247 } 248 249 const bufferedRanges = this.getBufferedTimeRanges(); 250 if (bufferedRanges.length === 0) { 251 return null; 252 } 253 254 let nearestRange = null; 255 let minDistance = Number.POSITIVE_INFINITY; 256 257 for (const range of bufferedRanges) { 258 if (timeSeconds >= range.start && timeSeconds <= range.end) { 259 return { ...range, distance: 0 }; 260 } 261 262 let distance; 263 distance = timeSeconds < range.start ? range.start - timeSeconds : timeSeconds - range.end; 264 265 if (distance < minDistance) { 266 minDistance = distance; 267 nearestRange = { ...range, distance }; 268 } 269 } 270 271 return nearestRange; 272 } 273 274 getBufferedTimeAhead() { 275 const videoCurrentTime = this.playbackState.currentTimeSeconds; 276 const duration = this.playbackState.durationSeconds; 277 const bufferedRanges = this.getBufferedTimeRanges(); 278 279 if ( 280 bufferedRanges.length === 0 || 281 !Number.isFinite(videoCurrentTime) || 282 !Number.isFinite(duration) || 283 duration <= 0 284 ) { 285 return null; 286 } 287 288 let maxContinuousBufferedEndTime = videoCurrentTime; 289 const GAP_TOLERANCE = 0.1; 290 291 for (const range of bufferedRanges) { 292 if (videoCurrentTime >= range.start && videoCurrentTime < range.end) { 293 maxContinuousBufferedEndTime = Math.max(maxContinuousBufferedEndTime, range.end); 294 } else if ( 295 range.start <= maxContinuousBufferedEndTime + GAP_TOLERANCE && 296 range.end > maxContinuousBufferedEndTime 297 ) { 298 maxContinuousBufferedEndTime = Math.max(maxContinuousBufferedEndTime, range.end); 299 } else if ( 300 range.start > videoCurrentTime && 301 maxContinuousBufferedEndTime === videoCurrentTime 302 ) { 303 break; 304 } else if (range.start > maxContinuousBufferedEndTime + GAP_TOLERANCE) { 305 break; 306 } 307 } 308 const timeAhead = Math.max(0, maxContinuousBufferedEndTime - videoCurrentTime); 309 return Math.min(timeAhead, duration - videoCurrentTime); 310 } 311 312 getBufferInfo() { 313 const currentTime = this.playbackState.currentTimeSeconds; 314 const duration = this.playbackState.durationSeconds; 315 const timeAhead = this.getBufferedTimeAhead(); 316 return { 317 bufferedTimeAhead: timeAhead, 318 currentTime, 319 duration, 320 ranges: this.getBufferedTimeRanges(), 321 }; 322 } 323 324 enqueueCommand(commandFunction, options = {}) { 325 return CommandHelpers.enqueueCommand(this, commandFunction, options); 326 } 327 328 playCommand() { 329 return CommandHelpers.playCommand(this); 330 } 331 332 pauseCommand() { 333 return CommandHelpers.pauseCommand(this); 334 } 335 336 seekToCommand(timeSeconds) { 337 return CommandHelpers.seekToCommand(this, timeSeconds); 338 } 339 340 seekByCommand(deltaSeconds) { 341 return CommandHelpers.seekByCommand(this, deltaSeconds); 342 } 343 344 loadVideoSource(cid) { 345 if (!this || !this.videoElement) { 346 throw new Error('Cannot load video source: VideoController or video element is invalid.'); 347 } 348 if (!cid) { 349 throw new Error('CID is required to load a video source.'); 350 } 351 352 try { 353 this._cleanupBeforeLoad(); 354 355 this.currentCid = cid; 356 357 return true; 358 } catch (error) { 359 this.currentCid = null; 360 361 throw error; 362 } 363 } 364 365 _trackLoadTime(loadTimeMs) { 366 if (!Number.isFinite(loadTimeMs) || loadTimeMs <= 0) { 367 return; 368 } 369 370 this._previousLoadTimes.push(loadTimeMs); 371 372 if (this._previousLoadTimes.length > this._maxPreviousLoadTimes) { 373 this._previousLoadTimes.shift(); 374 } 375 } 376 377 _calculateStallTimeout() { 378 const MIN_TIMEOUT_MS = 5000; 379 const MAX_TIMEOUT_MS = 15000; 380 const DEFAULT_TIMEOUT_MS = 8000; 381 382 if (navigator.connection) { 383 const connection = navigator.connection; 384 385 if (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g') { 386 return MAX_TIMEOUT_MS; 387 } 388 389 if (connection.effectiveType === '4g') { 390 return MIN_TIMEOUT_MS; 391 } 392 393 return DEFAULT_TIMEOUT_MS; 394 } 395 396 if (this._previousLoadTimes && this._previousLoadTimes.length > 0) { 397 const avgLoadTime = 398 this._previousLoadTimes.reduce((sum, time) => sum + time, 0) / 399 this._previousLoadTimes.length; 400 401 return Math.min(Math.max(avgLoadTime * 2, MIN_TIMEOUT_MS), MAX_TIMEOUT_MS); 402 } 403 404 return DEFAULT_TIMEOUT_MS; 405 } 406 407 _startStallDetectionTimeout() { 408 if (this._stallDetectionTimeoutId) { 409 return; 410 } 411 412 const cidStr = this.currentCid?.slice(0, 8) || 'unknown'; 413 const stallTimeoutMs = this._calculateStallTimeout(); 414 415 this._stallDetectionTimeoutId = setTimeout(async () => { 416 this._stallDetectionTimeoutId = null; 417 418 if (!this.videoElement || !this.currentCid) { 419 return; 420 } 421 422 if (!this.playbackState.isBuffering || this.videoElement.paused) { 423 return; 424 } 425 426 const bufferedTimeAhead = this.getBufferedTimeAhead(); 427 const isActuallyStalled = bufferedTimeAhead === null || bufferedTimeAhead < 0.5; 428 429 if (!isActuallyStalled) { 430 console.info( 431 `False stall detection for CID ${cidStr}, ${bufferedTimeAhead.toFixed(2)}s buffered ahead`, 432 ); 433 434 this._startStallDetectionTimeout(); 435 return; 436 } 437 438 console.warn(`Stall timeout reached for CID ${cidStr}, attempting recovery`); 439 440 const eventData = VideoEventData.createRecoveryData(this.currentCid, 'stall', false); 441 442 this.events.publish(VideoEvents.RECOVERY_STARTED, eventData); 443 444 try { 445 const success = await this._withPreservedVideoIndex(async () => { 446 return await recoveryCoordinator.recoverFromStall(this, this.currentCid); 447 }); 448 449 if (!success) { 450 console.warn( 451 `Recovery coordinator failed to recover from stall for CID ${cidStr}, trying fallback recovery`, 452 ); 453 454 const currentProviders = [...this.videoElement.querySelectorAll('source')] 455 .map(el => el.dataset.provider) 456 .filter(Boolean); 457 458 await this._withPreservedVideoIndex(async () => { 459 return await this.refreshVideoSources(this.currentCid, true, currentProviders); 460 }); 461 } 462 } catch (error) { 463 console.error(`Failed to recover from stall for CID ${cidStr}:`, error); 464 465 try { 466 const allProviders = [...this.videoElement.querySelectorAll('source')] 467 .map(el => el.dataset.provider) 468 .filter(Boolean); 469 470 await this._withPreservedVideoIndex(async () => { 471 return await this.refreshVideoSources(this.currentCid, true, allProviders); 472 }); 473 } catch (fallbackError) { 474 console.error(`Fallback recovery also failed for CID ${cidStr}:`, fallbackError); 475 476 this._logDetailedDiagnostics(cidStr); 477 } 478 } 479 }, stallTimeoutMs); 480 } 481 482 _clearStallDetectionTimeout() { 483 if (this._stallDetectionTimeoutId) { 484 clearTimeout(this._stallDetectionTimeoutId); 485 this._stallDetectionTimeoutId = null; 486 } 487 } 488 489 _estimateProvidersTriedCount() { 490 const DEFAULT_PROVIDERS_TO_BACKOFF = 1; 491 const MAX_PROVIDERS_TO_BACKOFF = 3; 492 493 if (this._currentLoadInfo?.startTime) { 494 const elapsedMs = performance.now() - this._currentLoadInfo.startTime; 495 const avgLoadTimeMs = 496 this._previousLoadTimes.length > 0 497 ? this._previousLoadTimes.reduce((sum, time) => sum + time, 0) / 498 this._previousLoadTimes.length 499 : 5000; 500 501 const estimatedProvidersTried = Math.ceil(elapsedMs / (avgLoadTimeMs / 2)); 502 503 return Math.min( 504 Math.max(estimatedProvidersTried, DEFAULT_PROVIDERS_TO_BACKOFF), 505 MAX_PROVIDERS_TO_BACKOFF, 506 ); 507 } 508 509 return DEFAULT_PROVIDERS_TO_BACKOFF; 510 } 511 512 _logDetailedDiagnostics(cidStr) { 513 if (!this.videoElement || !this.currentCid) { 514 console.warn('Cannot log diagnostics: Missing video element or CID'); 515 return; 516 } 517 518 try { 519 const videoEl = this.videoElement; 520 const diagnosticInfo = { 521 readyState: videoEl.readyState, 522 networkState: videoEl.networkState, 523 paused: videoEl.paused, 524 currentTime: videoEl.currentTime, 525 duration: videoEl.duration, 526 buffered: this._getBufferedRangesInfo(), 527 currentSrc: videoEl.currentSrc, 528 error: videoEl.error 529 ? { 530 code: videoEl.error.code, 531 message: videoEl.error.message, 532 } 533 : null, 534 sources: Array.from(videoEl.querySelectorAll('source')).map(src => ({ 535 src: src.src, 536 type: src.type, 537 provider: src.dataset.provider, 538 })), 539 540 recoveryAttempts: recoveryCoordinator.recoveryAttemptCounts.get(this.currentCid) || 0, 541 recoveryHistory: recoveryCoordinator.recoveryHistory.get(this.currentCid) || {}, 542 loadTimes: [...this._previousLoadTimes], 543 currentLoadInfo: this._currentLoadInfo || {}, 544 545 networkInfo: navigator.connection 546 ? { 547 effectiveType: navigator.connection.effectiveType, 548 downlink: navigator.connection.downlink, 549 rtt: navigator.connection.rtt, 550 saveData: navigator.connection.saveData, 551 } 552 : null, 553 }; 554 555 console.error( 556 `All recovery attempts failed for CID ${cidStr}. Detailed diagnostics:`, 557 diagnosticInfo, 558 ); 559 560 const providerHealth = videoRouter.getProviderHealth(); 561 console.debug('Provider health status:', providerHealth); 562 563 const cidPerformance = videoRouter.getCidProviderPerformance(this.currentCid); 564 console.debug(`Provider performance for CID ${cidStr}:`, cidPerformance); 565 } catch (error) { 566 console.error('Error logging detailed diagnostics:', error); 567 } 568 } 569 570 _getBufferedRangesInfo() { 571 if (!this.videoElement || !this.videoElement.buffered) { 572 return []; 573 } 574 575 const buffered = this.videoElement.buffered; 576 const ranges = []; 577 578 for (let i = 0; i < buffered.length; i++) { 579 ranges.push({ 580 start: buffered.start(i), 581 end: buffered.end(i), 582 }); 583 } 584 585 return ranges; 586 } 587 588 _verifyAndCorrectVideoIndex(videoIndex, cid, source = 'unknown') { 589 if (!cid || typeof videoIndex !== 'number') { 590 return videoIndex; 591 } 592 593 try { 594 const sourceList = videoSources.getSourceList(); 595 if (sourceList && sourceList.length > videoIndex) { 596 const videoAtIndex = sourceList[videoIndex]; 597 if (videoAtIndex && videoAtIndex.cid !== cid) { 598 console.warn( 599 `CID mismatch detected (${source}): Controller has ${cid.slice(0, 8)} but index ${videoIndex} points to ${videoAtIndex.cid.slice(0, 8)}`, 600 ); 601 602 const correctIndex = sourceList.findIndex(source => source.cid === cid); 603 if (correctIndex !== -1) { 604 console.info(`Found correct index ${correctIndex} for CID ${cid.slice(0, 8)}`); 605 return correctIndex; 606 } 607 } 608 } 609 } catch (error) { 610 console.warn(`Error verifying video index (${source}):`, error); 611 } 612 613 return videoIndex; 614 } 615 616 async _withPreservedVideoIndex(operation) { 617 let currentVideoIndex = null; 618 const currentCid = this.currentCid; 619 620 try { 621 if (this.videoSwitcher && typeof this.videoSwitcher.currentVideoIndex === 'number') { 622 currentVideoIndex = this.videoSwitcher.currentVideoIndex; 623 console.info( 624 `Captured current video index ${currentVideoIndex} before operation (direct reference)`, 625 ); 626 627 currentVideoIndex = this._verifyAndCorrectVideoIndex( 628 currentVideoIndex, 629 currentCid, 630 'direct reference', 631 ); 632 } else if ( 633 window.appInstance?.videoSwitcher && 634 typeof window.appInstance.videoSwitcher.currentVideoIndex === 'number' 635 ) { 636 currentVideoIndex = window.appInstance.videoSwitcher.currentVideoIndex; 637 console.info( 638 `Captured current video index ${currentVideoIndex} before operation (global reference)`, 639 ); 640 641 if (!this.videoSwitcher) { 642 this.videoSwitcher = window.appInstance.videoSwitcher; 643 } 644 645 currentVideoIndex = this._verifyAndCorrectVideoIndex( 646 currentVideoIndex, 647 currentCid, 648 'global reference', 649 ); 650 } else { 651 console.warn('No VideoSwitcher instance available to preserve video index'); 652 } 653 } catch (indexError) { 654 console.warn('Error capturing current video index:', indexError); 655 } 656 657 try { 658 return await operation(); 659 } finally { 660 if (currentVideoIndex !== null) { 661 try { 662 if (this.videoSwitcher) { 663 console.info( 664 `Restoring video index ${currentVideoIndex} after operation (direct reference)`, 665 ); 666 this.videoSwitcher.currentVideoIndex = currentVideoIndex; 667 } else if (window.appInstance?.videoSwitcher) { 668 console.info( 669 `Restoring video index ${currentVideoIndex} after operation (global reference)`, 670 ); 671 window.appInstance.videoSwitcher.currentVideoIndex = currentVideoIndex; 672 673 if (!this.videoSwitcher) { 674 this.videoSwitcher = window.appInstance.videoSwitcher; 675 } 676 } else { 677 console.warn('No VideoSwitcher instance available to restore video index'); 678 } 679 } catch (indexError) { 680 console.warn('Error restoring video index:', indexError); 681 } 682 } 683 } 684 } 685 686 async _recoverFromStall() { 687 if (!this.videoElement || !this.currentCid) { 688 return false; 689 } 690 691 return recoveryCoordinator.recoverFromStall(this, this.currentCid); 692 } 693 694 _setPlaceholderImage(cid) { 695 if (!this.videoElement) { 696 return false; 697 } 698 699 try { 700 this.videoElement.innerHTML = ''; 701 702 // Remove src attribute and use poster attribute instead of src for placeholder 703 this.videoElement.removeAttribute('src'); 704 this.videoElement.poster = AppConstants.VIDEO_STALL_PLACEHOLDER; 705 706 return true; 707 } catch (error) { 708 console.error(`Error setting placeholder for CID ${cid.slice(0, 8)}:`, error); 709 return false; 710 } 711 } 712 713 async _preemptivelyTestProviders(cid, altcid = '') { 714 try { 715 // Import the current-video-pretest module 716 const { pretestCurrentVideo } = await import('../../utils/video/current-video-pretest.js'); 717 718 if (!cid) { 719 console.warn('Cannot pretest providers: Missing CID'); 720 return []; 721 } 722 723 console.info(`Pretesting providers for current video: ${cid.slice(0, 8)}`); 724 725 const rankedProviders = await pretestCurrentVideo(cid, altcid); 726 727 if (rankedProviders && rankedProviders.length > 0) { 728 console.info( 729 `Pretested ${rankedProviders.length} providers for current video: ${rankedProviders.join(', ')}`, 730 ); 731 return rankedProviders; 732 } 733 734 console.info('No providers pretested for current video'); 735 return []; 736 } catch (error) { 737 console.warn('Error pretesting providers for current video:', error); 738 return []; 739 } 740 } 741 742 _createLoadedDataHandler(cid, sources = [], isLivestream = false) { 743 return () => { 744 if (!this._currentLoadInfo || this._currentLoadInfo.cid !== cid) { 745 return; 746 } 747 748 const loadEndTime = performance.now(); 749 const loadTimeMs = loadEndTime - this._currentLoadInfo.startTime; 750 751 this._trackLoadTime(loadTimeMs); 752 753 let defaultProviderName = isLivestream ? 'livestream' : 'unknown'; 754 755 if (Array.isArray(sources) && sources.length > 0 && sources[0].provider) { 756 defaultProviderName = sources[0].provider; 757 } 758 759 let successfulProvider = null; 760 761 if (this.videoElement?.currentSrc) { 762 try { 763 successfulProvider = videoRouter.getProviderFromUrl(this.videoElement.currentSrc); 764 765 if (successfulProvider) { 766 if (isLivestream) { 767 console.info( 768 `Livestream loaded from provider ${successfulProvider} in ${loadTimeMs.toFixed(0)}ms`, 769 ); 770 } else { 771 console.info( 772 `Video loaded from provider ${successfulProvider} in ${loadTimeMs.toFixed(0)}ms`, 773 ); 774 775 if (this._previousLoadTimes.length > 0) { 776 console.info( 777 `Tracked load time: ${loadTimeMs.toFixed(0)}ms, ` + 778 `Average of last ${this._previousLoadTimes.length}: ` + 779 `${( 780 this._previousLoadTimes.reduce((sum, time) => sum + time, 0) / 781 this._previousLoadTimes.length 782 ).toFixed(0)}ms`, 783 ); 784 } else { 785 console.info(`Tracked load time: ${loadTimeMs.toFixed(0)}ms (first load)`); 786 } 787 } 788 789 providerMetrics.recordLoadTime(successfulProvider, loadTimeMs); 790 } else { 791 successfulProvider = defaultProviderName; 792 console.info( 793 `${isLivestream ? 'Livestream' : 'Video'} loaded in ${loadTimeMs.toFixed(0)}ms ` + 794 `(provider detection failed, using ${successfulProvider})`, 795 ); 796 } 797 } catch (error) { 798 console.warn( 799 `Error determining successful provider for ${isLivestream ? 'livestream' : 'video'}:`, 800 error, 801 ); 802 successfulProvider = defaultProviderName; 803 } 804 } else { 805 successfulProvider = defaultProviderName; 806 console.info( 807 `${isLivestream ? 'Livestream' : 'Video'} loaded in ${loadTimeMs.toFixed(0)}ms`, 808 ); 809 } 810 811 const eventData = VideoEventData.createSourceLoadedData(cid, successfulProvider, loadTimeMs); 812 813 this.events.publish(VideoEvents.SOURCE_LOADED, eventData); 814 }; 815 } 816 817 setVideoSources(cid, sources, altcid) { 818 if (!this.videoElement) { 819 console.warn('setVideoSources called on invalid element.'); 820 return false; 821 } 822 823 if (!cid) { 824 console.warn('CID is required to set video sources.'); 825 return false; 826 } 827 828 try { 829 const loadStartTime = performance.now(); 830 831 const videoInfo = videoSources.getSourceList().find(source => source.cid === cid); 832 console.info( 833 `Video info for CID ${cid}:`, 834 videoInfo ? JSON.stringify(videoInfo) : 'Not found', 835 ); 836 const isLivestream = videoInfo?.isLivestream === true; 837 console.info(`Is livestream video: ${isLivestream}`); 838 839 const isDateCid = cid && /^\d{8}$/.test(cid); 840 console.info(`CID ${cid} is date format: ${isDateCid}`); 841 842 const sourceList = videoSources.getSourceList(); 843 console.info(`Total sources in list: ${sourceList.length}`); 844 console.info(`Is in livestream mode: ${videoSources.isLivestreamMode()}`); 845 846 const treatAsLivestream = isLivestream || isDateCid; 847 if (isDateCid && !isLivestream) { 848 console.info(`CID ${cid} is a date format, treating as livestream`); 849 } 850 851 if (treatAsLivestream) { 852 console.info('Loading livestream video:', cid); 853 854 if (Array.isArray(sources) && sources.length > 0) { 855 const livestreamSources = sources.filter(source => { 856 console.info( 857 `Checking livestream source: ${source.provider}, isLivestream=${source.isLivestream}, src=${source.src}`, 858 ); 859 860 if (source.isLivestream === true) { 861 console.info(`Source ${source.provider} is marked as livestream`); 862 return true; 863 } 864 865 if (source.src?.includes('/live/')) { 866 console.info(`Source ${source.provider} URL contains /live/ path`); 867 return true; 868 } 869 870 console.warn(`Skipping invalid livestream source: ${source.provider}`); 871 return false; 872 }); 873 874 if (livestreamSources.length === 0) { 875 console.error('No valid livestream sources found'); 876 console.error('Original sources:', JSON.stringify(sources)); 877 878 if (videoInfo?.isLivestream === true || (cid && /^\d{8}$/.test(cid))) { 879 console.info('Attempting to generate livestream sources directly'); 880 881 const dateValue = videoInfo?.rawDate || cid; 882 if (dateValue && /^\d{8}$/.test(dateValue)) { 883 console.info(`Using date value: ${dateValue}`); 884 885 const fallbackSources = [ 886 { 887 provider: 'cdnZero', 888 src: providerMapping.getProviderUrl('cdnZero', dateValue), 889 type: 'video/mp4', 890 isLivestream: true, 891 corsMode: 'anonymous', 892 }, 893 { 894 provider: 'cdnLoop', 895 src: providerMapping.getProviderUrl('cdnLoop', dateValue), 896 type: 'video/mp4', 897 isLivestream: true, 898 corsMode: 'anonymous', 899 }, 900 { 901 provider: 'cdnNetlify', 902 src: providerMapping.getProviderUrl('cdnNetlify', dateValue), 903 type: 'video/mp4', 904 isLivestream: true, 905 corsMode: 'anonymous', 906 }, 907 ]; 908 909 console.info(`Created ${fallbackSources.length} fallback livestream sources`); 910 return this.setVideoSources(cid, fallbackSources); 911 } 912 } 913 914 return false; 915 } 916 917 console.info(`Using ${livestreamSources.length} providers for livestream video`); 918 919 this._currentLoadInfo = { 920 cid, 921 isLivestream: true, 922 providers: livestreamSources.map(s => s.provider), 923 startTime: loadStartTime, 924 successfulProvider: null, 925 }; 926 927 if (this.hasUserInteracted) { 928 this._ensureVideoUnmuted(); 929 } 930 931 const loadedHandler = this._createLoadedDataHandler(cid, livestreamSources, true); 932 this.videoElement.addEventListener('loadeddata', loadedHandler, { once: true }); 933 934 while (this.videoElement.firstChild) { 935 this.videoElement.firstChild.remove(); 936 } 937 938 this.videoElement.removeAttribute('src'); 939 940 const shuffledSources = [...livestreamSources]; 941 for (let i = shuffledSources.length - 1; i > 0; i--) { 942 const j = Math.floor(Math.random() * (i + 1)); 943 [shuffledSources[i], shuffledSources[j]] = [shuffledSources[j], shuffledSources[i]]; 944 } 945 946 const providersUsed = []; 947 948 for (const source of shuffledSources) { 949 const sourceElement = document.createElement('source'); 950 sourceElement.src = source.src; 951 sourceElement.type = source.type; 952 sourceElement.dataset.provider = source.provider; 953 954 sourceElement.setAttribute('crossorigin', 'anonymous'); 955 956 providersUsed.push(source.provider); 957 this.videoElement.append(sourceElement); 958 } 959 960 this.videoElement.setAttribute('crossorigin', 'anonymous'); 961 962 console.info( 963 `Set ${shuffledSources.length} sources for livestream ${videoInfo.date || ''} in shuffled order:`, 964 providersUsed.join(', '), 965 ); 966 967 return true; 968 } 969 970 console.error('No sources provided for livestream video'); 971 return false; 972 } 973 974 if (!Array.isArray(sources) || sources.length === 0) { 975 console.warn('No valid sources provided for CID:', cid.slice(0, 8)); 976 return false; 977 } 978 979 this._currentLoadInfo = { 980 altcid, 981 cid, 982 providers: sources.map(s => s.provider), 983 startTime: loadStartTime, 984 successfulProvider: null, 985 }; 986 987 if (this.hasUserInteracted) { 988 this._ensureVideoUnmuted(); 989 } 990 991 const loadedHandler = this._createLoadedDataHandler(cid, sources, false); 992 this.videoElement.addEventListener('loadeddata', loadedHandler, { once: true }); 993 994 while (this.videoElement.firstChild) { 995 this.videoElement.firstChild.remove(); 996 } 997 998 this.videoElement.removeAttribute('src'); 999 1000 const shuffledSources = [...sources]; 1001 for (let i = shuffledSources.length - 1; i > 0; i--) { 1002 const j = Math.floor(Math.random() * (i + 1)); 1003 [shuffledSources[i], shuffledSources[j]] = [shuffledSources[j], shuffledSources[i]]; 1004 } 1005 1006 const providersUsed = []; 1007 1008 for (const source of shuffledSources) { 1009 const sourceElement = document.createElement('source'); 1010 sourceElement.src = source.src; 1011 sourceElement.type = source.type; 1012 sourceElement.dataset.provider = source.provider; 1013 providersUsed.push(source.provider); 1014 1015 if (altcid) { 1016 sourceElement.dataset.altcid = altcid; 1017 } 1018 1019 sourceElement.setAttribute('crossorigin', 'anonymous'); 1020 1021 this.videoElement.append(sourceElement); 1022 } 1023 1024 this.videoElement.setAttribute('crossorigin', 'anonymous'); 1025 1026 console.info( 1027 `Set ${shuffledSources.length} sources for ${cid.slice(0, 8)} in shuffled order:`, 1028 providersUsed.join(', '), 1029 ); 1030 1031 return true; 1032 } catch (error) { 1033 console.error(`Error setting sources for ${cid.slice(0, 8)}:`, error); 1034 return false; 1035 } 1036 } 1037 1038 async refreshVideoSources(cid, markCurrentAsFailed = true, providersToBackoff = []) { 1039 if (!this.videoElement || !cid) { 1040 console.warn('Cannot refresh sources: Missing video element or CID'); 1041 return false; 1042 } 1043 1044 const cidStr = cid.slice(0, 8); 1045 1046 const videoInfo = videoSources.getSourceList().find(source => source.cid === cid); 1047 const isLivestream = videoInfo?.isLivestream === true; 1048 1049 if (isLivestream) { 1050 console.info('Refreshing livestream video:', cid); 1051 1052 return await this._withPreservedVideoIndex(async () => { 1053 try { 1054 this._setPlaceholderImage(cid); 1055 1056 const dateValue = videoInfo.rawDate || videoInfo.cid || ''; 1057 1058 if (!dateValue || !/^\d+$/.test(dateValue)) { 1059 console.error('Invalid date value for livestream:', dateValue); 1060 return false; 1061 } 1062 1063 console.info(`Refreshing livestream with date: ${dateValue}`); 1064 1065 // Create a livestreamInfo object with the necessary information 1066 const livestreamInfo = { 1067 date: dateValue, 1068 }; 1069 1070 // Try to get sources from multiple providers using the new livestreamInfo parameter 1071 console.info(`Getting livestream sources with date: ${dateValue}`); 1072 const livestreamSources = videoRouter.getMultipleVideoSources( 1073 null, // No CID needed for livestream mode 1074 0, // No max sources limit 1075 null, // No altcid needed for livestream 1076 livestreamInfo, // Pass the livestream info as the fourth parameter 1077 ); 1078 1079 console.info(`Retrieved ${livestreamSources?.length || 0} livestream sources`); 1080 if (livestreamSources?.length > 0) { 1081 console.info('Livestream sources:', JSON.stringify(livestreamSources)); 1082 } 1083 1084 if (Array.isArray(livestreamSources) && livestreamSources.length > 0) { 1085 console.info(`Using ${livestreamSources.length} providers for livestream refresh`); 1086 1087 // Set the sources using our multiple providers 1088 const success = this.setVideoSources(cid, livestreamSources); 1089 if (success) { 1090 this.videoElement.load(); 1091 1092 // Restore muted state based on user interaction 1093 if (this.hasUserInteracted) { 1094 this._ensureVideoUnmuted(); 1095 } 1096 1097 return true; 1098 } 1099 } 1100 1101 // If no providers or if setting sources failed, try a direct fallback approach 1102 console.error('Failed to get any valid livestream sources for date:', dateValue); 1103 console.info('Attempting direct fallback for livestream'); 1104 1105 const fallbackSources = [ 1106 { 1107 provider: 'cdnZero', 1108 src: providerMapping.getProviderUrl('cdnZero', dateValue), 1109 type: 'video/mp4', 1110 isLivestream: true, 1111 corsMode: 'anonymous', 1112 }, 1113 { 1114 provider: 'cdnLoop', 1115 src: providerMapping.getProviderUrl('cdnLoop', dateValue), 1116 type: 'video/mp4', 1117 isLivestream: true, 1118 corsMode: 'anonymous', 1119 }, 1120 { 1121 provider: 'cdnNetlify', 1122 src: providerMapping.getProviderUrl('cdnNetlify', dateValue), 1123 type: 'video/mp4', 1124 isLivestream: true, 1125 corsMode: 'anonymous', 1126 }, 1127 ]; 1128 1129 console.info(`Created ${fallbackSources.length} direct fallback livestream sources`); 1130 const success = this.setVideoSources(cid, fallbackSources); 1131 if (success) { 1132 this.videoElement.load(); 1133 1134 if (this.hasUserInteracted) { 1135 this._ensureVideoUnmuted(); 1136 } 1137 1138 return true; 1139 } 1140 1141 console.error('All fallback attempts failed for livestream'); 1142 return false; 1143 } catch (error) { 1144 console.error('Error refreshing livestream video:', error); 1145 return false; 1146 } 1147 }); 1148 } 1149 1150 let recoveryType = 'error'; 1151 if (markCurrentAsFailed) { 1152 recoveryType = 'error'; 1153 } else if (providersToBackoff.length > 0) { 1154 recoveryType = 'stall'; 1155 } else { 1156 recoveryType = 'network_issue'; 1157 } 1158 1159 return await this._withPreservedVideoIndex(async () => { 1160 try { 1161 const wasMuted = this.videoElement.muted; 1162 1163 const currentProviders = [...this.videoElement.querySelectorAll('source')] 1164 .map(el => el.dataset.provider) 1165 .filter(Boolean); 1166 1167 this._setPlaceholderImage(cid); 1168 1169 const recoveryStartedData = VideoEventData.createRecoveryData(cid, recoveryType, false); 1170 1171 this.events.publish(VideoEvents.RECOVERY_STARTED, recoveryStartedData); 1172 1173 if (markCurrentAsFailed) { 1174 for (const provider of currentProviders) { 1175 if (provider) { 1176 videoRouter.updateProviderHealth(provider, false, cid); 1177 } 1178 } 1179 } else if (providersToBackoff.length > 0) { 1180 for (const provider of providersToBackoff) { 1181 if (provider) { 1182 videoRouter.updateProviderHealth(provider, false, cid); 1183 } 1184 } 1185 } 1186 1187 const altcid = videoInfo?.altcid || ''; 1188 1189 let success = false; 1190 1191 // Use current-video-pretest to get optimized sources 1192 try { 1193 const { pretestCurrentVideo, getTestResults } = await import( 1194 '../../utils/video/current-video-pretest.js' 1195 ); 1196 1197 let testResults = getTestResults(cid); 1198 1199 if ( 1200 !testResults || 1201 !testResults.rankedProviders || 1202 testResults.rankedProviders.length === 0 || 1203 (testResults.age && testResults.age > 60000) 1204 ) { 1205 console.info(`No recent test results for CID ${cid.slice(0, 8)}, running pretest`); 1206 await pretestCurrentVideo(cid, altcid); 1207 testResults = getTestResults(cid); 1208 } 1209 1210 let freshSources; 1211 1212 if (testResults?.rankedProviders && testResults.rankedProviders.length > 0) { 1213 console.info( 1214 `Using pretested providers for CID ${cid.slice(0, 8)}: ${testResults.rankedProviders.join(', ')}`, 1215 ); 1216 1217 freshSources = videoRouter.getMultipleVideoSources(cid, 0, altcid); 1218 1219 if (freshSources && freshSources.length > 0) { 1220 freshSources.sort((a, b) => { 1221 const aIndex = testResults.rankedProviders.indexOf(a.provider); 1222 const bIndex = testResults.rankedProviders.indexOf(b.provider); 1223 1224 if (aIndex !== -1 && bIndex !== -1) { 1225 return aIndex - bIndex; 1226 } 1227 1228 if (aIndex !== -1) { 1229 return -1; 1230 } 1231 if (bIndex !== -1) { 1232 return 1; 1233 } 1234 1235 return 0; 1236 }); 1237 1238 console.info( 1239 `Prioritized sources: ${freshSources 1240 .slice(0, 3) 1241 .map(s => s.provider) 1242 .join(', ')}...`, 1243 ); 1244 } 1245 } else { 1246 console.info( 1247 `No test results available for CID ${cid.slice(0, 8)}, using standard source selection`, 1248 ); 1249 freshSources = videoRouter.getMultipleVideoSources(cid, 0, altcid); 1250 } 1251 1252 success = this.setVideoSources(cid, freshSources, altcid); 1253 } catch (error) { 1254 console.warn( 1255 'Error using current-video-pretest, falling back to standard source selection:', 1256 error, 1257 ); 1258 1259 const freshSources = videoRouter.getMultipleVideoSources(cid, 0, altcid); 1260 success = this.setVideoSources(cid, freshSources, altcid); 1261 } 1262 1263 if (success) { 1264 this.videoElement.load(); 1265 1266 if (wasMuted !== this.videoElement.muted) { 1267 console.info( 1268 `Restoring muted state: ${wasMuted ? 'muted' : 'unmuted'} -> ${ 1269 this.videoElement.muted ? 'muted' : 'unmuted' 1270 }`, 1271 ); 1272 this.videoElement.muted = wasMuted; 1273 } 1274 1275 if (this.hasUserInteracted) { 1276 this._ensureVideoUnmuted(); 1277 } 1278 1279 const recoveryCompletedData = VideoEventData.createRecoveryData(cid, recoveryType, true); 1280 this.events.publish(VideoEvents.RECOVERY_COMPLETED, recoveryCompletedData); 1281 1282 return true; 1283 } 1284 1285 console.error(`Failed to refresh sources for CID ${cidStr}`); 1286 1287 const recoveryFailedData = VideoEventData.createRecoveryData(cid, recoveryType, false); 1288 this.events.publish(VideoEvents.RECOVERY_FAILED, recoveryFailedData); 1289 1290 return false; 1291 } catch (error) { 1292 console.error(`Error refreshing sources for CID ${cidStr}:`, error); 1293 1294 const recoveryFailedData = VideoEventData.createRecoveryData(cid, recoveryType, false); 1295 this.events.publish(VideoEvents.RECOVERY_FAILED, recoveryFailedData); 1296 1297 return false; 1298 } 1299 }); 1300 } 1301 1302 _safelyPauseVideo(video) { 1303 if (!video.paused) { 1304 try { 1305 video.pause(); 1306 } catch (pauseError) { 1307 console.warn('Error pausing video during reset:', pauseError); 1308 } 1309 } 1310 } 1311 1312 _safelyClearVideoSource(video) { 1313 try { 1314 video.removeAttribute('src'); 1315 } catch (srcError) { 1316 console.warn('Error removing src attribute during reset:', srcError); 1317 } 1318 } 1319 1320 _safelyClearMediaStream(video) { 1321 if (!video.srcObject) { 1322 return; 1323 } 1324 1325 try { 1326 if (video.srcObject instanceof MediaStream) { 1327 this._stopMediaStreamTracks(video.srcObject); 1328 } 1329 video.srcObject = null; 1330 } catch (streamError) { 1331 console.warn('Error clearing srcObject during reset:', streamError); 1332 } 1333 } 1334 1335 _stopMediaStreamTracks(mediaStream) { 1336 for (const track of mediaStream.getTracks()) { 1337 try { 1338 track.stop(); 1339 } catch (trackError) { 1340 console.warn('Error stopping MediaStream track:', trackError); 1341 } 1342 } 1343 } 1344 1345 _safelyReloadVideo(video) { 1346 try { 1347 video.load(); 1348 } catch (loadError) { 1349 console.warn('Error calling load() during reset:', loadError); 1350 } 1351 } 1352 1353 _resetVideoElement() { 1354 if (!this.videoElement) { 1355 console.warn('_resetVideoElement called on invalid element.'); 1356 this._resetInternalPlaybackState(); 1357 return; 1358 } 1359 1360 const video = this.videoElement; 1361 try { 1362 this._safelyPauseVideo(video); 1363 1364 this._safelyClearVideoSource(video); 1365 1366 this._safelyClearMediaStream(video); 1367 1368 this._safelyReloadVideo(video); 1369 1370 this._resetInternalPlaybackState(); 1371 } catch (error) { 1372 console.error('Critical error during video element reset:', error); 1373 1374 try { 1375 while (video.firstChild) { 1376 try { 1377 video.firstChild.remove(); 1378 } catch (e) { 1379 console.warn('Error removing child element:', e); 1380 break; 1381 } 1382 } 1383 1384 video.removeAttribute('src'); 1385 video.poster = AppConstants.VIDEO_STALL_PLACEHOLDER; 1386 1387 try { 1388 video.load(); 1389 } catch (reloadError) { 1390 console.warn('Error during forced reload after critical error:', reloadError); 1391 } 1392 1393 const recoveryFailedData = VideoEventData.createRecoveryData( 1394 this.currentCid || 'unknown', 1395 'critical_error', 1396 false, 1397 ); 1398 1399 this.events.publish(VideoEvents.RECOVERY_FAILED, recoveryFailedData); 1400 } catch (recoveryError) { 1401 console.error('Failed to recover from critical error:', recoveryError); 1402 } 1403 1404 this._resetInternalPlaybackState(); 1405 } 1406 } 1407 1408 _cleanupBeforeLoad() { 1409 if (this.commandQueue.length > 0) { 1410 const commandsToAbort = [...this.commandQueue]; 1411 this.commandQueue = []; 1412 for (const cmd of commandsToAbort) { 1413 cmd.reject?.(new Error('Video source changing, command aborted.')); 1414 } 1415 } 1416 this.isProcessingCommandQueue = false; 1417 EventHelpers._removeAllEventListeners(this); 1418 1419 this._clearStallDetectionTimeout(); 1420 1421 this._previousLoadTimes = []; 1422 1423 const hadUserInteracted = this.hasUserInteracted; 1424 1425 this._resetVideoElement(); 1426 1427 this.hasUserInteracted = hadUserInteracted; 1428 1429 if (this.videoElement) { 1430 EventHelpers._attachVideoEventListeners(this); 1431 1432 if (hadUserInteracted) { 1433 this.hasUserInteracted = true; 1434 this._ensureVideoUnmuted(); 1435 } 1436 } 1437 } 1438 1439 cleanup() { 1440 if (this.commandQueue.length > 0) { 1441 const commandsToAbort = [...this.commandQueue]; 1442 this.commandQueue = []; 1443 for (const cmd of commandsToAbort) { 1444 cmd.reject?.(new Error('VideoController cleanup initiated.')); 1445 } 1446 } 1447 this.isProcessingCommandQueue = false; 1448 1449 this._clearStallDetectionTimeout(); 1450 1451 this._previousLoadTimes = []; 1452 1453 EventHelpers._removeAllEventListeners(this); 1454 1455 this.subscriptions.unsubscribeAll(); 1456 1457 this.events.clearAllEvents(); 1458 1459 this.timeUpdateThrottler = null; 1460 this._resetInternalPlaybackState(); 1461 1462 if (this.videoElement) { 1463 this._resetVideoElement(); 1464 } 1465 1466 this.currentCid = null; 1467 this.videoElement = null; 1468 } 1469 } 1470 1471 export default VideoController;