From dea1d40c3df723566d764f4836cf3a820eaf4354 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 5 May 2026 09:39:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(embedded-mpv):=20polish=20bundle=20?= =?UTF-8?q?=E2=80=94=20popovers,=20mute,=20retry,=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Volume button now toggles mute on click; the slider opens via hover/focus in an absolute popover anchored above the button (with hover-bridge so the cursor can reach it without crossing a dead zone). Wheel over the volume area adjusts volume. - Audio track menu likewise becomes a click-toggle popover anchored to its button instead of a panel takeover; the back-arrow / mode-panel scaffolding is gone. - Slider gets explicit thumb/track styling for both WebKit and Firefox so it reads as part of the design system instead of a raw native control; aria-valuetext on the timeline announces formatted time. - Stalled overlay: if status remains "loading" for 30 s, surface a centered warning with a Retry button that disposes the session and recreates it via a retryNonce signal the playback effect tracks. - Keypress feedback overlay: ←/→ seek, ↑/↓ volume, M mute now flash a centered pill with the icon + delta so the action is visible (especially important in fullscreen where the controls are hidden). Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: f957cd9849e0 --- .../embedded-mpv-player.component.html | 384 +++++++++--------- .../embedded-mpv-player.component.scss | 244 ++++++++--- .../embedded-mpv-player.component.ts | 141 ++++++- 3 files changed, 500 insertions(+), 269 deletions(-) diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 6322e5177..30388d336 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -14,7 +14,7 @@ (focusin)="onPlayerInteraction()" >
- @if (isLoading()) { + @if (isLoading() && !stalled()) {
{{ statusLabel() }} }
+ } @else if (stalled()) { + } @else if (statusLabel()) {
} + + @if (feedbackOverlay(); as feedback) { + + }
@if (showControls() && isSupported()) {
- @if (volumePopoverOpen()) { +
+ + + + + +
+ +
+ +
+ {{ formatTime(session()?.positionSeconds) }} + @if (canSeek()) { + {{ formatTime(session()?.durationSeconds) }} + } @else { + + + LIVE + + } +
+
+ +
- - -
- } @else if (audioMenuOpen()) { -
- - - -
- } @else { -
- - - - - -
- -
- -
- {{ - formatTime(session()?.positionSeconds) - }} - @if (canSeek()) { - {{ - formatTime(session()?.durationSeconds) - }} - } @else { - - - LIVE + @if (volumePopoverOpen()) { + +
+ }
-
+ @if (hasAudioTracks()) {
-
- - @if (hasAudioTracks()) { -
- -
- } +
+ Audio tracks +
+
+ @for ( + track of audioTracks(); + track track.id; + let index = $index + ) { + + } +
+
+ } +
+ } - -
- } + +
} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss index 61a58f9f9..98c09171b 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -46,10 +46,6 @@ background: var(--mat-sys-surface); } -/* The native MPV NSView sits above the WebContents and is sized to the - viewport rect, so any region it covers cannot receive pointer events. - Reserve the control-bar height as a DOM-only strip at the bottom so - moving the cursor toward the bottom of the player wakes the controls. */ .embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport { bottom: var(--embedded-mpv-controls-height); } @@ -72,6 +68,78 @@ color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent); } +.embedded-mpv-player__stalled { + position: absolute; + inset: 0; + display: grid; + grid-auto-rows: max-content; + place-content: center; + justify-items: center; + gap: 14px; + padding: 24px; + text-align: center; + color: var(--mat-sys-on-surface); + z-index: 1; +} + +.embedded-mpv-player__stalled mat-icon { + color: var(--mat-sys-error); + font-size: 36px; + width: 36px; + height: 36px; +} + +.embedded-mpv-player__stalled p { + margin: 0; + max-width: 360px; + color: color-mix(in srgb, var(--mat-sys-on-surface) 84%, transparent); +} + +.embedded-mpv-player__feedback { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + color: var(--mat-sys-on-surface); + background: color-mix(in srgb, #000 65%, transparent); + border-radius: 999px; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + pointer-events: none; + z-index: 2; + animation: embedded-mpv-feedback 700ms ease-out forwards; +} + +.embedded-mpv-player__feedback mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #ffffff; +} + +@keyframes embedded-mpv-feedback { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); + } + 18% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 70% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1); + } +} + .embedded-mpv-player__status { position: absolute; inset: 16px 16px auto auto; @@ -185,7 +253,79 @@ .embedded-mpv-player__slider { width: 100%; - accent-color: var(--embedded-mpv-accent); + height: 18px; + appearance: none; + background: transparent; + cursor: pointer; +} + +.embedded-mpv-player__slider:focus-visible { + outline: 2px solid var(--embedded-mpv-accent); + outline-offset: 4px; + border-radius: 4px; +} + +/* WebKit / Chromium */ +.embedded-mpv-player__slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--mat-sys-on-surface) 22%, + transparent + ); +} + +.embedded-mpv-player__slider::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + margin-top: -5px; + border: 0; + border-radius: 50%; + background: var(--embedded-mpv-accent); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + transition: transform 120ms ease-out; +} + +.embedded-mpv-player__slider:hover::-webkit-slider-thumb, +.embedded-mpv-player__slider:focus-visible::-webkit-slider-thumb { + transform: scale(1.18); +} + +.embedded-mpv-player__slider:disabled::-webkit-slider-thumb { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) 32%, + transparent + ); + box-shadow: none; +} + +/* Firefox */ +.embedded-mpv-player__slider::-moz-range-track { + height: 4px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--mat-sys-on-surface) 22%, + transparent + ); +} + +.embedded-mpv-player__slider::-moz-range-thumb { + width: 14px; + height: 14px; + border: 0; + border-radius: 50%; + background: var(--embedded-mpv-accent); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + transition: transform 120ms ease-out; +} + +.embedded-mpv-player__slider:hover::-moz-range-thumb, +.embedded-mpv-player__slider:focus-visible::-moz-range-thumb { + transform: scale(1.18); } .embedded-mpv-player__control-panel { @@ -193,59 +333,59 @@ color: var(--mat-sys-on-surface); background: var(--embedded-mpv-glass); border: 1px solid var(--embedded-mpv-border); - border-radius: 16px; + border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); backdrop-filter: blur(18px); } -.embedded-mpv-player__mode-panel { +.embedded-mpv-player__volume-popover { + position: absolute; + bottom: calc(100% + 6px); + right: 0; display: grid; - grid-column: 1 / -1; - grid-template-columns: auto minmax(0, 1fr); - gap: 12px; - align-items: center; - min-width: 0; + grid-template-columns: minmax(160px, 200px); + gap: 4px; + padding: 12px 14px 10px; + z-index: 3; } -.embedded-mpv-player__back-button { - color: var(--mat-sys-on-surface); - background: color-mix(in srgb, var(--mat-sys-on-surface) 6%, transparent); +.embedded-mpv-player__volume-popover::before { + /* Hover-bridge so the cursor can move from the button to the popover + without crossing a dead zone. */ + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -8px; + height: 8px; } -.embedded-mpv-player__volume-panel { - display: grid; - grid-template-columns: auto minmax(140px, 1fr) auto; - gap: 12px; - align-items: center; - padding: 10px 12px; -} - -.embedded-mpv-player__volume-panel mat-icon { - color: var(--embedded-mpv-accent); - font-size: 20px; - width: 20px; - height: 20px; +.embedded-mpv-player__volume-popover .embedded-mpv-player__slider--volume { + width: 100%; } .embedded-mpv-player__volume-value { color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent); - font-size: 0.76rem; + font-size: 0.74rem; font-variant-numeric: tabular-nums; + text-align: right; white-space: nowrap; } -.embedded-mpv-player__audio-menu { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 12px; - align-items: center; - padding: 8px 10px; +.embedded-mpv-player__audio-popover { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + min-width: 220px; + max-width: min(420px, 80vw); + padding: 10px 12px; + z-index: 3; } .embedded-mpv-player__menu-title { - padding: 0 2px; + padding: 0 2px 6px; color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent); - font-size: 0.72rem; + font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; @@ -254,18 +394,15 @@ .embedded-mpv-player__audio-track-list { display: flex; - gap: 6px; - min-width: 0; - overflow-x: auto; - overflow-y: hidden; + flex-direction: column; + gap: 4px; + max-height: 240px; + overflow-y: auto; scrollbar-width: thin; } .embedded-mpv-player__audio-track { display: flex; - flex: 0 0 auto; - min-width: 154px; - max-width: 240px; align-items: center; justify-content: space-between; gap: 12px; @@ -273,7 +410,7 @@ color: var(--mat-sys-on-surface); background: transparent; border: 0; - border-radius: 12px; + border-radius: 10px; cursor: pointer; font: inherit; text-align: left; @@ -286,6 +423,7 @@ } .embedded-mpv-player__audio-track:hover, +.embedded-mpv-player__audio-track:focus-visible, .embedded-mpv-player__audio-track--selected { background: color-mix( in srgb, @@ -329,17 +467,9 @@ min-width: 0; } - .embedded-mpv-player__volume-panel { - grid-template-columns: auto 1fr; - } - - .embedded-mpv-player__volume-value { - grid-column: 1 / -1; - } - - .embedded-mpv-player__mode-panel--audio - .embedded-mpv-player__audio-menu { - grid-template-columns: 1fr; - gap: 6px; + .embedded-mpv-player__volume-popover, + .embedded-mpv-player__audio-popover { + right: 0; + left: auto; } } 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 47f9b35ba..0ba8eaa10 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 @@ -71,6 +71,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { readonly controlsVisible = signal(true); readonly volumePopoverOpen = signal(false); readonly audioMenuOpen = signal(false); + readonly stalled = signal(false); + readonly feedbackOverlay = signal<{ + icon: string; + label: string; + key: number; + } | null>(null); + private readonly retryNonce = signal(0); readonly isSupported = computed(() => this.support()?.supported ?? false); readonly canFullscreen = computed( @@ -174,6 +181,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { private boundsAnimationFrame: number | null = null; private controlsHideTimer: number | null = null; private activeBoundsSync: (() => void) | null = null; + private volumeCloseTimer: number | null = null; + private stalledTimer: number | null = null; + private feedbackTimer: number | null = null; + private feedbackKey = 0; private readonly onDocumentPointerDown = (event: PointerEvent) => { const playerRoot = this.playerRoot()?.nativeElement; const path = event.composedPath(); @@ -280,6 +291,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { const viewport = this.viewport(); const playback = this.playback(); const support = this.support(); + this.retryNonce(); if ( !viewport || @@ -430,6 +442,29 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.overlayVisibility.overlayActive(); untracked(() => this.activeBoundsSync?.()); }); + + effect(() => { + const status = this.session()?.status ?? null; + untracked(() => { + if (status === 'loading') { + if (this.stalledTimer === null) { + this.stalledTimer = window.setTimeout(() => { + this.stalled.set(true); + this.stalledTimer = null; + }, 30000); + } + return; + } + + if (this.stalledTimer !== null) { + clearTimeout(this.stalledTimer); + this.stalledTimer = null; + } + if (this.stalled()) { + this.stalled.set(false); + } + }); + }); } ngOnDestroy(): void { @@ -455,6 +490,19 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.boundsAnimationFrame = null; } + if (this.volumeCloseTimer !== null) { + clearTimeout(this.volumeCloseTimer); + this.volumeCloseTimer = null; + } + if (this.stalledTimer !== null) { + clearTimeout(this.stalledTimer); + this.stalledTimer = null; + } + if (this.feedbackTimer !== null) { + clearTimeout(this.feedbackTimer); + this.feedbackTimer = null; + } + this.clearControlsHideTimer(); } @@ -505,6 +553,11 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { return; } + this.flashFeedback( + deltaSeconds >= 0 ? 'forward_10' : 'replay_10', + `${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s` + ); + const nextPosition = Math.max(0, session.positionSeconds + deltaSeconds); const updatedSession = await window.electron.seekEmbeddedMpv( session.id, @@ -532,29 +585,12 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } } - toggleVolumePopover(): void { - this.volumePopoverOpen.update((open) => !open); - if (this.volumePopoverOpen()) { - this.audioMenuOpen.set(false); - } - this.revealControls(); - this.activeBoundsSync?.(); - } - toggleAudioMenu(): void { this.audioMenuOpen.update((open) => !open); if (this.audioMenuOpen()) { this.volumePopoverOpen.set(false); } this.revealControls(); - this.activeBoundsSync?.(); - } - - showDefaultControls(): void { - this.volumePopoverOpen.set(false); - this.audioMenuOpen.set(false); - this.revealControls(); - this.activeBoundsSync?.(); } onVolumeInput(event: Event): void { @@ -575,18 +611,85 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { const next = Math.max(0, Math.min(1, this.volume() + delta)); this.applyVolume(next); this.revealControls(); + this.flashFeedback( + this.volumeIconFor(next), + `${Math.round(next * 100)}%` + ); } - private toggleMute(): void { + toggleMute(): void { if (this.volume() > 0) { this.mutedVolume = this.volume(); this.applyVolume(0); + this.flashFeedback('volume_off', 'Muted'); } else { - this.applyVolume(this.mutedVolume || 0.5); + const restored = this.mutedVolume || 0.5; + this.applyVolume(restored); + this.flashFeedback( + this.volumeIconFor(restored), + `${Math.round(restored * 100)}%` + ); } this.revealControls(); } + onVolumeWheel(event: WheelEvent): void { + event.preventDefault(); + const delta = event.deltaY > 0 ? -0.05 : 0.05; + this.adjustVolume(delta); + } + + onVolumeHoverEnter(): void { + if (this.volumeCloseTimer !== null) { + clearTimeout(this.volumeCloseTimer); + this.volumeCloseTimer = null; + } + if (!this.volumePopoverOpen()) { + this.volumePopoverOpen.set(true); + this.audioMenuOpen.set(false); + } + } + + onVolumeHoverLeave(): void { + if (this.volumeCloseTimer !== null) { + clearTimeout(this.volumeCloseTimer); + } + this.volumeCloseTimer = window.setTimeout(() => { + this.volumePopoverOpen.set(false); + this.volumeCloseTimer = null; + }, 220); + } + + retry(): void { + this.stalled.set(false); + this.session.set(null); + this.sessionId.set(null); + this.retryNonce.update((n) => n + 1); + } + + private volumeIconFor(value: number): string { + if (value <= 0) { + return 'volume_off'; + } + return value < 0.5 ? 'volume_down' : 'volume_up'; + } + + private flashFeedback(icon: string, label: string): void { + if (this.feedbackTimer !== null) { + clearTimeout(this.feedbackTimer); + } + this.feedbackKey += 1; + this.feedbackOverlay.set({ + icon, + label, + key: this.feedbackKey, + }); + this.feedbackTimer = window.setTimeout(() => { + this.feedbackOverlay.set(null); + this.feedbackTimer = null; + }, 700); + } + private applyVolume(nextVolume: number): void { this.volume.set(nextVolume); localStorage.setItem('volume', String(nextVolume));