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.
| File | Job |
|---|---|
index.html | The shell: one page, every screen hidden or shown by class. |
app.js | Almost everything - the library, search, the player and Now Playing, the visualizers, settings, downloads, the TV mode. About 17,000 lines. |
reader.js | The book reader, around the vendored foliate-js (see The reader). |
sw.js, sw-register.js | The service worker and the file that registers it. |
style.css | All styling, including the TV mode (html.tv) and the safe-area variables. |
manifest.webmanifest | What 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:
- They can be put in any order. Hold one for 450ms and slide it; the others
move aside as it passes their middles. The order is kept on the account
(
GET/PATCH /api/prefs), so it follows the person to every device - the Apple TV reads it too. - The + at the end lists the pills that were put away; drag a pill onto it to put it away. Genres start put away. An empty list is stored as an empty list and never as null, or "I added Genres back" would read as "use the defaults" and hide it again.
- A sideways swipe steps between pills anywhere below the header, as pages of one strip. The moment the gesture locks sideways, the current page is moved into a "ghost" laid where it was and the neighbouring pill is pressed, so the next page is already loading while the finger is still down.
- The lit pill sits in the middle of its row, with spacers at each end so the first and last can get there.
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".
- Hold (450ms) opens a card's menu. Moving more than 10px cancels it - that is a scroll - and lifting early is a tap.
- Press, slide, release. A menu opened by a hold can be used without lifting the finger: slide over an option and it lights, lift on it to choose it. Lifting without having moved leaves the menu open to tap.
- In a shelf's list, a hold selects instead. Keep the finger down and drag on to another card, and everything between is selected, as a phone's photo gallery does. The bottom 120px of the screen scrolls the page, so a drag can reach any number of items. Hold any selected card to get the menu for the whole selection.
- A mouse uses right-click, the menu key or Shift+F10; a hover "..." button appears only for a fine pointer.
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.
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.
- Downloads come first even online. A downloaded song, book, film or photo is always read from the device.
- A film a browser can play is kept as it is. One it cannot is kept as the
HLS stream the app would have played - playlist, every piece and
hls.min.js- and played offline from a playlist rewritten toblob:addresses. The playlist is cached last, so an interrupted download is never mistaken for a whole one. - Positions are kept on the device too, marked unsynced until a save reaches the server; an unsynced position wins on the next open.
- Signing out clears it all, and the device remembers whose downloads they are, so a shared tablet does not hand one person's downloads to the next.
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:
- Nothing under
/api/is ever cached. Search results, sessions and positions are live state; a stale one is worse than no answer. - Media bytes are never touched. Any request with a
Rangeheader 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. - 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.
/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).
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.
minmax(0, 1fr), not1fr. A grid item's minimum width is its content's, so one unbreakable line pushed the first screen to 531px on a 390px phone.- Two explicit columns on a phone.
auto-fillwith 168px tracks made one column, and every result filled the screen. - Inputs at 16px. iOS Safari zooms in on any smaller input and does not zoom back out.
overflow-x: clipon the body and#app. A page mid-swipe hanging off the side made the page wider than the phone, which zoomed out and threw the tab bar around.cliprather thanhidden, so sticky headers keep working.- Safe areas through variables. Every edge uses
--safe-topand its siblings, which default toenv(safe-area-inset-*)but can be set by the Android app, whose web view reports zero for the camera cutout it draws into. A safe-area rule must also be in the rule that finally sets the padding, or a later rule silently overrides it. - Fractional header height.
--header-his measured withgetBoundingClientRect;offsetHeightrounds, and left a sliver through which the page showed above the pills. - Icons are SVG, never characters. The "⋯" glyph rendered as three dashes in the UI font, and hearts sit on a different baseline in every font.
- No tap highlight on cards. Chrome's tap flash is a rectangle over a button's whole box, however round the cover inside. It cannot be screenshotted, so it is switched off by name and replaced by the cover settling to 97%.
- Hover styles only for real pointers. On a touch screen
:hoversticks to whatever was last tapped.