/ app / services / video / controller.js
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;