Files
iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html
T
4grayandClaude Fable 5 4bc6f7e919 fix(embedded-mpv): stop native-view video jump by moving menus into the dock
Opening any control popover in the native-view embedded MPV dock used to
shrink the MPV view by a 300 px bottom cutout so the popover DOM stayed
clickable, which made mpv re-letterbox the video on every menu open/close.

All five menus now render horizontally inside the fixed-height controls
strip, so menu state never changes the native view bounds:

- volume expands as an inline horizontal slider next to the mute button
- audio/subtitle/speed/aspect morph the dock row into a back button, a
  panel title, and a scrollable chip ribbon (app-embedded-mpv-dock-panel)
  with wheel-to-horizontal-scroll mapping, edge fades, active-chip
  reveal/focus, roving arrow-key navigation, ellipsis + tooltips, and
  RTL-aware scrolling
- boundsProvider loses the menus.anyOpen() cutout branch and the
  MENU_OPEN_BOTTOM_CUTOUT_PX constant is removed; HIDDEN_BOUNDS for modal
  overlays is unchanged
- global arrow shortcuts (seek/volume) are suspended while a chip panel
  is open so arrows walk the chips; Esc, click-outside, and close-on-select
  semantics are preserved
- new EMBEDDED_MPV.PLAYER.BACK i18n key in all 18 languages

Regression coverage: the new dock-panels spec asserts the bounds provider
returns full host bounds while every menu is open (fails against the old
cutout behavior), plus panel morph/a11y/selection specs and a dedicated
dock-panel component spec (keyboard, wheel, tabindex, emits).

Frame-copy shared controls (app-player-controls) are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:55:55 +02:00

505 lines
22 KiB
HTML

<div
#playerRoot
class="embedded-mpv-player"
[class.embedded-mpv-player--fullscreen]="isFullscreen()"
[class.embedded-mpv-player--controls-enabled]="
!isFrameCopyEngine() && showControls() && isSupported()
"
[class.embedded-mpv-player--controls-visible]="
!isFrameCopyEngine() && controlsAreVisible()
"
[class.embedded-mpv-player--cursor-hidden]="
!isFrameCopyEngine() && hideCursor()
"
(pointerenter)="onPlayerInteraction()"
(pointermove)="onPlayerInteraction()"
(click)="onPlayerInteraction()"
(dblclick)="onPlayerDblClick($event)"
(focusin)="onPlayerInteraction()"
>
<div
#viewport
class="embedded-mpv-player__viewport"
(click)="onViewportClick($event)"
>
@if (isFrameCopyEngine()) {
<!-- Frame-copy engine: the preload frame pump paints helper
frames onto this canvas. It is ordinary DOM, so controls
and dialogs stack above it without native-layer tricks. -->
<canvas
class="embedded-mpv-player__frame-canvas"
data-embedded-mpv-frame
></canvas>
}
@if (isErrored()) {
<div
class="embedded-mpv-player__stalled"
role="alert"
aria-live="assertive"
>
<mat-icon>error_outline</mat-icon>
<p>{{ statusLabel() }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
<span>{{ 'EMBEDDED_MPV.PLAYER.RETRY' | translate }}</span>
</button>
</div>
} @else if (isLoading() && !stalled()) {
<div
class="embedded-mpv-player__loader"
role="status"
aria-live="polite"
>
<mat-progress-spinner
mode="indeterminate"
diameter="48"
strokeWidth="3"
/>
@if (statusLabel()) {
<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>{{ 'EMBEDDED_MPV.PLAYER.STALLED' | translate }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
<span>{{ 'EMBEDDED_MPV.PLAYER.RETRY' | translate }}</span>
</button>
</div>
} @else if (statusLabel()) {
<div
class="embedded-mpv-player__status"
role="status"
aria-live="polite"
>
{{ statusLabel() }}
</div>
}
@if (!isFrameCopyEngine() && feedback.current(); 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 (isFrameCopyEngine() && isSupported()) {
<app-player-controls
[controller]="sharedControls"
[playerSurface]="playerSurface()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
(nextEpisodeRequested)="requestNextEpisode()"
/>
}
@if (!isFrameCopyEngine() && showControls() && isSupported()) {
<div
class="embedded-mpv-player__controls"
[class.embedded-mpv-player__controls--visible]="
controlsAreVisible()
"
[class.embedded-mpv-player__controls--panel]="
dockPanels.active() !== null
"
>
@if (dockPanels.active(); as dockPanel) {
<app-embedded-mpv-dock-panel
[panel]="dockPanel"
[backLabel]="dockPanelBackLabel()"
(chipSelected)="dockPanels.select($event)"
(closed)="dockPanels.close()"
/>
} @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()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
[matTooltip]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
@if (showSeriesNavigation()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE'
| translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
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)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
[style.--slider-progress.%]="
canSeek()
? (timelineValue() /
((session()?.durationSeconds ?? 0) ||
1)) *
100
: 0
"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(timelineValue()) }}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM'
| translate
"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
<div
class="embedded-mpv-player__recording-status"
[class.embedded-mpv-player__recording-status--active]="
isRecording()
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
>
<mat-icon>{{
isRecording()
? 'fiber_manual_record'
: 'check_circle'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<div class="embedded-mpv-player__actions">
<div
class="embedded-mpv-player__volume-group"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
[matTooltip]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
@if (menus.volumeOpen()) {
<div
class="embedded-mpv-player__volume-inline"
role="group"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="
volume() * 100
"
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"
>{{ volumePercent() }}</span
>
</div>
}
</div>
@if (hasAudioTracks()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="audio"
(click)="toggleAudioMenu()"
[attr.aria-expanded]="menus.audioOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
matTooltipPosition="above"
>
<mat-icon>graphic_eq</mat-icon>
</button>
}
@if (capabilities().subtitles && hasSubtitleTracks()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="subtitle"
(click)="toggleSubtitleMenu()"
[attr.aria-expanded]="menus.subtitleOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
selectedSubtitleTrackId() === null
? 'subtitles_off'
: 'subtitles'
}}</mat-icon>
</button>
}
@if (capabilities().playbackSpeed) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="speed"
(click)="toggleSpeedMenu()"
[attr.aria-expanded]="menus.speedOpen()"
[attr.aria-label]="
('EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate) +
' ' +
speedLabel(playbackSpeed())
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
| translate
: {
speed: speedLabel(
playbackSpeed()
),
}
"
matTooltipPosition="above"
>
<mat-icon>speed</mat-icon>
</button>
}
@if (capabilities().aspectOverride) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="aspect"
(click)="toggleAspectMenu()"
[attr.aria-expanded]="menus.aspectOpen()"
[attr.aria-label]="
('EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate) +
' ' +
aspectLabel(aspectOverride())
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.ASPECT_TOOLTIP'
| translate
: {
aspect: aspectLabel(
aspectOverride()
),
}
"
matTooltipPosition="above"
>
<mat-icon>aspect_ratio</mat-icon>
</button>
}
@if (canRecord()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
[class.embedded-mpv-player__record-button--active]="
isRecording()
"
[disabled]="isLoading()"
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'stop_circle'
: 'fiber_manual_record'
}}</mat-icon>
</button>
}
<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>