An audiobook player looks like a solved problem until you point it at a real library. The files are M4B containers written by a dozen encoders that each read the spec differently, they run to hundreds of megabytes, and the one thing a listener actually cares about — where they stopped — is not in the file at all. Four problems fell out of that: getting chapters from files that disagree about how to store them, moving audio into a WebView without stalling it, keeping user data next to files the user is free to move, and giving the whole thing a reason to exist beyond pressing play.

Rust Reads, the WebView Draws

The split is drawn on I/O. Anything that touches a file — tag parsing, cover extraction, MP4 atom traversal, range-serving audio — is Rust, exposed across a single command and one custom URI scheme. Everything above that is Svelte 5 with runes over a set of plain writable stores. Pulling metadata extraction out of the WebView was not an optimisation; it was the difference between reading a tag and buffering hundreds of megabytes into a JavaScript heap that then has to collect it.

Audio moves over a custom protocol rather than the framework asset protocol, because the Web Audio graph behind the equalizer needs a media element source, and that needs CORS headers the asset protocol does not send. Once you are writing the handler anyway you own range handling — which turned out to matter far more than the equalizer did.

Chapters Are a Dialect, Not a Format

An M4B can carry chapters as a QuickTime text track or as a Nero atom buried under the movie header, and a given file may have both, one, or neither, with nothing in it to say which. The reader tries the structured form first and falls back, and both paths needed defences the spec does not mention: a sample-to-chunk table walk, because assuming one sample per chunk silently reports the first chapter of a 179-chapter file and nothing else; a sanity gate that discards a chapter set outright when any timestamp lands past the file real duration, because some re-muxers write a 44-byte stub that parses cleanly into the year 3940; and an allocation ceiling, because the entry count sizing every buffer is a number read out of an untrusted file.

Thirty Round Trips Before the First Second

Large M4Bs commonly carry their index at the end of the file. A WebView opening one issues a long run of sequential range requests near that end before it can play a single second, and the per-request cap decides how many. Raising the open-ended range cap from 2 MB to 16 MB collapsed roughly thirty round trips into a handful and took first-play latency on a 489 MB file from 30–60 seconds down to about ten. Moving the handler off the thread that draws the window stopped those requests serialising behind each other and behind the interface.

The remaining ten seconds are the file layout, not the code, so the last change was not a fix but a spinner — the wait now reads as loading rather than as broken.

Keyed on Something the User Can Rename

There is no database. Six JSON stores — library index, user data, positions, bookmarks, statistics, tier lists — each keyed by the audio file path, split apart because their write frequencies differ by orders of magnitude: a playback position is written every few seconds, the library index once per scan. That makes the path load-bearing, and it makes a book with no file at all a genuine design problem. It got a sentinel path rather than a nullable key, so every path-keyed store kept working without a migration and the branching moved to the handful of boundaries where the distinction actually means something.

Books You Have Read, Not Books You Own

The feature that justifies the app over a general media player is ranking. A user keeps as many tier lists as they want, each with its own tiers, colours, scope and scoring axes, drags covers between rows, and logs books finished elsewhere — an Audible listen, a library loan, a paper copy — as metadata-only entries that rank alongside the files. The library stops being an inventory of what is on disk and becomes a record of what someone has actually read.

Key Technical Decisions

Metadata extraction in Rust, not the WebView. Reading a tag should not mean buffering 500 MB into a JavaScript heap.

A custom audio protocol instead of the asset protocol. It sends the CORS headers the Web Audio graph needs, and hands you range handling worth owning.

Range cap matched to the file layout. Index-at-end containers turn a small cap into a serial round-trip loop before any audio plays.

A panic guard around the async responder. An unanswered request raises nothing; the audio element simply loads forever.

A sanity gate on parsed chapters. Showing nothing beats showing two chapters dated 3940, because the user cannot tell the difference.

A sentinel path for books with no file. A nullable primary key would have touched every store; a sentinel touched none.

Normalise on read instead of migrating on write. Eleven new fields shipped without a migration step or a version gate.