feat(player-controls): add shared engine-agnostic controls layer (#1148)

* feat(player-controls): shared engine-agnostic controls layer (flag off)

Introduce a shared, engine-agnostic player-controls layer in libs/ui/playback
as pure additive library code with no consumers yet.

- Contract (player-controls.model.ts): PlayerControlsCapabilities,
  PlayerControlsState, and PlayerControlsCommands make up the
  PlayerController interface every engine adapter implements.
- Single presentation component (app-player-controls): one controls UI
  binding purely to a PlayerController, with focused helpers for
  visibility auto-hide, volume, fullscreen (built-in DOM path), menus,
  keyboard shortcuts, seek/volume feedback, and the controls surface.
- Web-video adapter (web-video-controls.adapter.ts + host directive):
  drives the contract from an HTMLVideoElement, including optional
  HLS.js quality/audio-track integration and series episode navigation.
- Feature flag WEB_PLAYER_SHARED_CONTROLS (web-player-controls.flag.ts)
  defaults to OFF; no player component consumes the new layer yet, so
  runtime behavior is unchanged.
- docs/architecture/player-controls-contract.md documents the target
  architecture (later PRs add the embedded-MPV adapter, immersive
  overlay, and host-supplied fullscreen delegate).

Review-driven hardening: the web-video adapter now holds the host
series-navigation signal reactively (updates after setContext are
reflected); the shared controls template is fully localized via
ngx-translate (reusing the EMBEDDED_MPV.PLAYER.* keys); single click on
the viewport toggles play/pause deferred so a double-click still
fullscreens; keyboard shortcuts are shadow-DOM-safe (composedPath) and
guard against duplicate-instance double-execution (defaultPrevented);
and hidden controls now also disable shortcuts.

Test coverage extended per Codecov patch report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(player-controls): harden shared controls foundation

* fix(player-controls): restore detached adapter state

* fix(player-controls): harden keyboard and adapter state

* fix(player-controls): refresh readiness and hide timers

* fix(player-controls): keep controls root inside surface

* fix(player-controls): hide seek controls for live streams

* fix(player-controls): harden multi-engine control state

* fix(player-controls): respect runtime interaction availability

* fix(player-controls): gate loading toggles and localize mute

* fix(player-controls): harden surface and error states

* fix(player-controls): preserve volume and cursor state

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <serega05@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-07-16 12:56:30 +02:00
1 parent 59e08fd2d6
commit aa6ee85d3f
62 files changed
+7328

No files matched your search

@@ -0,0 +1,331 @@
# Player-Controls Contract
This document is the canonical reference for IPTVnator's additive,
engine-agnostic player-controls contract and shared default controls.
Embedded MPV rendering and native-view bounds behavior remain documented in
[embedded-mpv-native.md](./embedded-mpv-native.md).
## Current status
PR #1148 lands a shared-controls foundation only:
- the `PlayerController` contract, default state, and capability presets;
- the standalone `app-player-controls` presentation component and its
transient-state collaborators;
- a generic `WebVideoControlsAdapter` plus small host helpers;
- a default-off web rollout token; and
- focused unit/component tests.
No existing player consumes this layer yet. Video.js, html5+hls.js, ArtPlayer,
and embedded MPV keep their existing controls. The rollout token has no runtime
consumer in #1148, so changing it alone does not switch any player UI.
Engine-host wiring and an embedded-MPV adapter are follow-up work.
This distinction is intentional: the contract can be reviewed and hardened
without changing shipped playback behavior.
## Why this exists
Each playback engine currently owns both media integration and controls UI.
That makes behavior drift likely and makes a controls redesign depend on each
engine's implementation details.
The shared contract separates:
- **presentation** — what the controls render and which interactions they own;
- **state and capabilities** — the engine-neutral snapshot the UI reads; and
- **commands** — the small imperative surface an engine adapter implements.
Rendering and compositing remain engine responsibilities. In particular, the
contract does not make a native video surface behave like DOM content.
## Landed architecture
```text
┌──────────────────────────────────────────────────────────────────────┐
│ app-player-controls │
│ Standalone shared presentation component │
│ Menus · feedback · auto-hide · DOM fullscreen · shortcuts · scrub UI│
└───────────────────────────────┬──────────────────────────────────────┘
│ input.required<PlayerController>()
▼
┌──────────────────────────────────────────────────────────────────────┐
│ PlayerController │
│ capabilities: Signal<PlayerControlsCapabilities> │
│ state: Signal<PlayerControlsState> │
│ commands: PlayerControlsCommands │
└───────────────────────────────┬──────────────────────────────────────┘
│
adapters implement this boundary
│
┌──────────────────┴──────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ WebVideoControlsAdapter │ │ Embedded-MPV adapter │
│ Landed, generic, not wired │ │ FOLLOW-UP: not implemented │
└──────────────────────────────┘ └──────────────────────────────┘
```
The diagram shows the reusable boundary, not current runtime ownership. There
is no `app-player-controls` host in an existing engine component in #1148.
## The contract
The contract is defined in
`libs/ui/playback/src/lib/player-controls/player-controls.model.ts`.
```ts
interface PlayerController {
readonly capabilities: Signal<PlayerControlsCapabilities>;
readonly state: Signal<PlayerControlsState>;
readonly commands: PlayerControlsCommands;
}
```
### Capabilities
`PlayerControlsCapabilities` contains booleans for `seek`, `volume`,
`audioTracks`, `subtitles`, `playbackSpeed`, `aspectRatio`, `recording`,
`fullscreen`, and `seriesNavigation`.
The default is all-false. An adapter enables only features that its engine and
current runtime support. Capability flags primarily control whether optional UI
is rendered; state such as `canSeek`, `canPreviousEpisode`, and
`canNextEpisode` guards the corresponding action at runtime.
### State
`PlayerControlsState` is one reactive engine-neutral snapshot:
- playback status, loading/error message, and stalled state;
- current position, optional duration, live/VOD classification, and seekability;
- volume;
- pre-labelled audio/subtitle tracks and subtitle-enabled state;
- playback speed and aspect-ratio selections/presets;
- recording state; and
- previous/next episode availability.
Adapters translate engine types into this model. The controls component must not
import Video.js, hls.js, ArtPlayer, libmpv, Electron IPC, or native-view types.
### Commands
`PlayerControlsCommands` is an imperative, fire-and-forget surface:
- `togglePlay`
- `seekTo` / `seekBy`
- `setVolume`
- `setAudioTrack` / `setSubtitleTrack`
- `setPlaybackSpeed`
- `setAspectRatio`
- `toggleRecording`
Episode navigation is deliberately exposed as component outputs
(`previousEpisodeRequested` and `nextEpisodeRequested`) because the owning
playlist/portal feature decides which item to play.
Fullscreen is also outside the engine command contract. The landed component
uses `ControlsFullscreen`, which operates on the supplied DOM player surface
through `requestFullscreen()` / `document.exitFullscreen()`. There is no
fullscreen delegate or native-fullscreen IPC path in #1148.
## Shared default controls
`PlayerControlsComponent` is a standalone presentation component designed for a
future engine host to mount over or beside its playback surface.
It owns only transient presentation behavior:
- `ControlsMenuState` — single-open popovers;
- `ControlsFeedback` — temporary action feedback;
- `ControlsVisibility` — reveal and auto-hide state;
- `ControlsFullscreen` — DOM fullscreen;
- `ControlsVolume` — persisted/optimistic volume state reconciled from
controller state;
- `ControlsShortcuts` — document keyboard routing;
- `ControlsSurface` — pointer/click/double-click surface interactions; and
- `controls-view-model.ts` — derived display state.
### Keyboard ownership
Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys
with Meta/Cmd, Control, or Alt are ignored and are not prevented, so app and OS
accelerators retain ownership. Escape remains available to close controls
popovers even when a modifier is held or playback shortcuts are unavailable.
Buttons, form controls, links, ARIA menu controls, and content-editable targets
are also ignored anywhere in the event's composed path.
Action-specific keys are prevented only when the active controller can handle
them: seek requires both capability and current seekability, volume/mute
requires volume capability, and fullscreen requires an available DOM
fullscreen path. Unsupported keys retain their browser or application default.
When multiple shared-controls instances are mounted, the first attached
instance owns shortcuts initially. Pointer, focus, or control interaction
activates that instance through the normal reveal path. If the active instance
becomes unavailable, playback shortcuts fall back to the most recently attached
available instance; detaching the active instance also transfers ownership.
Escape remains a global dismissal action and closes popovers on every mounted
controls instance.
Auto-hide pauses while the pointer is over the controls bar or keyboard focus
is anywhere inside it. Focus entering a hidden bar reveals it; moving focus
between controls does not restart hiding, and leaving the bar resumes the normal
hide delay. In fullscreen playback, hiding the controls also hides the pointer
over both the controls host and the supplied player surface; revealing controls
or destroying the component restores the surface's previous inline cursor.
Open popovers are reconciled against the current capability and state snapshot.
If controls are hidden, a capability is removed, or the corresponding track
list becomes unavailable, the stale popover closes instead of pinning the
controls visible or consuming the next surface click.
Setting `showControls` to false also detaches playback-surface pointer, click,
and double-click handling. A hidden shared-controls instance therefore cannot
reveal, pause, or fullscreen the player underneath another UI layer.
### Timeline scrubbing
Timeline input is previewed locally while the user drags. The slider value,
played progress, accessible value text, and current-time label all render the
preview. The component sends exactly one `seekTo` command on the committed
`change` event, then clears the preview and returns to controller-reported
state. Non-finite values are ignored and finite values are clamped to the
available `[0, duration]` range.
The scrub slider and seek shortcuts require both the `seek` capability and
seekable runtime state. When seek is unsupported, the slider is omitted while
live and recording status remain visible. Volume shortcuts likewise require the
`volume` capability.
When a volume-capable controller first attaches, an existing `localStorage`
volume preference is applied before the first controller snapshot can reconcile
the optimistic value. With no saved preference, the controller snapshot remains
authoritative. If the same controller loses and later regains the volume
capability, initialization runs again for the new capability epoch. The volume
slider intentionally remains continuous: each volume `input` applies the
optimistic volume immediately.
## Web adapter (landed, not wired)
`WebVideoControlsAdapter` can translate an `HTMLVideoElement` into the shared
contract. It uses DOM/media events and accepts optional engine-specific track
accessors through `WebVideoControlsOptions`, so the adapter itself stays usable
in the PWA and does not import a concrete web engine.
Native media events refresh the adapter automatically. A future engine host
must call the public `refresh()` hook after engine-specific getters change
without a corresponding media event, including track lists, corrected duration,
or live/VOD classification. Source, readiness, progress, seeking, and playback
events that can invalidate the snapshot are observed directly.
Audio and subtitle capabilities are advertised only when the injected getter
returns a selectable list and the corresponding setter exists. Track setters
may complete synchronously or asynchronously; the adapter refreshes after
successful completion and contains synchronous throws or rejected promises
while an engine is changing source.
An injected non-`NaN` duration is authoritative, including positive infinity;
`NaN` falls back to the video element. Without an explicit `isLive` accessor,
only positive infinity implies live playback, so unknown duration is not
temporarily mislabeled as live. An attached element with no resource maps to
`idle`, paused preload/warm-up remains playable, and only actively playing media
with insufficient data maps to `loading`.
`web-video-controls.host.ts` contains small attachment/projection helpers for a
future host integration. No Video.js, html5+hls.js, or ArtPlayer component calls
those helpers in #1148.
The rollout symbols are:
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | ----------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Documents the intended rollout default. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view of the default. No runtime player consumes it yet. |
A follow-up web integration must explicitly consume the token, attach the
adapter to the active video element, mount `app-player-controls`, and only then
disable the engine's existing skin.
## Embedded MPV rendering constraints
The shared contract does not replace the existing embedded-MPV renderer or
controls in #1148. Any follow-up embedded adapter must preserve the two current
rendering paths.
### Frame-copy engine
The experimental frame-copy engine uploads helper-produced frames to
`<canvas data-embedded-mpv-frame>`. The canvas is ordinary DOM, so controls,
dialogs, and other DOM layers can stack above it normally. This makes the
frame-copy path overlay-friendly, although the current embedded player still
uses its existing controls until follow-up shared-controls wiring lands.
### Native-view engine
The native MPV surface paints outside Chromium's DOM stacking model. It keeps
the compositor-safe fixed controls dock below the viewport. Modal overlays hide
the native surface with `HIDDEN_BOUNDS`, and control popovers reserve a bottom
cutout so their DOM region remains interactive.
The transparent BrowserWindow / `NSWindowBelow` tunnel-and-backdrop approach is
not the shipped architecture. #1148 does not add transparency changes, backdrop
holes, native fullscreen IPC, native-view attachment APIs, or bounds-tick
machinery.
See [embedded-mpv-native.md](./embedded-mpv-native.md) for the authoritative
renderer, bounds, and platform details.
## Follow-up integrations
The following are design seams, not shipped #1148 behavior:
1. **Web hosts** — mount the component, consume the rollout token, attach
`WebVideoControlsAdapter`, and remove an engine skin only when the shared
controls are active.
2. **Embedded-MPV adapter** — implement `PlayerController` over
`EmbeddedMpvSessionController` without changing session ownership or native
rendering behavior.
3. **Embedded-MPV layout** — use normal DOM layering for frame-copy; retain a
compositor-safe dock for the native-view engine unless that engine's
compositing architecture changes independently.
4. **Background playback** — introduce a persistent player/session host above
route-scoped views. The contract is lifecycle-agnostic, but #1148 does not
add that host or change current teardown behavior.
## File map
Landed in #1148:
```text
libs/ui/playback/src/lib/player-controls/
├── player-controls.model.ts
├── player-controls-defaults.ts
├── player-controls.component.ts
├── player-controls.component.html
├── player-controls.component.scss
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-fullscreen.ts
├── controls-menu-selection.ts
├── controls-menu-state.ts
├── controls-shortcuts.ts
├── controls-surface.ts
├── controls-view-model.ts
├── controls-visibility.ts
├── controls-volume.ts
├── web-player-controls.flag.ts
├── web-video-controls.adapter.ts
├── web-video-controls.host.ts
└── index.ts
```
Focused specs live beside these files. The subtree is exported from
`libs/ui/playback/src/index.ts`.
Not landed in #1148:
- an embedded-MPV `PlayerController` adapter;
- a shared-controls host inside an existing web or embedded player;
- removal/replacement of any current engine skin; and
- persistent/background player ownership.