PROJECT · 2025 · AUTHOR

Crispy-Tivi: cross-platform IPTV and media streaming

A Flutter app for M3U, Xtream Codes, EPG, VOD, and live TV — with Chromecast, AirPlay, and cloud sync built in.

Screenshot of the Crispy-Tivi GitHub repo page showing the Flutter + Rust IPTV media app

This is the app that plays in my living room every evening. My household streams IPTV. Most of the apps in this space are either abandonware with 2014-era UX, paid products behind sketchy subscription paywalls, or so brittle that a provider URL change breaks the whole thing for a week. I got tired of all three.

Crispy-Tivi is the app I built because I wanted something I’d actually enjoy using — not tolerate.

Why it exists

IPTV is a genuinely chaotic ecosystem. There’s no standard. Providers serve M3U playlists, Xtream Codes APIs, or both, and they change their endpoints without warning. EPG data comes in multiple XML formats with varying quality. VOD catalogs can have twenty thousand entries or twenty. Clients that look fine on Android fall apart on tablets, and desktop support is usually an afterthought if it exists at all.

The apps that do exist treat this complexity as a fixed cost: slow channel switching, no search, no continue-watching, no grouping that actually makes sense. The paid tier of the major IPTV players costs money and still feels like someone’s first Flutter project. I mean that as a technical observation, not a dig — first Flutter projects are how you learn Flutter.

What I wanted was Plex-quality UX for an IPTV source I controlled, with zero cloud dependency. If my internet is up and my provider is up, the app should work. Full stop.

How it works

The app is Flutter, which gives me a single codebase that runs on Android, iOS, macOS, Windows, and Linux. That sounds like the obvious choice for a media app in 2025, and it is — Flutter’s rendering is genuinely good now and the platform channel story is mature enough that I can drop into Rust for the performance-critical paths without losing my mind.

The Rust layer handles the FFmpeg integration. HLS and DASH parsing, transcoding hints, buffer management — these are things where Dart’s garbage collector will eventually betray you at the wrong moment, usually during a live channel switch. Rust via Flutter’s FFI gives me predictable memory layout and no surprise pauses.

Protocol support covers the main IPTV formats: M3U and M3U-plus playlists (with custom group-title parsing), Xtream Codes API (login, stream, VOD, series), and XMLTV EPG. For casting, I implemented Chromecast and AirPlay natively — the player hands off a stream URL and the cast session handles the rest. Cloud sync stores favorites, watch progress, and custom channel groups; the sync layer is conflict-resolved with a last-write-wins strategy per item, which is wrong in theory and fine in practice for this use case.

The EPG renderer was the gnarliest piece. XMLTV files can be several hundred megabytes uncompressed, and you can’t parse that on the main thread unless you enjoy ANR dialogs. The EPG pipeline runs in a Dart isolate, parses into a compact binary format, and hands the result to the UI layer as a stream of channel-time-slot tuples. The grid view virtualizes aggressively — only the visible window and one screen of lookahead are in memory at any given time.

What’s interesting

The self-hoster community found the repo before I’d even written a proper README, which was flattering and mildly chaotic. The first wave of issues was almost entirely about M3U edge cases — character encoding, non-standard group-title escaping, playlists with ten thousand channels where the client was supposed to filter in real time. I learned more about the chaos of real-world M3U data in two weeks of issues than I’d accumulated in years of using these services as a consumer.

The feature request that surprised me most was series support. I’d thought of IPTV primarily as live TV plus VOD movies, but a large segment of users get their episodic content this way too, and the Xtream Codes series API is genuinely different from the VOD API — different endpoint structure, different metadata fields, different continuation semantics. Adding it properly meant rethinking the content model from “stream or movie” to “stream, movie, or series-with-episodes,” which cascaded through the player, the search index, and the continue-watching tracker.

Eight stars on GitHub doesn’t sound like a lot. But every one of those stars came with a real conversation. The people using this app are deep-in-it self-hosters running their own media stacks, and they have extremely specific opinions. That’s the best kind of user feedback.

What I’d change

The sync layer is the oldest and worst part of the codebase. Last-write-wins is a fine default, but it falls apart when the same user has the app on a phone and a tablet and watches different things on each. I want proper CRDT-based state for watch progress — it’s a solved problem and I just haven’t ported the implementation yet.

The Rust FFI boundary is also more manual than I’d like. I’m managing the object lifecycle across the boundary by hand, which is correct but tedious to audit. flutter_rust_bridge has gotten significantly better since I wrote the initial integration, and I want to migrate to it properly so the generated bindings handle the safety invariants I’m currently enforcing by convention.

And honestly? The UI needs a design pass. The UX is good — the flow works, the channel-switching latency is solid, the EPG grid is readable. But the visual design is “engineer made this,” which I say with full self-awareness. I know what it needs. I just haven’t sat down to do it yet.