The web app

The whole product, in plain JavaScript with no framework and no build step, embedded in the server binary. It is what a browser shows, what installs to a home screen, and what the Android and iPhone apps wrap.

The files

Everything lives in internal/webui/assets and is served by the Go server through go:embed, so a server and its web app can never be out of step.

FileJob
index.htmlThe shell: one page, every screen hidden or shown by class.
app.jsAlmost everything - the library, search, the player and Now Playing, the visualizers, settings, downloads, the TV mode. About 17,000 lines.
reader.jsThe book reader, around the vendored foliate-js (see The reader).
sw.js, sw-register.jsThe service worker and the file that registers it.
style.cssAll styling, including the TV mode (html.tv) and the safe-area variables.
manifest.webmanifestWhat makes it installable: name, icons, display: standalone.
vendor/foliate-js and hls.js, the only third-party code in the project, checked in and pinned.

Five tabs and their pills

The first design had a sideways row of ten filter chips - one per kind of media - most of which were off the screen on a phone. Now there are five tabs: Home, Music (mixes, radio, playlists, songs, albums, artists), Watch (films, TV), Books (audiobooks, ebooks, documents, authors, series, read along) and Photos (photos, people, places). They sit along the bottom on a phone and down the side on a computer. The old chips are still in the page, hidden, and a tab simply presses one, so every search path underneath stayed the same.

Inside a tab, a row of pills picks the page. A few things about them are worth knowing:

Making that swipe smooth on a real phone took several rounds, and the fixes are typical of the whole app: move the list's own nodes into the ghost rather than deep-cloning them (a clone's lazy images drew blank); pin the pills under the header so they do not jump; turn off scroll anchoring during the swipe (Chrome re-aimed the page by 40px); pause the shimmer animation while moving; write positions once a frame, not per touch event. On an iPhone, the body under pills gets touch-action: pan-y pinch-zoom, because Safari decides within a few pixels whether a drag is a scroll and then ignores being told otherwise.

Home

Home is a front page rather than "everything, alphabetically". It opens with one-tap music for an account that has music - Shuffle all music, Favorite songs, Music radio and New music - then runs in three groups: Continue and Recently played, then every "New" row (music first), then the favorites of each tab.

The Continue row is what this person is part way through, newest first. Books and films are SoundStorm's own records (state.Progress); an audiobook's place belongs to Audiobookshelf, so a book started in its phone app shows up here too. The New rows come from GET /api/home, which asks every source at once with a five-second limit each and leaves out a shelf that fails - the same rule the search follows, so one dead backend never blanks the page.

"Shuffle all" is a true random shuffle: each batch is drawn with equal odds from songs not played yet this session, never repeating until the whole library has been through (the app sends the last 1,500 songs it queued).

Holding, menus and selecting

On a touch screen there are no buttons over covers. Every card once carried a heart, an info button, add to queue, play next and download; they cluttered the art and every one of them was already in the menu, so they went. What is left on a cover is a small green tick for "downloaded".

The finger lifting at the end of a hold is still a click to the browser. If it reached the page, the page's "click outside closes the menu" rule would close the menu the hold had just opened - which is exactly what the first version did. Every hold swallows its own lift, and html.holding sets overscroll-behavior: none so a selection drag cannot trigger pull-to-refresh.

The owner can delete from the same menu. Delete from library asks the server what it would remove ("1 file, 321 KB. It stays in the bin for 30 days"), moves the files into a hidden bin, and offers Undo in a message at the bottom.

Dropping files in

Dragging files anywhere onto the window does what dragging them into the right folder would have done, including working out which folder that is. On a phone, which cannot drag, Settings → Add media opens a file picker instead.

It is two steps, not one upload. POST /api/upload/plan takes the list of paths and answers where each would go, so the panel can say "14 files, 2 skipped, all going to Films" before a gigabyte moves, and can ask a question when it genuinely cannot tell (is this mp3 a song or an audiobook chapter?). Then each file goes up alone as the whole body of PUT /api/upload. The rules for where things go are on The library and dropping files in.

A browser's folder reader, readEntries, hands back at most a hundred entries at a time. Calling it once silently loses the rest of a large folder - the classic way to lose half an album. walkEntry calls it until it returns an empty batch, and a test builds a fake entry tree to hold that, because no automated drag can produce real filesystem entries.

Downloads and offline mode

Anything can be downloaded to the device: songs, albums, audiobooks, ebooks, documents, films, episodes and photos. Downloads live in the browser's Cache API (soundstorm-offline-v1), keyed by the very address the app would ask the server for, with a small index in localStorage. One index and one Remove therefore serve every kind of media.

