Files
iptvnator/docs/superpowers/plans/2026-07-16-html5-shared-controls.md
T
4gray c49ea2f6a8 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
2026-07-16 21:30:41 +02:00

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

  • 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 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.

  • 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 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.

  • 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.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

  • 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-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.

  • 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.