feat(embedded-mpv): polish bundle — popovers, mute, retry, feedback

- 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) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-05 09:39:19 +02:00
1 parent e2a9af37db
commit dea1d40c3d
3 files changed
+500 -269

No files matched your search

@@ -14,7 +14,7 @@
(focusin)="onPlayerInteraction()"
>
<div #viewport class="embedded-mpv-player__viewport">
@if (isLoading()) {
@if (isLoading() && !stalled()) {
<div
class="embedded-mpv-player__loader"
role="status"
@@ -29,6 +29,19 @@
<span>{{ statusLabel() }}</span>
}
</div>
} @else if (stalled()) {
<div
class="embedded-mpv-player__stalled"
role="alert"
aria-live="assertive"
>
<mat-icon>warning_amber</mat-icon>
<p>This stream is taking longer than expected to start.</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
</button>
</div>
} @else if (statusLabel()) {
<div
class="embedded-mpv-player__status"
@@ -38,226 +51,211 @@
{{ statusLabel() }}
</div>
}
@if (feedbackOverlay(); as feedback) {
<div
class="embedded-mpv-player__feedback"
[attr.data-feedback-key]="feedback.key"
aria-hidden="true"
>
<mat-icon>{{ feedback.icon }}</mat-icon>
<span>{{ feedback.label }}</span>
</div>
}
</div>
@if (showControls() && isSupported()) {
<div
class="embedded-mpv-player__controls"
[class.embedded-mpv-player__controls--visible]="controlsAreVisible()"
[class.embedded-mpv-player__controls--panel-open]="
volumePopoverOpen() || audioMenuOpen()
"
>
@if (volumePopoverOpen()) {
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
matTooltipPosition="above"
>
<mat-icon>{{
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
aria-label="Back 10 seconds"
matTooltip="Back 10 seconds"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
aria-label="Forward 10 seconds"
matTooltip="Forward 10 seconds"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(session()?.positionSeconds) }}</span>
@if (canSeek()) {
<span>{{ formatTime(session()?.durationSeconds) }}</span>
} @else {
<span
class="embedded-mpv-player__live-badge"
aria-label="Live stream"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
}
</div>
</div>
<div class="embedded-mpv-player__actions">
<div
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--volume"
class="embedded-mpv-player__popover-anchor"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
(click)="showDefaultControls()"
aria-label="Back to player controls"
matTooltip="Back to controls"
matTooltipPosition="right"
>
<mat-icon>arrow_back</mat-icon>
</button>
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-panel"
role="dialog"
aria-label="Volume"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="
volume() <= 0 ? 'Unmute' : 'Mute'
"
[matTooltip]="volume() <= 0 ? 'Unmute' : 'Mute'"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
<input
class="embedded-mpv-player__slider"
type="range"
min="0"
max="1"
step="0.05"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span class="embedded-mpv-player__volume-value">
{{ volumeLabel() }}
</span>
</div>
</div>
} @else if (audioMenuOpen()) {
<div
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--audio"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
(click)="showDefaultControls()"
aria-label="Back to player controls"
matTooltip="Back to controls"
matTooltipPosition="right"
>
<mat-icon>arrow_back</mat-icon>
</button>
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-menu"
role="menu"
aria-label="Audio tracks"
>
<div class="embedded-mpv-player__menu-title">
Audio tracks
</div>
<div class="embedded-mpv-player__audio-track-list">
@for (
track of audioTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="selectAudioTrack(track.id)"
>
<span>{{ trackLabel(track, index) }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
</div>
} @else {
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
matTooltipPosition="right"
>
<mat-icon>{{
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
aria-label="Back 10 seconds"
matTooltip="Back 10 seconds"
matTooltipPosition="right"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
aria-label="Forward 10 seconds"
matTooltip="Forward 10 seconds"
matTooltipPosition="right"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
(input)="onTimelineInput($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{
formatTime(session()?.positionSeconds)
}}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else {
<span
class="embedded-mpv-player__live-badge"
aria-label="Live stream"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
@if (volumePopoverOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-popover"
role="dialog"
aria-label="Volume"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
type="range"
min="0"
max="1"
step="0.01"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
[attr.aria-valuetext]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span class="embedded-mpv-player__volume-value">
{{ volumeLabel() }}
</span>
}
</div>
</div>
}
</div>
<div class="embedded-mpv-player__actions">
@if (hasAudioTracks()) {
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleVolumePopover()"
[attr.aria-label]="volumeLabel()"
[matTooltip]="volumeLabel()"
matTooltipPosition="left"
(click)="toggleAudioMenu()"
[attr.aria-expanded]="audioMenuOpen()"
aria-label="Audio tracks"
matTooltip="Audio tracks"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
<mat-icon>graphic_eq</mat-icon>
</button>
</div>
@if (hasAudioTracks()) {
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleAudioMenu()"
@if (audioMenuOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Audio tracks"
matTooltip="Audio tracks"
matTooltipPosition="left"
>
<mat-icon>graphic_eq</mat-icon>
</button>
</div>
}
<div class="embedded-mpv-player__menu-title">
Audio tracks
</div>
<div class="embedded-mpv-player__audio-track-list">
@for (
track of audioTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="selectAudioTrack(track.id)"
>
<span>{{
trackLabel(track, index)
}}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="left"
>
<mat-icon>{{
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
</div>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="above"
>
<mat-icon>{{
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
</div>
</div>
}
</div>
@@ -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;
}
}
@@ -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));