/ app / ui / ui-manager.js
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  }