hls.js
The current HLS playback engine — latency profile, error recovery, snapshot poster fallback.
hls.js (^1.6.16) is the default video playback engine in the dashboard. Every
video tile — HoloVids grid, Holoprojector panel, public viewer at /p/<token> — uses
hls.js unless the operator explicitly opts into the low-latency engine (which is still
in-flight; see Low-latency player).
The upstream source is always the same shape: a .m3u8 URL served by the Restreamer
(datarhei/core) at RESTREAMER_API_URL/<feed-id>/index.m3u8. The dashboard never
generates manifests itself.
Why hls.js (and not native HLS)
Safari ships native HLS via <video src="...m3u8">. We don't rely on it because:
- The HUD overlay needs the same code path on every browser, and Safari's native player won't surface granular buffer/error events to a React layer in a uniform way.
- Snapshot-as-poster fallback requires manifest-load and fragment-load hooks; native HLS exposes a much sparser event set.
- Telemetry — first-frame, rebuffer count, dropped frames — has to land in a cross-browser path so the planned 3-second first-frame watchdog (Phase 2 of the low-latency player) works the same everywhere.
The single exception: when Hls.isSupported() returns false (older iOS WebViews), we
fall back to native HLS via <video src> with no HUD instrumentation.
Where it lives in src/
| File | Role |
|---|---|
src/components/restreaming/PanelViewer.tsx | The base player — initializes Hls, attaches to the <video>, owns the snapshot-poster fallback |
src/components/restreaming/PanelViewerWithHistory.tsx | Same player + the snapshot-history strip below the video |
src/components/restreaming/SnapshotHistoryTab.tsx | Side panel that browses ReductStore snapshots, jumps the player to the matching timestamp |
src/components/restreaming/HoloVidsAssignmentEditor.tsx | Layout-time picker — uses a smaller PanelViewer to preview each candidate feed |
src/lib/restreamer.ts | Builds the .m3u8 URL for a given feed (centralized so RESTREAMER_API_URL is read once) |
Player config
The hls.js instance is configured for low-latency-leaning behavior, accepting some fragility in exchange for lower glass-to-glass delay:
// effective config (paraphrased — see PanelViewer.tsx for the canonical form)
new Hls({
lowLatencyMode: true, // honor #EXT-X-PART tags if upstream emits them
liveSyncDuration: 3, // target 3s behind live edge
liveMaxLatencyDuration: 8, // bail to seek-to-live if we drift past 8s
maxBufferLength: 6, // keep 6s of forward buffer
backBufferLength: 0, // no back-buffer — we never seek backward on live
enableWorker: true, // demux/transmux in a Web Worker
capLevelToPlayerSize: true, // never pull a rendition larger than the tile
});The Restreamer's HLS output is single-rendition for now (no ABR ladder), so
capLevelToPlayerSize is a future-proofing flag — it does nothing until a multi-bitrate
output is configured upstream.
Snapshot-poster fallback
The dashboard never shows a black video tile. The flow:
flowchart LR
A[Mount tile] --> B[Render last snapshot as poster]
B --> C[Hls.loadSource]
C --> D{Manifest OK?}
D -- yes --> E[First fragment plays, poster fades]
D -- no, error --> F[Keep poster, schedule retry]
F --> G{Retry budget left?}
G -- yes --> C
G -- no --> H[Mark tile degraded]The poster image is the most recent snapshot for the feed, fetched from
/api/restreaming/snapshots/latest?feedId=<id>. If no snapshot exists, the tile shows
a static placeholder.
Error recovery
hls.js classifies errors as NETWORK_ERROR, MEDIA_ERROR, or OTHER_ERROR. The
dashboard handles the first two and surfaces the third:
| Error class | Handler |
|---|---|
NETWORK_ERROR (manifest 404, fragment 404, CORS) | Backoff retry up to 5 attempts, then mark degraded |
MEDIA_ERROR (decode failure, codec switch) | Call hls.recoverMediaError() once; if it recurs, destroy + recreate |
OTHER_ERROR | Log, surface in the HUD's status pill, don't auto-retry |
The retry budget resets when a fragment successfully plays. This means a feed that disconnects every 30 seconds (e.g. a flaky cellular backhaul) will keep retrying forever — by design; the operator sees a flapping "reconnecting" pill instead of a permanently-dead tile.
Visibility-gated tear-down
Tiles that are not visible (off-screen scroll, hidden behind a tab, page hidden via
document.visibilityState) destroy their Hls instance. They re-create on
re-visibility. This is enforced by an IntersectionObserver wrapping each tile.
Why: 16 simultaneous HLS workers on a 4×4 holoprojector grid pegged worker1's CPU. After visibility gating, only the visible tiles pay the cost.
The destroy/re-create cycle has a ~500ms cold-start cost. Operators who scroll rapidly through the HoloVids grid will see the tile "blink" as it tears down and rebuilds. This is acceptable — the alternative is a hot grid that melts the host.
What hls.js does not do
- Not used for the snapshot history scrub. That's a sequence of static JPEGs pulled from ReductStore, not an HLS playlist.
- Not used for the public group viewer's preview tiles. Those use the poster image only; the player only activates when a tile is expanded.
- Not used for KLV telemetry. hls.js does not expose private MPEG-TS PIDs; KLV demux is the job of the low-latency player.
Extension points
- Adding a new tile surface — import
PanelViewerand pass it a feed ID; do not reach for<video>+new Hls()directly. - Changing latency profile — edit the config block in
PanelViewer.tsx. Do not set per-tile overrides via props; latency tuning is global. - Adding a telemetry hook — subscribe to
Hls.Events.FRAG_LOADED/Hls.Events.ERRORinPanelViewer.tsxand emit through the existingnotify.tschannel.
See also
- Low-latency player — opt-in sub-second engine for the public viewer
- Restreaming — the plane that owns feeds and panels
- QA Runbook — operator-side ffprobe/gstreamer commands for diagnosing feed issues