<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zehra Uludağ Çapar</title>
    <description>The latest articles on DEV Community by Zehra Uludağ Çapar (@nightmaretv).</description>
    <link>https://dev.to/nightmaretv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3970445%2F490cbdb9-710f-49d4-8151-a57ad3eec82d.png</url>
      <title>DEV Community: Zehra Uludağ Çapar</title>
      <link>https://dev.to/nightmaretv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nightmaretv"/>
    <language>en</language>
    <item>
      <title>Why I built a native libmpv IPTV player for Windows — an HDR tone-mapping deep-dive</title>
      <dc:creator>Zehra Uludağ Çapar</dc:creator>
      <pubDate>Fri, 05 Jun 2026 21:23:13 +0000</pubDate>
      <link>https://dev.to/nightmaretv/why-i-built-a-native-libmpv-iptv-player-for-windows-an-hdr-tone-mapping-deep-dive-3ija</link>
      <guid>https://dev.to/nightmaretv/why-i-built-a-native-libmpv-iptv-player-for-windows-an-hdr-tone-mapping-deep-dive-3ija</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Up front, so there's no confusion:&lt;/strong&gt; the app I'm describing (Nightmare TV) is a &lt;em&gt;player only&lt;/em&gt;. 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem that started it
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An Android app running in an emulator.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thin ExoPlayer / libVLC wrapper.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wanted the thing that didn't exist: a &lt;strong&gt;native Windows&lt;/strong&gt; player with a &lt;strong&gt;reference-grade&lt;/strong&gt; video path. So I built it on &lt;strong&gt;libmpv&lt;/strong&gt; — the same playback core &lt;code&gt;mpv&lt;/code&gt; uses — with a Flutter desktop shell on top for the UI.&lt;/p&gt;

&lt;p&gt;This post is the part I actually find interesting: the HDR tone-mapping pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HDR "just passing through" isn't enough
&lt;/h2&gt;

&lt;p&gt;HDR10 content is mastered in the &lt;strong&gt;PQ (ST.2084)&lt;/strong&gt; 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 &lt;strong&gt;clips&lt;/strong&gt; — all the highlight detail collapses to maximum white.&lt;/p&gt;

&lt;p&gt;Tone-mapping is the process of &lt;em&gt;intelligently&lt;/em&gt; 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 &lt;em&gt;both&lt;/em&gt; the content and the display.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Here's the chain Nightmare TV runs per video frame, on the GPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HEVC/AV1 bitstream
  -&amp;gt; D3D11VA hardware decode (stays on the GPU)
  -&amp;gt; linearize PQ -&amp;gt; scene-linear light
  -&amp;gt; per-frame peak / average luminance detection (GPU histogram)
  -&amp;gt; BT.2390 EETF tone-mapping curve, set by detected peak + target
  -&amp;gt; gamut map BT.2020 -&amp;gt; display primaries
  -&amp;gt; re-encode to display transfer (PQ for HDR, BT.1886/sRGB for SDR)
  -&amp;gt; present
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two pieces matter most.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Per-frame peak detection
&lt;/h3&gt;

&lt;p&gt;Static tone-mapping uses the &lt;code&gt;MaxCLL&lt;/code&gt;/&lt;code&gt;MaxFALL&lt;/code&gt; metadata baked into the stream — if it's present at all. Live IPTV very often has &lt;strong&gt;wrong or missing&lt;/strong&gt; HDR metadata. So instead of trusting it, the engine computes a luminance histogram of the &lt;em&gt;actual frame&lt;/em&gt; on the GPU every frame, and derives the real peak. That number feeds the tone curve.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. BT.2390 EETF instead of a fixed knee
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.itu.int/pub/R-REP-BT.2390" rel="noopener noreferrer"&gt;BT.2390&lt;/a&gt; defines an &lt;strong&gt;EETF&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audio side (briefly)
&lt;/h2&gt;

&lt;p&gt;Video gets the headline, but audio passthrough was the other reason for going native. On Windows, Nightmare TV opens the output in &lt;strong&gt;WASAPI exclusive mode&lt;/strong&gt; and bitstreams &lt;strong&gt;Dolby Atmos (TrueHD/E-AC-3) and DTS-X&lt;/strong&gt; 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."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Flutter + libmpv specifically
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;libmpv&lt;/strong&gt; for the engine: reference tone-mapping, bitstream passthrough, &lt;code&gt;D3D11VA&lt;/code&gt; hardware decode for HEVC + AV1, and a render API that gives per-frame control. It's a C API; I drive it over FFI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flutter desktop&lt;/strong&gt; 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 &lt;strong&gt;not&lt;/strong&gt; in a Flutter texture doing CPU copies — the mpv render runs on the GPU and the Flutter layer composites around it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is &lt;em&gt;not&lt;/em&gt; Electron, and there's &lt;strong&gt;no telemetry by default&lt;/strong&gt; (crash reporting is opt-in). I mention that because "native desktop app" and "quietly phones home" sadly aren't mutually exclusive these days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;A few honest landmines, in case you're building something similar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows + non-ASCII usernames.&lt;/strong&gt; Several toolchain paths (Gradle's &lt;code&gt;java.io.tmpdir&lt;/code&gt;, Dart's &lt;code&gt;PUB_CACHE&lt;/code&gt;) break on &lt;code&gt;AF_UNIX&lt;/code&gt; sockets or Kotlin path handling when the Windows username has non-ASCII characters. The fix was forcing those caches to ASCII-only paths (&lt;code&gt;C:\Temp&lt;/code&gt;, &lt;code&gt;C:\PubCache&lt;/code&gt;). Cost me an entire evening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel-switch crashes from resolution changes.&lt;/strong&gt; Reusing one player surface across a 1080p -&amp;gt; 4K switch triggered a texture-resize race (&lt;code&gt;0xc0000409&lt;/code&gt;). The fix was a fresh player widget per stream, keyed on the URL — cheap, and it killed the crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HDR metadata you can't trust.&lt;/strong&gt; Already covered, but worth repeating: on live streams, assume the metadata is wrong and measure the frame.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this: &lt;strong&gt;"HDR support" in a player is not a checkbox, it's a pipeline.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the libmpv FFI integration, the D3D11VA decode path, or the Flutter compositing in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a solo dev. The player I built around this is &lt;a href="https://nightmaretv.net" rel="noopener noreferrer"&gt;Nightmare TV&lt;/a&gt; — native Windows + Android TV, bring-your-own-playlist, 14-day trial, no card. There's a &lt;a href="https://nightmaretv.net/learn/hdr-tone-mapping" rel="noopener noreferrer"&gt;longer HDR write-up here&lt;/a&gt; if you want the non-code version.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>windows</category>
      <category>cpp</category>
      <category>video</category>
      <category>flutter</category>
    </item>
  </channel>
</rss>
