# 3. Play the book in the page, not in Audiobookshelf ## Status **Superseded in part on 2026-08-10 by [ADR 9](0009-drop-audiobookshelf.md)**, which drops Audiobookshelf entirely: the write-through, the seeding, and the promise that the ABS app goes on working on the library files. Accepted. **Amended on 2026-08-07**: the sentence below saying "changing books is done by asking" no longer holds, and the paragraph headed *Choosing a book is a press now* at the end of this file says what replaced it and why. **Amended again on 2026-08-08**, and this one is a retraction rather than a refinement: the handset check recorded at the end of *Consequences* was written up as though it had settled two things, and it had settled one. The paragraph headed *The notification did not survive a boundary* says which half failed and how the check came to pass anyway. Everything else here stands — the page is still the player, the position is still somnia's own, and there is still no library to browse for something you do not have. ## Context somnia was built on the assumption that Audiobookshelf is the player, and [design.md](../design.md) argued for it at length. The ABS Android app already had screen-off playback, Bluetooth controls, a sleep timer with shake-to-extend, fade-out and smart rewind; writing any of that again looked like a great deal of work with nothing at the end of it that did not already exist. "Take me to where the horse dies" was therefore implemented as a write — `PATCH /api/me/progress/:id` — so that the next tap on play starts in the right place. The next tap on play is the problem. ABS has no transport API: nothing in it can make a client play, pause or seek. The Android app has no deep link either, so nothing outside the app can reach the button. The two gaps together mean somnia could move the book and then had to ask the listener to finish the job. A worse thing follows from the same gap. While a client holds an open playback session it syncs its own `currentTime` back every few seconds, so a position written underneath it is silently undone. `move_to` had grown a session hunt, a settle wait and three attempts at outlasting a player that would not give up — and even when it won, the audio already in flight kept playing, because there was no way to tell the app it had been moved. So the night went: pause by hand, ask, press play by hand. Two of those three are someone half awake operating a phone in the dark, which is the exact thing this project exists to remove. Bookmarks were rejected on those grounds already: a bookmark is a signpost, and finding the new one among all the others, in a menu, in the dark, is most of the work the agent was supposed to remove. "Now press play again" is the same rejection wearing different clothes. ## The alternatives we rejected Every one of these is a way to press play on the phone from somewhere else, so that ABS could stay the player. **Tasker or MacroDroid on the phone, driven from the VPS.** Both can hold an HTTP listener and dispatch a media key, so `move_to` could write the position and then ring the phone. It requires the VPS to open a connection into the tailnet, and the ACL never lists `tag:vps` as a source. That is not an oversight to patch: the VPS runs experiments, is treated as untrusted-ish, and the one-way rule is the whole reason it is safe to keep somnia on it. An ACL exception for a play button inverts the security model of the deployment to buy back one gesture. **The same, driven from the page over loopback.** The page is already on the phone, so it could talk to a listener on `127.0.0.1` and the VPS would initiate nothing. Three separate browser mechanisms stand in the way. The page is served over HTTPS and the listener speaks HTTP, which is mixed content and blocked outright. The request is cross-origin, so the listener has to satisfy a preflight it was never designed to answer. And reaching a local address from a public page needs Local Network Access permission, which Chrome has enforced since version 142 and which prompts — a permission dialog at 2am, in the dark, standing between them and the book, is worse than pressing play was. Each of the three is also one Chrome release away from moving again. **Home Assistant's companion app.** `notify.mobile_app_*` with `command_media` does precisely this, properly, over a channel built for it, and if Home Assistant were already running here it would be a few lines and this ADR would read differently. It is not running. Standing up HA — its server, its updates, its supervision, for the rest of the project's life — in order to press play in an app that is already playing is the largest dependency on the list bought for the smallest feature on it. **Termux plus Shizuku.** A shell on the phone can dispatch a media key without root, and it works well the day it is set up. It stops working at every reboot: Shizuku has to be re-granted over ADB or by a re-pair, and a bedside audiobook that needs a laptop after every restart is not a bedside audiobook. What the four have in common is that they buy one gesture by adding a second system on the phone which must keep working, silently, unattended, every night. The gesture is not worth that. ## Decision The PWA becomes the player. somnia rendered the audio, so it can serve it. `chapters` already carries idx, title, start_ms, end_ms and audio_file, so the manifest a player needs is one SELECT and each chapter is one route; Range requests, and therefore seeking, are Starlette's own. The page holds a single `