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 }