mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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));
|
||||
|
||||
Reference in new issue
Block a user