Files
iptvnator/docs/architecture/player-controls-contract.md
T
aa6ee85d3f 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>
2026-07-16 12:56:30 +02:00

16 KiB

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.

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

┌──────────────────────────────────────────────────────────────────────┐
│ 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.

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 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:

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.