* 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
10 KiB
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_CONTROLSby default. - Do not change the generic
PlayerControllerorWebVideoControlsAdaptercontracts. - 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:
- an explicit
ResolvedPortalPlayback.isLivevalue wins; - otherwise playback with
contentInfois VOD; and - playback without
contentInfois 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:
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
PlayerTrackvalues; - 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:
video.duration;- the last finite, positive
video.seekable.end(index)value; and - 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, thenlang, then a translated-neutral numbered fallback; - reject non-integer and out-of-range selections; and
- refresh on
AUDIO_TRACKS_UPDATED,AUDIO_TRACK_SWITCHING, andAUDIO_TRACK_SWITCHED.
Subtitle tracks:
- map
hls.subtitleTracks; - mark a track selected only when
hls.subtitleDisplayis true and its index equalshls.subtitleTrack; - selecting
-1setssubtitleTrack = -1and disablessubtitleDisplay; - selecting a valid index enables
subtitleDisplayand assigns the index; - reject other invalid values; and
- refresh on
SUBTITLE_TRACKS_UPDATED,SUBTITLE_TRACKS_CLEARED, andSUBTITLE_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 = falseactively hides default tracks, including tracks added after playback starts;showCaptions = truepreserves 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-shellasplayerSurface; - 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()anddestroy()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
isLivewins; - otherwise
contentInfomeans 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.