ui-manager.js
1 import { applyInitialState } from '../app.js'; 2 import AppConstants from '../config/constants.js'; 3 import { createEventEmitter, createLocalEventSubscriptionManager } from '../utils/event/index.js'; 4 import { MenuEventData, MenuEvents } from '../services/menu-events.js'; 5 import playlistDataService from '../services/playlist-data-service.js'; 6 import { 7 getHasUserInteracted, 8 getRepeatMode, 9 setUserInteracted, 10 } from '../services/user-state-events.js'; 11 import { VideoEvents } from '../services/video/video-events.js'; 12 import { showIconAnimation } from '../utils/animation/index.js'; 13 import { setRootCssVariables } from '../utils/dom/index.js'; 14 import { handleAbortedFetch } from '../utils/error/index.js'; 15 import { showMessage, showTemporaryMessage } from '../utils/ui/message.js'; 16 import { isVideoActuallyPlaying } from '../utils/video/index.js'; 17 import { updateSpinnerState } from './element-visibility-manager.js'; 18 import { AnimationManager } from './animation-manager.js'; 19 import { FrameAnalyzer } from './frame-analyzer.js'; 20 import { InteractionHandler } from './interaction-handler.js'; 21 import { LanguageManager } from './language-manager.js'; 22 import { MenuManager } from './menu-manager.js'; 23 import { PlaylistUIManager } from './playlist-ui-manager.js'; 24 import { StatsUIManager } from './stats-ui-manager.js'; 25 26 export class UIManager { 27 constructor(videoElement, videoController) { 28 if (!videoElement || !videoController) { 29 throw new Error('UIManager requires videoElement and VideoController instances.'); 30 } 31 32 this.events = createEventEmitter(); 33 34 this.subscriptions = createLocalEventSubscriptionManager(); 35 36 this.videoElement = videoElement; 37 this.videoController = videoController; 38 39 this.videoSwitcher = null; 40 41 this.elements = this.cacheDOMElements(); 42 this.animationManager = new AnimationManager(this); 43 44 this.interactionHandler = new InteractionHandler( 45 this, 46 this.videoController, 47 null, 48 null, 49 this.animationManager, 50 null, 51 ); 52 53 this._menuManager = null; 54 this._playlistUIManager = null; 55 this._languageManager = null; 56 this._statsUIManager = null; 57 this._frameAnalyzer = null; 58 this._playlistMenuOperationInProgress = false; 59 this._playlistMenuOperationAbortController = null; 60 this._eventSubscriptions = []; 61 this._isStartupSequenceActive = false; 62 this._spinnerHideTimeoutId = null; 63 this._isReloadInProgress = false; 64 this._isProgrammaticPause = false; 65 this.animationManager.isStartupSequenceActive = false; 66 this.currentColorHue = AppConstants.UI.DEFAULT_HUE || 320; 67 this.messageTimeoutId = null; 68 this.progressUpdateIntervalId = null; 69 70 this.eventHandlers = this.bindEventHandlers(); 71 } 72 73 setVideoSwitcher(videoSwitcher) { 74 this.videoSwitcher = videoSwitcher; 75 76 this.interactionHandler.videoSwitcher = videoSwitcher; 77 78 if (this._playlistUIManager) { 79 this._playlistUIManager.videoSwitcher = videoSwitcher; 80 } 81 82 if (this._menuManager) { 83 const menuManagerEvents = this._menuManager.getEventEmitter?.(); 84 if (menuManagerEvents) { 85 this.subscribeToMenuEvents(menuManagerEvents); 86 } 87 } 88 89 if (videoSwitcher?.getEventEmitter) { 90 const videoSwitcherEvents = videoSwitcher.getEventEmitter(); 91 if (videoSwitcherEvents) { 92 this.subscribeToVideoSwitcherEvents(videoSwitcherEvents); 93 } else { 94 console.warn('VideoSwitcher event emitter not available'); 95 } 96 } 97 } 98 99 subscribeToVideoSwitcherEvents(videoSwitcherEvents) { 100 if (!videoSwitcherEvents) { 101 console.warn('VideoSwitcher events not available'); 102 return; 103 } 104 105 this.subscriptions.subscribe( 106 videoSwitcherEvents, 107 VideoEvents.PROGRAMMATIC_PAUSE_TRIGGERED, 108 () => { 109 this._isProgrammaticPause = true; 110 this.showProgrammaticPauseAnimation(); 111 }, 112 this, 113 ); 114 115 this.subscriptions.subscribe( 116 videoSwitcherEvents, 117 VideoEvents.PROGRAMMATIC_ACTION_CONCLUDED, 118 () => { 119 this._isProgrammaticPause = false; 120 }, 121 this, 122 ); 123 } 124 125 subscribeToMenuEvents(menuManagerEvents) { 126 if (!menuManagerEvents) { 127 console.warn('Menu manager events not available, UI will not respond to menu events'); 128 return; 129 } 130 131 try { 132 this.subscriptions.subscribe( 133 menuManagerEvents, 134 MenuEvents.PLAYLIST_ITEM_SELECTED, 135 data => { 136 if (data?.cid && typeof data.index === 'number') { 137 this.events.publish( 138 MenuEvents.MENU_CLOSE_REQUESTED, 139 MenuEventData.createMenuSideData('right'), 140 ); 141 } 142 }, 143 this, 144 ); 145 } catch (error) { 146 console.error('Failed to subscribe to menu events:', error); 147 } 148 } 149 150 async _ensureMenuManagerLoaded() { 151 if (!this._menuManager) { 152 try { 153 this._menuManager = new MenuManager(this); 154 155 const menuManagerEvents = this._menuManager.getEventEmitter?.(); 156 157 if (menuManagerEvents) { 158 this.subscribeToMenuEvents(menuManagerEvents); 159 } else { 160 console.warn( 161 'MenuManager event emitter not available, UI will not respond to menu events', 162 ); 163 } 164 } catch (error) { 165 console.error('Failed to load MenuManager:', error); 166 throw error; 167 } 168 } 169 return this._menuManager; 170 } 171 172 async _ensurePlaylistUIManagerLoaded() { 173 if (!this._playlistUIManager) { 174 try { 175 this._playlistUIManager = new PlaylistUIManager( 176 this, 177 this.videoController, 178 this.videoSwitcher, 179 ); 180 181 if (this.videoSwitcher) { 182 this._playlistUIManager.videoSwitcher = this.videoSwitcher; 183 } 184 185 const menuManager = await this._ensureMenuManagerLoaded(); 186 const menuManagerEvents = menuManager.getEventEmitter?.(); 187 188 const playlistUIManagerEvents = this._playlistUIManager.getEventEmitter?.(); 189 if (playlistUIManagerEvents && menuManagerEvents) { 190 this.subscriptions.subscribe( 191 playlistUIManagerEvents, 192 MenuEvents.MENU_CLOSE_REQUESTED, 193 data => { 194 if (data?.side === 'right') { 195 menuManagerEvents.publish(MenuEvents.MENU_CLOSE_REQUESTED, data); 196 } 197 }, 198 this, 199 ); 200 201 this.subscriptions.subscribe( 202 playlistUIManagerEvents, 203 MenuEvents.MENU_CLOSE_READY, 204 data => { 205 if (data?.side === 'right') { 206 menuManagerEvents.publish(MenuEvents.MENU_CLOSE_READY, data); 207 } 208 }, 209 this, 210 ); 211 212 this.subscriptions.subscribe( 213 playlistUIManagerEvents, 214 MenuEvents.PLAYLIST_ITEM_SELECTED, 215 data => { 216 if (data?.cid && typeof data.index === 'number') { 217 menuManagerEvents.publish(MenuEvents.PLAYLIST_ITEM_SELECTED, data); 218 219 menuManagerEvents.publish( 220 MenuEvents.MENU_CLOSE_REQUESTED, 221 MenuEventData.createMenuSideData('right'), 222 ); 223 } 224 }, 225 this, 226 ); 227 } 228 } catch (error) { 229 console.error('Failed to load PlaylistUIManager:', error); 230 231 this._playlistUIManager = { 232 cleanup: () => {}, 233 populatePlaylist: () => { 234 showTemporaryMessage({ 235 durationMs: 3000, 236 isReloadInProgress: this._isReloadInProgress, 237 message: 'Playlist unavailable', 238 messageElement: this.get('message-overlay'), 239 timeoutIdRef: { current: this.messageTimeoutId }, 240 visibleClassName: AppConstants.UI.CSS_CLASSES.VISIBLE, 241 }); 242 return Promise.resolve(); 243 }, 244 videoSwitcher: this.videoSwitcher, 245 }; 246 } 247 } 248 return this._playlistUIManager; 249 } 250 251 async _ensureLanguageManagerLoaded() { 252 if (!this._languageManager) { 253 try { 254 this._languageManager = new LanguageManager(this); 255 } catch (error) { 256 console.error('Failed to load LanguageManager:', error); 257 258 this._languageManager = { 259 cleanup: () => {}, 260 }; 261 262 showMessage({ 263 message: 'Language selection unavailable', 264 messageElement: this.get('message-overlay'), 265 timeoutIdRef: { current: this.messageTimeoutId }, 266 }); 267 } 268 } 269 return this._languageManager; 270 } 271 272 getMenuManager() { 273 return this._ensureMenuManagerLoaded(); 274 } 275 276 getPlaylistUIManager() { 277 return this._ensurePlaylistUIManagerLoaded(); 278 } 279 280 getLanguageManager() { 281 return this._ensureLanguageManagerLoaded(); 282 } 283 284 async _ensureStatsUIManagerLoaded() { 285 if (!this._statsUIManager) { 286 try { 287 this._statsUIManager = new StatsUIManager(this, this.videoController); 288 289 const videoControllerEvents = this.videoController.getEventEmitter?.(); 290 291 if (videoControllerEvents && this._statsUIManager.subscribeToVideoEvents) { 292 this._statsUIManager.subscribeToVideoEvents(videoControllerEvents); 293 } 294 } catch (error) { 295 console.error('Failed to load StatsUIManager:', error); 296 297 this._statsUIManager = { 298 cleanup: () => {}, 299 startUpdating: () => {}, 300 stopUpdating: () => {}, 301 }; 302 303 showMessage({ 304 message: 'Stats unavailable', 305 messageElement: this.get('message-overlay'), 306 timeoutIdRef: { current: this.messageTimeoutId }, 307 }); 308 } 309 } 310 return this._statsUIManager; 311 } 312 getEventEmitter() { 313 return this.events; 314 } 315 316 async _ensureFrameAnalyzerLoaded() { 317 if (!this._frameAnalyzer) { 318 try { 319 this._frameAnalyzer = new FrameAnalyzer(this.videoElement, this); 320 321 this._frameAnalyzer.onColorApplied = rgb => { 322 this.updateProgressBar(); 323 }; 324 325 const videoControllerEvents = this.videoController.getEventEmitter?.(); 326 327 if (videoControllerEvents) { 328 this._frameAnalyzer.subscribeToVideoEvents(videoControllerEvents); 329 } else { 330 console.warn( 331 'VideoController event emitter not available, frame analyzer will not respond to playback events', 332 ); 333 } 334 335 this._frameAnalyzer.startAnalysis(); 336 } catch (error) { 337 console.error('Failed to load FrameAnalyzer:', error); 338 339 import('../utils/canvas/color-conversion.js') 340 .then(({ rgbToHsl, hslToRgb }) => { 341 const defaultRgb = { r: 255, g: 51, b: 153 }; 342 const hsl = rgbToHsl(defaultRgb.r, defaultRgb.g, defaultRgb.b); 343 344 const brightRgb = hslToRgb(hsl.h, hsl.s, 60); 345 346 this._frameAnalyzer = { 347 analyzeCurrentFrame: () => ({ 348 hue: 320, 349 rgb: { r: brightRgb.r, g: brightRgb.g, b: brightRgb.b }, 350 }), 351 cleanup: () => {}, 352 startAnalysis: () => {}, 353 stopAnalysis: () => {}, 354 }; 355 }) 356 .catch(error => { 357 console.error( 358 'Failed to load color conversion utilities for fallback analyzer:', 359 error, 360 ); 361 362 this._frameAnalyzer = { 363 analyzeCurrentFrame: () => ({ 364 hue: 320, 365 rgb: { r: 255, g: 105, b: 180 }, 366 }), 367 cleanup: () => {}, 368 startAnalysis: () => {}, 369 stopAnalysis: () => {}, 370 }; 371 }); 372 } 373 } 374 return this._frameAnalyzer; 375 } 376 377 bindEventHandlers() { 378 return { 379 handleVideoEnded: this.handleVideoEnded.bind(this), 380 381 handleVideoMetadataLoaded: this.handleVideoMetadataLoaded.bind(this), 382 383 handleVideoPause: this.handleVideoPause.bind(this), 384 385 handleVideoPlay: this.handleVideoPlay.bind(this), 386 387 handleVideoPlaybackError: this.handleVideoPlaybackError.bind(this), 388 handleVideoPlaying: this.handleVideoPlaying.bind(this), 389 }; 390 } 391 392 async initializeUI() { 393 if (!this.get('container')) { 394 console.error('UIManager Init Error: Root container element not found.'); 395 396 if (this.get('message-overlay')) { 397 showMessage('UI initialization error'); 398 399 try { 400 await showIconAnimation({ 401 container: document.body, 402 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 403 getIcon: url => Promise.resolve(url), 404 imageUrl: 'https://img.icons8.com/neon/256/error.png', 405 position: AppConstants.UI.ANIMATION_POSITIONS.MID, 406 preventDuringStartup: false, 407 animationType: 'error', 408 }); 409 } catch (animError) { 410 console.error('Failed to show error animation:', animError); 411 } 412 } 413 414 return; 415 } 416 417 this.attachBaseEventListeners(); 418 this.updateSpinnerVisualState(false); 419 420 import('../utils/canvas/color-conversion.js').then(({ rgbToHsl, hslToRgb }) => { 421 const defaultRgb = { r: 255, g: 51, b: 153 }; 422 const hsl = rgbToHsl(defaultRgb.r, defaultRgb.g, defaultRgb.b); 423 424 const brightRgb = hslToRgb(hsl.h, hsl.s, 60); 425 426 const rgbString = `${brightRgb.r}, ${brightRgb.g}, ${brightRgb.b}`; 427 428 setRootCssVariables({ 429 hue: `${this.currentColorHue}`, 430 'primary-rgb': rgbString, 431 }); 432 }); 433 434 try { 435 await this._ensureFrameAnalyzerLoaded(); 436 } catch (error) { 437 console.warn('Failed to initialize frame analyzer during UI initialization:', error); 438 } 439 440 this.updateAllVisuals(); 441 442 window.addEventListener('resize', this._handleWindowResize.bind(this)); 443 444 await this._initializeMenuToggleButtons(); 445 446 this._attachProgressBarClickHandler(); 447 } 448 449 async _loadMenuButtonIcon(buttonElement, iconKey) { 450 if (!buttonElement) { 451 return; 452 } 453 454 const imgElement = buttonElement.querySelector('img[data-src]'); 455 if (!imgElement) { 456 return; 457 } 458 459 try { 460 imgElement.src = iconUrl || imgElement.dataset.src; 461 } catch (error) { 462 imgElement.src = imgElement.dataset.src; 463 console.warn(`Failed to set ${iconKey} icon src, using fallback:`, error); 464 } 465 } 466 467 _initializeMenuToggleButtons() { 468 const leftMenuToggle = this.get('leftMenuToggleButton'); 469 const rightMenuToggle = this.get('rightMenuToggleButton'); 470 const leftMenuClose = this.get('leftMenuCloseButton'); 471 const rightMenuClose = this.get('rightMenuCloseButton'); 472 473 if (leftMenuToggle && !leftMenuToggle._hasMenuToggleListener) { 474 const leftToggleHandler = async () => { 475 const menuManager = await this._ensureMenuManagerLoaded(); 476 menuManager.toggleMenu('left'); 477 }; 478 leftMenuToggle.addEventListener('click', leftToggleHandler); 479 leftMenuToggle._menuToggleHandler = leftToggleHandler; 480 leftMenuToggle._hasMenuToggleListener = true; 481 } 482 483 if (rightMenuToggle && !rightMenuToggle._hasMenuToggleListener) { 484 const rightToggleHandler = async () => { 485 const menuManager = await this._ensureMenuManagerLoaded(); 486 menuManager.toggleMenu('right'); 487 }; 488 rightMenuToggle.addEventListener('click', rightToggleHandler); 489 rightMenuToggle._menuToggleHandler = rightToggleHandler; 490 rightMenuToggle._hasMenuToggleListener = true; 491 } 492 493 if (leftMenuClose && !leftMenuClose._hasMenuCloseListener) { 494 const leftCloseHandler = async () => { 495 const menuManager = await this._ensureMenuManagerLoaded(); 496 menuManager.closeMenu('left'); 497 }; 498 leftMenuClose.addEventListener('click', leftCloseHandler); 499 leftMenuClose._menuCloseHandler = leftCloseHandler; 500 leftMenuClose._hasMenuCloseListener = true; 501 } 502 503 if (rightMenuClose && !rightMenuClose._hasMenuCloseListener) { 504 const rightCloseHandler = async () => { 505 const menuManager = await this._ensureMenuManagerLoaded(); 506 menuManager.closeMenu('right'); 507 }; 508 rightMenuClose.addEventListener('click', rightCloseHandler); 509 rightMenuClose._menuCloseHandler = rightCloseHandler; 510 rightMenuClose._hasMenuCloseListener = true; 511 } 512 } 513 514 _handleWindowResize() { 515 try { 516 import('../utils/dom/viewport.js').then(({ setDynamicViewportHeightProperty }) => { 517 setDynamicViewportHeightProperty(); 518 }); 519 520 import('../services/app-events.js').then(({ AppEvents, AppEventData }) => { 521 const eventData = AppEventData.createViewportResizedData( 522 window.innerWidth, 523 window.innerHeight, 524 ); 525 526 this.events.publish(AppEvents.VIEWPORT_RESIZED, eventData); 527 }); 528 529 this.updateAllVisuals(); 530 531 const videoElement = this.get('videoElement'); 532 if (videoElement) { 533 const isFilled = videoElement.classList.contains('fill'); 534 if (isFilled) { 535 videoElement.style.objectFit = 'cover'; 536 } else { 537 videoElement.style.objectFit = 'contain'; 538 } 539 } 540 541 if (this._playlistUIManager && this.get('rightMenuElement')?.classList.contains('visible')) { 542 this._playlistUIManager.scrollToCurrentItem(); 543 } 544 } catch (error) { 545 console.error('Error handling window resize:', error); 546 } 547 } 548 549 attachBaseEventListeners() { 550 const handlers = this.eventHandlers; 551 552 const videoControllerEvents = this.videoController.getEventEmitter?.(); 553 554 if (videoControllerEvents) { 555 this.subscriptions.subscribe( 556 videoControllerEvents, 557 VideoEvents.PLAYBACK_ERROR, 558 handlers.handleVideoPlaybackError, 559 this, 560 ); 561 562 this.subscriptions.subscribe( 563 videoControllerEvents, 564 VideoEvents.PLAYBACK_STARTED, 565 handlers.handleVideoPlay, 566 this, 567 ); 568 569 this.subscriptions.subscribe( 570 videoControllerEvents, 571 VideoEvents.PLAYING, 572 handlers.handleVideoPlaying, 573 this, 574 ); 575 576 this.subscriptions.subscribe( 577 videoControllerEvents, 578 VideoEvents.PLAYBACK_PAUSED, 579 handlers.handleVideoPause, 580 this, 581 ); 582 583 this.subscriptions.subscribe( 584 videoControllerEvents, 585 VideoEvents.METADATA_LOADED, 586 handlers.handleVideoMetadataLoaded, 587 this, 588 ); 589 590 this.subscriptions.subscribe( 591 videoControllerEvents, 592 VideoEvents.PROGRAMMATIC_PAUSE_TRIGGERED, 593 () => { 594 this._isProgrammaticPause = true; 595 this.showProgrammaticPauseAnimation(); 596 }, 597 this, 598 ); 599 600 this.subscriptions.subscribe( 601 videoControllerEvents, 602 VideoEvents.PROGRAMMATIC_ACTION_CONCLUDED, 603 () => { 604 this._isProgrammaticPause = false; 605 }, 606 this, 607 ); 608 } else { 609 console.error( 610 'VideoController does not have a local event emitter, UI events will not work properly', 611 ); 612 } 613 } 614 615 detachBaseEventListeners() {} 616 617 updateAllVisuals() { 618 this.updateProgressBar(); 619 } 620 621 updateProgressBar() { 622 const videoElement = this.get('videoElement'); 623 const progressContainer = this.get('progressContainer'); 624 625 if (!videoElement || !progressContainer) { 626 console.warn('Progress bar update failed: Missing video element or progress container'); 627 return; 628 } 629 630 const progressLine = progressContainer.querySelector('.progress-line'); 631 632 if (!progressLine) { 633 console.warn('Progress bar update failed: Missing progress line element'); 634 return; 635 } 636 window.getComputedStyle(progressLine); 637 const { duration } = videoElement; 638 const { currentTime } = videoElement; 639 640 if (Number.isNaN(duration) || duration <= 0 || Number.isNaN(currentTime)) { 641 progressLine.style.width = '0%'; 642 return; 643 } 644 645 const progressPercentage = (currentTime / duration) * 100; 646 progressLine.style.width = `${progressPercentage}%`; 647 648 if (progressContainer.style.opacity !== '1') { 649 progressContainer.style.opacity = '1'; 650 } 651 } 652 653 async startPeriodicVisualUpdates() { 654 try { 655 const frameAnalyzer = await this._ensureFrameAnalyzerLoaded(); 656 frameAnalyzer.startAnalysis(); 657 } catch (error) { 658 console.warn('Failed to start frame analysis:', error); 659 } 660 661 if (!this.isStartupSequenceActive()) { 662 this.startProgressUpdates(); 663 } 664 } 665 666 startProgressUpdates() { 667 this.stopProgressUpdates(); 668 669 this.progressUpdateIntervalId = setInterval(() => { 670 this.updateProgressBar(); 671 }, AppConstants.UI.PROGRESS_UPDATE_INTERVAL_MS); 672 } 673 674 stopProgressUpdates() { 675 if (this.progressUpdateIntervalId) { 676 clearInterval(this.progressUpdateIntervalId); 677 this.progressUpdateIntervalId = null; 678 } 679 } 680 681 stopPeriodicVisualUpdates() { 682 if (this._frameAnalyzer) { 683 this._frameAnalyzer.stopAnalysis(); 684 } 685 this.stopProgressUpdates(); 686 } 687 688 _attachProgressBarClickHandler() { 689 const progressContainer = this.get('progressContainer'); 690 691 if (!progressContainer) { 692 console.warn('Progress container not found, cannot attach click handler'); 693 return; 694 } 695 696 if (progressContainer._progressClickHandler) { 697 progressContainer.removeEventListener('click', progressContainer._progressClickHandler); 698 } 699 700 const clickHandler = e => { 701 if (this.isStartupSequenceActive()) { 702 return; 703 } 704 705 const videoElement = this.get('videoElement'); 706 if (!videoElement || !Number.isFinite(videoElement.duration) || videoElement.duration <= 0) { 707 return; 708 } 709 710 const rect = progressContainer.getBoundingClientRect(); 711 const clickX = e.clientX - rect.left; 712 const percentage = clickX / rect.width; 713 714 const clampedPercentage = Math.max(0, Math.min(1, percentage)); 715 716 const seekTime = videoElement.duration * clampedPercentage; 717 718 try { 719 this.videoController.seekToCommand(seekTime).catch(error => { 720 console.error('Progress bar seek failed:', error); 721 showMessage('Seek failed'); 722 }); 723 724 this.updateProgressBar(); 725 } catch (error) { 726 console.error('Progress bar seek failed:', error); 727 showMessage('Seek failed'); 728 } 729 }; 730 731 progressContainer._progressClickHandler = clickHandler; 732 733 progressContainer.addEventListener('click', clickHandler); 734 735 this._setupProgressBarAutoShrink(progressContainer); 736 } 737 738 _setupProgressBarAutoShrink(progressContainer) { 739 if (!progressContainer) { 740 return; 741 } 742 743 if (!progressContainer._autoShrinkTimerId) { 744 progressContainer._autoShrinkTimerId = null; 745 } 746 747 const EXPANDED_CLASS = 'expanded'; 748 749 const resetAutoShrinkTimer = () => { 750 if (progressContainer._autoShrinkTimerId) { 751 clearTimeout(progressContainer._autoShrinkTimerId); 752 progressContainer._autoShrinkTimerId = null; 753 } 754 755 progressContainer.classList.add(EXPANDED_CLASS); 756 757 progressContainer.style.height = '11px'; 758 progressContainer.style.background = 'rgba(0, 0, 0, 0.75) !important'; 759 760 progressContainer._autoShrinkTimerId = setTimeout(() => { 761 progressContainer.classList.remove(EXPANDED_CLASS); 762 763 progressContainer.style.height = '7px'; 764 progressContainer.style.background = 'rgba(0, 0, 0, 0.5) !important'; 765 766 progressContainer._autoShrinkTimerId = null; 767 }, 1000); 768 }; 769 770 if (progressContainer._pointerEnterHandler) { 771 progressContainer.removeEventListener('pointerenter', progressContainer._pointerEnterHandler); 772 } 773 if (progressContainer._pointerMoveHandler) { 774 progressContainer.removeEventListener('pointermove', progressContainer._pointerMoveHandler); 775 } 776 if (progressContainer._pointerLeaveHandler) { 777 progressContainer.removeEventListener('pointerleave', progressContainer._pointerLeaveHandler); 778 } 779 780 progressContainer._pointerEnterHandler = resetAutoShrinkTimer; 781 progressContainer._pointerMoveHandler = resetAutoShrinkTimer; 782 783 progressContainer._pointerLeaveHandler = () => { 784 if (progressContainer._autoShrinkTimerId) { 785 clearTimeout(progressContainer._autoShrinkTimerId); 786 progressContainer._autoShrinkTimerId = null; 787 } 788 789 progressContainer.classList.remove(EXPANDED_CLASS); 790 progressContainer.style.height = '7px'; 791 progressContainer.style.background = 'rgba(0, 0, 0, 0.5) !important'; 792 }; 793 794 progressContainer._touchStartHandler = resetAutoShrinkTimer; 795 796 progressContainer.addEventListener('pointerenter', progressContainer._pointerEnterHandler); 797 progressContainer.addEventListener('pointermove', progressContainer._pointerMoveHandler); 798 progressContainer.addEventListener('pointerleave', progressContainer._pointerLeaveHandler); 799 progressContainer.addEventListener('touchstart', progressContainer._touchStartHandler, { 800 passive: true, 801 }); 802 } 803 804 updateSpinnerVisualState(forceShow = null) { 805 const spinner = this.get('spinnerElement'); 806 if (!spinner) { 807 return; 808 } 809 810 const timeoutRef = { current: this._spinnerHideTimeoutId }; 811 812 const result = updateSpinnerState({ 813 spinnerElement: spinner, 814 videoElement: this.videoElement, 815 videoController: this.videoController, 816 videoSwitcher: this.videoSwitcher, 817 isVideoPaused: () => this.interactionHandler.isVideoPaused(), 818 forceShow, 819 visibleClassName: AppConstants.UI.CSS_CLASSES.VISIBLE, 820 timeoutRef, 821 }); 822 823 this._spinnerHideTimeoutId = timeoutRef.current; 824 825 return result; 826 } 827 async toggleFullscreen(position = AppConstants.UI.ANIMATION_POSITIONS.MID) { 828 const container = this.get('container'); 829 const elementToFullscreen = container || document.documentElement; 830 try { 831 if (!this.isFullscreen()) { 832 await showIconAnimation({ 833 container: this.get('container'), 834 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 835 getIcon: url => Promise.resolve(url), 836 imageUrl: 'https://img.icons8.com/neon/256/full-screen.png', 837 position, 838 animationType: 'fullscreen-enter', 839 }); 840 841 elementToFullscreen.requestFullscreen?.().catch(async error => { 842 console.warn('Fullscreen request failed:', error); 843 844 showMessage('Fullscreen Error'); 845 }); 846 } else { 847 await showIconAnimation({ 848 container: this.get('container'), 849 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 850 getIcon: url => Promise.resolve(url), 851 imageUrl: 'https://img.icons8.com/neon/256/application-window.png', 852 position, 853 animationType: 'fullscreen-exit', 854 }); 855 856 this.exitFullscreen().catch(async error => { 857 console.warn('Exit fullscreen failed:', error); 858 859 showMessage('Fullscreen Error'); 860 }); 861 } 862 } catch (error) { 863 console.error('Error toggling fullscreen:', error); 864 865 showMessage('Fullscreen Error'); 866 } 867 } 868 869 isFullscreen() { 870 return Boolean(document.fullscreenElement); 871 } 872 873 exitFullscreen() { 874 return document.exitFullscreen?.() || Promise.resolve(); 875 } 876 877 async toggleVideoFit(position = AppConstants.UI.ANIMATION_POSITIONS.MID) { 878 if (!this.videoElement) { 879 return; 880 } 881 882 const isFilled = this.videoElement.classList.contains('fill'); 883 this.get('container'); 884 if (isFilled) { 885 this.videoElement.classList.remove('fill'); 886 887 await showIconAnimation({ 888 container: this.get('container'), 889 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 890 getIcon: url => Promise.resolve(url), 891 imageUrl: 'https://img.icons8.com/neon/256/fit-to-width.png', 892 position, 893 animationType: 'videofit', 894 }); 895 } else { 896 this.videoElement.classList.add('fill'); 897 898 await showIconAnimation({ 899 container: this.get('container'), 900 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 901 getIcon: url => Promise.resolve(url), 902 imageUrl: 'https://img.icons8.com/neon/256/original-size.png', 903 position, 904 animationType: 'videofit', 905 }); 906 } 907 } 908 909 isStartupSequenceActive() { 910 return this._isStartupSequenceActive === true; 911 } 912 913 showStartupAnimation() { 914 this._isStartupSequenceActive = true; 915 this.animationManager.isStartupSequenceActive = true; 916 this.animationManager.showStartupAnimation(); 917 this.stopPeriodicVisualUpdates(); 918 this.updateSpinnerVisualState(); 919 920 if (this._startupTransitionTimeoutId) { 921 clearTimeout(this._startupTransitionTimeoutId); 922 this._startupTransitionTimeoutId = null; 923 } 924 } 925 926 transitionStartupToPulsatingPlay() { 927 if (!this.isStartupSequenceActive()) { 928 return; 929 } 930 931 if (this._startupTransitionTimeoutId) { 932 clearTimeout(this._startupTransitionTimeoutId); 933 this._startupTransitionTimeoutId = null; 934 } 935 this.animationManager.transitionStartupToPulsatingPlay(); 936 } 937 938 hideStartupAnimation() { 939 if (!this.isStartupSequenceActive()) { 940 return; 941 } 942 943 if (this._startupTransitionTimeoutId) { 944 clearTimeout(this._startupTransitionTimeoutId); 945 this._startupTransitionTimeoutId = null; 946 } 947 948 this._isStartupSequenceActive = false; 949 this.animationManager.isStartupSequenceActive = false; 950 951 this.animationManager.hideStartupAnimation(); 952 } 953 954 _isVideoActuallyPlaying() { 955 return isVideoActuallyPlaying(this.videoElement, () => this.interactionHandler.isVideoPaused()); 956 } 957 958 _showPlayRetryMessage(message, position) { 959 showMessage(message); 960 this.interactionHandler.showPlayPauseIconAnimation(true, position); 961 this.transitionStartupToPulsatingPlay(); 962 } 963 964 async _handleSuccessfulPlayback() { 965 this.hideStartupAnimation(); 966 applyInitialState(); 967 await this.startPeriodicVisualUpdates(); 968 this.updateSpinnerVisualState(false); 969 } 970 971 _handleFailedPlayback() { 972 this.stopPeriodicVisualUpdates(); 973 this.updateSpinnerVisualState(false); 974 } 975 976 async _attemptVideoPlayback(position) { 977 const firstInteractionTimeoutMs = 10_000; 978 979 try { 980 this.updateSpinnerVisualState(true); 981 982 if (this.videoElement.readyState < HTMLMediaElement.HAVE_METADATA) { 983 try { 984 await Promise.race([ 985 new Promise(resolve => { 986 const metadataHandler = () => { 987 this.videoElement.removeEventListener('loadedmetadata', metadataHandler); 988 resolve(); 989 }; 990 this.videoElement.addEventListener('loadedmetadata', metadataHandler, { once: true }); 991 992 if (this.videoElement.readyState >= HTMLMediaElement.HAVE_METADATA) { 993 resolve(); 994 } 995 }), 996 new Promise((_, reject) => 997 setTimeout(() => reject(new Error('Metadata loading timeout')), 5000), 998 ), 999 ]); 1000 } catch (metadataError) { 1001 console.warn('Metadata loading timed out, attempting playback anyway:', metadataError); 1002 } 1003 } 1004 1005 await this.videoController.playCommand(firstInteractionTimeoutMs); 1006 1007 await new Promise(resolve => setTimeout(resolve, 100)); 1008 1009 const videoActuallyPlaying = this._isVideoActuallyPlaying(); 1010 1011 if (videoActuallyPlaying) { 1012 this.hideStartupAnimation(); 1013 this._updatePlaybackStateUI(); 1014 return true; 1015 } 1016 this._showPlayRetryMessage('Click/Tap again to play', position); 1017 return false; 1018 } catch (playError) { 1019 console.error('First interaction play error:', playError); 1020 this._showPlayRetryMessage('Tap again to retry playback', position); 1021 return false; 1022 } finally { 1023 this.updateSpinnerVisualState(false); 1024 } 1025 } 1026 1027 async handleFirstInteraction(position = AppConstants.UI.ANIMATION_POSITIONS.MID) { 1028 if (!this.isStartupSequenceActive()) { 1029 return false; 1030 } 1031 1032 setUserInteracted(); 1033 let playbackStarted = false; 1034 1035 try { 1036 if (this.videoElement.muted) { 1037 this.videoElement.muted = false; 1038 await new Promise(resolve => setTimeout(resolve, 50)); 1039 } 1040 1041 if (this.videoElement.paused) { 1042 playbackStarted = await this._attemptVideoPlayback(position); 1043 } else { 1044 playbackStarted = true; 1045 this.hideStartupAnimation(); 1046 } 1047 1048 if (playbackStarted) { 1049 await this._handleSuccessfulPlayback(); 1050 } else { 1051 this._handleFailedPlayback(); 1052 } 1053 1054 return true; 1055 } catch (error) { 1056 this.stopPeriodicVisualUpdates(); 1057 1058 if (error.name === 'NotAllowedError') { 1059 this._showPlayRetryMessage('Click/Tap again to play', position); 1060 } else { 1061 console.error('Error during first interaction playback:', error); 1062 this.transitionStartupToPulsatingPlay(); 1063 } 1064 1065 return true; 1066 } finally { 1067 if (!playbackStarted) { 1068 this.updateSpinnerVisualState(false); 1069 } 1070 } 1071 } 1072 1073 async handleVideoPlaybackError(eventData) { 1074 const error = eventData.detail?.error || eventData.error; 1075 1076 if (error?.name === 'NotAllowedError') { 1077 if (this.isStartupSequenceActive()) { 1078 this.transitionStartupToPulsatingPlay(); 1079 } else { 1080 this.interactionHandler.showPlayPauseIconAnimation(true, this.AnimationPositions.MID); 1081 this.stopPeriodicVisualUpdates(); 1082 } 1083 } else { 1084 const errorMessage = `Playback Error: ${error?.message || error?.name || 'Unknown'}`; 1085 1086 showMessage(errorMessage); 1087 1088 try { 1089 await showIconAnimation({ 1090 container: this.get('container'), 1091 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 1092 getIcon: url => Promise.resolve(url), 1093 imageUrl: 'https://img.icons8.com/neon/256/error.png', 1094 position: AppConstants.UI.ANIMATION_POSITIONS.MID, 1095 preventDuringStartup: false, 1096 animationType: 'error', 1097 }); 1098 } catch (animError) { 1099 console.error('Failed to show error animation:', animError); 1100 } 1101 1102 this.stopPeriodicVisualUpdates(); 1103 this.updateSpinnerVisualState(); 1104 } 1105 } 1106 1107 handleVideoMetadataLoaded() { 1108 this.updateAllVisuals(); 1109 if (this.isStartupSequenceActive()) { 1110 applyInitialState(); 1111 } else { 1112 applyInitialState(); 1113 this.updateSpinnerVisualState(); 1114 } 1115 1116 import('../utils/dom/viewport.js').then(({ handleOrientationChange }) => { 1117 handleOrientationChange(); 1118 }); 1119 } 1120 1121 async _updatePlaybackStateUI() { 1122 if (!this.videoElement) { 1123 return; 1124 } 1125 1126 const isPlaying = !this.interactionHandler.isVideoPaused(); 1127 1128 this.updateSpinnerVisualState(!isPlaying); 1129 1130 if (isPlaying) { 1131 await this.startPeriodicVisualUpdates(); 1132 } else { 1133 this.stopPeriodicVisualUpdates(); 1134 } 1135 1136 this.updateAllVisuals(); 1137 } 1138 1139 async handleVideoPlay() { 1140 if (this._isProgrammaticPause) { 1141 this._isProgrammaticPause = false; 1142 } 1143 1144 if (this.isStartupSequenceActive()) { 1145 if (getHasUserInteracted()) { 1146 this.transitionStartupToPulsatingPlay(); 1147 } else { 1148 this.transitionStartupToPulsatingPlay(); 1149 1150 if (this.videoElement && !this.videoElement.muted) { 1151 this.videoElement.muted = true; 1152 } 1153 } 1154 } else { 1155 await this.startPeriodicVisualUpdates(); 1156 1157 this.interactionHandler.showPlayPauseIconAnimation( 1158 false, 1159 AppConstants.UI.ANIMATION_POSITIONS.MID, 1160 ); 1161 } 1162 1163 this.updateSpinnerVisualState(); 1164 } 1165 1166 async handleVideoPlaying() { 1167 const videoActuallyPlaying = isVideoActuallyPlaying(this.videoElement, () => 1168 this.interactionHandler.isVideoPaused(), 1169 ); 1170 1171 if (!videoActuallyPlaying) { 1172 return; 1173 } 1174 1175 await this.startPeriodicVisualUpdates(); 1176 1177 if (this.isStartupSequenceActive()) { 1178 if (getHasUserInteracted()) { 1179 this.hideStartupAnimation(); 1180 } else { 1181 this.transitionStartupToPulsatingPlay(); 1182 1183 if (this.videoElement && !this.videoElement.muted) { 1184 this.videoElement.muted = true; 1185 } 1186 } 1187 } 1188 1189 this.updateSpinnerVisualState(false); 1190 1191 if (this.videoSwitcher?.isLoading) { 1192 this.videoSwitcher.isLoading = false; 1193 } 1194 } 1195 1196 _handleVideoStopped(isEnded = false) { 1197 this.stopPeriodicVisualUpdates(); 1198 this.updateSpinnerVisualState(); 1199 1200 if (!this.isStartupSequenceActive()) { 1201 if (!isEnded) { 1202 this.interactionHandler.showPlayPauseIconAnimation( 1203 true, 1204 AppConstants.UI.ANIMATION_POSITIONS.MID, 1205 ); 1206 } 1207 1208 if (isEnded) { 1209 this._prepareForNextVideo(); 1210 } 1211 } 1212 } 1213 1214 async showProgrammaticPauseAnimation(position = AppConstants.UI.ANIMATION_POSITIONS.MID) { 1215 try { 1216 await showIconAnimation({ 1217 container: this.get('container'), 1218 durationMs: AppConstants.UI.ANIMATION_DURATION_MS, 1219 getIcon: url => Promise.resolve(url), 1220 imageUrl: 'https://img.icons8.com/neon/256/shuffle.png', 1221 position, 1222 animationType: 'shuffle', 1223 }); 1224 } catch (error) { 1225 console.warn('Failed to show programmatic pause animation:', error); 1226 } 1227 } 1228 1229 handleVideoPause() { 1230 this.stopPeriodicVisualUpdates(); 1231 this.updateSpinnerVisualState(); 1232 1233 if (!this.isStartupSequenceActive() && !this._isProgrammaticPause) { 1234 this.interactionHandler.showPlayPauseIconAnimation( 1235 true, 1236 AppConstants.UI.ANIMATION_POSITIONS.MID, 1237 ); 1238 } 1239 } 1240 1241 handleVideoEnded() { 1242 console.debug('UIManager.handleVideoEnded called, but this is now handled by VideoSwitcher'); 1243 } 1244 1245 _prepareForNextVideo() { 1246 if (!this.videoSwitcher) { 1247 console.warn('Cannot prepare for next video: VideoSwitcher not available'); 1248 return; 1249 } 1250 1251 this.updateSpinnerVisualState(true); 1252 1253 const repeatMode = getRepeatMode(); 1254 1255 console.debug(`Preparing for next video (Repeat mode: ${repeatMode ? 'ON' : 'OFF'})`); 1256 1257 try { 1258 const hasInteracted = getHasUserInteracted(); 1259 if (hasInteracted && this.videoElement?.muted) { 1260 console.info('Ensuring video is unmuted based on user interaction state'); 1261 this.videoElement.muted = false; 1262 } 1263 1264 if (this._playlistUIManager) { 1265 setTimeout(() => { 1266 this._playlistUIManager.populatePlaylist(); 1267 }, 1000); 1268 } 1269 } catch (error) { 1270 console.error('Error preparing for next video:', error); 1271 showMessage('Error preparing next video'); 1272 } finally { 1273 if (this.videoElement) { 1274 this.updateSpinnerVisualState(false); 1275 } 1276 } 1277 } 1278 1279 async onMenuOpened(side) { 1280 if (side === 'left') { 1281 try { 1282 const statsUIManager = await this._ensureStatsUIManagerLoaded(); 1283 if (statsUIManager) { 1284 if ('statsVisible' in statsUIManager) { 1285 if (statsUIManager.statsVisible) { 1286 statsUIManager.startUpdating(); 1287 } 1288 } else { 1289 statsUIManager.startUpdating(); 1290 } 1291 } 1292 } catch (error) { 1293 console.error('Failed to initialize stats UI manager:', error); 1294 } 1295 } else if (side === 'right') { 1296 await this._handlePlaylistMenuOpen(); 1297 } 1298 } 1299 1300 _cancelPreviousPlaylistOperation() { 1301 if (this._playlistMenuOperationInProgress && this._playlistMenuOperationAbortController) { 1302 this._playlistMenuOperationAbortController.abort(); 1303 } 1304 1305 this._playlistMenuOperationAbortController = new AbortController(); 1306 return this._playlistMenuOperationAbortController.signal; 1307 } 1308 1309 _resetPlaylistSearch(signal) { 1310 if (signal.aborted) { 1311 return; 1312 } 1313 1314 const searchInput = this.get('playlistSearchInput'); 1315 const searchClearButton = this.get('playlistSearchClearButton'); 1316 1317 if (searchInput) { 1318 searchInput.value = ''; 1319 playlistDataService.currentSearchQuery = ''; 1320 } 1321 1322 if (searchClearButton) { 1323 searchClearButton.classList.remove('visible'); 1324 } 1325 } 1326 1327 async _populatePlaylist(signal) { 1328 if (signal.aborted) { 1329 return; 1330 } 1331 1332 try { 1333 if (this._playlistUIManager?.populatePlaylist) { 1334 await this._playlistUIManager.populatePlaylist(signal); 1335 } else { 1336 console.warn('PlaylistUIManager.populatePlaylist is not available'); 1337 this._showPlaylistMessage('Playlist unavailable'); 1338 } 1339 } catch (error) { 1340 console.error('Error populating playlist:', error); 1341 this._showPlaylistMessage('Error loading playlist'); 1342 } 1343 } 1344 1345 _showPlaylistMessage(message) { 1346 showMessage(message); 1347 } 1348 1349 _handlePlaylistError(error) { 1350 if ( 1351 handleAbortedFetch(error, { 1352 context: 'playlist menu operation', 1353 logError: true, 1354 rethrow: false, 1355 }) 1356 ) { 1357 if (this._playlistMenuState === 'opening') { 1358 this._playlistMenuState = 'closed'; 1359 } 1360 1361 const playlistContainer = this.get('playlistContainer'); 1362 if (playlistContainer) { 1363 playlistContainer.classList.remove('loading'); 1364 } 1365 1366 console.debug('Playlist operation was intentionally aborted'); 1367 } else { 1368 console.error('Error handling playlist menu open:', error); 1369 this._showPlaylistMessage('Error opening playlist'); 1370 } 1371 } 1372 1373 async _handlePlaylistMenuOpen() { 1374 const signal = this._cancelPreviousPlaylistOperation(); 1375 this._playlistMenuOperationInProgress = true; 1376 1377 try { 1378 const playlistUIManager = await this._ensurePlaylistUIManagerLoaded(); 1379 if (signal.aborted) { 1380 return; 1381 } 1382 1383 this._resetPlaylistSearch(signal); 1384 if (signal.aborted) { 1385 return; 1386 } 1387 1388 await this._populatePlaylist(signal); 1389 if (signal.aborted) { 1390 return; 1391 } 1392 } catch (error) { 1393 this._handlePlaylistError(error); 1394 } finally { 1395 if (!signal.aborted) { 1396 this._playlistMenuOperationInProgress = false; 1397 } 1398 } 1399 } 1400 1401 onMenuClosed(side) { 1402 if (side === 'left') { 1403 if (this._statsUIManager) { 1404 this._statsUIManager.stopUpdating(); 1405 } 1406 } else if (side === 'right') { 1407 if (this._playlistMenuOperationInProgress) { 1408 if (this._playlistMenuOperationAbortController) { 1409 this._playlistMenuOperationAbortController.abort(); 1410 this._playlistMenuOperationInProgress = false; 1411 } 1412 } 1413 1414 if (this.videoElement) { 1415 this.videoElement.style.display = 'none'; 1416 1417 requestAnimationFrame(() => { 1418 if (this.videoElement) { 1419 this.videoElement.style.display = 'block'; 1420 1421 this.updateSpinnerVisualState(); 1422 } 1423 }); 1424 } 1425 } 1426 } 1427 1428 cleanup() { 1429 for (const unsubscribe of this._eventSubscriptions) { 1430 if (typeof unsubscribe === 'function') { 1431 unsubscribe(); 1432 } 1433 } 1434 this._eventSubscriptions = []; 1435 1436 this.subscriptions.unsubscribeAll(); 1437 1438 this.events.clearAllEvents(); 1439 1440 this.stopPeriodicVisualUpdates(); 1441 this.stopProgressUpdates(); 1442 clearTimeout(this.messageTimeoutId); 1443 1444 if (this._spinnerHideTimeoutId) { 1445 clearTimeout(this._spinnerHideTimeoutId); 1446 this._spinnerHideTimeoutId = null; 1447 } 1448 1449 if (this._playlistMenuOperationInProgress) { 1450 if (this._playlistMenuOperationAbortController) { 1451 this._playlistMenuOperationAbortController.abort(); 1452 this._playlistMenuOperationAbortController = null; 1453 } 1454 this._playlistMenuOperationInProgress = false; 1455 } 1456 1457 this.detachBaseEventListeners(); 1458 1459 const progressContainer = this.get('progressContainer'); 1460 if (progressContainer) { 1461 if (progressContainer._progressClickHandler) { 1462 progressContainer.removeEventListener('click', progressContainer._progressClickHandler); 1463 progressContainer._progressClickHandler = null; 1464 } 1465 1466 if (progressContainer._pointerEnterHandler) { 1467 progressContainer.removeEventListener( 1468 'pointerenter', 1469 progressContainer._pointerEnterHandler, 1470 ); 1471 progressContainer._pointerEnterHandler = null; 1472 } 1473 if (progressContainer._pointerMoveHandler) { 1474 progressContainer.removeEventListener('pointermove', progressContainer._pointerMoveHandler); 1475 progressContainer._pointerMoveHandler = null; 1476 } 1477 if (progressContainer._pointerLeaveHandler) { 1478 progressContainer.removeEventListener( 1479 'pointerleave', 1480 progressContainer._pointerLeaveHandler, 1481 ); 1482 progressContainer._pointerLeaveHandler = null; 1483 } 1484 1485 if (progressContainer._touchStartHandler) { 1486 progressContainer.removeEventListener('touchstart', progressContainer._touchStartHandler); 1487 progressContainer._touchStartHandler = null; 1488 } 1489 1490 if (progressContainer._autoShrinkTimerId) { 1491 clearTimeout(progressContainer._autoShrinkTimerId); 1492 progressContainer._autoShrinkTimerId = null; 1493 } 1494 } 1495 1496 window.removeEventListener('resize', this._handleWindowResize.bind(this)); 1497 1498 this._cleanupMenuButtonListeners(); 1499 1500 this._frameAnalyzer?.cleanup(); 1501 this.interactionHandler?.cleanup(); 1502 this._menuManager?.cleanup(); 1503 this._playlistUIManager?.cleanup(); 1504 this._statsUIManager?.cleanup(); 1505 this.animationManager?.cleanup(); 1506 this._languageManager?.cleanup(); 1507 this._isStartupSequenceActive = false; 1508 this._isReloadInProgress = false; 1509 } 1510 1511 _cleanupMenuButtonListeners() { 1512 const leftMenuToggle = this.get('leftMenuToggleButton'); 1513 const rightMenuToggle = this.get('rightMenuToggleButton'); 1514 const leftMenuClose = this.get('leftMenuCloseButton'); 1515 const rightMenuClose = this.get('rightMenuCloseButton'); 1516 1517 if (leftMenuToggle?._menuToggleHandler) { 1518 leftMenuToggle.removeEventListener('click', leftMenuToggle._menuToggleHandler); 1519 leftMenuToggle._menuToggleHandler = undefined; 1520 leftMenuToggle._hasMenuToggleListener = undefined; 1521 } 1522 1523 if (rightMenuToggle?._menuToggleHandler) { 1524 rightMenuToggle.removeEventListener('click', rightMenuToggle._menuToggleHandler); 1525 rightMenuToggle._menuToggleHandler = undefined; 1526 rightMenuToggle._hasMenuToggleListener = undefined; 1527 } 1528 1529 if (leftMenuClose?._menuCloseHandler) { 1530 leftMenuClose.removeEventListener('click', leftMenuClose._menuCloseHandler); 1531 1532 leftMenuClose._menuCloseHandler = undefined; 1533 1534 leftMenuClose._hasMenuCloseListener = undefined; 1535 } 1536 1537 if (rightMenuClose?._menuCloseHandler) { 1538 rightMenuClose.removeEventListener('click', rightMenuClose._menuCloseHandler); 1539 1540 rightMenuClose._menuCloseHandler = undefined; 1541 1542 rightMenuClose._hasMenuCloseListener = undefined; 1543 } 1544 } 1545 1546 get(key) { 1547 return this.elements[key] ?? null; 1548 } 1549 1550 cacheDOMElements() { 1551 const query = selector => document.querySelector(selector); 1552 const getId = id => document.getElementById(id); 1553 const elementSelectors = { 1554 centerInteractionZone: '.zone.center-zone', 1555 1556 container: '.container', 1557 1558 interactionZoneContainer: '#interaction-zones', 1559 1560 languageSelector: '#language-selector', 1561 1562 leftInteractionZone: '.zone.left-zone', 1563 1564 leftMenuCloseButton: '#left-menu .close-menu', 1565 1566 leftMenuElement: '#left-menu', 1567 1568 leftMenuToggleButton: '#left-menu-toggle', 1569 1570 messageOverlay: '#message-overlay', 1571 1572 playlistElement: '#playlist-items', 1573 1574 playlistSearchClearButton: '#playlist-search-clear', 1575 1576 playlistSearchInput: '#playlist-search', 1577 1578 playPauseAnimationIcon: '#play-pause-animation', 1579 1580 progressContainer: '#progress-container', 1581 1582 reshuffleButton: '#playlist-reshuffle-button', 1583 1584 rightInteractionZone: '.zone.right-zone', 1585 1586 rightMenuCloseButton: '#right-menu .close-menu', 1587 1588 rightMenuElement: '#right-menu', 1589 1590 rightMenuToggleButton: '#right-menu-toggle', 1591 1592 seekAnimationIcon: '#fast-seek-animation', 1593 1594 spinnerElement: '#spinner', 1595 1596 startupAnimationElement: '#startup-animation', 1597 1598 statsContentElement: '#stats-content', 1599 1600 statsToggle: '#stats-toggle', 1601 1602 livestreamToggle: '#livestream-toggle', 1603 1604 switchAnimationIcon: '#switch-animation', 1605 1606 videoElement: '#video-player', 1607 1608 videoWrapper: '.video-wrapper', 1609 1610 volumeAnimationIcon: '#volume-animation', 1611 }; 1612 const cache = {}; 1613 1614 for (const key in elementSelectors) { 1615 const selector = elementSelectors[key]; 1616 1617 cache[key] = selector.startsWith('#') ? getId(selector.slice(1)) : query(selector); 1618 } 1619 1620 this.validateCoreElements(cache); 1621 1622 return cache; 1623 } 1624 1625 validateCoreElements(elements) { 1626 const criticalElementKeys = [ 1627 'container', 1628 'videoWrapper', 1629 'videoElement', 1630 'interactionZoneContainer', 1631 'progressContainer', 1632 ]; 1633 const missingKeys = criticalElementKeys.filter(key => !elements[key]); 1634 1635 if (missingKeys.length > 0) { 1636 const errorMsg = `UIManager Error: Missing critical DOM elements required for initialization: ${missingKeys.join(', ')}. Check HTML structure and selectors.`; 1637 console.error(errorMsg); 1638 throw new Error(errorMsg); 1639 } 1640 } 1641 }