feat(html-player): add feature-flagged shared controls (#1194)

* feat(html-player): bridge engine state to shared controls

* fix(html-player): avoid HLS subtitle event reentry

* fix(html-player): restore delayed HLS default subtitles

* refactor(html-player): split controls bridge collaborators

* feat(html-player): add feature-flagged shared controls

* test(html-player): cover shared-controls source ownership

* feat(html-player): pass shared-controls playback metadata

* docs(player-controls): describe HTML5 shared-controls bridge

* docs(player-controls): clarify HTML5 rollout effect

* fix(html-player): reveal diagnostics from fullscreen

* fix(html-player): defer HLS event resolution

* refactor(html-player): isolate video element session
This commit is contained in:
4gray authored and GitHub committed 2026-07-16 21:30:41 +02:00
1 parent f611ea3d7c
commit c49ea2f6a8
29 files changed
+4185 -236

No files matched your search

+87 -36
View File
@@ -7,8 +7,7 @@ Embedded MPV rendering and native-view bounds behavior remain documented in
## Current status
The shared-controls foundation from PR #1148 now has its first runtime
consumer:
The shared-controls foundation from PR #1148 now has two runtime consumers:
- the `PlayerController` contract, default state, and capability presets;
- the standalone `app-player-controls` presentation component and its
@@ -17,7 +16,8 @@ consumer:
- a default-off web rollout token;
- the component-scoped `EmbeddedMpvControlsAdapter`;
- an `EmbeddedMpvPlayerComponent` host integration for the frame-copy engine;
and
- a feature-flagged `HtmlVideoPlayerComponent` integration backed by
`WebVideoControlsAdapter` and a player-local engine bridge; and
- focused unit/component tests.
When Embedded MPV reports `engine: 'frame-copy'`, the component mounts
@@ -26,10 +26,17 @@ When Embedded MPV reports `engine: 'frame-copy'`, the component mounts
component keeps the existing compositor-safe controls dock. Exactly one of
those control systems is active at a time.
Video.js, html5+hls.js, and ArtPlayer do not consume the shared layer yet. Their
existing skins remain active, and `WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains
default-off with no runtime web host consuming it. Changing that token alone
does not switch any web player UI.
When `WEB_PLAYER_SHARED_CONTROLS` is enabled, the built-in HTML5 player mounts
the same presentation component over its real player shell and disables the
native video controls. Its local bridge supplies HLS/native tracks, corrected
MPEG-TS VOD duration, and authoritative live/VOD metadata to the generic web
adapter. When the flag is disabled, the native controls and legacy series
navigation remain unchanged and the adapter is not attached.
Video.js and ArtPlayer do not consume the shared layer yet. Their existing
skins remain active, and `WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains
default-off, so the guarded HTML5 integration does not change normal runtime
behavior.
This rollout is intentionally engine-selective: frame-copy can use normal DOM
layering, while the native platform view cannot. The integration also includes
@@ -75,19 +82,21 @@ contract does not make a native video surface behave like DOM content.
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ WebVideoControlsAdapter │ │ EmbeddedMpvControlsAdapter │
│ Landed, generic, not wired │ │ Landed, component-scoped │
└──────────────────────────────┘ └──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ EmbeddedMpvPlayerComponent │
│ frame-copy: shared controls │
│ native-view: legacy dock │
└──────────────────────────────┘
│ Generic, component-scoped │ │ Component-scoped │
└──────────────┬───────────────┘ └──────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ HtmlVideoPlayerComponent │ │ EmbeddedMpvPlayerComponent │
│ flag on: shared controls │ │ frame-copy: shared controls │
│ flag off: native controls │ │ native-view: legacy dock │
└──────────────────────────────┘ └──────────────────────────────┘
```
The embedded host selects controls from the reported engine before rendering
them. It never mounts the shared overlay and legacy dock together.
The HTML5 host likewise selects native or shared controls before rendering and
never attaches the web adapter while the native path is active.
## The contract
@@ -160,8 +169,8 @@ size follows the fullscreen DOM surface.
## Shared default controls
`PlayerControlsComponent` is a standalone presentation component. The
frame-copy Embedded MPV host mounts it over its DOM canvas; future web hosts can
mount the same component over their playback surfaces.
frame-copy Embedded MPV host mounts it over its DOM canvas, and the guarded
HTML5 host mounts it over `.html-video-player-shell`.
It owns only transient presentation behavior:
@@ -218,6 +227,15 @@ a modal/backdrop overlay is active, so transport, seek, volume, and fullscreen
actions cannot leak through it. Escape keeps the shared component's generic
popover-dismissal behavior.
The HTML5 host applies the same ownership rule while its playback diagnostic is
visible: `WebPlayerViewComponent` passes
`interactionEnabled = visiblePlaybackDiagnostic() === null`, and the HTML5
component binds that value to both `showControls` and `shortcutsEnabled`.
If that shell owns DOM fullscreen, the host exits fullscreen before hiding the
controls so the sibling diagnostic banner and its retry/fallback actions remain
visible; fullscreen owned by another element is left untouched. Retrying
playback or clearing the diagnostic restores both interaction paths.
Frame-copy recording transitions use the adapter's playback/session identity as
their `transitionKey`. Session disposal, retry, channel changes, and engine
handoff therefore clear stale recording ownership without showing a false
@@ -245,15 +263,15 @@ 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)
## Web adapter and HTML5 engine bridge
`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
Native media events refresh the adapter automatically. An 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.
@@ -271,20 +289,34 @@ 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.
`HtmlVideoPlayerControlsBridge` attaches the adapter to the HTML5 video element
and delegates engine-specific work to focused HLS and native-text-track
collaborators. HLS track IDs remain the list indices accepted by hls.js. Native
caption/subtitle IDs remain stable for the lifetime of a source through a
`WeakMap`, even when the browser removes or reorders tracks. Source replacement
removes track listeners before the old HLS instance is destroyed, resets any
per-source subtitle override, and leaves exactly one engine source bound.
Live/VOD classification comes from `WebPlayerViewComponent.resolvedIsLive`:
explicit `ResolvedPortalPlayback.isLive` wins, otherwise content metadata means
VOD and its absence means live. The same computed value configures Video.js,
the HTML5 bridge, and mpegts.js; media duration is never used to infer the
classification.
Raw MPEG-TS VOD can expose `video.duration === Infinity`. For that source only,
the bridge uses the first finite positive value from `video.duration`, the last
valid seekable end, or the last valid buffered end. Without a known duration it
keeps the source classified as VOD while seeking remains unavailable.
`web-video-controls.host.ts` still contains small generic
attachment/projection helpers. Video.js and ArtPlayer do not call them yet.
The rollout symbols are:
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | ----------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Documents the intended web rollout default. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view of the default. No runtime web player consumes it. |
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.
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | -------------------------------------------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Keeps existing web-player skins active in normal runtime builds. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view consumed by the HTML5 host to switch atomically between native and shared controls. |
## Embedded MPV rendering constraints
@@ -335,9 +367,9 @@ renderer, bounds, and platform details.
The remaining design seams are:
1. **Web hosts** — mount the component, consume the rollout token, attach
`WebVideoControlsAdapter`, and remove an engine skin only when the shared
controls are active.
1. **Video.js and ArtPlayer** — add engine-specific adapters/bridges, consume
the rollout token, and remove each engine skin only when shared controls are
active.
2. **Native-view UI** — retain the compositor-safe dock unless the native
engine's compositing architecture changes independently. A native-view
migration is not part of the frame-copy rollout.
@@ -388,5 +420,24 @@ libs/ui/playback/src/lib/embedded-mpv-player/
```
The adapter and recording helpers are component-scoped through
`EmbeddedMpvPlayerComponent`. Web host wiring, removal of web engine skins, and
`EmbeddedMpvPlayerComponent`.
The guarded HTML5 integration lives in:
```text
libs/ui/playback/src/lib/html-video-player/
├── html-video-element-session.ts
├── html-video-player-controls.bridge.ts
├── html-video-player-hls-controls.ts
├── html-video-player-native-text-tracks.ts
├── html-video-player.component.ts
└── html-video-player.component.html
```
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`. The bridge and its collaborators are player-local
because HLS/native track identity, caption preference, and cleanup are tied to
one active source. `HtmlVideoElementSession` separately owns native video-event
attachment, persisted volume, start-time/time/ended propagation, and the
flag-off post-play caption behavior. Video.js/ArtPlayer skin removal and
persistent/background player ownership have not landed.
@@ -0,0 +1,620 @@
# HTML5 Shared Controls Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild the useful HTML5 portion of #1152 so the built-in `<video>` player can use shared controls behind the existing default-off feature flag, with correct live/VOD metadata, track lifecycle, diagnostics gating, and cleanup.
**Architecture:** A player-local `HtmlVideoPlayerControlsBridge` owns HLS/native track projection, MPEG-TS VOD duration correction, caption preference, and adapter lifecycle. `HtmlVideoPlayerComponent` owns playback engines and chooses native versus shared chrome from `WEB_PLAYER_SHARED_CONTROLS`; `WebPlayerViewComponent` supplies authoritative playback metadata and interaction availability.
**Tech Stack:** Angular standalone components and signals, hls.js 1.6, mpegts.js, DOM media/text-track APIs, Jest/TestBed, Nx.
---
### Task 1: Add the HTML5 engine-to-controls bridge
**Files:**
- Create: `libs/ui/playback/src/lib/html-video-player/html-video-player-controls.bridge.ts`
- Create: `libs/ui/playback/src/lib/html-video-player/html-video-player-hls-controls.ts`
- Create: `libs/ui/playback/src/lib/html-video-player/html-video-player-native-text-tracks.ts`
- Create: focused `html-video-player-controls.*.spec.ts` suites and
`html-video-player-controls.spec-fixtures.ts`
- Modify: `libs/ui/playback/tsconfig.lib.json` to exclude test-only fixture
modules from the production TypeScript program
- [x] **Step 1: Write failing MPEG-TS duration tests**
Create a video fixture whose `duration`, `seekable`, and `buffered` properties
can be replaced. Cover:
```ts
expect(readState({ duration: 120, seekableEnd: 115 }).durationSeconds).toBe(
120
);
expect(
readState({ duration: Infinity, seekableEnd: 115 }).durationSeconds
).toBe(115);
expect(
readState({
duration: Infinity,
seekableEnd: NaN,
bufferedEnd: 112,
}).durationSeconds
).toBe(112);
expect(
readState({
duration: Infinity,
seekableThrows: true,
bufferedThrows: true,
}).durationSeconds
).toBeNull();
```
Assert all cases remain `isLive: false`, and that `canSeek` is false until a
seekable range exists.
- [x] **Step 2: Run the focused test and verify the red state**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=html-video-player-controls.bridge
```
Expected: failure because `HtmlVideoPlayerControlsBridge` does not exist.
- [x] **Step 3: Add the bridge lifecycle and corrected-duration source**
Define:
```ts
export type HtmlVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls };
export interface HtmlVideoPlayerControlsBridgeConfig {
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
```
Implement an exported class with:
```ts
attach(): void;
setSource(source: HtmlVideoControlsSource): void;
refreshInputs(): void;
clearSource(): void;
destroy(): void;
```
`attach()` must be idempotent and call:
```ts
this.adapter.attach(this.video, {
isLive: this.isLive,
getDuration: () => this.readDuration(),
getAudioTracks: () => this.getAudioTracks(),
setAudioTrack: (id) => this.setAudioTrack(id),
getSubtitleTracks: () => this.getSubtitleTracks(),
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
});
```
For non-live mpegts sources, `readDuration()` must return the first finite,
positive value from the video duration, last seekable end, then last buffered
end. For every other source return `NaN`, allowing the adapter to read the
native element duration.
- [x] **Step 4: Write failing HLS projection and lifecycle tests**
Use a fake HLS object with stable `on`/`off` spies and mutable:
```ts
audioTracks;
audioTrack;
subtitleTracks;
subtitleTrack;
subtitleDisplay;
```
Cover:
- audio IDs are current-list indices and selection follows `audioTrack`;
- subtitle selection requires both `subtitleDisplay` and matching index;
- labels prefer `name`, then `lang`, then `Audio N` / `Subtitle N`;
- valid audio/subtitle choices update HLS;
- subtitle `-1` disables display and selection;
- non-integer, stale, and out-of-range IDs are no-ops;
- adapter refresh occurs for `AUDIO_TRACKS_UPDATED`,
`AUDIO_TRACK_SWITCHING`, `AUDIO_TRACK_SWITCHED`,
`SUBTITLE_TRACKS_UPDATED`, `SUBTITLE_TRACKS_CLEARED`,
`SUBTITLE_TRACK_SWITCH`, and `MANIFEST_LOADING`;
- rebinding unregisters the exact old callback references before the old HLS
instance can be destroyed.
- [x] **Step 5: Implement HLS track mapping and listeners**
Use current-list indices:
```ts
return hls.audioTracks.map((track, index) => ({
id: index,
label: track.name || track.lang || `Audio ${index + 1}`,
selected: index === hls.audioTrack,
}));
```
Use the corresponding subtitle projection:
```ts
selected: hls.subtitleDisplay === true && index === hls.subtitleTrack;
```
Register one named refresh callback per bridge/source and unregister it with
the same function reference. Do not call `hls.off(event)` without a listener.
- [x] **Step 6: Write failing native text-track and preference tests**
Provide a fake `TextTrackList` implementing indexed access and
`addEventListener`/`removeEventListener`. Cover:
- only `captions` and `subtitles` tracks are projected;
- labels prefer `label`, then `language`, then `Subtitle N`;
- IDs remain stable when an earlier track is removed;
- selecting a valid ID shows it and hides other eligible tracks;
- selecting `-1` hides all eligible tracks;
- invalid/stale IDs are no-ops;
- `addtrack`, `removetrack`, and `change` refresh the adapter;
- `showCaptions = false` suppresses a default track arriving later;
- without a user override, returning the preference to true restores the
engine/default showing state that was suppressed;
- explicit selection and explicit off survive later track events and
`refreshInputs()` calls;
- source replacement clears the override and resets per-source IDs; and
- destroy removes all listeners and detaches the adapter exactly once even
when called twice.
- [x] **Step 7: Implement native tracks, caption preference, and cleanup**
Use per-source maps:
```ts
private nativeTrackIds = new WeakMap<TextTrack, number>();
private suppressedNativeModes = new WeakMap<TextTrack, TextTrackMode>();
private nextNativeTrackId = 0;
private subtitleOverride: number | null = null;
```
On preference suppression, remember a track's original mode only once and
hide any showing caption/subtitle. When preference returns to true and no
explicit override exists, restore remembered modes. Track selection sets the
override before changing modes. `clearSource()` resets all per-source maps and
the override, removes listeners, clears the active source, and refreshes the
adapter. `destroy()` calls `clearSource()` and `adapter.detach()` once.
- [x] **Step 8: Run bridge tests**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=html-video-player-controls.bridge
```
Expected: all bridge tests pass.
- [x] **Step 9: Commit the bridge**
```bash
git add \
libs/ui/playback/src/lib/html-video-player/html-video-player-controls.bridge.ts \
libs/ui/playback/src/lib/html-video-player/html-video-player-controls.bridge.spec.ts
git commit -m "feat(html-player): bridge engine state to shared controls"
```
### Task 2: Mount shared controls in the HTML5 player
**Files:**
- Modify: `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
- Modify: `libs/ui/playback/src/lib/html-video-player/html-video-player.component.html`
- Create: focused `html-video-player.component.shared-controls*.spec.ts` suites
and `html-video-player.component.shared-controls.spec-fixtures.ts`
- Modify: `libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts`
- [x] **Step 1: Write failing feature-flag selection tests**
In the default-off suite assert:
```ts
expect(video.controls).toBe(true);
expect(query(By.directive(PlayerControlsComponent))).toBeNull();
expect(queryLegacySeriesControls()).not.toBeNull();
expect(adapter.attach).not.toHaveBeenCalled();
```
In a dedicated flag-on suite override:
```ts
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true }
```
and assert one shared-controls instance, `video.controls === false`, and no
legacy series-controls instance.
- [x] **Step 2: Write failing surface, interaction, and context tests**
For the shared-controls instance assert:
```ts
expect(playerControls.playerSurface()).toBe(
fixture.debugElement.query(By.css('.html-video-player-shell')).nativeElement
);
expect(playerControls.showControls()).toBe(false);
expect(playerControls.shortcutsEnabled()).toBe(false);
```
after setting `interactionEnabled = false`. Change `seriesNavigation` after
initial render and assert the adapter state/capabilities reflect the new value.
- [x] **Step 3: Verify the component tests fail**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='html-video-player.component'
```
Expected: failures because the HTML5 component does not yet import shared
controls or expose the new inputs.
- [x] **Step 4: Add feature-flagged template ownership**
Import and provide:
```ts
(PlayerControlsComponent, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter);
```
Add:
```ts
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
readonly controlsAdapter = inject(WebVideoControlsAdapter);
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
@Input() isLive = true;
@Input() interactionEnabled = true;
```
Use the actual shell as the template surface:
```html
<div #playerRoot class="html-video-player-shell">
<video
#videoPlayer
id="video-player"
autoplay
[controls]="!sharedControls"
></video>
@if (sharedControls) {
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[showControls]="interactionEnabled"
[shortcutsEnabled]="interactionEnabled"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
</div>
```
- [x] **Step 5: Write failing source-order and mpegts metadata tests**
Cover the Angular initial order where `ngOnChanges` starts a source before
`ngOnInit` creates the bridge. Assert that the retained HLS source is bound
after initialization.
For a raw TS channel assert:
```ts
expect(mpegts.createPlayer).toHaveBeenCalledWith({
type: 'mpegts',
isLive: false,
url: expect.any(String),
});
```
when the input is VOD. Replace HLS with native playback and assert old HLS
listeners are removed before `destroy()`.
- [x] **Step 6: Integrate bridge and source lifecycle**
Make `hls` nullable and retain:
```ts
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
private controlsSource: HtmlVideoControlsSource | null = null;
```
In `ngOnInit`, only for the enabled flag:
```ts
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
this.controlsBridge = new HtmlVideoPlayerControlsBridge({
video: this.videoPlayer.nativeElement,
adapter: this.controlsAdapter,
isLive: () => this.isLive,
showCaptions: () => this.showCaptions,
});
this.controlsBridge.attach();
if (this.controlsSource) {
this.controlsBridge.setSource(this.controlsSource);
}
```
At the start of every source replacement:
```ts
this.controlsBridge?.clearSource();
this.controlsSource = null;
```
Then tear down old mpegts/HLS engines. After creating a new engine, assign and
bind exactly one source:
```ts
this.controlsSource = { kind: 'hls', hls: this.hls };
this.controlsBridge?.setSource(this.controlsSource);
```
Use `{ kind: 'mpegts' }` or `{ kind: 'native' }` for the other paths. Pass
`this.isLive` into `mpegts.createPlayer`.
In `ngOnChanges`, update the series signal and call `refreshInputs()` when
`isLive` or `showCaptions` changes. Keep the existing flag-off
`handlePlayOperation()` caption behavior unchanged.
- [x] **Step 7: Make destruction idempotent and ordered**
Destroy the controls bridge before destroying the HLS instance so listener
removal is observable and deterministic:
```ts
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.controlsSource = null;
```
Then perform existing mpegts/HLS teardown and set the engine fields to null.
- [x] **Step 8: Run HTML5 component and bridge tests**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='html-video-player'
```
Expected: all HTML5 suites pass in both flag states.
- [x] **Step 9: Commit the host integration**
```bash
git add libs/ui/playback/src/lib/html-video-player/
git commit -m "feat(html-player): add feature-flagged shared controls"
```
### Task 3: Supply playback metadata and diagnostic interaction gating
**Files:**
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.html`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts`
- Create:
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts`
- [x] **Step 1: Extend the HTML5 test stub and write failing metadata tests**
Add stub inputs:
```ts
readonly isLive = input(true);
readonly interactionEnabled = input(true);
```
Cover:
```ts
{ isLive: false } // explicit VOD wins
{ isLive: true, contentInfo: vodInfo } // explicit live wins
{ contentInfo: vodInfo } // inferred VOD
{} // inferred live
```
Select the HTML5 player and assert the stub receives the resolved value.
- [x] **Step 2: Write failing diagnostic interaction tests**
Select HTML5, emit a playback issue, and assert:
```ts
expect(htmlPlayer.interactionEnabled()).toBe(false);
```
Call `retryPlayback()` and assert it becomes true. Also call
`handlePlaybackIssue(null)` after a new issue and assert interactions are
re-enabled. When the HTML5 player shell owns DOM fullscreen, assert the
diagnostic transition exits that shell; an unrelated fullscreen element must
remain untouched.
- [x] **Step 3: Verify web-player-view tests fail**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=web-player-view.component
```
Expected: failures because the HTML5 inputs and public computed values do not
exist.
- [x] **Step 4: Add shared authoritative computed values**
Replace the private duplicated classification helper with:
```ts
readonly resolvedIsLive = computed(() => {
const playback = this.resolvedPlayback();
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
});
readonly playbackInteractionEnabled = computed(
() => this.visiblePlaybackDiagnostic() === null
);
```
Read `resolvedIsLive()` inside the constructor effect and use it for
`setVjsOptions`. Use it again in `retryPlayback()`.
- [x] **Step 5: Pass values to HTML5**
Add:
```html
[isLive]="resolvedIsLive()" [interactionEnabled]="playbackInteractionEnabled()"
```
to the `app-html-video-player` branch.
- [x] **Step 6: Run focused view and HTML5 tests**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='web-player-view.component|html-video-player'
```
Expected: all selected suites pass.
- [x] **Step 7: Commit metadata and gating**
```bash
git add \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.html \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
git commit -m "feat(html-player): use authoritative playback metadata"
```
### Task 4: Document and validate the HTML5 consumer
**Files:**
- Modify: `docs/architecture/player-controls-contract.md`
- Modify: `AGENTS.md`
- Modify: `CLAUDE.md`
- Add: `docs/superpowers/specs/2026-07-16-html5-shared-controls-design.md`
- Add: `docs/superpowers/plans/2026-07-16-html5-shared-controls.md`
- [x] **Step 1: Update canonical player-controls documentation**
Document:
- HTML5 is the second shared-controls consumer, behind the default-off web
feature flag;
- the player-local bridge owns HLS/native tracks and MPEG-TS duration;
- live/VOD state comes from resolved playback metadata;
- diagnostics disable shared pointer and keyboard ownership; and
- Video.js/ArtPlayer remain future migrations.
- [x] **Step 2: Mirror the ownership contract**
Update the shared-player-controls sections in `AGENTS.md` and `CLAUDE.md` with
the same behavior and key file paths. Keep the mirrored process text
consistent.
- [x] **Step 3: Run the complete validation ladder**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand
pnpm nx lint ui-playback --skip-nx-cache
pnpm run typecheck:ci
pnpm run i18n:check
pnpm exec prettier --check \
libs/ui/playback/src/lib/html-video-player \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.html \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts \
docs/architecture/player-controls-contract.md \
docs/superpowers/specs/2026-07-16-html5-shared-controls-design.md \
docs/superpowers/plans/2026-07-16-html5-shared-controls.md \
AGENTS.md CLAUDE.md
git diff --check
```
Expected: every command exits successfully.
- [x] **Step 4: Perform the test-impact pass**
Record that:
- `ui-playback` unit tests cover the flag switch, track lifecycle, metadata,
diagnostics, and cleanup;
- no new runtime behavior is enabled by default;
- broad Electron E2E is deferred to PR CI because the shared-controls flag is
compile-time default-off; and
- manual visual verification is required before enabling the flag globally,
not before merging this guarded integration.
Recorded on 2026-07-16:
- `ui-playback`: 51 suites and 492 tests passed;
- `ui-playback` lint, repository web/backend typecheck, i18n drift check,
Prettier, and `git diff --check` passed;
- no E2E suite was added because the compile-time rollout flag remains
default-off and the existing runtime workflow is unchanged; and
- visual/manual playback verification remains a prerequisite for changing the
rollout default, not for merging this guarded consumer.
- [x] **Step 5: Commit docs and final validation fixes**
```bash
git add \
docs/architecture/player-controls-contract.md \
docs/superpowers/specs/2026-07-16-html5-shared-controls-design.md \
docs/superpowers/plans/2026-07-16-html5-shared-controls.md \
AGENTS.md CLAUDE.md
git commit -m "docs(player-controls): describe HTML5 shared-controls bridge"
```
- [x] **Step 6: Review the complete branch**
Run a spec-compliance review followed by a code-quality review against
`origin/master...HEAD`. Resolve every important finding, rerun affected tests,
then request GitHub Codex and Greptile reviews after opening the fresh PR.
@@ -0,0 +1,259 @@
# HTML5 Shared Controls Design
## Status
Implemented and branch-validated as part of the
embedded-MPV/shared-controls merge plan. This rebuilds the unique HTML5
integration proposed in #1152 on top of the merged shared-controls contract and
frame-copy implementation, without merging the obsolete stacked history from
#1150/#1151. The guarded integration is pending pull-request review and merge;
the rollout default remains off.
## Goal
Use `app-player-controls` as the optional controls UI for the built-in HTML5
player while preserving its current native controls and behavior when
`WEB_PLAYER_SHARED_CONTROLS` is disabled.
## Non-goals
- Do not enable `WEB_PLAYER_SHARED_CONTROLS` by default.
- Do not change the generic `PlayerController` or
`WebVideoControlsAdapter` contracts.
- Do not infer live/VOD state from `HTMLVideoElement.duration`.
- Do not migrate Video.js or ArtPlayer in this PR.
- Do not redesign playback diagnostics or replace their existing banner.
- Do not add engine-specific knowledge to the generic shared-controls adapter.
## Why the old #1152 commit is not merged directly
The old commit correctly identified the HTML5 `<video>` element as a natural
consumer of `WebVideoControlsAdapter`, but its implementation predates the
current playback metadata, diagnostic overlay, MPEG-TS VOD handling, and
track-lifecycle requirements. In particular, it:
- classified live playback from `video.duration`;
- attached only a bare video adapter with no HLS/native track bridge;
- did not cleanly rebind engine listeners on source replacement;
- did not disable interactions while playback diagnostics own the UI; and
- used the custom-element host instead of the actual player shell as the
fullscreen and pointer surface.
The new implementation keeps the useful feature-flagged UI switch while
rebuilding its lifecycle against the current architecture.
## Architecture
### Authoritative playback classification
`WebPlayerViewComponent` exposes one `resolvedIsLive` computed value:
1. an explicit `ResolvedPortalPlayback.isLive` value wins;
2. otherwise playback with `contentInfo` is VOD; and
3. playback without `contentInfo` is live.
The same value configures Video.js and is passed to
`HtmlVideoPlayerComponent`. The HTML5 player passes it to mpegts.js and to the
shared-controls adapter. Media duration is never used to decide whether a
source is live.
### HTML5 controls bridge
Add a player-local `HtmlVideoPlayerControlsBridge`. It owns all glue between
the engine-specific source lifecycle and the engine-agnostic
`WebVideoControlsAdapter`:
```ts
type HtmlVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls };
interface HtmlVideoPlayerControlsBridgeConfig {
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
```
The bridge:
- attaches the adapter once;
- supplies authoritative live/VOD and corrected duration accessors;
- projects HLS audio and subtitle tracks into shared `PlayerTrack` values;
- projects native text tracks for native and mpegts.js sources;
- refreshes the adapter when engine track state changes;
- applies caption preference until the user makes an explicit per-source
subtitle choice;
- removes listeners before an HLS instance is destroyed; and
- supports idempotent source clearing and destruction.
The implementation keeps the bridge focused by delegating HLS projection and
caption policy to `HtmlVideoPlayerHlsControls`, and native text-track identity
and policy to `HtmlVideoPlayerNativeTextTracks`.
`HtmlVideoPlayerComponent` remains responsible for creating and destroying
native, HLS, and mpegts.js playback engines. Before replacing a source it asks
the bridge to clear the current source, then tears down the old engine. Once
the new engine exists it binds the corresponding source to the bridge.
`HtmlVideoElementSession` owns the native video-element listeners, persisted
volume, start-time/time/ended propagation, and the legacy post-play caption
behavior. It initializes lazily because initial `ngOnChanges` may start
playback before `ngOnInit` attaches the native event listeners.
Angular calls the initial `ngOnChanges` before `ngOnInit`, so the component
retains the current controls source even when playback starts before the bridge
is created. `ngOnInit` creates the bridge, attaches the adapter, and binds that
retained source.
### MPEG-TS VOD duration
Raw MPEG-TS VOD can leave `video.duration` at `Infinity`. For a non-live
mpegts.js source, the corrected duration accessor uses the first finite,
positive value from:
1. `video.duration`;
2. the last finite, positive `video.seekable.end(index)` value; and
3. the last finite, positive `video.buffered.end(index)` value.
Ranges are scanned from the end and accessor failures are ignored. If no
duration is known, the accessor returns `NaN`. The adapter still classifies the
source as VOD, but disables seeking until a positive duration and seekable
range are available.
### HLS tracks
Shared track IDs are HLS list indices because the hls.js setters accept list
indices.
Audio tracks:
- map `hls.audioTracks`;
- select the item whose index equals `hls.audioTrack`;
- label with `name`, then `lang`, then a translated-neutral numbered fallback;
- reject non-integer and out-of-range selections; and
- refresh on `AUDIO_TRACKS_UPDATED`, `AUDIO_TRACK_SWITCHING`, and
`AUDIO_TRACK_SWITCHED`.
Subtitle tracks:
- map `hls.subtitleTracks`;
- mark a track selected only when `hls.subtitleDisplay` is true and its index
equals `hls.subtitleTrack`;
- selecting `-1` sets `subtitleTrack = -1` and disables
`subtitleDisplay`;
- selecting a valid index enables `subtitleDisplay` and assigns the index;
- reject other invalid values; and
- refresh on `SUBTITLE_TRACKS_UPDATED`, `SUBTITLE_TRACKS_CLEARED`, and
`SUBTITLE_TRACK_SWITCH`.
The bridge also refreshes on `MANIFEST_LOADING`, because the installed hls.js
version clears its internal audio/subtitle groups at manifest start without
emitting an audio-track-cleared event.
### Native text tracks
Native and mpegts.js sources expose `video.textTracks`. The bridge includes
only `subtitles` and `captions` tracks and assigns stable numeric IDs through a
per-source `WeakMap<TextTrack, number>`, so removals or reordering do not change
the identity of remaining tracks.
It listens for `addtrack`, `removetrack`, and `change`. Selecting a valid ID
sets that track to `showing` and hides other caption/subtitle tracks. Selecting
`-1` hides all of them. A stale or invalid ID is a no-op.
### Caption preference and user override
Each source starts without an explicit subtitle override:
- `showCaptions = false` actively hides default tracks, including tracks added
after playback starts;
- `showCaptions = true` preserves the engine-selected/default state and does
not arbitrarily select the first track. If the bridge previously suppressed
that state while the preference was off, it restores the remembered engine
modes when the preference returns to on.
Once the user selects a subtitle track or explicitly turns subtitles off, that
choice wins over later track events and settings emissions for the rest of the
source. Replacing or clearing the source resets the override.
The feature-flag-off path keeps the current post-play caption behavior
unchanged.
### Shared-controls host and interaction ownership
When `WEB_PLAYER_SHARED_CONTROLS` is enabled,
`HtmlVideoPlayerComponent`:
- removes the native video skin;
- renders exactly one `app-player-controls`;
- passes `.html-video-player-shell` as `playerSurface`;
- forwards series-navigation events; and
- uses a reactive signal for series-navigation context.
When the flag is disabled, the native video controls and existing standalone
series-navigation controls remain unchanged and the adapter is never attached.
`WebPlayerViewComponent` derives
`playbackInteractionEnabled = visiblePlaybackDiagnostic() === null` and passes
it to the HTML5 player. The HTML5 shared-controls instance binds both
`showControls` and `shortcutsEnabled` to that input. This hides the bar,
detaches click/double-click ownership, and disables shortcuts while the
diagnostic banner is active. Retrying or clearing the diagnostic re-enables
interactions. Because the diagnostic banner is outside the HTML5 fullscreen
shell, disabling interactions also exits fullscreen when that shell is the
current fullscreen owner; unrelated fullscreen elements are not affected.
## Error handling and cleanup
- Invalid or stale track selections are ignored.
- Track/range accessors tolerate transient browser exceptions.
- Old HLS listeners are removed before `hls.destroy()`.
- `clearSource()` and `destroy()` are idempotent.
- Destroying the component removes native media listeners, destroys active
playback engines, removes text/HLS track listeners, and detaches the adapter.
## Testing
### Bridge unit coverage
- MPEG-TS duration priority, invalid ranges, and throwing range accessors;
- HLS audio/subtitle projection, selection, off state, and invalid IDs;
- HLS update/switch refresh;
- native text-track filtering, stable IDs, selection, and off state;
- native add/remove/change refresh;
- suppression of late default captions when the preference is off;
- preservation of explicit selection/off across later events and preference
changes;
- source replacement resetting the override;
- old HLS listener removal on rebind; and
- listener/adapter cleanup with idempotent destroy.
### Component integration coverage
- flag on: one shared-controls instance, no native skin or legacy series
controls;
- flag off: native skin and legacy series controls, no adapter attachment;
- the player shell is the interaction/fullscreen surface;
- the interaction input gates both shared-controls inputs;
- a diagnostic exits only the HTML5 player's own fullscreen shell;
- series-navigation context remains reactive;
- mpegts.js receives authoritative live/VOD metadata; and
- source replacement does not retain old HLS tracks/listeners.
### Web-player-view coverage
- explicit `isLive` wins;
- otherwise `contentInfo` means VOD and its absence means live;
- the resolved value reaches the HTML5 player;
- a visible diagnostic disables HTML5 shared interactions; and
- retrying or clearing the diagnostic re-enables them.
## Documentation impact
Update `docs/architecture/player-controls-contract.md`, `AGENTS.md`, and
`CLAUDE.md` to document the feature-flagged HTML5 consumer, authoritative
live/VOD metadata, engine track bridge, and diagnostic interaction gating.
The root README remains unchanged while the feature flag defaults to off.