refactor(embedded-mpv): isolate controls timing

This commit is contained in:
4gray committed 2026-07-16 17:36:14 +02:00
1 parent fec7190dd4
commit 051c7f9ff4
5 files changed
+400 -278

No files matched your search

+18 -9
View File
@@ -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
@@ -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<RecordingFeedback | null>(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;
}
}
@@ -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<RecordingFeedback | null>(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);
}
}
@@ -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<boolean>;
readonly isLoading: () => boolean;
readonly isErrored: () => boolean;
readonly isStalled: () => boolean;
readonly isPlaying: () => boolean;
readonly statusLabel: () => string;
readonly togglePaused: () => void | Promise<void>;
readonly toggleFullscreen: () => void | Promise<void>;
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
);
}
}
@@ -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<string | null>(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<void> {
this.revealControls();
this.legacyInteractions.revealControls();
await this.controller.togglePaused();
}
async toggleFullscreen(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
this.revealControls(false);
this.legacyInteractions.revealControls(false);
await this.controller.setAspect(aspect);
this.menus.close('aspect');
this.scheduleControlsHide();
this.legacyInteractions.scheduleControlsHide();
}
async toggleRecording(): Promise<void> {
@@ -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;