/ app / ui / interaction-handler.js
interaction-handler.js
   1  import AppConstants from '../config/constants.js';
   2  import { ElementProvider, UIManagerElementProvider } from '../interfaces/element-provider.js';
   3  import { UIManagerMenuStateProvider } from '../interfaces/menu-state-provider.js';
   4  import { UIManagerStartupSequenceProvider } from '../interfaces/startup-sequence-provider.js';
   5  import { createEventEmitter, createLocalEventSubscriptionManager } from '../utils/event/index.js';
   6  import {
   7    getHasUserInteracted,
   8    getRepeatMode,
   9    setRepeatMode,
  10    setUserInteracted,
  11  } from '../services/user-state-events.js';
  12  import * as videoSources from '../services/video/sources.js';
  13  import * as videoRouter from '../services/video/router.js';
  14  import { VideoEvents, VideoEventData } from '../services/video/video-events.js';
  15  import { getNormalizedPosition, showIconAnimation } from '../utils/animation/index.js';
  16  import { checkMenuVisibility } from '../utils/ui/index.js';
  17  import { getRandomIndexExcluding } from '../utils/formatting/index.js';
  18  import { handleAbortedFetch } from '../utils/error/index.js';
  19  import { showMessage, showTemporaryMessage } from '../utils/ui/message.js';
  20  
  21  async function showErrorWithAnimation(
  22    errorMessage,
  23    position = AppConstants.UI.ANIMATION_POSITIONS.MID,
  24    container = null,
  25  ) {
  26    showMessage(errorMessage);
  27  
  28    try {
  29      await showIconAnimation({
  30        container: container || document.querySelector('.container'),
  31        durationMs: AppConstants.UI.ANIMATION_DURATION_MS,
  32        getIcon: url => Promise.resolve(url),
  33        imageUrl: 'https://img.icons8.com/neon/256/error.png',
  34        position,
  35        animationType: 'error',
  36      });
  37    } catch (error) {
  38      console.warn('Failed to show error animation:', error);
  39    }
  40  }
  41  
  42  function resetCountersAndTimeouts(counters, timeoutHandles, keys = null) {
  43    const keysToReset = keys || Object.keys(counters);
  44    for (const k of keysToReset) {
  45      clearTimeout(timeoutHandles[k]);
  46      timeoutHandles[k] = null;
  47      counters[k] = 0;
  48    }
  49  }
  50  
  51  export class InteractionHandler {
  52    static isKeyListenerInitialized = false;
  53  
  54    constructor(
  55      elementProvider,
  56      videoController,
  57      videoSwitcher,
  58      menuStateProvider,
  59      animationManager,
  60      startupSequenceProvider = null,
  61    ) {
  62      this.events = createEventEmitter();
  63  
  64      this.subscriptions = createLocalEventSubscriptionManager();
  65  
  66      if (elementProvider && !(elementProvider instanceof ElementProvider)) {
  67        const uiManager = elementProvider;
  68        this.elementProvider = new UIManagerElementProvider(uiManager);
  69        this.menuStateProvider =
  70          menuStateProvider ||
  71          new UIManagerMenuStateProvider(uiManager, AppConstants.UI.CSS_CLASSES.VISIBLE);
  72        this.startupSequenceProvider =
  73          startupSequenceProvider || new UIManagerStartupSequenceProvider(uiManager);
  74  
  75        this.uiManager = uiManager;
  76  
  77        this.uiManagerEvents = uiManager?.getEventEmitter?.();
  78      } else {
  79        this.elementProvider = elementProvider;
  80        this.menuStateProvider = menuStateProvider;
  81        this.startupSequenceProvider = startupSequenceProvider;
  82        this.uiManager = null;
  83        this.uiManagerEvents = null;
  84      }
  85  
  86      this.videoController = videoController;
  87      this.videoSwitcher = videoSwitcher;
  88      this.animationManager = animationManager;
  89      if (!this.elementProvider || !videoController || !animationManager) {
  90        console.error('InteractionHandler missing critical dependencies!');
  91  
  92        return;
  93      }
  94  
  95      this.tapState = this.createTapState();
  96      this.pointerState = this.createPointerState();
  97      this.keyPressTrackers = this.createKeyPressTrackers();
  98  
  99      this.CssClasses = AppConstants.UI.CSS_CLASSES;
 100      this.AnimationPositions = AppConstants.UI.ANIMATION_POSITIONS;
 101      this.MultiTapDelayMs = AppConstants.UI.MULTI_TAP_DELAY_MS;
 102      this.SwipeMinDistancePx = AppConstants.UI.SWIPE_MIN_DISTANCE_PX;
 103      this.SwipeMaxTimeMs = AppConstants.UI.SWIPE_MAX_TIME_MS;
 104      this.SwipeReloadMinDistancePx = AppConstants.UI.SWIPE_RELOAD_MIN_DISTANCE_PX;
 105      this.TapMaxDurationMs = AppConstants.UI.TAP_MAX_DURATION_MS;
 106      this.SeekTimeSeconds = AppConstants.UI.SEEK_TIME_SECONDS;
 107      this.TapMaxMovePx = AppConstants.UI.TAP_MAX_MOVE_THRESHOLD_PX;
 108      this.MaxTaps = 3;
 109  
 110      this._listenersAttached = {
 111        keyboard: false,
 112        pointerDown: false,
 113        pointerMoveUp: false,
 114        wheel: false,
 115        zoneFeedback: false,
 116      };
 117  
 118      this._eventHandlers = {};
 119  
 120      Object.defineProperty(this, 'eventHandlers', {
 121        get() {
 122          return this._eventHandlers || {};
 123        },
 124        set(value) {
 125          this._eventHandlers = value;
 126        },
 127      });
 128  
 129      this.init();
 130    }
 131  
 132    getEventEmitter() {
 133      return this.events;
 134    }
 135  
 136    async init() {
 137      try {
 138        await this._attachPointerDownListener();
 139        await this._attachKeyboardListener();
 140      } catch (error) {
 141        console.error('Failed to initialize interaction handler:', error);
 142      }
 143    }
 144  
 145    createTapState() {
 146      return {
 147        counters: { c: 0, l: 0, r: 0 },
 148        lastTapTime: 0,
 149        lastTapZone: null,
 150        timeoutHandles: { c: null, l: null, r: null },
 151      };
 152    }
 153  
 154    createPointerState() {
 155      return {
 156        currentX: 0,
 157        currentY: 0,
 158        downTarget: null,
 159        downZone: null,
 160        isTracking: false,
 161        pointerId: null,
 162        startTime: 0,
 163        startX: 0,
 164        startY: 0,
 165      };
 166    }
 167  
 168    createKeyPressTrackers() {
 169      return {
 170        seekLeft: { timer: null },
 171        seekRight: { timer: null },
 172      };
 173    }
 174  
 175    _attachPointerDownListener() {
 176      if (this._listenersAttached.pointerDown) {
 177        return;
 178      }
 179      const zonesContainer = this.elementProvider.get('interactionZoneContainer');
 180      if (!zonesContainer) {
 181        console.error('Zones container not found for pointerdown listener');
 182        return;
 183      }
 184      const handler = this.handlePointerDown.bind(this);
 185      zonesContainer.addEventListener('pointerdown', handler, { passive: true });
 186      this.eventHandlers.pointerdown = handler;
 187      this._listenersAttached.pointerDown = true;
 188    }
 189  
 190    _attachPointerMoveUpListeners() {
 191      if (this._listenersAttached.pointerMoveUp) {
 192        return;
 193      }
 194      const moveHandler = this.handlePointerMove.bind(this);
 195      const upHandler = this.handlePointerUp.bind(this);
 196  
 197      window.addEventListener('pointermove', moveHandler, { passive: false });
 198      window.addEventListener('pointerup', upHandler, { passive: true });
 199      window.addEventListener('pointercancel', upHandler, { passive: true });
 200  
 201      this.eventHandlers.pointermove = moveHandler;
 202      this.eventHandlers.pointerup = upHandler;
 203      this._listenersAttached.pointerMoveUp = true;
 204    }
 205  
 206    _attachKeyboardListener() {
 207      if (this._listenersAttached.keyboard || InteractionHandler.isKeyListenerInitialized) {
 208        return;
 209      }
 210      const handler = this.handleGlobalKeyDown.bind(this);
 211      document.addEventListener('keydown', handler);
 212      this.eventHandlers.keydown = handler;
 213      this._listenersAttached.keyboard = true;
 214      InteractionHandler.isKeyListenerInitialized = true;
 215    }
 216  
 217    _attachWheelListener() {
 218      if (this._listenersAttached.wheel) {
 219        return;
 220      }
 221      const videoElement = this.elementProvider.get('videoElement');
 222      if (!videoElement) {
 223        console.error('Video element not found for wheel listener');
 224        return;
 225      }
 226      const handler = this.handleMouseWheel.bind(this);
 227      const wheelTarget =
 228        this.elementProvider.get('interactionZoneContainer') ||
 229        videoElement.parentElement ||
 230        document.body;
 231      wheelTarget.addEventListener('wheel', handler, { passive: false });
 232      this.eventHandlers.wheel = handler;
 233      this._listenersAttached.wheel = true;
 234    }
 235  
 236    _attachInteractionZoneFeedback() {
 237      if (this._listenersAttached.zoneFeedback) {
 238        return;
 239      }
 240  
 241      const setupZone = (zoneElement, _zoneKey) => {
 242        if (!zoneElement) {
 243          return;
 244        }
 245        if (zoneElement._feedbackListeners) {
 246          return;
 247        }
 248  
 249        const listeners = {
 250          down: e => {
 251            if (this.pointerState.isTracking && this.pointerState.pointerId === e.pointerId) {
 252              zoneElement.classList.add(this.CssClasses.ZONE_ACTIVE);
 253            }
 254          },
 255          upOrCancel: () => {
 256            zoneElement.classList.remove(this.CssClasses.ZONE_ACTIVE);
 257          },
 258        };
 259  
 260        zoneElement.addEventListener('pointerdown', listeners.down, {
 261          passive: true,
 262        });
 263  
 264        window.addEventListener('pointerup', listeners.upOrCancel, {
 265          passive: true,
 266        });
 267        window.addEventListener('pointercancel', listeners.upOrCancel, {
 268          passive: true,
 269        });
 270  
 271        zoneElement._feedbackListeners = listeners;
 272      };
 273  
 274      setupZone(this.elementProvider.get('leftInteractionZone'), 'l');
 275      setupZone(this.elementProvider.get('centerInteractionZone'), 'c');
 276      setupZone(this.elementProvider.get('rightInteractionZone'), 'r');
 277  
 278      this._listenersAttached.zoneFeedback = true;
 279    }
 280  
 281    _removeInteractionZoneFeedback() {
 282      const removeZoneListeners = zoneElement => {
 283        if (zoneElement?._feedbackListeners) {
 284          zoneElement.removeEventListener('pointerdown', zoneElement._feedbackListeners.down);
 285          window.removeEventListener('pointerup', zoneElement._feedbackListeners.upOrCancel);
 286          window.removeEventListener('pointercancel', zoneElement._feedbackListeners.upOrCancel);
 287          zoneElement.classList.remove(this.CssClasses.ZONE_ACTIVE);
 288          zoneElement._feedbackListeners = undefined;
 289        }
 290      };
 291      removeZoneListeners(this.elementProvider.get('leftInteractionZone'));
 292      removeZoneListeners(this.elementProvider.get('centerInteractionZone'));
 293      removeZoneListeners(this.elementProvider.get('rightInteractionZone'));
 294      this._listenersAttached.zoneFeedback = false;
 295    }
 296  
 297    async _attachRemainingListeners() {
 298      await this._attachPointerMoveUpListeners();
 299  
 300      setTimeout(async () => {
 301        try {
 302          const listenersToAttach = [];
 303          if (!this._listenersAttached.keyboard && !InteractionHandler.isKeyListenerInitialized) {
 304            listenersToAttach.push(this._attachKeyboardListener());
 305          }
 306          listenersToAttach.push(this._attachWheelListener());
 307  
 308          await Promise.all(listenersToAttach);
 309  
 310          this._attachInteractionZoneFeedback();
 311        } catch (error) {
 312          console.error('Error attaching additional interaction listeners:', error);
 313        }
 314      }, 0);
 315    }
 316  
 317    detachInteractionListeners() {
 318      if (!this.elementProvider || !this.eventHandlers) {
 319        console.warn('Missing elementProvider or eventHandlers during detach');
 320        return;
 321      }
 322  
 323      const zonesContainer = this.elementProvider.get('interactionZoneContainer');
 324      if (zonesContainer && this.eventHandlers.pointerdown) {
 325        zonesContainer.removeEventListener('pointerdown', this.eventHandlers.pointerdown);
 326      }
 327      if (this.eventHandlers.pointermove) {
 328        window.removeEventListener('pointermove', this.eventHandlers.pointermove);
 329      }
 330      if (this.eventHandlers.pointerup) {
 331        window.removeEventListener('pointerup', this.eventHandlers.pointerup);
 332        window.removeEventListener('pointercancel', this.eventHandlers.pointerup);
 333      }
 334      if (this.eventHandlers.keydown) {
 335        document.removeEventListener('keydown', this.eventHandlers.keydown);
 336        InteractionHandler.isKeyListenerInitialized = false;
 337      }
 338      if (this.eventHandlers.wheel) {
 339        const wheelTarget =
 340          this.elementProvider.get('interactionZoneContainer') ||
 341          this.elementProvider.get('videoElement')?.parentElement ||
 342          document.body;
 343        wheelTarget.removeEventListener('wheel', this.eventHandlers.wheel);
 344      }
 345  
 346      try {
 347        this._removeInteractionZoneFeedback();
 348      } catch (error) {
 349        console.warn('Error removing zone feedback:', error);
 350      }
 351  
 352      if (this._listenersAttached) {
 353        for (const key of Object.keys(this._listenersAttached)) {
 354          this._listenersAttached[key] = false;
 355        }
 356      }
 357    }
 358  
 359    async resetTapState(zoneKey = null) {
 360      if (!this.tapState) {
 361        return;
 362      }
 363      const keys = zoneKey ? [zoneKey] : null;
 364  
 365      resetCountersAndTimeouts(this.tapState.counters, this.tapState.timeoutHandles, keys);
 366  
 367      if (!zoneKey) {
 368        this.tapState.lastTapZone = null;
 369        this.tapState.lastTapTime = 0;
 370      }
 371    }
 372  
 373    resetAllKeyPressStates() {
 374      if (!this.keyPressTrackers) {
 375        return;
 376      }
 377  
 378      for (const tracker of Object.values(this.keyPressTrackers)) {
 379        if (tracker?.timer) {
 380          clearTimeout(tracker.timer);
 381          tracker.timer = null;
 382        }
 383      }
 384    }
 385  
 386    resetKeyPressState(key) {
 387      const tracker = this.keyPressTrackers[key];
 388      if (tracker?.timer) {
 389        clearTimeout(tracker.timer);
 390        tracker.timer = null;
 391      }
 392    }
 393  
 394    resetPointerState() {
 395      if (!this.pointerState) {
 396        return;
 397      }
 398      this.pointerState.isTracking = false;
 399      this.pointerState.pointerId = null;
 400      this.pointerState.downTarget = null;
 401      this.pointerState.downZone = null;
 402      this.pointerState.startX = 0;
 403      this.pointerState.startY = 0;
 404      this.pointerState.currentX = 0;
 405      this.pointerState.currentY = 0;
 406      this.pointerState.startTime = 0;
 407    }
 408  
 409    cleanupState() {
 410      if (this.tapState) {
 411        this.resetTapState().catch(error =>
 412          console.error('Error resetting tap state during cleanup:', error),
 413        );
 414      }
 415  
 416      this.resetAllKeyPressStates();
 417  
 418      this.resetPointerState();
 419    }
 420  
 421    isInteractionOnOverlay(targetElement) {
 422      if (!targetElement) {
 423        return false;
 424      }
 425  
 426      const onOverlay = targetElement.closest(`
 427              button, a, input, select, textarea,
 428              .controls-overlay [data-interactive],
 429              [data-prevent-interaction]
 430          `);
 431      const inZone = targetElement.closest('.zone');
 432  
 433      return (onOverlay && !inZone) || targetElement.closest('[data-prevent-interaction]');
 434    }
 435  
 436    getZoneKey(zoneElement) {
 437      if (!zoneElement) {
 438        return null;
 439      }
 440      if (zoneElement.classList.contains('left-zone')) {
 441        return 'l';
 442      }
 443      if (zoneElement.classList.contains('center-zone')) {
 444        return 'c';
 445      }
 446      if (zoneElement.classList.contains('right-zone')) {
 447        return 'r';
 448      }
 449      return null;
 450    }
 451  
 452    isVideoPaused() {
 453      return this.videoController?.videoElement?.paused ?? true;
 454    }
 455  
 456    isMenuOpen() {
 457      if (this.menuStateProvider) {
 458        return this.menuStateProvider.isMenuOpen();
 459      }
 460  
 461      if (this.uiManager) {
 462        const leftMenu = this.uiManager.get('leftMenuElement');
 463        const rightMenu = this.uiManager.get('rightMenuElement');
 464        const cssClass = AppConstants.UI.CSS_CLASSES.VISIBLE;
 465  
 466        return checkMenuVisibility(leftMenu, cssClass) || checkMenuVisibility(rightMenu, cssClass);
 467      }
 468  
 469      return false;
 470    }
 471  
 472    async handlePointerDown(e) {
 473      try {
 474        if (!this.pointerState) {
 475          return;
 476        }
 477  
 478        setUserInteracted();
 479  
 480        if (!this._listenersAttached.pointerMoveUp) {
 481          await this._attachRemainingListeners();
 482        }
 483  
 484        if (
 485          e.button !== 0 ||
 486          (this.pointerState.isTracking && this.pointerState.pointerId !== e.pointerId)
 487        ) {
 488          return;
 489        }
 490  
 491        const downTarget = e.target;
 492  
 493        if (this.isInteractionOnOverlay(downTarget)) {
 494          await this.resetTapState();
 495          return;
 496        }
 497  
 498        const downZone = downTarget?.closest('.zone');
 499  
 500        this.pointerState.startX = e.clientX;
 501        this.pointerState.startY = e.clientY;
 502        this.pointerState.currentX = e.clientX;
 503        this.pointerState.currentY = e.clientY;
 504        this.pointerState.startTime = Date.now();
 505        this.pointerState.pointerId = e.pointerId;
 506        this.pointerState.isTracking = true;
 507        this.pointerState.downTarget = downTarget;
 508        this.pointerState.downZone = downZone;
 509      } catch (error) {
 510        console.error('Error handling pointer down:', error);
 511        this.resetPointerState();
 512      }
 513    }
 514  
 515    handlePointerMove(e) {
 516      if (!this.pointerState?.isTracking || e.pointerId !== this.pointerState.pointerId) {
 517        return;
 518      }
 519  
 520      this.pointerState.currentX = e.clientX;
 521      this.pointerState.currentY = e.clientY;
 522  
 523      const deltaX = Math.abs(this.pointerState.currentX - this.pointerState.startX);
 524      const deltaY = Math.abs(this.pointerState.currentY - this.pointerState.startY);
 525      const isMovingSignificantly = deltaX > this.TapMaxMovePx || deltaY > this.TapMaxMovePx;
 526      const isInZone = Boolean(this.pointerState.downZone);
 527  
 528      if (isMovingSignificantly && isInZone) {
 529        e.preventDefault();
 530      }
 531    }
 532  
 533    _isValidPointerState(e) {
 534      if (
 535        !this.pointerState ||
 536        !this.pointerState.isTracking ||
 537        e.pointerId !== this.pointerState.pointerId
 538      ) {
 539        if (this.pointerState && e.pointerId === this.pointerState.pointerId) {
 540          this.resetPointerState();
 541        }
 542        return false;
 543      }
 544      return true;
 545    }
 546  
 547    async _handleMenuClosingOnTap(downTarget, isQuickTap) {
 548      if (!this.isMenuOpen()) {
 549        return false;
 550      }
 551  
 552      const isClickOnMenu = downTarget?.closest('.sidebar');
 553      if (isQuickTap && !isClickOnMenu) {
 554        try {
 555          const menuManager = await this.menuStateProvider.getMenuManager();
 556          const leftMenu = this.elementProvider.get('leftMenuElement');
 557          const rightMenu = this.elementProvider.get('rightMenuElement');
 558          const cssClass = AppConstants.UI.CSS_CLASSES.VISIBLE;
 559  
 560          let menuClosed = false;
 561          if (checkMenuVisibility(leftMenu, cssClass)) {
 562            await menuManager.closeMenu('left');
 563            menuClosed = true;
 564          }
 565          if (checkMenuVisibility(rightMenu, cssClass)) {
 566            await menuManager.closeMenu('right');
 567            menuClosed = true;
 568          }
 569  
 570          return menuClosed;
 571        } catch (error) {
 572          console.error('Error closing menu:', error);
 573        }
 574      }
 575      return false;
 576    }
 577  
 578    _createClickPosition(endX, endY) {
 579      return {
 580        x: `${(endX / window.innerWidth) * 100}%`,
 581        y: `${(endY / window.innerHeight) * 100}%`,
 582      };
 583    }
 584  
 585    async handlePointerUp(e) {
 586      if (!this._isValidPointerState(e)) {
 587        return;
 588      }
 589  
 590      const { downTarget, downZone, startTime, startX, startY } = this.pointerState;
 591      const endX = this.pointerState.currentX !== startX ? this.pointerState.currentX : e.clientX;
 592      const endY = this.pointerState.currentY !== startY ? this.pointerState.currentY : e.clientY;
 593      const endTime = Date.now();
 594      const duration = endTime - startTime;
 595      const deltaX = endX - startX;
 596      const deltaY = endY - startY;
 597      const absDeltaX = Math.abs(deltaX);
 598      const absDeltaY = Math.abs(deltaY);
 599  
 600      this.resetPointerState();
 601  
 602      try {
 603        if (this.isInteractionOnOverlay(downTarget)) {
 604          await this.resetTapState();
 605          return;
 606        }
 607  
 608        const isQuickTap =
 609          duration < this.TapMaxDurationMs &&
 610          absDeltaX <= this.TapMaxMovePx &&
 611          absDeltaY <= this.TapMaxMovePx;
 612  
 613        const menuClosed = await this._handleMenuClosingOnTap(downTarget, isQuickTap);
 614        if (menuClosed) {
 615          await this.resetTapState();
 616          return;
 617        }
 618  
 619        const isPotentialSwipe =
 620          duration < this.SwipeMaxTimeMs &&
 621          (absDeltaX > this.SwipeMinDistancePx || absDeltaY > this.SwipeMinDistancePx);
 622  
 623        if (isPotentialSwipe) {
 624          const swipeHandled = await this.tryHandleSwipe(
 625            deltaX,
 626            deltaY,
 627            absDeltaX,
 628            absDeltaY,
 629            downZone,
 630          );
 631          if (swipeHandled) {
 632            await this.resetTapState();
 633            return;
 634          }
 635        }
 636  
 637        const isClassicTap = isQuickTap;
 638        const zoneKey = this.getZoneKey(downZone);
 639  
 640        if (zoneKey && (isClassicTap || !isPotentialSwipe)) {
 641          const clickPosition = this._createClickPosition(endX, endY);
 642          this.handleZoneTap(zoneKey, clickPosition);
 643          return;
 644        }
 645  
 646        await this.resetTapState();
 647      } catch (error) {
 648        console.error('Error processing pointer up logic:', error);
 649        await this.resetTapState();
 650      }
 651    }
 652  
 653    _getZonePosition(zoneKey) {
 654      switch (zoneKey) {
 655        case 'l':
 656          return this.AnimationPositions.LEFT;
 657        case 'r':
 658          return this.AnimationPositions.RIGHT;
 659        default:
 660          return this.AnimationPositions.MID;
 661      }
 662    }
 663  
 664    async _handleVerticalSwipe(zoneKey, deltaY, zonePosition) {
 665      switch (zoneKey) {
 666        case 'r': {
 667          const volumeChange =
 668            deltaY < 0 ? AppConstants.UI.VOLUME_STEP : -AppConstants.UI.VOLUME_STEP;
 669          await this.adjustVolumeWithPosition(volumeChange, zonePosition);
 670          return true;
 671        }
 672        case 'l': {
 673          this.uiManager.toggleVideoFit(zonePosition);
 674          return true;
 675        }
 676        case 'c': {
 677          if (deltaY > this.SwipeReloadMinDistancePx) {
 678            await this.switchToRandomVideo(zonePosition);
 679            return true;
 680          }
 681          if (deltaY < -this.SwipeReloadMinDistancePx) {
 682            this.toggleRepeatMode(zonePosition);
 683            return true;
 684          }
 685          break;
 686        }
 687      }
 688      return false;
 689    }
 690  
 691    async _handleHorizontalSwipe(deltaX, menuManager, leftMenu, rightMenu) {
 692      const cssClass = AppConstants.UI.CSS_CLASSES.VISIBLE;
 693      const isLeftMenuOpen = checkMenuVisibility(leftMenu, cssClass);
 694      const isRightMenuOpen = checkMenuVisibility(rightMenu, cssClass);
 695  
 696      if (deltaX < -this.SwipeMinDistancePx) {
 697        if (isLeftMenuOpen) {
 698          await menuManager.closeMenu('left');
 699          return true;
 700        }
 701        await menuManager.toggleMenu('right');
 702        return true;
 703      }
 704  
 705      if (deltaX > this.SwipeMinDistancePx) {
 706        if (isRightMenuOpen) {
 707          await menuManager.closeMenu('right');
 708          return true;
 709        }
 710        await menuManager.toggleMenu('left');
 711        return true;
 712      }
 713  
 714      return false;
 715    }
 716  
 717    async tryHandleSwipe(deltaX, deltaY, absDeltaX, absDeltaY, startZone) {
 718      try {
 719        const zoneKey = this.getZoneKey(startZone);
 720        const zonePosition = this._getZonePosition(zoneKey);
 721  
 722        const isVerticalSwipe = absDeltaY > absDeltaX && absDeltaY > this.SwipeMinDistancePx;
 723        if (isVerticalSwipe) {
 724          const handled = await this._handleVerticalSwipe(zoneKey, deltaY, zonePosition);
 725          if (handled) {
 726            return true;
 727          }
 728        }
 729  
 730        const isHorizontalSwipe = absDeltaX > absDeltaY && absDeltaX > this.SwipeMinDistancePx;
 731        if (isHorizontalSwipe) {
 732          try {
 733            const menuManager = await this.menuStateProvider.getMenuManager();
 734            const leftMenu = this.elementProvider.get('leftMenuElement');
 735            const rightMenu = this.elementProvider.get('rightMenuElement');
 736  
 737            return await this._handleHorizontalSwipe(deltaX, menuManager, leftMenu, rightMenu);
 738          } catch (error) {
 739            console.error('Error handling menu swipe:', error);
 740            return false;
 741          }
 742        }
 743  
 744        return false;
 745      } catch (error) {
 746        console.error('Error handling swipe action:', error);
 747        await showErrorWithAnimation(
 748          'Swipe action failed',
 749          this.AnimationPositions.MID,
 750          AppConstants.UI.ERROR_MESSAGE_DURATION_MS,
 751          this.elementProvider.get('container'),
 752          this.elementProvider.get('message-overlay'),
 753        );
 754        return false;
 755      }
 756    }
 757  
 758    async handleZoneTap(zoneKey, clickPosition) {
 759      try {
 760        if (this.startupSequenceProvider.isStartupSequenceActive()) {
 761          const startupHandled =
 762            await this.startupSequenceProvider.handleFirstInteraction(clickPosition);
 763          if (startupHandled) {
 764            await this.resetTapState();
 765            return;
 766          }
 767        }
 768  
 769        const now = Date.now();
 770        const isContinuingMultiTap =
 771          zoneKey === this.tapState.lastTapZone &&
 772          now - this.tapState.lastTapTime < this.MultiTapDelayMs;
 773  
 774        if (!isContinuingMultiTap) {
 775          await this.resetTapState(zoneKey);
 776        }
 777  
 778        this.tapState.counters[zoneKey] = Math.min(
 779          (this.tapState.counters[zoneKey] || 0) + 1,
 780          this.MaxTaps,
 781        );
 782        const currentTapCount = this.tapState.counters[zoneKey];
 783  
 784        this.tapState.lastTapZone = zoneKey;
 785        this.tapState.lastTapTime = now;
 786  
 787        if (this.tapState.timeoutHandles[zoneKey]) {
 788          clearTimeout(this.tapState.timeoutHandles[zoneKey]);
 789          this.tapState.timeoutHandles[zoneKey] = null;
 790        }
 791  
 792        this.tapState.timeoutHandles[zoneKey] = setTimeout(async () => {
 793          try {
 794            if (this.tapState.counters[zoneKey] === currentTapCount) {
 795              await this.performTapAction(zoneKey, clickPosition, currentTapCount);
 796  
 797              await this.resetTapState(zoneKey);
 798            }
 799          } catch (actionError) {
 800            console.error(
 801              `Error performing tap action via timeout (Zone: ${zoneKey}, Count: ${currentTapCount}):`,
 802              actionError,
 803            );
 804            await this.resetTapState(zoneKey);
 805          } finally {
 806            if (this.tapState.timeoutHandles[zoneKey] !== null) {
 807              this.tapState.timeoutHandles[zoneKey] = null;
 808            }
 809          }
 810        }, this.MultiTapDelayMs);
 811      } catch (error) {
 812        console.error('Error in handleZoneTap:', error);
 813        await this.resetTapState(zoneKey);
 814      }
 815    }
 816  
 817    async _handleSingleTap(zone, pos) {
 818      return this.togglePlayPause(pos);
 819    }
 820  
 821    async _handleDoubleTap(zone, pos) {
 822      const seekSecs = this.SeekTimeSeconds;
 823  
 824      switch (zone) {
 825        case 'l':
 826          this.seekVideoBy(-seekSecs, pos);
 827          return true;
 828        case 'r':
 829          this.seekVideoBy(seekSecs, pos);
 830          return true;
 831        case 'c':
 832          this.uiManager.toggleFullscreen();
 833          return true;
 834        default:
 835          return false;
 836      }
 837    }
 838  
 839    async _handleTripleTap(zone, pos) {
 840      switch (zone) {
 841        case 'l':
 842          await this.switchVideo(-1, pos);
 843          return true;
 844        case 'r':
 845          await this.switchVideo(1, pos);
 846          return true;
 847        default:
 848          return false;
 849      }
 850    }
 851  
 852    async performTapAction(zone, pos, count) {
 853      if (this.isMenuOpen() && count === 1 && (zone === 'l' || zone === 'c' || zone === 'r')) {
 854        console.warn('Tap action (play/pause) ignored while menu is open.');
 855        return;
 856      }
 857  
 858      try {
 859        if (count === 1) {
 860          await this._handleSingleTap(zone, pos);
 861        } else if (count === 2) {
 862          await this._handleDoubleTap(zone, pos);
 863        } else if (count >= 3) {
 864          await this._handleTripleTap(zone, pos);
 865        }
 866      } catch (error) {
 867        console.error(`Tap action failed: Zone=${zone}, Count=${count}`, error);
 868        await showErrorWithAnimation(
 869          `Action Failed: ${error?.message || 'Unknown error'}`,
 870          this.AnimationPositions.MID,
 871          AppConstants.UI.ERROR_MESSAGE_DURATION_MS,
 872          this.elementProvider.get('container'),
 873          this.elementProvider.get('message-overlay'),
 874        );
 875      }
 876    }
 877  
 878    async _handleEscapeKey(e) {
 879      if (this.isMenuOpen()) {
 880        try {
 881          const menuManager = await this.menuStateProvider.getMenuManager();
 882          const leftMenu = this.elementProvider.get('leftMenuElement');
 883          const rightMenu = this.elementProvider.get('rightMenuElement');
 884          const cssClass = AppConstants.UI.CSS_CLASSES.VISIBLE;
 885  
 886          let menuClosed = false;
 887          if (checkMenuVisibility(leftMenu, cssClass)) {
 888            await menuManager.closeMenu('left');
 889            menuClosed = true;
 890          }
 891          if (checkMenuVisibility(rightMenu, cssClass)) {
 892            await menuManager.closeMenu('right');
 893            menuClosed = true;
 894          }
 895  
 896          if (menuClosed) {
 897            e.preventDefault();
 898            return true;
 899          }
 900        } catch (error) {
 901          console.error('Error closing menu with Escape key:', error);
 902        }
 903      }
 904  
 905      if (this.uiManager.isFullscreen()) {
 906        this.uiManager.toggleFullscreen();
 907        e.preventDefault();
 908        return true;
 909      }
 910  
 911      return false;
 912    }
 913  
 914    async _handleStartupInteraction(e) {
 915      const firstInteractionKeys = [
 916        'Space',
 917        'KeyK',
 918        'NumpadDecimal',
 919        'ArrowUp',
 920        'ArrowDown',
 921        'ArrowLeft',
 922        'KeyJ',
 923        'KeyA',
 924        'ArrowRight',
 925        'KeyL',
 926        'KeyD',
 927        'Enter',
 928        'NumpadEnter',
 929        'KeyF',
 930        'KeyV',
 931        'Backslash',
 932        'NumpadAdd',
 933        'NumpadSubtract',
 934        'KeyZ',
 935        'KeyX',
 936        'KeyS',
 937        'KeyC',
 938        'Digit0',
 939        'Digit1',
 940        'Digit2',
 941        'Digit3',
 942        'Digit4',
 943        'Digit5',
 944        'Digit6',
 945        'Digit7',
 946        'Digit8',
 947        'Digit9',
 948        'Numpad0',
 949        'Numpad1',
 950        'Numpad2',
 951        'Numpad3',
 952        'Numpad4',
 953        'Numpad5',
 954        'Numpad6',
 955        'Numpad7',
 956        'Numpad8',
 957        'Numpad9',
 958        'Home',
 959        'End',
 960      ];
 961  
 962      if (!firstInteractionKeys.includes(e.code)) {
 963        return false;
 964      }
 965  
 966      const startupHandled = await this.startupSequenceProvider.handleFirstInteraction(
 967        this.AnimationPositions.MID,
 968      );
 969  
 970      if (startupHandled) {
 971        e.preventDefault();
 972      }
 973  
 974      return true;
 975    }
 976  
 977    async _handlePlaybackKeys(e) {
 978      switch (e.code) {
 979        case 'Space':
 980        case 'KeyK':
 981        case 'NumpadDecimal':
 982          if (this.isMenuOpen()) {
 983            console.warn('Play/Pause key ignored while menu is open.');
 984          } else {
 985            await this.togglePlayPause(this.AnimationPositions.MID);
 986          }
 987          return true;
 988  
 989        case 'KeyF':
 990        case 'Enter':
 991        case 'NumpadEnter':
 992          this.uiManager.toggleFullscreen();
 993          return true;
 994      }
 995      return false;
 996    }
 997  
 998    async _handleVolumeKeys(e) {
 999      const volStep = AppConstants.UI.VOLUME_STEP;
1000  
1001      switch (e.code) {
1002        case 'ArrowUp':
1003          await this.adjustVolumeWithPosition(volStep, this.AnimationPositions.TOP);
1004          return true;
1005        case 'ArrowDown':
1006          await this.adjustVolumeWithPosition(-volStep, this.AnimationPositions.BOT);
1007          return true;
1008      }
1009      return false;
1010    }
1011  
1012    _handleSeekingKeys(e) {
1013      const isLeftSeekKey = ['ArrowLeft', 'KeyJ', 'KeyA'].includes(e.code);
1014      const isRightSeekKey = ['ArrowRight', 'KeyL', 'KeyD'].includes(e.code);
1015  
1016      if (isLeftSeekKey) {
1017        this.resetKeyPressState('seekRight');
1018        this.handleArrowKeyPress(-1);
1019        return true;
1020      }
1021  
1022      if (isRightSeekKey) {
1023        this.resetKeyPressState('seekLeft');
1024        this.handleArrowKeyPress(1);
1025        return true;
1026      }
1027  
1028      switch (e.code) {
1029        case 'Home':
1030          this.seekVideoTo(0);
1031          return true;
1032        case 'End':
1033          if (Number.isFinite(this.videoController.playbackState.durationSeconds)) {
1034            this.seekVideoTo(this.videoController.playbackState.durationSeconds);
1035          } else {
1036            showTemporaryMessage('Video duration unknown');
1037          }
1038          return true;
1039      }
1040  
1041      return false;
1042    }
1043  
1044    async _handleNavigationKeys(e) {
1045      switch (e.code) {
1046        case 'KeyZ':
1047          this.switchVideo(-1, this.AnimationPositions.LEFT);
1048          return true;
1049        case 'KeyX':
1050          this.switchVideo(1, this.AnimationPositions.RIGHT);
1051          return true;
1052        case 'KeyS':
1053          await this.switchToRandomVideo(this.AnimationPositions.MID);
1054          return true;
1055        case 'KeyC':
1056          this.toggleRepeatMode(this.AnimationPositions.MID);
1057          return true;
1058      }
1059      return false;
1060    }
1061  
1062    _handleDisplayKeys(e) {
1063      switch (e.code) {
1064        case 'KeyV':
1065        case 'Backslash':
1066        case 'NumpadAdd':
1067        case 'NumpadSubtract':
1068          this.uiManager.toggleVideoFit(this.AnimationPositions.MID);
1069          return true;
1070      }
1071      return false;
1072    }
1073  
1074    _handleDigitKeys(e) {
1075      const digitMatch = e.code.match(/^Digit(\d)$/) || e.code.match(/^Numpad(\d)$/);
1076      if (digitMatch?.[1]) {
1077        const digit = Number.parseInt(digitMatch[1], 10);
1078        const percentage = digit * 0.1;
1079        this.seekVideoToPercentage(percentage);
1080        return true;
1081      }
1082      return false;
1083    }
1084  
1085    async handleGlobalKeyDown(e) {
1086      try {
1087        setUserInteracted();
1088  
1089        const targetTag = e.target.tagName.toLowerCase();
1090        const isInputFocused =
1091          ['input', 'textarea', 'select'].includes(targetTag) || e.target.isContentEditable;
1092        const isUnhandledModifier =
1093          e.altKey ||
1094          e.shiftKey ||
1095          ((e.ctrlKey || e.metaKey) && !['r', 'R', 'f', 'F'].includes(e.key));
1096  
1097        if (isInputFocused || isUnhandledModifier) {
1098          this.resetAllKeyPressStates();
1099          return;
1100        }
1101  
1102        if (e.key === 'Escape') {
1103          const escapeHandled = await this._handleEscapeKey(e);
1104          if (escapeHandled) {
1105            return;
1106          }
1107        }
1108  
1109        if (this.startupSequenceProvider.isStartupSequenceActive()) {
1110          const startupHandled = await this._handleStartupInteraction(e);
1111          if (startupHandled) {
1112            return;
1113          }
1114        }
1115  
1116        const isSeekKey = ['ArrowLeft', 'KeyJ', 'KeyA', 'ArrowRight', 'KeyL', 'KeyD'].includes(
1117          e.code,
1118        );
1119        if (!isSeekKey) {
1120          this.resetAllKeyPressStates();
1121        }
1122  
1123        let handled = false;
1124  
1125        handled =
1126          (await this._handlePlaybackKeys(e)) ||
1127          (await this._handleVolumeKeys(e)) ||
1128          this._handleSeekingKeys(e) ||
1129          this._handleDisplayKeys(e) ||
1130          (await this._handleNavigationKeys(e)) ||
1131          this._handleDigitKeys(e);
1132  
1133        if (!handled && (e.code === 'F5' || e.code === 'KeyR')) {
1134          return;
1135        }
1136  
1137        if (handled) {
1138          e.preventDefault();
1139          e.stopPropagation();
1140        }
1141      } catch (error) {
1142        console.error('Error handling keydown:', error);
1143        this.resetAllKeyPressStates();
1144      }
1145    }
1146  
1147    async handleMouseWheel(e) {
1148      try {
1149        if (
1150          this.startupSequenceProvider.isStartupSequenceActive() ||
1151          this.isInteractionOnOverlay(e.target)
1152        ) {
1153          return;
1154        }
1155  
1156        const delta = e.deltaY;
1157        if (delta === 0) {
1158          return;
1159        }
1160  
1161        e.preventDefault();
1162        e.stopPropagation();
1163  
1164        const volumeChange = delta < 0 ? AppConstants.UI.VOLUME_STEP : -AppConstants.UI.VOLUME_STEP;
1165        const position = getNormalizedPosition(e);
1166  
1167        await this.adjustVolumeWithPosition(volumeChange, position);
1168      } catch (error) {
1169        console.error('Error handling mouse wheel:', error);
1170      }
1171    }
1172  
1173    handleArrowKeyPress(direction) {
1174      const seekSecs = this.SeekTimeSeconds;
1175      const animationPosition =
1176        direction === -1 ? this.AnimationPositions.LEFT : this.AnimationPositions.RIGHT;
1177  
1178      this.seekVideoBy(seekSecs * direction, animationPosition);
1179    }
1180  
1181    async togglePlayPause(position = this.AnimationPositions.MID) {
1182      try {
1183        if (this.startupSequenceProvider.isStartupSequenceActive()) {
1184          const startupHandled = await this.startupSequenceProvider.handleFirstInteraction(position);
1185          if (startupHandled) {
1186            return;
1187          }
1188        }
1189  
1190        if (this.isMenuOpen()) {
1191          console.warn('togglePlayPause ignored while menu is open.');
1192          return;
1193        }
1194  
1195        if (!this.videoController?.videoElement) {
1196          await showErrorWithAnimation('Video player not ready', this.AnimationPositions.MID);
1197          return;
1198        }
1199  
1200        const video = this.videoController.videoElement;
1201        const isPaused = this.isVideoPaused();
1202  
1203        if (isPaused) {
1204          setUserInteracted();
1205          if (video.muted) {
1206            this.videoController._ensureVideoUnmuted();
1207  
1208            this.showVolumeIconAnimation(video.volume, false, position, 0.1);
1209          }
1210  
1211          await video.play();
1212        } else {
1213          if (this.uiManager?._isProgrammaticPause) {
1214            this.uiManager._isProgrammaticPause = false;
1215          }
1216          video.pause();
1217        }
1218      } catch (error) {
1219        console.error('Play/Pause error:', error);
1220  
1221        const currentPausedState = this.isVideoPaused();
1222        this.showPlayPauseIconAnimation(currentPausedState, position);
1223  
1224        if (error.name === 'NotAllowedError') {
1225          await showErrorWithAnimation(
1226            'Playback blocked by browser',
1227            position,
1228            AppConstants.UI.ERROR_MESSAGE_DURATION_MS,
1229            this.elementProvider.get('container'),
1230            this.elementProvider.get('message-overlay'),
1231          );
1232        } else if (
1233          !handleAbortedFetch(error, { context: 'togglePlayPause', logError: true, rethrow: false })
1234        ) {
1235          await showErrorWithAnimation(
1236            'Play/Pause Failed',
1237            position,
1238            AppConstants.UI.ERROR_MESSAGE_DURATION_MS,
1239            this.elementProvider.get('container'),
1240            this.elementProvider.get('message-overlay'),
1241          );
1242        }
1243      }
1244    }
1245  
1246    _canAdjustVolume() {
1247      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1248        return false;
1249      }
1250  
1251      const video = this.videoController?.videoElement;
1252      if (!video) {
1253        return false;
1254      }
1255  
1256      if (!getHasUserInteracted()) {
1257        showMessage('Interact to enable audio control');
1258        return false;
1259      }
1260  
1261      return true;
1262    }
1263  
1264    _calculateNewVolumeSettings(video, volumeChange) {
1265      const currentVol = video.volume;
1266      const currentMuted = video.muted;
1267  
1268      let newVol = Math.min(1, Math.max(0, currentVol + volumeChange));
1269      newVol = Math.round(newVol * 100) / 100;
1270  
1271      let finalMuted = currentMuted;
1272      if (currentMuted && volumeChange > 0) {
1273        finalMuted = false;
1274      }
1275  
1276      return { currentVol, currentMuted, newVol, finalMuted };
1277    }
1278  
1279    _applyVolumeChanges(video, newVol, finalMuted, currentVol, currentMuted) {
1280      let changed = false;
1281  
1282      if (finalMuted !== currentMuted) {
1283        video.muted = finalMuted;
1284        changed = true;
1285  
1286        if (!finalMuted) {
1287          video.volume = newVol;
1288        }
1289      } else if (newVol !== currentVol && !finalMuted) {
1290        video.volume = newVol;
1291        changed = true;
1292      }
1293  
1294      return changed;
1295    }
1296  
1297    _shouldShowVolumeAnimation(changed, currentVol, volumeChange, currentMuted) {
1298      return (
1299        changed ||
1300        (currentVol === 0 && volumeChange < 0) ||
1301        (currentVol === 1 && volumeChange > 0) ||
1302        (currentMuted && volumeChange < 0)
1303      );
1304    }
1305  
1306    async adjustVolumeWithPosition(volumeChange, position) {
1307      if (!this._canAdjustVolume()) {
1308        return;
1309      }
1310  
1311      const video = this.videoController.videoElement;
1312  
1313      const { currentVol, currentMuted, newVol, finalMuted } = this._calculateNewVolumeSettings(
1314        video,
1315        volumeChange,
1316      );
1317  
1318      const changed = this._applyVolumeChanges(video, newVol, finalMuted, currentVol, currentMuted);
1319  
1320      const shouldAnimate = this._shouldShowVolumeAnimation(
1321        changed,
1322        currentVol,
1323        volumeChange,
1324        currentMuted,
1325      );
1326      if (shouldAnimate) {
1327        this.showVolumeIconAnimation(video.volume, video.muted, position, volumeChange);
1328      }
1329    }
1330  
1331    seekVideoBy(seconds, position) {
1332      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1333        return;
1334      }
1335  
1336      const animationDirection = seconds > 0 ? 'right' : 'left';
1337      const animPosition =
1338        position || (seconds > 0 ? this.AnimationPositions.RIGHT : this.AnimationPositions.LEFT);
1339  
1340      this.showSeekIconAnimation(animationDirection, animPosition);
1341  
1342      try {
1343        this.videoController.seekByCommand(seconds).catch(async error => {
1344          console.error('Seek by command failed:', error);
1345          await showErrorWithAnimation('Seek failed', animPosition);
1346        });
1347      } catch (error) {
1348        console.error('Seek by command failed:', error);
1349        showMessage('Seek failed');
1350      }
1351    }
1352  
1353    seekVideoTo(timeSeconds) {
1354      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1355        return;
1356      }
1357  
1358      if (typeof timeSeconds !== 'number' || !Number.isFinite(timeSeconds) || timeSeconds < 0) {
1359        console.warn(`Invalid seek target time: ${timeSeconds}`);
1360        return;
1361      }
1362  
1363      try {
1364        this.videoController.seekToCommand(timeSeconds).catch(async error => {
1365          console.error('Seek to command failed:', error);
1366          await showErrorWithAnimation('Seek failed', this.AnimationPositions.MID);
1367        });
1368      } catch (error) {
1369        console.error('Seek to command failed:', error);
1370        showMessage('Seek failed');
1371      }
1372    }
1373  
1374    seekVideoToPercentage(percentage) {
1375      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1376        return;
1377      }
1378  
1379      const videoElement = this.elementProvider.get('videoElement');
1380      const duration = videoElement?.duration;
1381  
1382      if (Number.isFinite(duration) && duration > 0 && percentage >= 0 && percentage <= 1) {
1383        this.seekVideoTo(duration * percentage);
1384      } else {
1385        showMessage('Video duration unknown');
1386      }
1387    }
1388  
1389    async switchVideo(direction, position) {
1390      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1391        return;
1392      }
1393      if (!this.videoSwitcher) {
1394        await showErrorWithAnimation('Video switching unavailable', position);
1395        return;
1396      }
1397  
1398      const animDirection = direction > 0 ? 'right' : 'left';
1399  
1400      await this.showSwitchIconAnimation(animDirection, position);
1401  
1402      try {
1403        this.videoSwitcher.switchVideo(direction, false, null);
1404      } catch (error) {
1405        if (!handleAbortedFetch(error, { context: 'switchVideo', logError: true, rethrow: false })) {
1406          console.error('Video switch failed:', error);
1407          await showErrorWithAnimation('Video switch failed', position);
1408        }
1409      }
1410    }
1411  
1412    async switchToRandomVideo(position = this.AnimationPositions.MID) {
1413      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1414        return;
1415      }
1416      if (!this.videoSwitcher) {
1417        await showErrorWithAnimation('Video switching unavailable', position);
1418        return;
1419      }
1420  
1421      const totalSources = videoSources.getSourceCount();
1422  
1423      if (totalSources <= 1) {
1424        showMessage('Not enough videos to shuffle');
1425        return;
1426      }
1427  
1428      const currentIndex = this.videoSwitcher.currentVideoIndex;
1429      const targetIndex = getRandomIndexExcluding(totalSources - 1, currentIndex);
1430  
1431      const targetVideoInfo = videoSources.getVideoInfoByIndex(targetIndex);
1432      const targetVideoName = targetVideoInfo?.name || `Video ${targetIndex + 1}`;
1433  
1434      console.debug(`Selected random video index ${targetIndex}`);
1435  
1436      const shufflePosition = this.AnimationPositions.MID;
1437  
1438      try {
1439        await showIconAnimation({
1440          container: this.elementProvider.get('container'),
1441          durationMs: AppConstants.UI.ANIMATION_DURATION_MS,
1442          getIcon: url => Promise.resolve(url),
1443          imageUrl: 'https://img.icons8.com/neon/256/shuffle.png',
1444          position: shufflePosition,
1445          animationType: 'shuffle',
1446        });
1447      } catch (error) {
1448        console.error('Failed to show shuffle icon animation:', error);
1449      }
1450  
1451      showMessage(`Shuffling to: ${targetVideoName}`);
1452  
1453      try {
1454        this.videoSwitcher.switchToVideoByIndex(targetIndex, null, true);
1455      } catch (error) {
1456        if (
1457          !handleAbortedFetch(error, {
1458            context: 'switchToRandomVideo',
1459            logError: true,
1460            rethrow: false,
1461          })
1462        ) {
1463          console.error('Random video switch failed:', error);
1464          await showErrorWithAnimation('Random video switch failed', position);
1465        }
1466      }
1467    }
1468  
1469    async toggleRepeatMode(position = this.AnimationPositions.MID) {
1470      if (this.startupSequenceProvider.isStartupSequenceActive()) {
1471        return;
1472      }
1473  
1474      const currentRepeatMode = getRepeatMode();
1475      const newRepeatMode = !currentRepeatMode;
1476  
1477      setRepeatMode(newRepeatMode);
1478      const isRepeating = newRepeatMode;
1479  
1480      showMessage(isRepeating ? 'Repeat Mode: ON' : 'Sequential Mode: ON');
1481  
1482      try {
1483        const iconUrl = isRepeating
1484          ? 'https://img.icons8.com/neon/256/repeat.png'
1485          : 'https://img.icons8.com/neon/256/shuffle.png';
1486        const animationType = isRepeating ? 'repeat' : 'shuffle';
1487  
1488        await showIconAnimation({
1489          container: this.elementProvider.get('container'),
1490          durationMs: AppConstants.UI.ANIMATION_DURATION_MS,
1491          getIcon: url => Promise.resolve(url),
1492          imageUrl: iconUrl,
1493          position,
1494          animationType,
1495        });
1496      } catch (error) {
1497        console.error('Failed to handle repeat/shuffle mode change:', error);
1498      }
1499    }
1500  
1501    async showPlayPauseIconAnimation(isPaused, position = AppConstants.UI.ANIMATION_POSITIONS.MID) {
1502      const iconUrl = isPaused
1503        ? 'https://img.icons8.com/neon/256/pause.png'
1504        : 'https://img.icons8.com/neon/256/play.png';
1505      const animationType = isPaused ? 'pause' : 'play';
1506  
1507      try {
1508        await showIconAnimation({
1509          container: this.elementProvider.get('container'),
1510          durationMs: AppConstants.UI.ANIMATION_DURATION_MS,
1511          getIcon: url => Promise.resolve(url),
1512          imageUrl: iconUrl,
1513          position,
1514          animationType,
1515        });
1516      } catch (error) {
1517        console.warn('Failed to show play/pause animation:', error);
1518      }
1519    }
1520  
1521    async showVolumeIconAnimation(
1522      volume,
1523      isMuted,
1524      position = AppConstants.UI.ANIMATION_POSITIONS.MID,
1525      volumeChange = 0,
1526    ) {
1527      try {
1528        let iconUrl;
1529        if (isMuted || volume <= 0) {
1530          iconUrl = 'https://img.icons8.com/neon/256/mute.png';
1531        } else if (volume < 0.33) {
1532          iconUrl = 'https://img.icons8.com/neon/256/low-volume.png';
1533        } else if (volume < 0.67) {
1534          iconUrl = 'https://img.icons8.com/neon/256/medium-volume.png';
1535        } else {
1536          iconUrl = 'https://img.icons8.com/neon/256/high-volume.png';
1537        }
1538  
1539        let changeIconUrl = null;
1540        if (volumeChange > 0) {
1541          changeIconUrl = 'https://img.icons8.com/neon/96/plus.png';
1542        } else if (volumeChange < 0) {
1543          changeIconUrl = 'https://img.icons8.com/neon/96/minus.png';
1544        }
1545  
1546        await showIconAnimation({
1547          container: this.elementProvider.get('container'),
1548          durationMs: AppConstants.UI.ANIMATION_DURATION_MS,
1549          getIcon: url => Promise.resolve(url),
1550          imageUrl: iconUrl,
1551          changeIconUrl,
1552          position,
1553          animationType: 'volume',
1554        });
1555      } catch (error) {
1556        console.warn('Failed to show volume icon animation:', error);
1557      }
1558    }
1559  
1560    async showSeekIconAnimation(direction, position = AppConstants.UI.ANIMATION_POSITIONS.MID) {
1561      const iconUrl =
1562        direction === 'right'
1563          ? 'https://img.icons8.com/neon/256/fast-forward.png'
1564          : 'https://img.icons8.com/neon/256/rewind.png';
1565  
1566      try {
1567        await showIconAnimation({
1568          container: this.elementProvider.get('container'),
1569          durationMs: AppConstants.UI.ANIMATION_DURATION_MS,
1570          getIcon: url => Promise.resolve(url),
1571          imageUrl: iconUrl,
1572          position,
1573          animationType: 'seek',
1574        });
1575      } catch (error) {
1576        console.warn('Failed to show seek animation:', error);
1577      }
1578    }
1579  
1580    async showSwitchIconAnimation(direction, position = AppConstants.UI.ANIMATION_POSITIONS.MID) {
1581      if (!this.videoSwitcher) {
1582        console.warn('VideoSwitcher not available, cannot show switch animation');
1583        return;
1584      }
1585  
1586      try {
1587        const container = this.elementProvider.get('container');
1588        await this.videoSwitcher.showSwitchAnimation(direction, position, container);
1589      } catch (error) {
1590        console.warn('Failed to show switch animation:', error);
1591      }
1592    }
1593  
1594    cleanup() {
1595      try {
1596        this.cleanupState();
1597  
1598        this.detachInteractionListeners();
1599  
1600        this.subscriptions.unsubscribeAll();
1601  
1602        this.events.clearAllEvents();
1603      } catch (error) {
1604        console.error('Error during interaction handler cleanup:', error);
1605      }
1606    }
1607  }