From 051c7f9ff44b7ac3162ca908f7754f53e8f084f5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 16 Jul 2026 17:36:14 +0200 Subject: [PATCH] refactor(embedded-mpv): isolate controls timing --- docs/architecture/embedded-mpv-native.md | 27 +- .../embedded-mpv-controls-recording-timers.ts | 65 +++++ .../embedded-mpv-controls-recording.ts | 68 +---- .../embedded-mpv-legacy-interactions.ts | 249 ++++++++++++++++ .../embedded-mpv-player.component.ts | 269 ++++-------------- 5 files changed, 400 insertions(+), 278 deletions(-) create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-timers.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 1291e873b..f664f4945 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -369,10 +369,17 @@ controls host. The renderer files live under `EmbeddedMpvSessionController`, and projects series navigation and recording. - `embedded-mpv-controls-recording.ts` — frame-copy shared-controls recording coordinator. Serializes toggles, correlates command settlement with fresh - same-owner snapshots, owns acknowledgement/message timers, and cancels on - ownership or engine changes. + same-owner snapshots, and cancels pending state on ownership or engine + changes. - `embedded-mpv-controls-recording-feedback.ts` — semantic raw/translated recording feedback values and late translation resolution. +- `embedded-mpv-controls-recording-timers.ts` — frame-copy recording feedback + signal plus acknowledgement and message-dismiss timer ownership. Keeps + transient timing lifecycle out of the recording correlation state machine. +- `embedded-mpv-legacy-interactions.ts` — native-view-only pointer listeners, + click/double-click arbitration, popover closing, controls auto-hide, and + volume-close timers. An engine handoff cancels its pending legacy + interactions before frame-copy takes ownership. - `embedded-mpv-shortcuts.ts` — native-view-only `EmbeddedMpvShortcuts` class with `attach(handlers)` / `detach()`. Owns the legacy document keydown listener and routes through a callback interface; the component supplies @@ -394,8 +401,9 @@ controls host. The renderer files live under - `embedded-mpv-player.component.ts` — view shell and engine-specific controls host. It mounts shared controls only for frame-copy and the legacy dock only for native-view; holds view children, derived `computed` signals, DOM event - listeners, and effects for session lifecycle, bounds sync, session fan-out, - playback-ended emission, engine handoff, and native-only recording ticks. + handling for fullscreen, and effects for session lifecycle, bounds sync, + session fan-out, playback-ended emission, engine handoff, and native-only + recording ticks. ### Bounds compositing strategy @@ -433,11 +441,12 @@ the helper's render size. feedback, and recording elapsed timer stay authoritative. The shared recording adapter is inert outside frame-copy. -An engine handoff clears native controls-hide/click/volume timers and closes -native menus when frame-copy takes ownership. Legacy feedback is cleared on -each engine transition and its overlay is never rendered for frame-copy, so a -late native command completion cannot paint above shared controls. The handoff -also changes the shared recording owner, which cancels pending operations and +An engine handoff asks `EmbeddedMpvLegacyInteractions` to clear native +controls-hide/click/volume timers and close native menus when frame-copy takes +ownership. Legacy feedback is cleared on each engine transition and its overlay +is never rendered for frame-copy, so a late native command completion cannot +paint above shared controls. The handoff also changes the shared recording +owner, which cancels pending operations, acknowledgement/message timers, and feedback. This prevents both systems from acting on the same session. Both engines keep the component's `fullscreenchange` listener because diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-timers.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-timers.ts new file mode 100644 index 000000000..355d70572 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-timers.ts @@ -0,0 +1,65 @@ +import { signal } from '@angular/core'; +import type { RecordingFeedback } from './embedded-mpv-controls-recording-feedback'; + +const RECORDING_ACK_TIMEOUT_MS = 5000; +const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; + +export class EmbeddedMpvControlsRecordingTimers { + private readonly feedbackState = signal(null); + readonly feedback = this.feedbackState.asReadonly(); + + private acknowledgementTimer: number | null = null; + private messageTimer: number | null = null; + private destroyed = false; + + setFeedback(feedback: RecordingFeedback | null, autoDismiss = false): void { + if (this.destroyed) { + return; + } + this.clearMessage(); + this.feedbackState.set(feedback); + if (!feedback || !autoDismiss) { + return; + } + const timerId = window.setTimeout(() => { + if (!this.destroyed && this.feedbackState() === feedback) { + this.feedbackState.set(null); + } + if (this.messageTimer === timerId) { + this.messageTimer = null; + } + }, RECORDING_MESSAGE_DISMISS_DELAY_MS); + this.messageTimer = timerId; + } + + scheduleAcknowledgement(callback: () => void): void { + this.clearAcknowledgement(); + this.acknowledgementTimer = window.setTimeout(() => { + this.acknowledgementTimer = null; + callback(); + }, RECORDING_ACK_TIMEOUT_MS); + } + + clearAcknowledgement(): void { + if (this.acknowledgementTimer === null) { + return; + } + window.clearTimeout(this.acknowledgementTimer); + this.acknowledgementTimer = null; + } + + destroy(): void { + this.destroyed = true; + this.clearAcknowledgement(); + this.clearMessage(); + this.feedbackState.set(null); + } + + private clearMessage(): void { + if (this.messageTimer === null) { + return; + } + window.clearTimeout(this.messageTimer); + this.messageTimer = null; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts index efa39035c..8f3f57782 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts @@ -1,4 +1,3 @@ -import { signal } from '@angular/core'; import type { EmbeddedMpvSession, ResolvedPortalPlayback, @@ -8,11 +7,9 @@ import { RECORDING_TRANSLATION, type RecordingFeedback, } from './embedded-mpv-controls-recording-feedback'; +import { EmbeddedMpvControlsRecordingTimers } from './embedded-mpv-controls-recording-timers'; import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; -const RECORDING_ACK_TIMEOUT_MS = 5000; -const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; - const RECORDING_OPERATION = { START: 'start', STOP: 'stop', @@ -50,13 +47,11 @@ export interface RecordingToggleContext { } export class EmbeddedMpvControlsRecording { - private readonly feedbackState = signal(null); - readonly feedback = this.feedbackState.asReadonly(); + private readonly timers = new EmbeddedMpvControlsRecordingTimers(); + readonly feedback = this.timers.feedback; private pending: PendingRecordingOperation | null = null; private operationGeneration = 0; - private acknowledgementTimer: number | null = null; - private messageTimer: number | null = null; private ownerIdentity: string | null | undefined; private destroyed = false; @@ -92,7 +87,9 @@ export class EmbeddedMpvControlsRecording { sawErrorClear: initialError === null, }; this.setFeedback(null); - this.scheduleAcknowledgementTimeout(generation); + this.timers.scheduleAcknowledgement(() => + this.handleAcknowledgementTimeout(generation) + ); const command = kind === RECORDING_OPERATION.START ? this.controller.startRecording( @@ -207,9 +204,9 @@ export class EmbeddedMpvControlsRecording { } destroy(): void { - this.setFeedback(null); this.destroyed = true; this.cancelPending(); + this.timers.destroy(); } private handleAcknowledgementTimeout(generation: number): void { @@ -217,7 +214,6 @@ export class EmbeddedMpvControlsRecording { if (!pending || pending.generation !== generation) { return; } - this.acknowledgementTimer = null; this.reconcile( this.controller.session(), this.currentPlaybackIdentity() @@ -280,7 +276,9 @@ export class EmbeddedMpvControlsRecording { } if (outcome) { this.setFeedback(outcome.feedback); - this.scheduleAcknowledgementTimeout(generation); + this.timers.scheduleAcknowledgement(() => + this.handleAcknowledgementTimeout(generation) + ); return; } if (reconciledPending.timeoutFeedback) { @@ -296,7 +294,7 @@ export class EmbeddedMpvControlsRecording { return; } pending.observedOutcome = outcome; - this.clearAcknowledgementTimer(); + this.timers.clearAcknowledgement(); if (pending.commandSettled) { this.completePending(outcome.feedback, outcome.autoDismiss); } else if ( @@ -332,7 +330,7 @@ export class EmbeddedMpvControlsRecording { feedback: RecordingFeedback | null, autoDismiss = false ): void { - this.clearAcknowledgementTimer(); + this.timers.clearAcknowledgement(); this.pending = null; this.setFeedback(feedback, autoDismiss); } @@ -340,51 +338,13 @@ export class EmbeddedMpvControlsRecording { private cancelPending(): void { this.operationGeneration += 1; this.pending = null; - this.clearAcknowledgementTimer(); + this.timers.clearAcknowledgement(); } private setFeedback( feedback: RecordingFeedback | null, autoDismiss = false ): void { - if (this.destroyed) { - return; - } - this.clearMessageTimer(); - this.feedbackState.set(feedback); - if (!feedback || !autoDismiss) { - return; - } - const timerId = window.setTimeout(() => { - if (!this.destroyed && this.feedbackState() === feedback) { - this.feedbackState.set(null); - } - if (this.messageTimer === timerId) { - this.messageTimer = null; - } - }, RECORDING_MESSAGE_DISMISS_DELAY_MS); - this.messageTimer = timerId; - } - - private clearAcknowledgementTimer(): void { - if (this.acknowledgementTimer !== null) { - window.clearTimeout(this.acknowledgementTimer); - this.acknowledgementTimer = null; - } - } - - private scheduleAcknowledgementTimeout(generation: number): void { - this.clearAcknowledgementTimer(); - this.acknowledgementTimer = window.setTimeout( - () => this.handleAcknowledgementTimeout(generation), - RECORDING_ACK_TIMEOUT_MS - ); - } - - private clearMessageTimer(): void { - if (this.messageTimer !== null) { - window.clearTimeout(this.messageTimer); - this.messageTimer = null; - } + this.timers.setFeedback(feedback, autoDismiss); } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts new file mode 100644 index 000000000..343da45bf --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts @@ -0,0 +1,249 @@ +import type { WritableSignal } from '@angular/core'; +import type { EmbeddedMpvMenuState } from './embedded-mpv-ui-state'; + +const HIDE_CONTROLS_DELAY_MS = 2500; +const VOLUME_POPOVER_CLOSE_DELAY_MS = 220; +const VIEWPORT_CLICK_PAUSE_DELAY_MS = 250; + +export interface EmbeddedMpvLegacyInteractionsDeps { + readonly isAvailable: () => boolean; + readonly playerRoot: () => HTMLElement | null; + readonly menus: EmbeddedMpvMenuState; + readonly controlsVisible: WritableSignal; + readonly isLoading: () => boolean; + readonly isErrored: () => boolean; + readonly isStalled: () => boolean; + readonly isPlaying: () => boolean; + readonly statusLabel: () => string; + readonly togglePaused: () => void | Promise; + readonly toggleFullscreen: () => void | Promise; + readonly triggerBoundsSync: () => void; +} + +export class EmbeddedMpvLegacyInteractions { + private controlsHideTimer: number | null = null; + private volumeCloseTimer: number | null = null; + private viewportClickTimer: number | null = null; + private attached = false; + + private readonly onDocumentPointerDown = (event: PointerEvent) => { + if (!this.deps.isAvailable()) { + return; + } + const playerRoot = this.deps.playerRoot(); + if (!playerRoot || event.composedPath().includes(playerRoot)) { + return; + } + this.closePopovers(); + }; + + private readonly onDocumentPointerMove = (event: PointerEvent) => { + if (!this.deps.isAvailable()) { + return; + } + const playerRoot = this.deps.playerRoot(); + if ( + playerRoot && + !event.composedPath().includes(playerRoot) && + this.isPointerInsidePlayer(event) + ) { + this.revealControls(); + } + }; + + constructor(private readonly deps: EmbeddedMpvLegacyInteractionsDeps) {} + + attach(): void { + if (this.attached || typeof document === 'undefined') { + return; + } + document.addEventListener('pointerdown', this.onDocumentPointerDown); + document.addEventListener('pointermove', this.onDocumentPointerMove, { + passive: true, + }); + this.attached = true; + } + + dispose(): void { + if (this.attached && typeof document !== 'undefined') { + document.removeEventListener( + 'pointerdown', + this.onDocumentPointerDown + ); + document.removeEventListener( + 'pointermove', + this.onDocumentPointerMove + ); + } + this.attached = false; + this.clearControlsHideTimer(); + this.clearVolumeCloseTimer(); + this.clearViewportClickTimer(); + } + + isAvailable(): boolean { + return this.deps.isAvailable(); + } + + handleEngineTransition(isFrameCopyEngine: boolean): void { + if (!isFrameCopyEngine) { + return; + } + this.clearControlsHideTimer(); + this.clearVolumeCloseTimer(); + this.clearViewportClickTimer(); + this.deps.menus.closeAll(); + } + + onPlayerInteraction(): void { + if (!this.deps.isAvailable()) { + return; + } + this.revealControls(); + } + + onViewportClick(event: MouseEvent): void { + if (!this.deps.isAvailable()) { + return; + } + const target = event.target as HTMLElement | null; + if (target?.closest('button, input, [role="slider"]')) { + return; + } + if (this.deps.menus.anyOpen()) { + this.deps.menus.closeAll(); + return; + } + if ( + this.deps.isLoading() || + this.deps.isErrored() || + this.deps.isStalled() + ) { + return; + } + // Delay the single-click transport toggle so a double-click can + // cancel it and claim the interaction for fullscreen instead. + this.clearViewportClickTimer(); + this.viewportClickTimer = window.setTimeout(() => { + this.viewportClickTimer = null; + void this.deps.togglePaused(); + }, VIEWPORT_CLICK_PAUSE_DELAY_MS); + } + + onPlayerDblClick(event: MouseEvent): void { + if (!this.deps.isAvailable()) { + return; + } + const target = event.target as HTMLElement | null; + if (target?.closest('button, input, [role="slider"]')) { + return; + } + this.clearViewportClickTimer(); + void this.deps.toggleFullscreen(); + } + + onVolumeHoverEnter(): void { + if (!this.deps.isAvailable()) { + return; + } + this.clearVolumeCloseTimer(); + this.deps.menus.open('volume'); + } + + onVolumeHoverLeave(): void { + if (!this.deps.isAvailable()) { + return; + } + this.clearVolumeCloseTimer(); + this.volumeCloseTimer = window.setTimeout(() => { + this.deps.menus.close('volume'); + this.volumeCloseTimer = null; + }, VOLUME_POPOVER_CLOSE_DELAY_MS); + } + + closePopovers(): void { + if (!this.deps.isAvailable() || !this.deps.menus.anyOpen()) { + return; + } + this.deps.menus.closeAll(); + this.deps.triggerBoundsSync(); + this.scheduleControlsHide(); + } + + revealControls(scheduleHide = true): void { + if (!this.deps.isAvailable()) { + this.clearControlsHideTimer(); + return; + } + this.deps.controlsVisible.set(true); + if (scheduleHide) { + this.clearControlsHideTimer(); + this.scheduleControlsHide(); + } + } + + scheduleControlsHide(): void { + if (!this.deps.isAvailable()) { + this.clearControlsHideTimer(); + return; + } + if ( + !this.deps.isPlaying() || + this.deps.menus.anyOpen() || + Boolean(this.deps.statusLabel()) + ) { + this.clearControlsHideTimer(); + return; + } + if (!this.deps.controlsVisible() || this.controlsHideTimer !== null) { + return; + } + this.controlsHideTimer = window.setTimeout(() => { + if ( + this.deps.isPlaying() && + !this.deps.menus.anyOpen() && + !this.deps.statusLabel() + ) { + this.deps.controlsVisible.set(false); + } + }, HIDE_CONTROLS_DELAY_MS); + } + + private clearControlsHideTimer(): void { + if (this.controlsHideTimer === null) { + return; + } + window.clearTimeout(this.controlsHideTimer); + this.controlsHideTimer = null; + } + + private clearVolumeCloseTimer(): void { + if (this.volumeCloseTimer === null) { + return; + } + window.clearTimeout(this.volumeCloseTimer); + this.volumeCloseTimer = null; + } + + private clearViewportClickTimer(): void { + if (this.viewportClickTimer === null) { + return; + } + window.clearTimeout(this.viewportClickTimer); + this.viewportClickTimer = null; + } + + private isPointerInsidePlayer(event: PointerEvent): boolean { + const playerRoot = this.deps.playerRoot(); + if (!playerRoot) { + return false; + } + const rect = playerRoot.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index f980a7d96..a5a388c24 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -26,6 +26,7 @@ import { import { PlayerControlsComponent } from '../player-controls/player-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; +import { EmbeddedMpvLegacyInteractions } from './embedded-mpv-legacy-interactions'; import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts'; @@ -49,10 +50,7 @@ import { volumeIcon, } from './embedded-mpv-format.utils'; -const HIDE_CONTROLS_DELAY_MS = 2500; const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; -const VOLUME_POPOVER_CLOSE_DELAY_MS = 220; -const VIEWPORT_CLICK_PAUSE_DELAY_MS = 250; @Component({ selector: 'app-embedded-mpv-player', @@ -302,49 +300,38 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { }); private mutedVolume = 0; - private controlsHideTimer: number | null = null; - private volumeCloseTimer: number | null = null; private recordingMessageTimer: number | null = null; - private viewportClickTimer: number | null = null; private lastEndedSessionId: string | null = null; private readonly recordingTick = signal(Date.now()); private readonly recordingMessage = signal(null); + private readonly legacyInteractions: EmbeddedMpvLegacyInteractions; - private readonly onDocumentPointerDown = (event: PointerEvent) => { - if (this.isFrameCopyEngine()) { - return; - } - const playerRoot = this.playerRoot()?.nativeElement; - if (!playerRoot || event.composedPath().includes(playerRoot)) { - return; - } - this.closePopovers(); - }; - private readonly onDocumentPointerMove = (event: PointerEvent) => { - if (this.isFrameCopyEngine()) { - return; - } - const playerRoot = this.playerRoot()?.nativeElement; - if ( - playerRoot && - !event.composedPath().includes(playerRoot) && - this.isPointerInsidePlayer(event) - ) { - this.revealControls(); - } - }; private readonly onFullscreenChange = () => { const playerRoot = this.playerRoot()?.nativeElement; this.isFullscreen.set( Boolean(playerRoot && document.fullscreenElement === playerRoot) ); - if (!this.isFrameCopyEngine()) { - this.revealControls(); - } + this.legacyInteractions.revealControls(); this.controller.triggerBoundsSync(); }; constructor() { + this.legacyInteractions = new EmbeddedMpvLegacyInteractions({ + isAvailable: () => !this.isFrameCopyEngine(), + playerRoot: () => this.playerRoot()?.nativeElement ?? null, + menus: this.menus, + controlsVisible: this.controlsVisible, + isLoading: this.isLoading, + isErrored: this.isErrored, + isStalled: this.stalled, + isPlaying: this.isPlaying, + statusLabel: this.statusLabel, + togglePaused: () => this.togglePaused(), + toggleFullscreen: () => this.toggleFullscreen(), + triggerBoundsSync: () => this.controller.triggerBoundsSync(), + }); + this.legacyInteractions.attach(); + this.sharedControls.configure({ playback: this.playback, seriesNavigation: this.seriesNavigation, @@ -356,15 +343,6 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { 'fullscreenchange', this.onFullscreenChange ); - document.addEventListener( - 'pointerdown', - this.onDocumentPointerDown - ); - document.addEventListener( - 'pointermove', - this.onDocumentPointerMove, - { passive: true } - ); } this.controller.setBoundsProvider((host) => { @@ -393,13 +371,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.shortcuts.attach({ isAvailable: () => - !this.isFrameCopyEngine() && + this.legacyInteractions.isAvailable() && !this.overlayVisibility.overlayActive(), - onEscape: () => { - if (!this.isFrameCopyEngine()) { - this.closePopovers(); - } - }, + onEscape: () => this.legacyInteractions.closePopovers(), togglePaused: () => void this.togglePaused(), toggleFullscreen: () => void this.toggleFullscreen(), seekBy: (delta) => void this.seekBy(delta), @@ -457,7 +431,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { currentTime: session.positionSeconds, duration: session.durationSeconds ?? 0, }); - this.scheduleControlsHide(); + this.legacyInteractions.scheduleControlsHide(); }); }); @@ -491,13 +465,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { untracked(() => { this.feedback.clear(); this.setRecordingMessage(null); - if (!isFrameCopyEngine) { - return; - } - this.clearControlsHideTimer(); - this.clearVolumeCloseTimer(); - this.clearViewportClickTimer(); - this.menus.closeAll(); + this.legacyInteractions.handleEngineTransition( + isFrameCopyEngine + ); }); }); } @@ -510,86 +480,30 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { 'fullscreenchange', this.onFullscreenChange ); - document.removeEventListener( - 'pointerdown', - this.onDocumentPointerDown - ); - document.removeEventListener( - 'pointermove', - this.onDocumentPointerMove - ); } - this.clearVolumeCloseTimer(); + this.legacyInteractions.dispose(); this.clearRecordingMessageTimer(); - this.clearControlsHideTimer(); - this.clearViewportClickTimer(); - } - - private clearVolumeCloseTimer(): void { - if (this.volumeCloseTimer !== null) { - window.clearTimeout(this.volumeCloseTimer); - this.volumeCloseTimer = null; - } - } - - private clearViewportClickTimer(): void { - if (this.viewportClickTimer !== null) { - window.clearTimeout(this.viewportClickTimer); - this.viewportClickTimer = null; - } } onPlayerInteraction(): void { - if (this.isFrameCopyEngine()) { - return; - } - this.revealControls(); + this.legacyInteractions.onPlayerInteraction(); } onViewportClick(event: MouseEvent): void { - if (this.isFrameCopyEngine()) { - return; - } - const target = event.target as HTMLElement | null; - if (target?.closest('button, input, [role="slider"]')) { - return; - } - if (this.menus.anyOpen()) { - this.menus.closeAll(); - return; - } - if (this.isLoading() || this.isErrored() || this.stalled()) { - return; - } - // The pause is deferred so a double-click (fullscreen) can cancel it; - // pause feedback is the transport icon in the dock — the native video - // surface paints over any DOM overlay we could flash here. - this.clearViewportClickTimer(); - this.viewportClickTimer = window.setTimeout(() => { - this.viewportClickTimer = null; - void this.togglePaused(); - }, VIEWPORT_CLICK_PAUSE_DELAY_MS); + this.legacyInteractions.onViewportClick(event); } onPlayerDblClick(event: MouseEvent): void { - if (this.isFrameCopyEngine()) { - return; - } - const target = event.target as HTMLElement | null; - if (target?.closest('button, input, [role="slider"]')) { - return; - } - this.clearViewportClickTimer(); - void this.toggleFullscreen(); + this.legacyInteractions.onPlayerDblClick(event); } async togglePaused(): Promise { - this.revealControls(); + this.legacyInteractions.revealControls(); await this.controller.togglePaused(); } async toggleFullscreen(): Promise { - this.revealControls(); + this.legacyInteractions.revealControls(); const playerRoot = this.playerRoot()?.nativeElement; if (!playerRoot || !this.canFullscreen()) { return; @@ -608,7 +522,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } async seekBy(deltaSeconds: number): Promise { - this.revealControls(); + this.legacyInteractions.revealControls(); const ok = await this.controller.seekBy(deltaSeconds); if (ok) { this.feedback.flash( @@ -619,7 +533,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } requestPreviousEpisode(): void { - this.revealControls(); + this.legacyInteractions.revealControls(); if (!this.canPreviousEpisode()) { return; } @@ -627,7 +541,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } requestNextEpisode(): void { - this.revealControls(); + this.legacyInteractions.revealControls(); if (!this.canNextEpisode()) { return; } @@ -635,7 +549,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } onTimelineInput(event: Event): void { - this.revealControls(); + this.legacyInteractions.revealControls(); this.scrubPosition.set( Number((event.target as HTMLInputElement).value) ); @@ -650,7 +564,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { onVolumeInput(event: Event): void { const next = Number((event.target as HTMLInputElement).value); this.applyVolume(next); - this.revealControls(false); + this.legacyInteractions.revealControls(false); } onVolumeWheel(event: WheelEvent): void { @@ -659,16 +573,11 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } onVolumeHoverEnter(): void { - this.clearVolumeCloseTimer(); - this.menus.open('volume'); + this.legacyInteractions.onVolumeHoverEnter(); } onVolumeHoverLeave(): void { - this.clearVolumeCloseTimer(); - this.volumeCloseTimer = window.setTimeout(() => { - this.menus.close('volume'); - this.volumeCloseTimer = null; - }, VOLUME_POPOVER_CLOSE_DELAY_MS); + this.legacyInteractions.onVolumeHoverLeave(); } toggleMute(): void { @@ -688,52 +597,52 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { `${Math.round(restored * 100)}%` ); } - this.revealControls(); + this.legacyInteractions.revealControls(); } toggleAudioMenu(): void { this.menus.toggle('audio'); - this.revealControls(); + this.legacyInteractions.revealControls(); } toggleSubtitleMenu(): void { this.menus.toggle('subtitle'); - this.revealControls(); + this.legacyInteractions.revealControls(); } toggleSpeedMenu(): void { this.menus.toggle('speed'); - this.revealControls(); + this.legacyInteractions.revealControls(); } toggleAspectMenu(): void { this.menus.toggle('aspect'); - this.revealControls(); + this.legacyInteractions.revealControls(); } async selectAudioTrack(trackId: number): Promise { - this.revealControls(false); + this.legacyInteractions.revealControls(false); await this.controller.setAudioTrack(trackId); this.menus.close('audio'); - this.scheduleControlsHide(); + this.legacyInteractions.scheduleControlsHide(); } async selectSubtitleTrack(trackId: number): Promise { - this.revealControls(false); + this.legacyInteractions.revealControls(false); await this.controller.setSubtitleTrack(trackId); this.menus.close('subtitle'); - this.scheduleControlsHide(); + this.legacyInteractions.scheduleControlsHide(); } async selectSpeed(speed: number): Promise { - this.revealControls(false); + this.legacyInteractions.revealControls(false); await this.controller.setSpeed(speed); this.menus.close('speed'); - this.scheduleControlsHide(); + this.legacyInteractions.scheduleControlsHide(); } async selectAspect(aspect: string): Promise { - this.revealControls(false); + this.legacyInteractions.revealControls(false); await this.controller.setAspect(aspect); this.menus.close('aspect'); - this.scheduleControlsHide(); + this.legacyInteractions.scheduleControlsHide(); } async toggleRecording(): Promise { @@ -741,7 +650,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { return; } - this.revealControls(false); + this.legacyInteractions.revealControls(false); if (this.isRecording()) { const recording = await this.controller.stopRecording(); if (recording?.targetPath) { @@ -771,7 +680,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { ); this.flashRecordingFailed(); } - this.scheduleControlsHide(); + this.legacyInteractions.scheduleControlsHide(); return; } @@ -876,7 +785,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { private adjustVolume(delta: number): void { const next = Math.max(0, Math.min(1, this.volume() + delta)); this.applyVolume(next); - this.revealControls(); + this.legacyInteractions.revealControls(); this.feedback.flash(volumeIcon(next), `${Math.round(next * 100)}%`); } @@ -885,76 +794,6 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { persistVolume(value); void this.controller.applyVolume(value); } - - private closePopovers(): void { - if (!this.menus.anyOpen()) { - return; - } - this.menus.closeAll(); - this.controller.triggerBoundsSync(); - this.scheduleControlsHide(); - } - - private revealControls(scheduleHide = true): void { - if (this.isFrameCopyEngine()) { - this.clearControlsHideTimer(); - return; - } - this.controlsVisible.set(true); - if (scheduleHide) { - this.clearControlsHideTimer(); - this.scheduleControlsHide(); - } - } - - private scheduleControlsHide(): void { - if (this.isFrameCopyEngine()) { - this.clearControlsHideTimer(); - return; - } - if ( - !this.isPlaying() || - this.menus.anyOpen() || - Boolean(this.statusLabel()) - ) { - this.clearControlsHideTimer(); - return; - } - if (!this.controlsVisible() || this.controlsHideTimer !== null) { - return; - } - this.controlsHideTimer = window.setTimeout(() => { - if ( - this.isPlaying() && - !this.menus.anyOpen() && - !this.statusLabel() - ) { - this.controlsVisible.set(false); - } - }, HIDE_CONTROLS_DELAY_MS); - } - - private clearControlsHideTimer(): void { - if (this.controlsHideTimer === null) { - return; - } - window.clearTimeout(this.controlsHideTimer); - this.controlsHideTimer = null; - } - - private isPointerInsidePlayer(event: PointerEvent): boolean { - const playerRoot = this.playerRoot()?.nativeElement; - if (!playerRoot) { - return false; - } - const rect = playerRoot.getBoundingClientRect(); - return ( - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom - ); - } } export type EmbeddedMpvAudioTrackView = EmbeddedMpvAudioTrack;