* 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>
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
PlayerControllercontract, default state, and capability presets; - the standalone
app-player-controlspresentation component and its transient-state collaborators; - a generic
WebVideoControlsAdapterplus 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:
togglePlayseekTo/seekBysetVolumesetAudioTrack/setSubtitleTracksetPlaybackSpeedsetAspectRatiotoggleRecording
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; andcontrols-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:
- Web hosts — mount the component, consume the rollout token, attach
WebVideoControlsAdapter, and remove an engine skin only when the shared controls are active. - Embedded-MPV adapter — implement
PlayerControlleroverEmbeddedMpvSessionControllerwithout changing session ownership or native rendering behavior. - 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.
- 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
PlayerControlleradapter; - a shared-controls host inside an existing web or embedded player;
- removal/replacement of any current engine skin; and
- persistent/background player ownership.