mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
f611ea3d7c
commit
c49ea2f6a8
29 files changed
+4185
-236
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user