Android

A small Kotlin app around the server's own web page. The page still decides everything; the app adds what Android's web view cannot do - a real music player that survives alarms, calls and a locked screen, photo backup, the full screen - and the same app installs on Google TV, Android TV and Fire TV.

A web view shell

The app lives in android/: a handful of Kotlin files, no Compose and no AppCompat. On first launch it asks for the server's address, checks that /healthz answers like SoundStorm before keeping it (so a typo is caught at the door, not as a strange page), and then shows the page edge to edge. It answers alert, confirm and prompt, sends links off the server to the browser, offers Try again or Change server when a load fails, and hands file pickers and a film's full screen to the platform.

FileJob
MainActivity.ktThe web view, the system bars, navigation rules, the back gesture
PageScript.ktScript run in the page before its own, on the server's origin only
NativeAudio.kt, AudioService.ktSongs played natively by Media3
PlaybackService.kt, MediaBridge.ktThe lock-screen player for what the page plays itself
PhotoBackup.ktPhone photo backup
WebCookies.kt, ServerAddress.ktCookies for native requests; the saved servers

The web app cannot tell the Android and iPhone apps apart, by design. Both give the page window.soundstormApp and the same message channel (window.webkit.messageHandlers.soundstorm.postMessage), so "Change server" and the other app-only features needed no Android-specific code in app.js.

PageScript and the message channel

PageScript is injected with addDocumentStartJavaScript, for the server's origin only, so it runs before any of the page's scripts. It sets up window.soundstormApp, the message channel, and a navigator.mediaSession - which Android's WebView simply does not have. The page already describes what is playing through that API (title, artist, cover, position, which buttons work), so the stand-in passes it to the app for the lock screen and notification, and hands button presses back to the page's own handlers.

The channel is addWebMessageListener, limited to the server's origin and the main frame - not addJavascriptInterface, which every frame of every origin would see, EPUB chapters included. A book should never be able to talk to the app.

Native audio with Media3

The first versions let the page do all the playing, with a foreground service holding the media session. Two bugs came from that, both reported from a real phone:

Each got a workaround, and then the owner chose the real fix: songs from the server are played by Media3's ExoPlayer in a media session service (AudioService), which Android treats as a music player. It keeps the foreground, pauses for an alarm or call and plays on after, pauses when headphones come out, and gives the lock screen, notification and car their controls.

The page still decides everything. PageScript gives the page's <audio id="audio-player"> a stand-in on the element itself, installed the moment it is parsed. Setting its src to a song, play, pause, seeking, volume and speed become messages to NativeAudio, and the player's state comes back every half second as the element's own events - playing, pause, timeupdate, ended. The lyrics, the looks and the timeline keep reading currentTime as before and never know the difference.

What stays in the page: downloads (they are blob: addresses the native player cannot read), audiobooks and crossfade's second element, which is why crossfade is off in the app. For those, PlaybackService still provides the lock-screen player from the page's media session.

Handing over the next songs

The next song is handed to the player ahead of time (queueNext), so it moves into it by itself - gapless, never stopping between songs. It then tells the page the song ended; the page sets the next song as always and finds it already playing.

Android also reclaims a background web view's memory by ending its page. The app used to tear the old page down in a way that told the player to stop, so music stopped while somebody was in another app. Now a page Android ends is dropped without touching the music, and since a page that is gone cannot hand over the next song, the page hands over the next ten up front (queueUpcoming), each with its title, artist, album and cover for the lock screen.

The page takes the music back

When the app is looked at again and the page is rebuilt, it asks the player what it is doing (askState). The page keeps its queue on the device as each song starts (saveNativeQueue, up to 220 songs around the one playing). If the song playing is in that kept queue, the page plays it through its normal path, and the stand-in recognises it as the song already in the player: nothing is reloaded, the position is the same, playing or paused, with the queue rebuilt around it.

Two smaller fixes from the same work: a song that had played to the end would not play again (a finished ExoPlayer must be sent back to the start, and the same song asked for again looked "already loaded"), and after the player moved into the next song by itself, the stand-in kept reporting the old one until it started afresh on ended.

Photo backup with WorkManager

A phone's photos and videos are sent to the person's own photo folder on the server (PhotoBackup.kt). The page decides nothing about the mechanics: Settings → Your photos has the switch and options and talks to the app through soundstormApp.backup(...); the app does the work.

After signing in on a phone with Pictures, the page asks once, "Back up this phone's photos?", and tells a member plainly that the owner of the server can see them. It is not offered on a TV. On a shared phone, signing out (or another account appearing) turns backup off, the phone sends the account it was turned on for, and the server refuses photos meant for somebody else; the next person is asked afresh. Backup goes to the secure name the page was on, never plain http on the Wi-Fi when a secure name exists.

Several saved servers

Since 0.19 the connect screen lists Your servers above the address box: the latest used first, the one in use ticked, a tap to switch, a long press to rename or remove one (ServerAddress.all, kept as a list beside the current address). Each server keeps its own sign-in, since cookies belong to an address, so switching back asks for nothing. The iPhone and Apple TV apps have the same list.

Full screen and the camera cutout

Both system bars are hidden everywhere, at the owner's asking - something the installed web app could never do, since Chrome owns its bars. A swipe in from an edge shows them for a moment. The app draws into the camera cutout, so there is no black strip over the camera.

The catch: the web view reports zero for the cutout it draws into (measured: a 136-pixel cutout, env(safe-area-inset-top) read 0). So the stylesheet never uses env() directly. Every edge is spaced by --safe-top and its siblings, which default to the env() values in a browser and on the iPhone, and which the Android app sets to the real cutout size in CSS pixels. That change was first made only on an Android branch, whose web files the server never serves - so the installed app's title sat under the camera until it was brought to main. It is why the project now has one branch.

The status bar takes the page's theme colour, watched with a MutationObserver on meta[name=theme-color], so it matches Now Playing's backdrop.

The back gesture

Back steps back through the page's own history first (closing a menu, Now Playing, a film - see the web app), and with nothing left it moves the app to the background rather than closing it, since closing would stop the music. Android 16 no longer calls onBackPressed for an app built for API 36, so the system closed the app from inside a menu; MainActivity now registers an OnBackInvokedCallback that does the same job.

Security

A review pass looked at the app with fresh eyes, and version 0.16 fixed what it found:

Plain http is still allowed, because an install is often first reached by its LAN address and Android cannot allow only private address ranges.

One thing is for the developer only: on the install that has training switched on, Settings in the app can send a playback report - what the native player saw (volume, audio focus, outputs, the page's commands) - for chasing sound that cuts out mid-song (0.20). No other install shows it.

Releases

APKs are built on the Windows development machine from android/ on main (gradlew assembleDebug), with versionCode raised every time so a phone installs over the last one. They are published as GitHub pre-releases named android-<version> and never marked Latest, because the Latest release is where the server's Windows installer lives.

The same app on a TV

Google TV, Android TV and Fire TV run this same app, not a second one. The manifest adds the leanback launcher category and a banner, and marks leanback and a touchscreen as not required so phones install it exactly as before. On a TV the web view's user agent gains SoundStormTV/1, and the page switches into its TV mode: remote-control focus, films full screen, no mini-player. That is described on TVs.