Offline, the app stays itself rather than showing an error screen: each tab lists only what is on the device, Home shows one strip per downloaded kind, and search looks through the downloads. The browser's online event brings everything back.

The service worker's rules

The service worker (sw.js) exists so the app can be installed to a home screen. It is deliberately the smallest thing that does that, because every failure of a cache in front of a media server is worse than the problem it solves. It has three rules:

  1. Nothing under /api/ is ever cached. Search results, sessions and positions are live state; a stale one is worse than no answer.
  2. Media bytes are never touched. Any request with a Range header is left alone. A worker that answers a range request without honouring it breaks seeking in a way that looks exactly like a corrupt file.
  3. Page loads are never answered. It used to serve a cached shell when loading failed - which hid the browser's certificate warning after a reinstall or renewal and left "cannot reach SoundStorm" with no way past it. Now the browser fails the page itself, with the warning people know how to handle.

The one exception to rule 3 is opening the app offline for downloads, and it applies only on the real *.soundstorm.dev names, only when the network actually failed. Those names carry trusted certificates that renew themselves, so the trap rule 3 exists for cannot happen there. On an IP address, localhost or a self-signed certificate, the rule holds exactly.

What the worker does answer - the app's own /static/ files - is network-first. At home the server is metres away, so the cache is a fallback for the seconds it restarts, and an update can never leave anybody pinned to an old build. Requests it does not handle are never passed to respondWith at all, so the browser behaves as if no worker existed.

The worker is served from /sw.js, not /static/sw.js: a service worker's scope is its own folder, and from /static/ it could never control /, the only page there is. It would register, report success and intercept nothing. The manifest is likewise served by hand, because Go's MIME table has no .webmanifest and Chrome ignores a manifest not sent as JSON.

Installing it as an app

On an iPhone, Share → Add to Home Screen works from any address. On Android, Chrome's Install app only appears where a service worker can register - and Chrome refuses to register one on an origin with a certificate error, even after the warning was clicked through. So the app installs only from the secure https://<id>.home.soundstorm.dev:8099 name, which has a real Let's Encrypt certificate (see Certificates and names).

For a long time every installability test ran against localhost or with certificate errors ignored - both of which count as secure whatever the certificate. The test truthfully reported a registered worker while a real phone could not install the app at all. A check that cannot see the failure it is meant to catch is worse than none, because it is believed.

The content security policy, and no inline scripts

The shell is served with a strict Content-Security-Policy, and it is load-bearing rather than hardening. Books can contain scripts, and the reader renders chapters in a blob: frame that shares SoundStorm's origin; script-src 'self' is part of what keeps a stranger's EPUB from running JavaScript with the session.

The cost is that inline <script> is refused - silently, except in the console. The service-worker registration first shipped inline; the app worked, nothing looked wrong, and "Add to Home Screen" made a bookmark instead of an app. The fix was never to loosen the policy but to move the script into sw-register.js, and TestShellHasNoInlineScript walks the shell so the next one cannot slip in. Files under /static/ carry a no-script policy of their own, and no folder listing or second copy of the page is served there.

Settings, and the back button

The account page is a page of its own, not a card pushed into the library. Its cards are grouped into pills - Library, Playback, Devices, People, Account - which can be reordered and swiped like any other row. While Settings is open, the search box searches settings: every card containing all the words typed, across every pill. Per-device choices (streaming quality, film quality, keep the screen on, audiobook loudness) live in localStorage; per-person ones (pill order, cover look, audiobook speed) live on the account.

Three account cards arrived together. On this device lists who is kept on the device for "Who's listening?" and sets a PIN; Sign in a TV takes the code a TV shows (a scanned QR code opens the page at /?link= and asks "Sign in a TV?" instead, then takes the code out of the address); and while new devices need approval, any signed-in page is asked to allow a waiting sign-in. A 403 with mustRenew from any request puts up the screen for choosing a new password (showRenew). See accounts.

The app never changes its address, so for a while Android's back gesture had nothing to go back to and closed the app from inside a film. Now one history entry is kept armed while anything back should close is open (backTarget, in order: menu, selection, photo, book, film, Now Playing, Settings, a detail page, a search, any tab but Home). Back pops it, the top thing closes, and it is armed again if anything is left. Something closed by its own X takes the entry away, so back never needs pressing twice.

Switching page swaps the old content at once for grey placeholder covers with a slow shimmer (showSkeleton). The previous page sitting there read as the new one, and the word "Loading..." read as stuck.

Mobile layout lessons

Most of these were found by measuring - a script asserts the page is no wider than the screen on every view - rather than by looking.