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 }