DEV Community

hammi
hammi

Posted on

I fake screen brightness with a CSS filter, and only use the real API when the shell allows it

I'm the developer of RKMovies, a 0.91 MB media player for Android and Windows. One of the small features people ask about is the brightness gesture: swipe up on the right half of the player and the screen dims.

Here's the thing though. There is no one brightness API that works everywhere my app runs. So I built a two-layer one.

Layer 1: the native path, when it exists

On Android the WebView shell exposes a JS bridge. If window.AndroidBridge.setBrightness exists, I call it and I'm done. That changes the actual system brightness, which is what you want most of the time.

On the Windows build there's a local backend, so I debounce and fire GET /api/system/brightness?val=... instead. 50 ms debounce, because the gesture fires dozens of times per second and I don't want to flood the IPC.

Layer 2: the CSS filter fallback

WebViews and browser contexts can't touch system brightness. So every time the gesture moves, I also set a filter directly on the <video> element:

function applyPlayerBrightness(art, val) {
  const clamped = Math.max(0.1, Math.min(1.5, Math.round(val * 100) / 100));
  art._brightness = clamped;
  if (art.video) {
    art.video.style.filter = "brightness(" + (0.4 + clamped * 0.7) + ")";
  }
  setSystemBrightness(Math.min(1.0, clamped)); // native layer, clamped to 0..1
  // ...HUD update
}
Enter fullscreen mode Exit fullscreen mode

Note the math: user-facing range is 10% to 150%, but the filter maps it to 0.4x to 1.45x. I never go below 0.4. A pure black screen feels broken, not dim, and people think the app crashed. 0.4 is the floor where the picture is still readable.

The filter only darkens/brightens the video itself, not the player UI. That's actually a feature: the controls stay readable while the movie dims, which is exactly what you want at 2am under a blanket.

Why not just use the native API everywhere

Because "everywhere" is a lie. Android WebView: no. Windows WebView2: no (hence the local backend). Plain browser PWA: no. If I depended on the native path alone, the gesture would silently do nothing on two of my three platforms.

The filter runs on the compositor, so it costs basically nothing on the CPU. On a low-battery video player that's the whole point. I'd rather spend zero battery on brightness and keep the decoder on the GPU.

The edge case nobody warns you about

CSS filter on a hardware-decoded video can force a software compositing path on some old GPUs, which tanks battery. I tested on a 2GB Redmi and a Moto G4 and the filter stayed on the compositor, but if you ship this, test on your lowest-end device. If it falls off the fast path, drop the filter layer and keep only the native API.

The desktop edition uses the same two-layer code, just with the local /api/system/brightness endpoint as layer 1 instead of the Android bridge.

If you're building a player and want gesture brightness without a native app, this split is the cheapest version that actually works everywhere. Ship the filter first, add the native path when the shell gives you one.

Top comments (0)