Application

Theater

A media library that transcodes video inside the browser and streams it back end-to-end encrypted — without the server ever seeing a frame.

Role
Sole architect and engineer
Year
2026
Stack
ffmpeg.wasm · HLS · hls.js · Stripe
Live
theater.muhkoo.com

The problem

Encrypted video and streaming video pull against each other. Streaming wants a server that can segment a file, transcode it to several bitrates, and serve byte ranges on demand. End-to-end encryption forbids that server from seeing the file at all.

Most products that advertise encrypted media quietly resolve the conflict by decrypting server-side — which means the operator can watch your library. I wanted to find out whether the honest version was actually buildable in a browser.

How it works

Transcoding runs client-side through ffmpeg.wasm, which turns an uploaded file into HLS segments without it ever leaving the machine in plaintext. Each segment is encrypted before upload, so the origin stores opaque blobs and a manifest it cannot read.

Playback is where it gets interesting. A custom hls.js loader intercepts each segment fetch, decrypts in memory, and hands plaintext to the media element — which preserves adaptive bitrate switching, the thing that usually dies first when you interfere with a streaming pipeline.

Theater's library view with a featured title and cover art below it Theater running on a phone
Library and playback — every asset shown is decrypted in the browser

What I'd do differently

In-browser transcoding is right for privacy and brutal for throughput. A feature-length file can pin a laptop for a long time, and no amount of optimization changes the fact that you are asking a tab to do a workstation's job. I ended up building a companion GPU-assisted transcoder for people who want their library ready this century — which is the correct answer, but I should have planned for it rather than discovering it under load.

The hls.js integration also taught me to touch as little of someone else's pipeline as possible. My first version overrode more of the loader chain, and adaptive switching broke in ways that only appeared under real network variance — never locally. Constraining the change to the fetch path alone fixed it.