* 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
19 KiB
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.tssuites andhtml-video-player-controls.spec-fixtures.ts -
Modify:
libs/ui/playback/tsconfig.lib.jsonto exclude test-only fixture modules from the production TypeScript program -
Step 1: Write failing MPEG-TS duration tests
Create a video fixture whose duration, seekable, and buffered properties
can be replaced. Cover:
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.
- Step 2: Run the focused test and verify the red state
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=html-video-player-controls.bridge
Expected: failure because HtmlVideoPlayerControlsBridge does not exist.
- Step 3: Add the bridge lifecycle and corrected-duration source
Define:
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:
attach(): void;
setSource(source: HtmlVideoControlsSource): void;
refreshInputs(): void;
clearSource(): void;
destroy(): void;
attach() must be idempotent and call:
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.
- Step 4: Write failing HLS projection and lifecycle tests
Use a fake HLS object with stable on/off spies and mutable:
audioTracks;
audioTrack;
subtitleTracks;
subtitleTrack;
subtitleDisplay;
Cover:
-
audio IDs are current-list indices and selection follows
audioTrack; -
subtitle selection requires both
subtitleDisplayand matching index; -
labels prefer
name, thenlang, thenAudio N/Subtitle N; -
valid audio/subtitle choices update HLS;
-
subtitle
-1disables 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, andMANIFEST_LOADING; -
rebinding unregisters the exact old callback references before the old HLS instance can be destroyed.
-
Step 5: Implement HLS track mapping and listeners
Use current-list indices:
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:
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.
- Step 6: Write failing native text-track and preference tests
Provide a fake TextTrackList implementing indexed access and
addEventListener/removeEventListener. Cover:
-
only
captionsandsubtitlestracks are projected; -
labels prefer
label, thenlanguage, thenSubtitle N; -
IDs remain stable when an earlier track is removed;
-
selecting a valid ID shows it and hides other eligible tracks;
-
selecting
-1hides all eligible tracks; -
invalid/stale IDs are no-ops;
-
addtrack,removetrack, andchangerefresh the adapter; -
showCaptions = falsesuppresses 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.
-
Step 7: Implement native tracks, caption preference, and cleanup
Use per-source maps:
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.
- Step 8: Run bridge tests
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=html-video-player-controls.bridge
Expected: all bridge tests pass.
- Step 9: Commit the bridge
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.tssuites andhtml-video-player.component.shared-controls.spec-fixtures.ts -
Modify:
libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts -
Step 1: Write failing feature-flag selection tests
In the default-off suite assert:
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:
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true }
and assert one shared-controls instance, video.controls === false, and no
legacy series-controls instance.
- Step 2: Write failing surface, interaction, and context tests
For the shared-controls instance assert:
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.
- Step 3: Verify the component tests fail
Run:
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.
- Step 4: Add feature-flagged template ownership
Import and provide:
(PlayerControlsComponent, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter);
Add:
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:
<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>
- 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:
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().
- Step 6: Integrate bridge and source lifecycle
Make hls nullable and retain:
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
private controlsSource: HtmlVideoControlsSource | null = null;
In ngOnInit, only for the enabled flag:
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:
this.controlsBridge?.clearSource();
this.controlsSource = null;
Then tear down old mpegts/HLS engines. After creating a new engine, assign and bind exactly one source:
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.
- Step 7: Make destruction idempotent and ordered
Destroy the controls bridge before destroying the HLS instance so listener removal is observable and deterministic:
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.controlsSource = null;
Then perform existing mpegts/HLS teardown and set the engine fields to null.
- Step 8: Run HTML5 component and bridge tests
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='html-video-player'
Expected: all HTML5 suites pass in both flag states.
- Step 9: Commit the host integration
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 -
Step 1: Extend the HTML5 test stub and write failing metadata tests
Add stub inputs:
readonly isLive = input(true);
readonly interactionEnabled = input(true);
Cover:
{ 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.
- Step 2: Write failing diagnostic interaction tests
Select HTML5, emit a playback issue, and assert:
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.
- Step 3: Verify web-player-view tests fail
Run:
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.
- Step 4: Add shared authoritative computed values
Replace the private duplicated classification helper with:
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().
- Step 5: Pass values to HTML5
Add:
[isLive]="resolvedIsLive()" [interactionEnabled]="playbackInteractionEnabled()"
to the app-html-video-player branch.
- Step 6: Run focused view and HTML5 tests
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='web-player-view.component|html-video-player'
Expected: all selected suites pass.
- Step 7: Commit metadata and gating
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 -
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.
-
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.
- Step 3: Run the complete validation ladder
Run:
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.
- Step 4: Perform the test-impact pass
Record that:
ui-playbackunit 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-playbacklint, repository web/backend typecheck, i18n drift check, Prettier, andgit diff --checkpassed; -
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.
-
Step 5: Commit docs and final validation fixes
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"
- 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.