Up front, so there's no confusion: the app I'm describing (Nightmare TV) is a player only. You bring your own M3U / Xtream Codes playlist — it ships with no channels and no content. This post is about the playback engineering, not about where streams come from. Think "VLC for IPTV," not a content service.
The problem that started it
I watch a lot of live content on my PC — sports, mostly. And every IPTV player I tried on Windows fell into one of two buckets:
- An Android app running in an emulator. TiviMate and the good mobile players are Android-only, so on a desktop you end up in an emulator or a VM. Input lag, no real HDR path, fans spinning.
- A thin ExoPlayer / libVLC wrapper. These run natively, but most of them treat HDR as "pass the HDR10 metadata to the display and hope." On an SDR panel — or even a lot of HDR panels — bright skies in a football match blow out to a flat white blob, and 4K HEVC with a DTS track stutters because the decode path isn't doing what you think it is.
I wanted the thing that didn't exist: a native Windows player with a reference-grade video path. So I built it on libmpv — the same playback core mpv uses — with a Flutter desktop shell on top for the UI.
This post is the part I actually find interesting: the HDR tone-mapping pipeline.
Why HDR "just passing through" isn't enough
HDR10 content is mastered in the PQ (ST.2084) transfer function against a mastering display — often 1000 nits, sometimes 4000. Your screen is whatever it is: a 350-nit SDR laptop, a 600-nit "HDR400" monitor, an 800-nit OLED. If you map PQ straight to the panel, everything above the panel's peak just clips — all the highlight detail collapses to maximum white.
Tone-mapping is the process of intelligently compressing the mastering range into the display range so you keep highlight detail instead of clipping it. The naive version (a fixed curve, or clipping) is what most wrapper players ship. The good version adapts to both the content and the display.
The pipeline
Here's the chain Nightmare TV runs per video frame, on the GPU:
HEVC/AV1 bitstream
-> D3D11VA hardware decode (stays on the GPU)
-> linearize PQ -> scene-linear light
-> per-frame peak / average luminance detection (GPU histogram)
-> BT.2390 EETF tone-mapping curve, set by detected peak + target
-> gamut map BT.2020 -> display primaries
-> re-encode to display transfer (PQ for HDR, BT.1886/sRGB for SDR)
-> present
Two pieces matter most.
1. Per-frame peak detection
Static tone-mapping uses the MaxCLL/MaxFALL metadata baked into the stream — if it's present at all. Live IPTV very often has wrong or missing HDR metadata. So instead of trusting it, the engine computes a luminance histogram of the actual frame on the GPU every frame, and derives the real peak. That number feeds the tone curve.
The payoff on live content is exactly where wrappers fall apart: a stadium with a bright sky and players in shadow keeps detail in both, because the curve is set from what's on screen, not from a metadata field somebody forgot to set.
2. BT.2390 EETF instead of a fixed knee
BT.2390 defines an EETF — a tone-mapping curve with a soft knee that leaves shadows and midtones essentially untouched and only rolls off the highlights near the display's peak. Compared to a fixed Reinhard/Hable curve, it preserves mid-tone contrast (faces, grass, jerseys) and only spends its compression budget where it's needed.
mpv's render API exposes enough control to drive this per-frame, which is the whole reason I went with libmpv rather than rolling decode myself or living inside ExoPlayer's higher-level surface.
The audio side (briefly)
Video gets the headline, but audio passthrough was the other reason for going native. On Windows, Nightmare TV opens the output in WASAPI exclusive mode and bitstreams Dolby Atmos (TrueHD/E-AC-3) and DTS-X untouched to an AVR over HDMI. A lot of wrappers silently decode to PCM stereo and you never know you lost the object audio. Exclusive mode is the difference between "my receiver lights up Atmos" and "it says PCM 2.0."
Why Flutter + libmpv specifically
-
libmpv for the engine: reference tone-mapping, bitstream passthrough,
D3D11VAhardware decode for HEVC + AV1, and a render API that gives per-frame control. It's a C API; I drive it over FFI. - Flutter desktop for the shell: one UI codebase that also targets Android TV, with a real focus/remote model for the 10-foot experience. The video itself is not in a Flutter texture doing CPU copies — the mpv render runs on the GPU and the Flutter layer composites around it.
It is not Electron, and there's no telemetry by default (crash reporting is opt-in). I mention that because "native desktop app" and "quietly phones home" sadly aren't mutually exclusive these days.
Things that bit me
A few honest landmines, in case you're building something similar:
-
Windows + non-ASCII usernames. Several toolchain paths (Gradle's
java.io.tmpdir, Dart'sPUB_CACHE) break onAF_UNIXsockets or Kotlin path handling when the Windows username has non-ASCII characters. The fix was forcing those caches to ASCII-only paths (C:\Temp,C:\PubCache). Cost me an entire evening. -
Channel-switch crashes from resolution changes. Reusing one player surface across a 1080p -> 4K switch triggered a texture-resize race (
0xc0000409). The fix was a fresh player widget per stream, keyed on the URL — cheap, and it killed the crash. - HDR metadata you can't trust. Already covered, but worth repeating: on live streams, assume the metadata is wrong and measure the frame.
Takeaway
If you take one thing from this: "HDR support" in a player is not a checkbox, it's a pipeline. Passing HDR10 metadata to the display is not the same as tone-mapping, and on real-world live content the difference is the entire highlight range of the picture. Measuring the frame per-frame and driving a BT.2390 curve off that measurement is what keeps bright scenes from blowing out.
Happy to go deeper on the libmpv FFI integration, the D3D11VA decode path, or the Flutter compositing in the comments.
I'm a solo dev. The player I built around this is Nightmare TV — native Windows + Android TV, bring-your-own-playlist, 14-day trial, no card. There's a longer HDR write-up here if you want the non-code version.
Top comments (0)