feat(embedded-mpv): keyboard shortcuts and double-click fullscreen

Add the standard set of player shortcuts to match the audio/HTML video
players already in the app:

- Space, K   — play/pause
- F          — toggle fullscreen
- Left/Right — seek -5 s / +5 s
- Up/Down    — volume +/- 5 %
- M          — toggle mute (restores prior volume)

Shortcuts are ignored while focus is in an input/select/textarea or while
any MatDialog (e.g. command palette) is open, so the palette's filter and
dialog inputs keep their keys. Volume changes route through a shared
applyVolume helper so keyboard/wheel/slider all persist to localStorage
and the active session.

Also bind dblclick on the player root to toggle fullscreen, with a guard
so double-clicks on buttons/sliders don't trigger it. Note: the native
MPV NSView intercepts mouse events over the video itself, so dblclick
only fires on DOM-receiving regions (currently the controls strip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-05 09:26:26 +02:00
1 parent 32779f99a4
commit e2a9af37db
2 files changed
+86 -3

No files matched your search

@@ -10,6 +10,7 @@
(pointerenter)="onPlayerInteraction()"
(pointermove)="onPlayerInteraction()"
(click)="onPlayerInteraction()"
(dblclick)="onPlayerDblClick($event)"
(focusin)="onPlayerInteraction()"
>
<div #viewport class="embedded-mpv-player__viewport">
@@ -194,12 +194,51 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
};
private readonly onDocumentKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
if (event.key === 'Escape') {
this.closePopovers();
return;
}
this.closePopovers();
if (this.shouldIgnoreShortcut(event)) {
return;
}
switch (event.key) {
case ' ':
case 'k':
case 'K':
event.preventDefault();
void this.togglePaused();
return;
case 'f':
case 'F':
event.preventDefault();
void this.toggleFullscreen();
return;
case 'ArrowLeft':
event.preventDefault();
void this.seekBy(-5);
return;
case 'ArrowRight':
event.preventDefault();
void this.seekBy(5);
return;
case 'ArrowUp':
event.preventDefault();
this.adjustVolume(0.05);
return;
case 'ArrowDown':
event.preventDefault();
this.adjustVolume(-0.05);
return;
case 'm':
case 'M':
event.preventDefault();
this.toggleMute();
return;
}
};
private mutedVolume = 0;
private readonly onFullscreenChange = () => {
const playerRoot = this.playerRoot()?.nativeElement;
this.isFullscreen.set(
@@ -520,9 +559,37 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
onVolumeInput(event: Event): void {
const nextVolume = Number((event.target as HTMLInputElement).value);
this.applyVolume(nextVolume);
this.revealControls(false);
}
onPlayerDblClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input, [role="slider"]')) {
return;
}
void this.toggleFullscreen();
}
private adjustVolume(delta: number): void {
const next = Math.max(0, Math.min(1, this.volume() + delta));
this.applyVolume(next);
this.revealControls();
}
private toggleMute(): void {
if (this.volume() > 0) {
this.mutedVolume = this.volume();
this.applyVolume(0);
} else {
this.applyVolume(this.mutedVolume || 0.5);
}
this.revealControls();
}
private applyVolume(nextVolume: number): void {
this.volume.set(nextVolume);
localStorage.setItem('volume', String(nextVolume));
this.revealControls(false);
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
@@ -539,6 +606,21 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
.catch(() => undefined);
}
private shouldIgnoreShortcut(event: KeyboardEvent): boolean {
if (this.overlayVisibility.overlayActive()) {
return true;
}
const target = event.target as HTMLElement | null;
if (!target) {
return false;
}
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return true;
}
return target.isContentEditable;
}
async selectAudioTrack(trackId: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {