/ app / ui / stats-ui-manager.js
stats-ui-manager.js
  1  import AppConstants from '../config/constants.js';
  2  import { createEventEmitter, createLocalEventSubscriptionManager } from '../utils/event/index.js';
  3  import * as providerMetrics from '../services/metrics/provider-metrics.js';
  4  import * as videoRouter from '../services/video/router.js';
  5  import * as videoSources from '../services/video/sources.js';
  6  import { formatBytes, formatTime, shortCid } from '../utils/formatting/index.js';
  7  import { safeRequestIdleCallback } from '../utils/timing/safe-request-idle-callback.js';
  8  import { renderProviderRow, showMessage } from '../utils/ui/index.js';
  9  import * as nextVideoPretest from '../utils/video/next-video-pretest.js';
 10  import { LRUCache } from '../utils/caching/lru-cache.js';
 11  
 12  const MAX_CACHE_ENTRIES = 50;
 13  const CACHE_ENTRY_MAX_AGE_MS = 60 * 1000;
 14  
 15  export class StatsUIManager {
 16    constructor(uiManager, videoController) {
 17      this.events = createEventEmitter();
 18      this.subscriptions = createLocalEventSubscriptionManager();
 19      this.uiManager = uiManager;
 20      this.videoController = videoController;
 21      this.videoSwitcher = uiManager?.videoSwitcher;
 22      this.videoControllerEvents = videoController?.getEventEmitter?.();
 23      this.statsContentElement = this.uiManager.get('statsContentElement');
 24      this.statsToggleButton = this.uiManager.get('statsToggle');
 25      this.statsUpdateIntervalId = null;
 26      this.isUpdating = false;
 27      this.statsVisible = false;
 28      this.providerStats = null;
 29      this.providerStatsFailureCount = 0;
 30      this.currentVideoProviders = [];
 31      this._lastUpdateTime = 0;
 32      this._pendingUpdate = null;
 33  
 34      this._cachedTestResults = new LRUCache(MAX_CACHE_ENTRIES, CACHE_ENTRY_MAX_AGE_MS);
 35  
 36      this._handleStatsAreaClick = this._handleStatsAreaClick.bind(this);
 37      this._updateStatsDisplayInternal = this._updateStatsDisplayInternal.bind(this);
 38      this.toggleStatsVisibility = this.toggleStatsVisibility.bind(this);
 39      this._handleVideoSourceChange = this._handleVideoSourceChange.bind(this);
 40    }
 41  
 42    getEventEmitter() {
 43      return this.events;
 44    }
 45  
 46    subscribeToVideoEvents(videoControllerEvents) {
 47      if (!videoControllerEvents) {
 48        console.warn(
 49          'Video controller events not available, stats UI will not respond to video events',
 50        );
 51        return;
 52      }
 53  
 54      this.subscriptions.subscribe(
 55        videoControllerEvents,
 56        'video:sourceChanged',
 57        this._handleVideoSourceChange,
 58        this,
 59      );
 60  
 61      this.subscriptions.subscribe(
 62        videoControllerEvents,
 63        'video:loaded',
 64        this._handleVideoSourceChange,
 65        this,
 66      );
 67    }
 68  
 69    _attachEventListeners() {
 70      this.statsContentElement?.addEventListener('click', this._handleStatsAreaClick);
 71      this.statsToggleButton?.addEventListener('click', this.toggleStatsVisibility);
 72  
 73      if (this.videoControllerEvents) {
 74        this.subscribeToVideoEvents(this.videoControllerEvents);
 75      } else {
 76        console.error('VideoController event emitter not available, stats will not update properly');
 77      }
 78    }
 79  
 80    _detachEventListeners() {
 81      this.statsContentElement?.removeEventListener('click', this._handleStatsAreaClick);
 82      this.statsToggleButton?.removeEventListener('click', this.toggleStatsVisibility);
 83  
 84      this.subscriptions.unsubscribeAll();
 85    }
 86  
 87    _handleVideoSourceChange() {
 88      this.videoSwitcher = this.uiManager?.videoSwitcher;
 89  
 90      this._updateCurrentVideoProviders();
 91  
 92      if (this.statsVisible) {
 93        this._updateStatsDisplayInternal().catch(error =>
 94          console.error('Stats update error after source change:', error),
 95        );
 96      }
 97    }
 98  
 99    _updateCurrentVideoProviders() {
100      if (!this.videoController?.videoElement) {
101        this.currentVideoProviders = [];
102        return;
103      }
104  
105      const isLivestream = videoSources.isLivestreamMode();
106  
107      const sourceElements = [...this.videoController.videoElement.querySelectorAll('source')];
108  
109      if (isLivestream) {
110        if (sourceElements.length > 0) {
111          this.currentVideoProviders = sourceElements
112            .map(sourceEl => sourceEl.dataset.provider)
113            .filter(Boolean);
114        } else if (this.videoController.videoElement.src) {
115          const currentSrc = this.videoController.videoElement.src;
116          const provider = videoRouter.getProviderFromUrl(currentSrc);
117  
118          if (provider) {
119            this.currentVideoProviders = [provider];
120          } else {
121            this.currentVideoProviders = [];
122          }
123        } else {
124          this.currentVideoProviders = [];
125        }
126      } else {
127        this.currentVideoProviders = sourceElements
128          .map(sourceEl => sourceEl.dataset.provider)
129          .filter(Boolean);
130      }
131    }
132  
133    toggleStatsVisibility() {
134      if (!this.statsContentElement) {
135        return;
136      }
137  
138      this.statsVisible = !this.statsVisible;
139      this.statsContentElement.classList.toggle('hidden', !this.statsVisible);
140  
141      if (this.statsVisible) {
142        this._updateStatsDisplayInternal().catch(error =>
143          console.error('Stats update error (toggleStatsVisibility):', error),
144        );
145      }
146    }
147  
148    _handleStatsAreaClick(event) {
149      const { target } = event;
150      const copyButton = target.closest('.copy-cid-btn');
151      const copyAltButton = target.closest('.copy-altcid-btn');
152  
153      if (copyButton?.dataset?.cid) {
154        event.stopPropagation();
155        this._handleCopyCidClick(copyButton);
156      } else if (copyAltButton?.dataset?.altcid) {
157        event.stopPropagation();
158        this._handleCopyAltCidClick(copyAltButton);
159      }
160    }
161  
162    _handleCopyCidClick(button) {
163      const cidToCopy = button.dataset.cid;
164      navigator.clipboard
165        .writeText(cidToCopy)
166        .then(() => {
167          const originalHTML = button.innerHTML;
168          button.innerHTML =
169            '<img width="24" height="24" src="https://img.icons8.com/neon/96/checked-checkbox.png" alt="Copied" class="copy-icon-img"/>';
170          button.disabled = true;
171          button.style.cursor = 'default';
172          setTimeout(() => {
173            button.innerHTML = originalHTML;
174            button.disabled = false;
175            button.style.cursor = 'pointer';
176          }, 1500);
177        })
178        .catch(error => {
179          console.warn('Failed to copy CID:', error);
180          this.uiManager?.showTemporaryMessage('Failed to copy CID.');
181        });
182    }
183  
184    _handleCopyAltCidClick(button) {
185      const altCidToCopy = button.dataset.altcid;
186      navigator.clipboard
187        .writeText(altCidToCopy)
188        .then(() => {
189          const originalHTML = button.innerHTML;
190          button.innerHTML =
191            '<img width="24" height="24" src="https://img.icons8.com/neon/96/checked-checkbox.png" alt="Copied" class="copy-icon-img"/>';
192          button.disabled = true;
193          button.style.cursor = 'default';
194          setTimeout(() => {
195            button.innerHTML = originalHTML;
196            button.disabled = false;
197            button.style.cursor = 'pointer';
198          }, 1500);
199        })
200        .catch(error => {
201          console.warn('Failed to copy Alt CID:', error);
202          this.uiManager?.showTemporaryMessage('Failed to copy Alt CID.');
203        });
204    }
205  
206    _renderCopyBtnHtml(cid) {
207      if (!cid || cid === 'N/A') {
208        return '';
209      }
210      return ` <button class="copy-cid-btn primary-border primary-shadow-hover" data-cid="${cid}" title="Copy Full CID"><img width="24" height="24" src="https://img.icons8.com/neon/64/one-page.png" alt="copy" class="copy-icon-img icon-base-24"/></button>`;
211    }
212  
213    _renderCopyAltCidBtnHtml(altcid) {
214      if (!altcid || altcid === 'N/A') {
215        return '';
216      }
217      return ` <button class="copy-altcid-btn primary-border primary-shadow-hover" data-altcid="${altcid}" title="Copy Alt CID"><img width="24" height="24" src="https://img.icons8.com/neon/64/two-pages.png" alt="copy alt" class="copy-icon-img icon-base-24"/></button>`;
218    }
219  
220    showTemporaryMessage(message) {
221      showMessage(message);
222    }
223  
224    startUpdating() {
225      if (this.isUpdating || !this.statsContentElement) {
226        return;
227      }
228  
229      this.isUpdating = true;
230      this._attachEventListeners();
231      this._updateCurrentVideoProviders();
232  
233      safeRequestIdleCallback(
234        () => {
235          if (this.isUpdating) {
236            this._updateStatsDisplayInternal().catch(error =>
237              console.error('Initial stats update error:', error),
238            );
239          }
240        },
241        { timeout: 250 },
242      );
243  
244      this.statsUpdateIntervalId = setInterval(() => {
245        if (this.isUpdating) {
246          this._updateStatsDisplayInternal().catch(error => {
247            console.error('Interval stats update error:', error);
248          });
249        } else {
250          this.stopUpdating();
251        }
252      }, AppConstants.UI.STATS_UPDATE_INTERVAL_MS);
253    }
254  
255    stopUpdating() {
256      if (!this.isUpdating && !this.statsUpdateIntervalId) {
257        return;
258      }
259  
260      this.isUpdating = false;
261      this._detachEventListeners();
262      if (this.statsUpdateIntervalId) {
263        clearInterval(this.statsUpdateIntervalId);
264        this.statsUpdateIntervalId = null;
265      }
266  
267      if (this._pendingUpdate) {
268        clearTimeout(this._pendingUpdate);
269        this._pendingUpdate = null;
270      }
271    }
272  
273    async _fetchProviderStats() {
274      try {
275        if (this.currentVideoProviders.length === 0) {
276          this._updateCurrentVideoProviders();
277        }
278  
279        const videoIndex = this.videoSwitcher?.currentVideoIndex ?? -1;
280        const sourceList = videoSources.getSourceList();
281        const currentVideoInfo = sourceList[videoIndex];
282        const currentCid = currentVideoInfo?.cid ?? null;
283        const isLivestream = videoSources.isLivestreamMode();
284  
285        if (!currentCid) {
286          return {
287            error: isLivestream ? 'No livestream selected' : 'No current video CID available',
288            providers: [],
289          };
290        }
291  
292        let testResults = null;
293  
294        const cachedResults = this._cachedTestResults.get(currentCid);
295        const now = Date.now();
296  
297        if (!cachedResults || now - cachedResults.timestamp > 5000) {
298          try {
299            if (nextVideoPretest && typeof nextVideoPretest.getTestResults === 'function') {
300              testResults = nextVideoPretest.getTestResults(currentCid);
301  
302              const hasNewProviders =
303                testResults?.responseTimeData &&
304                (!cachedResults?.responseTimeData ||
305                  JSON.stringify(Object.keys(testResults.responseTimeData).sort()) !==
306                    JSON.stringify(Object.keys(cachedResults?.responseTimeData || {}).sort()));
307  
308              if (hasNewProviders) {
309                console.info(
310                  'Got response time data from next-video-pretest for providers:',
311                  Object.keys(testResults.responseTimeData).join(', '),
312                );
313              }
314  
315              if (testResults) {
316                this._cachedTestResults.set(currentCid, {
317                  ...testResults,
318                  timestamp: now,
319                });
320              }
321            }
322          } catch (error) {
323            console.warn('Failed to get test results from next-video-pretest:', error);
324          }
325        } else {
326          testResults = cachedResults;
327        }
328  
329        const providerHealth = videoRouter.getProviderHealth();
330        const providerBackoff = videoRouter.getProviderBackoffState();
331        const providerAvailability = videoRouter.getProviderAvailability();
332        const metrics = providerMetrics.getAllMetrics();
333        const cidPerformance = videoRouter.getCidProviderPerformance(currentCid) || {};
334  
335        const allProviderKeys = [...this.currentVideoProviders];
336  
337        if (testResults?.rankedProviders) {
338          testResults.rankedProviders.forEach(provider => {
339            if (!allProviderKeys.includes(provider)) {
340              allProviderKeys.push(provider);
341            }
342          });
343        }
344  
345        const formattedProviders = allProviderKeys.map(key => {
346          const health = providerHealth[key] || { failure: 0, success: 0 };
347          const totalRequests = health.success + health.failure;
348          const successRate = totalRequests > 0 ? health.success / totalRequests : 0;
349          const backoffData = providerBackoff[key];
350          const isInBackoff = Boolean(backoffData);
351          const availability = providerAvailability[key];
352          const providerMetric = metrics[key] || {};
353          const healthScore = providerMetric.healthScore || 50;
354          const avgResponseTime = providerMetric.avgResponseTime || null;
355          const avgLoadTime = providerMetric.avgLoadTime || null;
356          const cidScore = cidPerformance[key] || null;
357  
358          const testResponseTime = testResults?.responseTimeData?.[key] || null;
359          const testAvailability = testResults?.availabilityResults?.[key];
360          const testRank = testResults?.rankedProviders
361            ? testResults.rankedProviders.indexOf(key)
362            : -1;
363  
364          if (testResponseTime !== null && window.DEBUG_PROVIDER_RESPONSE_TIMES) {
365            console.debug(`Provider ${key} test response time: ${testResponseTime}ms`);
366          }
367  
368          const providerData = {
369            key,
370            backoffData,
371            availability: testAvailability !== undefined ? testAvailability : availability,
372            healthScore,
373            testRank: testRank >= 0 ? testRank + 1 : null,
374            avgResponseTime: testResponseTime || avgResponseTime,
375            avgLoadTime,
376            successRate,
377          };
378  
379          const healthStatus = this._determineProviderHealthStatus(providerData);
380  
381          return {
382            availability: testAvailability !== undefined ? testAvailability : availability,
383  
384            avgLoadTime,
385  
386            avgResponseTime: testResponseTime || avgResponseTime,
387  
388            backoffData,
389  
390            backoffFailures: backoffData ? backoffData.consecutiveFailures : 0,
391  
392            backoffRemaining: backoffData ? Math.ceil(backoffData.remainingMs / 1000) : 0,
393  
394            bytesTransferred: providerMetric.totalBytes || 0,
395  
396            cidScore,
397  
398            disabled: isInBackoff,
399  
400            errorCount: health.failure,
401  
402            healthScore,
403  
404            healthStatus,
405  
406            inBackoff: isInBackoff,
407  
408            isActive: this.currentVideoProviders.includes(key),
409  
410            testRank: testRank >= 0 ? testRank + 1 : null,
411            testAge: testResults?.age ? Math.round(testResults.age / 1000) : null,
412  
413            key,
414  
415            latency: testResponseTime || (avgResponseTime ? Math.round(avgResponseTime) : 0),
416  
417            orderIndex: this.currentVideoProviders.indexOf(key),
418  
419            successCount: health.success,
420            successRate,
421          };
422        });
423  
424        this.providerStatsFailureCount = 0;
425  
426        return {
427          currentCid,
428          providers: formattedProviders,
429          testResults: testResults
430            ? {
431                age: testResults.age ? Math.round(testResults.age / 1000) : null,
432                timestamp: testResults.timestamp,
433              }
434            : null,
435          timestamp: Date.now(),
436        };
437      } catch (statsError) {
438        console.warn('Error fetching provider stats:', statsError);
439        this.providerStatsFailureCount = (this.providerStatsFailureCount || 0) + 1;
440  
441        if (this.providerStatsFailureCount >= 2 || !this.providerStats) {
442          return {
443            error: statsError.message || 'Failed to get provider stats',
444            providers: [],
445          };
446        }
447  
448        console.warn(
449          `Using previous provider stats due to fetch failure (${this.providerStatsFailureCount})`,
450        );
451        return this.providerStats;
452      }
453    }
454  
455    _renderPlayerStats() {
456      const videoController = this.videoController;
457      const playbackState = videoController?.playbackState ?? {};
458      const bufferInfo = videoController?.getBufferInfo() ?? {};
459      const videoIndex = this.videoSwitcher?.currentVideoIndex ?? -1;
460      const sourceList = videoSources.getSourceList();
461      const currentVideoInfo = sourceList[videoIndex];
462      const currentCid = currentVideoInfo?.cid ?? 'N/A';
463      const currentAltCid = currentVideoInfo?.altcid ?? 'N/A';
464  
465      const isLivestream = videoSources.isLivestreamMode();
466  
467      const cidHtml = isLivestream
468        ? ''
469        : `<li><strong>CID:</strong> <span class="cid-display" title="${currentCid}">${shortCid(currentCid)}${this._renderCopyBtnHtml(currentCid)}${this._renderCopyAltCidBtnHtml(currentAltCid)}</span></li>`;
470  
471      return `<ul>${cidHtml}<li><strong>Time:</strong> ${formatTime(playbackState.currentTimeSeconds)} / ${formatTime(playbackState.durationSeconds)} s</li><li><strong>Buffered:</strong> ${formatTime(bufferInfo.bufferedTimeAhead)} s</li></ul>`;
472    }
473  
474    async _renderProviderStatsTable(providerStats) {
475      if (providerStats?.error && !providerStats.providers?.length) {
476        return `<p>Info unavailable (Error: ${providerStats.error}).</p>`;
477      }
478      if (!providerStats?.providers?.length) {
479        return '<p>No provider data available.</p>';
480      }
481  
482      const isLivestream = videoSources.isLivestreamMode();
483      const videoIndex = this.videoSwitcher?.currentVideoIndex ?? -1;
484      const sourceList = videoSources.getSourceList();
485      const hasSelectedVideo = videoIndex >= 0 && videoIndex < sourceList.length;
486  
487      let statsHtml = '';
488  
489      if (!isLivestream && providerStats.testResults) {
490        statsHtml += `<div class="test-results-info">`;
491        const ageText = providerStats.testResults.age
492          ? `${providerStats.testResults.age}s ago`
493          : 'recent';
494        statsHtml += `<p><strong>Provider Test Results:</strong> ${ageText}</p>`;
495        statsHtml += '</div>';
496      }
497  
498      let providersToShow = [...providerStats.providers];
499  
500      if (!isLivestream) {
501        const providerConfig = AppConstants.PROVIDERS.CONFIG;
502        providersToShow = providersToShow.filter(
503          provider => !providerConfig[provider.key]?.isLivestreamProvider,
504        );
505      }
506  
507      if (isLivestream && hasSelectedVideo) {
508        const livestreamProviderKeys = ['cdnZero', 'cdnLoop', 'cdnNetlify'];
509  
510        providersToShow = providersToShow.filter(provider =>
511          livestreamProviderKeys.includes(provider.key),
512        );
513  
514        const existingLivestreamProviderKeys = providersToShow.map(p => p.key);
515  
516        const missingProviderKeys = livestreamProviderKeys.filter(
517          key => !existingLivestreamProviderKeys.includes(key),
518        );
519  
520        for (const key of missingProviderKeys) {
521          const isActive = this.currentVideoProviders.includes(key);
522  
523          let color = 'gray';
524          if (isActive) {
525            color = 'blue';
526          }
527  
528          providersToShow.push({
529            key,
530            isActive,
531            healthStatus: {
532              color,
533              description: '',
534              status: '',
535            },
536            latency: 0,
537            successCount: isActive ? 1 : 0,
538            errorCount: 0,
539            orderIndex: livestreamProviderKeys.indexOf(key),
540          });
541        }
542        providersToShow.sort((a, b) => {
543          return livestreamProviderKeys.indexOf(a.key) - livestreamProviderKeys.indexOf(b.key);
544        });
545  
546        providersToShow.forEach(provider => {
547          if (provider.isActive) {
548            provider.healthStatus.color = 'blue';
549          }
550        });
551      } else {
552        providersToShow.sort((a, b) => {
553          if (a.testRank !== null && b.testRank !== null) {
554            return a.testRank - b.testRank;
555          }
556  
557          if (a.testRank !== null) {
558            return -1;
559          }
560          if (b.testRank !== null) {
561            return 1;
562          }
563  
564          if (a.isActive !== b.isActive) {
565            return a.isActive ? -1 : 1;
566          }
567  
568          return a.orderIndex - b.orderIndex;
569        });
570      }
571  
572      statsHtml += `<div class="provider-stats-table">
573            <div class="provider-table-header">
574                <div class="provider-col provider-col-base provider-name"><div class="provider-header-name"><img width="24" height="24" src="https://img.icons8.com/neon/96/linux-client.png" alt="Type" class="provider-icon-img icon-base-24"/><img width="24" height="24" src="https://img.icons8.com/neon/96/server.png" alt="Status" class="provider-icon-img icon-base-24"/></div></div>
575                <div class="provider-col provider-col-base provider-success" title="Avg Response Time (ms)"><img width="24" height="24" src="https://img.icons8.com/neon/96/cloud-sync.png" alt="Avg Response Time" class="provider-icon-img icon-base-24"/></div>
576                <div class="provider-col provider-col-base provider-errors" title="Errors"><img width="24" height="24" src="https://img.icons8.com/neon/96/sad-cloud.png" alt="Errors" class="provider-icon-img icon-base-24"/></div>
577            </div>`;
578  
579      const providerHtmlPromises = providersToShow.map(async provider => {
580        const modifiedProvider = { ...provider };
581  
582        if (modifiedProvider.healthStatus) {
583          modifiedProvider.healthStatus = {
584            ...modifiedProvider.healthStatus,
585            status: '',
586            description: '',
587          };
588        }
589  
590        return renderProviderRow(modifiedProvider, formatBytes);
591      });
592  
593      const providerHtmlList = await Promise.all(providerHtmlPromises);
594      statsHtml += providerHtmlList.join('');
595      statsHtml += '</div>';
596  
597      return statsHtml;
598    }
599  
600    /**
601     * Determines provider health status with fixed thresholds
602     * @param {Object} provider - Provider information
603     * @returns {Object} Health status object
604     */
605    _determineProviderHealthStatus(provider) {
606      const { key, backoffData, availability, healthScore, testRank } = provider;
607  
608      // If provider is in backoff, return backoff status
609      if (backoffData) {
610        return {
611          color: 'gray',
612          description: 'Provider in backoff',
613  
614          status: '',
615        };
616      }
617  
618      if (availability === false) {
619        return {
620          color: 'red',
621          description: 'Provider unavailable',
622  
623          status: '',
624        };
625      }
626  
627      const excellentThreshold = 85;
628      const goodThreshold = 70;
629      const okishThreshold = 50;
630  
631      let adjustedScore = healthScore;
632      if (testRank !== null && testRank <= 3) {
633        const rankBoost = (4 - testRank) * 5; // Rank 1: +15, Rank 2: +10, Rank 3: +5
634        adjustedScore = Math.min(100, adjustedScore + rankBoost);
635      }
636  
637      if (adjustedScore >= excellentThreshold) {
638        return {
639          color: 'blue',
640          description: 'Excellent health',
641  
642          status: '',
643          adjustedScore,
644        };
645      }
646  
647      if (adjustedScore >= goodThreshold) {
648        return {
649          color: 'green',
650          description: 'Good health',
651  
652          status: '',
653          adjustedScore,
654        };
655      }
656  
657      if (adjustedScore >= okishThreshold) {
658        return {
659          color: 'yellow',
660          description: 'Fair health',
661  
662          status: '',
663          adjustedScore,
664        };
665      }
666  
667      return {
668        color: 'red',
669        description: 'Poor health',
670  
671        status: '',
672        adjustedScore,
673      };
674    }
675  
676    async _updateStatsDisplayInternal() {
677      if (!this.isUpdating || !this.statsContentElement || !this.statsVisible) {
678        return;
679      }
680  
681      const now = Date.now();
682      if (this._lastUpdateTime && now - this._lastUpdateTime < 1000) {
683        if (!this._pendingUpdate) {
684          this._pendingUpdate = setTimeout(() => {
685            this._pendingUpdate = null;
686  
687            (async () => {
688              await this._updateStatsDisplayInternal();
689            })().catch(error => console.error('Error in throttled stats update:', error));
690          }, 1000);
691        }
692        return;
693      }
694      this._lastUpdateTime = now;
695  
696      await /** @type {Promise<void>} */ (
697        new Promise(resolve => {
698          requestAnimationFrame(async () => {
699            if (!this.isUpdating || !this.statsVisible) {
700              return resolve();
701            }
702  
703            let finalHtml = '';
704            try {
705              this.providerStats = await this._fetchProviderStats();
706  
707              const playerHtml = this._renderPlayerStats();
708              const providerHtml = await this._renderProviderStatsTable(this.providerStats);
709  
710              finalHtml = playerHtml + providerHtml;
711            } catch (error) {
712              console.error('Error generating stats HTML:', error);
713              finalHtml = '<p class="error-message">Error loading stats.</p>';
714            }
715  
716            if (this.statsContentElement.innerHTML !== finalHtml) {
717              this.statsContentElement.innerHTML = finalHtml;
718            }
719            resolve();
720          });
721        })
722      );
723    }
724  
725    cleanup() {
726      this.stopUpdating();
727  
728      this.subscriptions.unsubscribeAll();
729  
730      this.events.clearAllEvents();
731  
732      this.providerStats = null;
733      this.currentVideoProviders = [];
734  
735      if (this._cachedTestResults) {
736        this._cachedTestResults.clear();
737        this._cachedTestResults = null;
738      }
739    }
740  }