Terminal soul, native body. An Android radio player for cliamp radio and the 53,000-station Radio Browser directory.
Built from the Kleeamp.dc.html concept: one monospace face, hairline rules
instead of cards, phosphor green as the only accent, and transport keys with real
mechanical travel.
Grab the APK from releases, or:
gh release download --repo cliamp/kleeamp --pattern "*.apk"
adb install -r kleeamp-*.apkTwo workflows, first-party actions only.
build.yml compiles debug and release on every push. Release is in there on purpose:
R8 only runs on release, and a missing keep rule compiles clean then dies at startup.
release.yml fires on a v* tag, builds a signed APK and attaches it to a GitHub
release. Version comes from the tag, versionCode from the run number, so each build
installs over the last.
git tag -a v0.0.2 -m "kleeamp 0.0.2"
git push origin v0.0.2Signing uses a stable release key held in repository secrets, not the debug key. CI
generates a fresh debug keystore every run, so debug-signed releases would each carry a
different key and could never be upgraded over. The workflow refuses to publish an APK
whose certificate reads CN=Android Debug.
kleeamp-release.jks and keystore.properties are gitignored and exist only on the
author's machine. Losing both means no future build can install over an existing one.
This is one workspace of a monorepo; everything below runs from android/.
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkRequires JDK 21 (set in gradle.properties), Android SDK 36, and a local.properties
with sdk.dir. Release builds are signed with the debug key; swap signingConfig in
app/build.gradle.kts before shipping anything.
| Screen | What is on it |
|---|---|
| PLAY | Station, live ICY track title, brick meter, --- STREAMING --- rule, transport |
| LIB | cliamp's 15 channels with live listener counts, then the directory, paged 60 at a time |
| QUEUE | Up next, favourites, history |
| :CMD | Command bar with its own keyboard: :play, :tag, :country, :eq, :random |
| SCOPE | 32-column spectrum off the real FFT, 7-band equaliser, presets |
| STATS | cliamp radio listeners, 31-day session history, top countries and cities |
| SERVERS | Music servers: add one through a wizard, then browse and play it |
| SETTINGS | Buffer depth, cellular, haptics, visualiser, dark/light/system palette |
Playback runs in a MediaSessionService, so the lockscreen, notification and Bluetooth
controls all drive the same player.
Point kleeamp at a music server you own. Navidrome first, and because it speaks Subsonic the same client covers gonic, airsonic and other Subsonic servers.
Adding one runs a wizard: server URL, username, password, then a probe against
/rest/ping.view. Nothing is written until the server answers, so a typo fails in the
wizard rather than surfacing later as an empty library. Browsing gives newest, most
played, A-Z, artists and starred, drilling into an album's tracks; playing a track queues
the whole album.
Two things worth knowing about the design:
- The spec is declarative, ported from cliamp desktop's
cmd/setup.go. A provider is a name, an intro, a list of fields and a validate function, so adding the next one is a data change rather than a screen.FieldSpec.onlyIfis carried over because Jellyfin accepts either an API token or user plus password. - Credentials go through the Android Keystore, AES-256-GCM, not the preferences
DataStore the settings use. Subsonic signs every request with
md5(password + salt), so the password has to stay retrievable rather than being traded for a token once. For the same reason a track's stored url is an opaquecliamp-provider://reference signed at play time: a real stream URL embeds a token that never expires, and persisting one to history would put a replayable credential in a plain file.
The one provider that is not a music server. Give it a host, a password or a pasted
private key, and one music folder per line; add as many hosts as you have. Leave the
folder field empty and the probe looks in ~/Music, /srv/music and the other usual
places and fills in what it finds.
There is no album endpoint on a filesystem, so the tree is walked once over SFTP and
kept in sftp_tracks. Tags are not read: pulling the header of every file would be tens
of thousands of round trips, and Artist/Album/01 - Title.flac already says all of it.
Rows are written as they are found, so the library fills in while the walk is still
going, and a rescan replaces rather than merges - anything still carrying the previous
scan's id at the end is what has since been deleted from the server.
Playback is a real stream, not a download: SftpDataSource opens the remote file at
DataSpec.position, so seeking inside a track works and nothing is staged on disk.
Connections are pooled three per host - the playing track, the one the player primes
behind it, and a scan alongside both.
Tailscale SSH works and needs no credentials at all: pick Tailscale in the wizard and
give it a host and a username. tailscaled terminates the connection itself and
authenticates on tailnet identity - the WireGuard session is the credential - so it
offers no password or public-key method, only none. The client sends none first in
every case, the way OpenSSH's own does, so a Tailscale host works whichever credential
type an account was set up with. It does serve the SFTP subsystem, so browsing and
streaming are unchanged.
Two things to get right on the Tailscale side: the phone needs to be on the tailnet (the
Tailscale app is a system VPN, so kleeamp's socket rides it like any other app's), and the
ACL wants "action": "accept" rather than "check" - check mode asks for a browser
re-auth that a background media player has nowhere to show.
Host keys are trust-on-first-use and pinned afterwards. The first probe shows the
fingerprint in OpenSSH's own format, so it can be checked against ssh-keygen -lf on the
server, and it is stored against the host it was seen on - repointing an account at a
different machine is a different host, not a key change.
sshj and BouncyCastle cost about 2.3 MB of the release APK. Android ships a cut-down
BouncyCastle under the name BC that is missing most of what a current key exchange
needs, so the SSH layer removes it and registers the real one; TLS still goes through
Conscrypt and provider secrets still go through AndroidKeyStore.
Streams carry no cover art, so the app looks for the station's own branding: the og:image on its homepage, then the apple-touch-icon, then the favicon the directory recorded. About 23 of 25 top stations yield something usable. Whatever turns up is letterboxed onto the plate rather than centre-cropped, because most og:images are 1200x630 wordmarks and a square crop cuts them in half.
cliamp's own channels are excluded on purpose. cliamp.stream has an og:image, but it is a marketing screenshot of the desktop app; the generated plate is per-channel, already square, and reads better.
Artwork loads after playback starts, never before, so a slow homepage cannot sit between the tap and the audio.
Live radio dies in two ways and only one of them raises an error.
A hard failure (socket dropped, server 502, DNS gone) surfaces as onPlayerError, after
which ExoPlayer parks in STATE_IDLE and never retries on its own. A silent stall keeps
the connection open but stops delivering, leaving the player in STATE_BUFFERING
indefinitely with no error at all. The second is the common one on a train.
Reconnector handles both: exponential backoff (1s, 2s, 4s, 8s, 15s, then every 30s)
for errors, a 20 second watchdog for stalls, and a ConnectivityManager callback so
coming back into signal retries immediately instead of waiting out the backoff. Malformed
containers and unsupported codecs are not retried, since those will never succeed.
The player shows RECONNECTING . n in amber while this runs. Red stays reserved for
destructive actions.
Radio has no cover art, so the app draws its own: the striped plate and the phosphor mark, generated per station at 512px. That is not decoration. Android derives the media player's background and accent from the artwork, so with no artwork the notification is grey system chrome and with it the whole chip picks up the green.
A one-item queue also means Media3 offers no prev/next, which leaves a single lonely play button. Three custom session commands replace them: previous station, favourite, next station. Station stepping walks your favourites, or cliamp's channels if you have none, exactly as the widget does.
A resizable widget and a quick settings tile. Radio is worth a widget in a way a music library is not: you nearly always want the same few stations, so the widget skips browsing entirely.
| Size | What is on it |
|---|---|
| 2x1 | Station and play/pause |
| 3x1 | Adds the live track, prev/next, and the --- STREAMING --- rule |
| 4x2 | Adds quick-tune chips for your favourites, or cliamp's channels if you have none |
Add it from Settings -> Home screen -> Add widget, or from the launcher's widget picker. The tile goes in from the quick settings editor.
Widget taps go through a MediaController rather than startForegroundService. The
intent route looks cheaper, but it gives the service five seconds to call
startForeground and tuning a station has to read preferences and follow a playlist
redirect first; on a slow connection that window closes and the system kills the app.
radio.cliamp.stream/streams.m3useeds the channel list at launch. The fifteen built-ins inCliampRadio.ktare the offline fallback.radio.cliamp.stream/statisticssupplies live listener counts and daily history.all.api.radio-browser.infois resolved at runtime for the directory, with failover across mirrors. Plays are reported back to their click counter.
../docs/design.md is the source of truth for palette, type scale, control geometry and
the brick meter, read out of ui/theme/ rather than out of the concept. ../docs/concept.md
is the original spec and ../docs/Kleeamp.dc.html the twelve artboards it describes.
Open the HTML in any browser. It renders standalone: the canvas runtime it was authored with is not included, and does not need to be, because the artboards are plain HTML and CSS and the font link and keyframes still apply from the body.
The five built-in palettes in ui/theme/Palette.kt began as sRGB conversions of the
concept's oklch values; ui/theme/OmarchyThemes.kt adds twenty-two more, generated from
the themes Omarchy ships. Two rules worth repeating: amber means "this lives somewhere
else" and nothing else, red is only ever destructive, and there is never a third accent
hue.
Settings → themes → Import theme file turns a JSON file into a theme. The
file maps every palette role to a hex colour, plus the mode flag — the whole
32-role contract from ui/theme/Palette.kt, not just a ground and an accent,
because a partial theme would leave bevels and meter cells sitting on it as
grey overlay:
{
"dark": true,
"canvas": "#0A0D0A",
"ground": "#0A0D0A",
"ink": "#F2F7F3",
"accent": "#73E889"
}A good import applies immediately as the custom theme (with swatches, like
the rest) and follows into the widgets. An optional "name" in the file
("name": "noir dawn") labels it in Settings instead of custom. A bad one
is refused with the exact fault named (missing "accent", bad colour for "ground": blue) and nothing is written. A left swipe on the imported theme
deletes it, falling back to system.
Current limits, all temporary: JSON only (no TOML import yet), no live editing (change the file, import again), and an absent or broken custom theme can never brick the app — it falls back to system.
Where the build departs from the document, and why:
| Concept | Here | Why |
|---|---|---|
| Scrubber with playhead | Both, chosen per source | Seekable sources get the scrubber; live streams get --- STREAMING ---, matching renderSeekBar() in the cliamp TUI. |
| Monogram plate | Real art where it exists, a flat plate with the item's initial as fallback | Local tags, provider getCoverArt, then a station's og:image. None of that is invented art; the plate is one surface with its own first letter - no patterns that could be mistaken for a real cover - and it still covers the misses. |
| Amber means remote host | Also means reconnecting | Red is reserved for destructive actions, so amber was the only honest choice left. |
| Identity strip on the player | Removed | Its format readout moved into the meta line under the title. |
| Three descending bars as the mark | The real eight-bar logo | The concept's mark was a stand-in for exactly this. |
| Remote hosts screen | cliamp radio statistics | Same amber semantics, pointed at the thing that actually is remote. |
| One monospace face everywhere | Poppins for text, JetBrains Mono for numeric readouts | One face could not carry both an editorial title and a clock that ticks without shifting. The widget falls back to system mono either way: Android widgets cannot load res/font. |
| Two palettes | Twenty-seven | Every Omarchy theme, generated into the same 32-role contract. |
| Animated brick meter | Static rule in widget and notification | Neither surface can animate. |
Free to use, copy and modify for yourself, see ../LICENSE. You may not
distribute it, sell it or sublicense it.
Bundled third-party components keep their own terms, listed in
../THIRD-PARTY-NOTICES.md. JetBrains Mono ships under the SIL Open Font License,
so ../licenses/JetBrainsMono-OFL.txt has to travel with any build.