Skip to content

feat(playback): add Ferrite (WASM/WebCodecs) inline player for software HEVC#1111

Closed
multiduplikator wants to merge 1 commit into
4gray:masterfrom
multiduplikator:feat/ferrite-1.3.4
Closed

feat(playback): add Ferrite (WASM/WebCodecs) inline player for software HEVC#1111
multiduplikator wants to merge 1 commit into
4gray:masterfrom
multiduplikator:feat/ferrite-1.3.4

Conversation

@multiduplikator

Copy link
Copy Markdown

What

Adds Ferrite — a <canvas> + WebAssembly/WebCodecs player (ferrite.js) — as a fourth inline web/PWA player, so HEVC plays where the browser has no hardware decoder (the current gap: <video> + hls.js/mpegts.js can't software-decode HEVC). It has an automatic WebCodecs hardware tier and falls back to the default inline player for HLS (.m3u/.m3u8).

Requires a cross-origin-isolated context (COOP/COEP) for SharedArrayBuffer; when isolation is off it degrades gracefully with an explicit unsupported banner (no black screen).

How it's wired

  • FerritePlayerComponent (same component I/O as HtmlVideoPlayerComponent, rendering to a canvas) with the separable concerns split out — ferrite-session, ferrite-event-wiring, ferrite-controls, ferrite-debug-overlay, overlay-interactions.
  • Slotted into WebPlayerViewComponent's selectedPlayer() ladder as VideoPlayer.Ferrite; teardown always via the facade's canonical destroy() (releases the transferred OffscreenCanvas + clears all listeners).
  • IPTVnator-owned Material controls (play/mute/volume/fullscreen, VOD seek bar, software-tier deinterlace select, and a "Dyna" audio-dynamics select) + a long-press diagnostic overlay (surfaces isolated: NO on a devtools-less PWA).
  • Settings + command-palette registration, i18n (23 locales), the diagnostics model, and asset bundling with an opt-in nx serve web --configuration=ferrite that adds the COOP/COEP headers for local testing. No change to any existing player path.

ferrite.js 1.3.4

Bumps the engine to 1.3.4, which moves to a four-worker topology (demux · video-decode · audio-decode · present). The four self-contained worker chunks are copied into the served assets and fed to the facade via its {worker,presentWorker,audioWorker,demuxWorker}Url overrides — the esbuild builder does not rewrite a dependency's internal new Worker(new URL('./x.js', import.meta.url)), so the explicit same-origin URLs are required for the isolated context. Audio now runs off the main thread (an AudioWorklet-driven master clock), and A/V sync uses an mpv-faithful display-sync corrector (tight lip-sync after pause/resume).

Review history

Supersedes #1098 (and the earlier #1092 / #1091). This is a clean, rebased, single-commit re-submission so the review bot gets a fresh pass. All prior findings are incorporated: no hardcoded BACKEND_URL; canvasKey keys on the effective URL (url + epgParams); formatPlayer has the 'ferrite' arm; the ferrite dev-serve uses the development build. On the earlier soft note about ferrite-player.component.ts length — the separable concerns are already extracted into the five sibling files above, and what remains is the cohesive lifecycle core (it passes lint; there is no max-lines rule).

Testing

  • nx lint ui-playback clean; nx test ui-playback green (140 tests, incl. the ferrite specs — the component spec locks the four-worker wiring).
  • Local smoke test in a cross-origin-isolated dev-serve: live + VOD HEVC playback, pause/resume lip-sync, channel-zap teardown (no orphaned workers), and the HLS → default-player fallback.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds Ferrite — a <canvas> + WASM/WebCodecs inline player — as a fourth web/PWA inline player option for software HEVC decoding where the browser has no hardware decoder. It slots into the existing WebPlayerViewComponent player-selection ladder, adds Material-based on-canvas controls, a long-press diagnostic overlay, settings/command-palette registration, 23-locale i18n, and an opt-in dev-serve configuration with the required COOP/COEP headers.

  • New FerritePlayerComponent renders to a <canvas> keyed by effective URL, wires a four-worker ferrite.js facade (demux/video-decode/audio-decode/present) with auto-idle controls, VOD seek, deinterlace and audio-dynamics selects, and a long-press debug panel that surfaces crossOriginIsolated state on devtools-less PWA devices.
  • Fallback logic in selectedPlayer() drops Ferrite back to VideoJs for .m3u/.m3u8 sources; when COOP/COEP is absent the facade emits an error that surfaces the existing external-fallback banner rather than a black screen.
  • Dev-serve ferrite configuration copies the four worker chunks and WASM engine assets into the served assets/ directory, and sets COOP/COEP headers on the dev server for local cross-origin-isolation testing.

Confidence Score: 4/5

The new Ferrite player path is additive and fully isolated from all existing player paths — no existing playback flow is modified. The canvas-keyed channel-zap teardown, outside-zone player creation, and graceful degradation when crossOriginIsolated is absent are all implemented correctly and covered by the new test suite.

The component file sits above the project's 300-line style guideline (346 lines), and the dev-serve config opens the dev server to all network interfaces with unrestricted host matching. Neither is a runtime correctness issue, but both warrant follow-up before the config pattern is copied elsewhere.

ferrite-player.component.ts (line-budget overage) and apps/web/project.json (the ferrite serve config's host/allowedHosts settings) are the two spots worth a second look before merge.

Important Files Changed

Filename Overview
libs/ui/playback/src/lib/ferrite-player/ferrite-player.component.ts New 346-line component — above the repo's 300-line soft limit; otherwise architecturally sound with correct zone isolation, keyed canvas, and teardown lifecycle.
libs/ui/playback/src/lib/ferrite-player/ferrite-session.ts Clean session factory — player creation outside Angular zone, self-removing VOD-resume MEDIA_INFO handler, best-effort teardown with destroy().
libs/ui/playback/src/lib/ferrite-player/ferrite-event-wiring.ts Well-structured event adapter — TIME_UPDATE throttled to ~4 Hz, debug clock writes are panel-gated, fatal vs. non-fatal error distinction is explicit.
apps/web/project.json Adds asset-copy rules for four ferrite.js worker chunks and WASM engine, plus a dev-serve 'ferrite' config with COOP/COEP headers — but uses allowedHosts:true + host:0.0.0.0 which is broadly permissive.
libs/ui/playback/src/lib/ferrite-player/ferrite-controls.component.ts Purely presentational controls bar — no playback state owned, scrub-vs-track-click distinction handled correctly, deint/dyna selects gated correctly.
libs/ui/playback/src/lib/ferrite-player/overlay-interactions.ts Clean framework-free pointer wiring — idle timer, long-press on canvas only, full cleanup returned as a teardown function.
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts selectedPlayer() computed correctly falls back to VideoJs for .m3u/.m3u8 sources; formatPlayer switch, isLive signal, and FerritePlayerComponent import all added cleanly.
libs/shared/interfaces/src/lib/settings.interface.ts Adds VideoPlayer.Ferrite = 'ferrite' enum member with a descriptive JSDoc comment.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User
    participant WPV as WebPlayerViewComponent
    participant FPC as FerritePlayerComponent
    participant FSS as ferrite-session.ts
    participant FEW as ferrite-event-wiring.ts
    participant Lib as ferrite.js facade

    U->>WPV: select channel (Ferrite player)
    WPV->>WPV: selectedPlayer() computed (falls back to VideoJs if .m3u/.m3u8)
    WPV->>FPC: "@defer load + [channel] input"
    FPC->>FPC: "canvasKey() recomputes - @for renders canvas"
    FPC->>FSS: "startFerriteSession({url, canvas, ...})"
    FSS->>Lib: Ferrite.createPlayer() outside Angular zone
    FSS->>FEW: wireFerriteEvents(player, url, sinks)
    FEW->>Lib: player.on(ERROR/MEDIA_INFO/TIME_UPDATE/...)
    FSS->>Lib: attachCanvas(canvas)
    FSS->>Lib: load() + play()
    Lib-->>FEW: MEDIA_INFO event
    FEW-->>FPC: tier/format signals updated (zone.run)
    Lib-->>FEW: TIME_UPDATE (throttled ~4Hz)
    FEW-->>FPC: currentTime/duration signals + timeUpdate output (zone.run)
    Lib-->>FEW: ERROR (fatal)
    FEW-->>FPC: playbackIssue output (zone.run)
    U->>FPC: channel zap
    FPC->>FPC: teardown() - stopFerriteSession()
    FPC->>Lib: player.destroy() (terminates workers, releases OffscreenCanvas)
    FPC->>FPC: "canvasKey() recomputes - @for destroys old canvas, creates new"
    FPC->>FSS: startFerriteSession (new URL + fresh canvas)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as User
    participant WPV as WebPlayerViewComponent
    participant FPC as FerritePlayerComponent
    participant FSS as ferrite-session.ts
    participant FEW as ferrite-event-wiring.ts
    participant Lib as ferrite.js facade

    U->>WPV: select channel (Ferrite player)
    WPV->>WPV: selectedPlayer() computed (falls back to VideoJs if .m3u/.m3u8)
    WPV->>FPC: "@defer load + [channel] input"
    FPC->>FPC: "canvasKey() recomputes - @for renders canvas"
    FPC->>FSS: "startFerriteSession({url, canvas, ...})"
    FSS->>Lib: Ferrite.createPlayer() outside Angular zone
    FSS->>FEW: wireFerriteEvents(player, url, sinks)
    FEW->>Lib: player.on(ERROR/MEDIA_INFO/TIME_UPDATE/...)
    FSS->>Lib: attachCanvas(canvas)
    FSS->>Lib: load() + play()
    Lib-->>FEW: MEDIA_INFO event
    FEW-->>FPC: tier/format signals updated (zone.run)
    Lib-->>FEW: TIME_UPDATE (throttled ~4Hz)
    FEW-->>FPC: currentTime/duration signals + timeUpdate output (zone.run)
    Lib-->>FEW: ERROR (fatal)
    FEW-->>FPC: playbackIssue output (zone.run)
    U->>FPC: channel zap
    FPC->>FPC: teardown() - stopFerriteSession()
    FPC->>Lib: player.destroy() (terminates workers, releases OffscreenCanvas)
    FPC->>FPC: "canvasKey() recomputes - @for destroys old canvas, creates new"
    FPC->>FSS: startFerriteSession (new URL + fresh canvas)
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
libs/ui/playback/src/lib/ferrite-player/ferrite-player.component.ts:1-10
**File exceeds the 300-line policy**

`ferrite-player.component.ts` is 346 lines. The repo's `CLAUDE.md` engineering rule sets a 300-line soft limit for TypeScript files, with 350–400 as the hard maximum. The PR description notes the concerns are already split into five sibling files, but the component itself still sits 46 lines over the soft target. Possible extractions to close the gap: the three `effect()` bodies (playback, overlay-wiring, volume) could move into a dedicated `ferrite-lifecycle.ts` helper that accepts the component's signals/refs as arguments, or the set of overlay action handlers (`togglePlay`, `toggleMute`, `applyVolume`, `toggleFullscreen`, `onSeek`, `onDeintChange`, `onDynaChange`) could be grouped into a small `ferrite-overlay-actions.ts` file. Either would bring the component down near the target without changing any public API.

### Issue 2 of 2
apps/web/project.json:164-168
`allowedHosts: true` accepts requests from any hostname, which combined with `host: "0.0.0.0"` makes the dev server reachable from—and impersonatable by—any origin on the same network. The preferred hardening for a local COOP/COEP test is to leave `host` at the loopback default (`localhost`) and enumerate only the hostnames actually needed. If LAN testing across devices is genuinely required, scoping to a single LAN hostname is safer than opening to all.

```suggestion
                "ferrite": {
                    "buildTarget": "web:build:development",
                    "headers": {
```

Reviews (1): Last reviewed commit: "feat(playback): add Ferrite (WASM/WebCod..." | Re-trigger Greptile

Comment on lines +1 to +10
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
HostListener,
inject,
input,
NgZone,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File exceeds the 300-line policy

ferrite-player.component.ts is 346 lines. The repo's CLAUDE.md engineering rule sets a 300-line soft limit for TypeScript files, with 350–400 as the hard maximum. The PR description notes the concerns are already split into five sibling files, but the component itself still sits 46 lines over the soft target. Possible extractions to close the gap: the three effect() bodies (playback, overlay-wiring, volume) could move into a dedicated ferrite-lifecycle.ts helper that accepts the component's signals/refs as arguments, or the set of overlay action handlers (togglePlay, toggleMute, applyVolume, toggleFullscreen, onSeek, onDeintChange, onDynaChange) could be grouped into a small ferrite-overlay-actions.ts file. Either would bring the component down near the target without changing any public API.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: libs/ui/playback/src/lib/ferrite-player/ferrite-player.component.ts
Line: 1-10

Comment:
**File exceeds the 300-line policy**

`ferrite-player.component.ts` is 346 lines. The repo's `CLAUDE.md` engineering rule sets a 300-line soft limit for TypeScript files, with 350–400 as the hard maximum. The PR description notes the concerns are already split into five sibling files, but the component itself still sits 46 lines over the soft target. Possible extractions to close the gap: the three `effect()` bodies (playback, overlay-wiring, volume) could move into a dedicated `ferrite-lifecycle.ts` helper that accepts the component's signals/refs as arguments, or the set of overlay action handlers (`togglePlay`, `toggleMute`, `applyVolume`, `toggleFullscreen`, `onSeek`, `onDeintChange`, `onDynaChange`) could be grouped into a small `ferrite-overlay-actions.ts` file. Either would bring the component down near the target without changing any public API.

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/iptvnator/github/4gray/iptvnator/-/custom-context?memory=d7399d0f-9d03-4561-9aed-4525c6d78ad1))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/web/project.json
Comment on lines +164 to +168
"ferrite": {
"buildTarget": "web:build:development",
"host": "0.0.0.0",
"allowedHosts": true,
"headers": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 allowedHosts: true accepts requests from any hostname, which combined with host: "0.0.0.0" makes the dev server reachable from—and impersonatable by—any origin on the same network. The preferred hardening for a local COOP/COEP test is to leave host at the loopback default (localhost) and enumerate only the hostnames actually needed. If LAN testing across devices is genuinely required, scoping to a single LAN hostname is safer than opening to all.

Suggested change
"ferrite": {
"buildTarget": "web:build:development",
"host": "0.0.0.0",
"allowedHosts": true,
"headers": {
"ferrite": {
"buildTarget": "web:build:development",
"headers": {
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/project.json
Line: 164-168

Comment:
`allowedHosts: true` accepts requests from any hostname, which combined with `host: "0.0.0.0"` makes the dev server reachable from—and impersonatable by—any origin on the same network. The preferred hardening for a local COOP/COEP test is to leave `host` at the loopback default (`localhost`) and enumerate only the hostnames actually needed. If LAN testing across devices is genuinely required, scoping to a single LAN hostname is safer than opening to all.

```suggestion
                "ferrite": {
                    "buildTarget": "web:build:development",
                    "headers": {
```

How can I resolve this? If you propose a fix, please make it concise.

…re HEVC

Adds Ferrite as a new inline web player — canvas + WASM software decode, with an
automatic WebCodecs hardware tier — for software HEVC where the browser has no
hardware decoder (web/PWA; requires a cross-origin-isolated context).

- ferrite-player component + session/event wiring (live + VOD), IPTVnator-owned
  Material controls (play/mute/volume/fullscreen, VOD seek bar, software-tier
  deinterlace select, "Dyna" audio-dynamics select) and a long-press diagnostic
  overlay; teardown via the facade's canonical destroy() (releases the transferred
  canvas + clears all listeners).
- web-player-view routing (HLS m3u/m3u8 falls back to the default inline player),
  settings + command-palette registration, i18n, docs, and the engine/worker asset
  bundling with an opt-in COOP/COEP isolated dev-serve configuration.
- ferrite.js 1.3.4: four self-contained worker chunks (demux, video-decode,
  audio-decode, present) are copied into the served assets and fed to the facade via
  the {worker,presentWorker,audioWorker,demuxWorker}Url overrides — the esbuild
  builder does not rewrite a dependency's internal new Worker(new URL(...)). Audio
  runs off the main thread (an AudioWorklet-driven clock) and A/V sync uses the
  mpv-faithful display-sync corrector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@multiduplikator

Copy link
Copy Markdown
Author

Thanks for the review! Addressed the two P2 findings:

  • allowedHosts: true + host: 0.0.0.0 in the ferrite dev-serve config — removed both from the committed config, so it now defaults to localhost. The COOP/COEP headers (the actual point of the config) are unchanged, so cross-origin-isolated testing still works; network/on-device testing stays available as an explicit opt-in (nx serve web --configuration=ferrite --host 0.0.0.0 --allowed-hosts all). No wide-open default ships in the repo.
  • ferrite-player.component.ts at 346 lines (>300 soft guideline) — left as-is: it's under the 350 hard limit and passes lint, and the separable concerns are already extracted into the five sibling files (ferrite-session, ferrite-event-wiring, ferrite-controls, ferrite-debug-overlay, overlay-interactions). What remains is the cohesive component lifecycle; splitting it further would push Angular-bound player/signal handlers into context-passing free functions, which reads worse. Happy to break it up differently if you'd prefer a specific split.

@multiduplikator

Copy link
Copy Markdown
Author

Superseded by #1119 — the same integration rebased onto current master and bumped to the newly-published ferrite.js@1.3.5 (mpv-faithful live pause/resume, WebCodecs resume lookahead, engine on emscripten 6.0.2). Closing in favor of #1119.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant