Commit Graph
111 Commits
Author SHA1 Message Date
KeelrLeek f50cf2018a android auto: a tap in the car plays, with no Activity anywhere in the process
Android Auto starts this app by binding a service. There is no Activity, no
WebView, no `tauri::AppHandle` — and until now no way to make a sound, because
the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it
into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it,
`playback::init` on a normal launch or a car tapping a row into a process with
no UI, and everyone after that gets the same one.

**That singleton is the whole safety story.** `Player::spawn` opens an output
stream, so two of them in one process is two AAudio streams playing over each
other — the failure this module exists to make impossible. The event receiver
is single-consumer, so the pump lives there too and offers a subscription slot
for the WebView to claim when it finally appears, rather than a second engine
being stood up beside the first.

The queue here is deliberately not the app's queue. The JS one keeps shuffle,
repeat, `alts` failover, provenance and the three-ahead preload; this is a
vector and an index whose only job is that the album keeps playing when there
is no JS alive to advance it. Its metadata is read ONCE, when the queue is
built, so advancing never touches the database again — which matters when the
thing advancing is a background thread in a process with no UI.

Resolution happens in Rust, against the library, read-only. A tapped
`ss:track:<id>` brings its whole album at the right index, because selecting
one song and getting silence after it is not what a record does. Nothing
trusts a URI from the controller: the tree only ever publishes `ss:` ids, so
there is no file path for anyone to hand back, and an id that resolves to
nothing returns a count of zero rather than a guess.

Kotlin's half is three things that each fail invisibly on their own.
`COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn
as playable and does nothing when tapped. `onAddMediaItems` passes ids through
instead of media3's default `UnsupportedOperationException`, which kills the
tap before it reaches the player. And `handleSetMediaItems` does the work on
its own thread, because the first tap in a cold process opens a database AND
starts an audio engine, and it is called on the application thread where ten
seconds is an ANR.

**Two things the device taught, both of which looked like silence.** The
headless path has to request audio focus itself: the first run showed a live
AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain
at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car
tapping a cold process has no JS to ask. And it has to write the card itself:
`getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so
the second run played audio while the session still reported `STATE_NONE` with
no title — a head unit showing a blank player over music. The engine knows what
it started, and that now goes into `MediaHub.update` in the shape the card
already reads.

Proven by the prober, which gained a `play` mode for it: bind cold, issue
`playFromMediaId` through the session token the browser handed back — Auto's
own path, not an interface of ours — and read the state back. `ss:track:1`
answers `queued=13` (the album behind it), and the controller reports
`state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across
the run. `probe.sh <depth> <recent> <mediaId>` is the invocation.

**Not done, and it is the display rather than the audio.** Nothing tells Kotlin
when the fallback queue advances, so the card will keep showing track one while
track two plays. The position moves on its own — `SimpleBasePlayer`
extrapolates it while playing — but a track change and a seek both need the
pump to push, and the pump currently only speaks to a subscriber that exists
when there is a WebView. The handover the design asks for (a UI arriving over
live headless playback, and exactly one queue driver surviving) is untested for
the same reason.
2026-09-15 11:49:40 -05:00
KeelrLeek 3f56f274c5 android auto: the covers, and the grid a record is owed
Round fifty put the tree in the car with every row a bare line of text. This
gives the rows their sleeves and asks for albums to be drawn as a grid — the
two things the design picked that a tree alone cannot express, and the only
parts of a car screen the app actually controls.

**Artwork goes out as a `content://`, not as bytes.** media3 will carry a cover
as data in the metadata and the legacy stub Android Auto binds turns that into
a bitmap per row — inside a single Binder reply with a one-megabyte ceiling for
the whole list. Two hundred rows of even thumbnailed JPEG is a
`TransactionTooLargeException` on a list that worked yesterday. A URI is a few
dozen bytes and the host fetches only what it draws. The provider stays
`exported="false"`; each browse grants read on its own URIs to the package that
asked, which `onGetChildren` already knows.

Reaching the library needed a new `file_paths.xml` in the res overlay, and it
needs BOTH spellings of one directory. Covers live under the app's DATA dir,
which `files-path` cannot reach — that tag means `getFilesDir()`, the `files/`
subdirectory. `root-path` can, but FileProvider resolves a file through
`getCanonicalPath()` before matching, and for the primary user
`/data/user/0/<pkg>` canonicalises to `/data/data/<pkg>`, so a root scoped to
`/data/user/` never matches on a phone. `/data/user/` still earns its place:
a car's driver profile is user 10 and does not canonicalise away.

The grid is `CONTENT_STYLE_BROWSABLE_HINT` on the parent's `LibraryParams` —
grid for `ss:albums` and for an artist's albums, list for artists and tracks.
Those keys belong to the host rather than to media3, which is why the same
constants serve a phone-projected car and a head unit alike.

**Three failures, one cause: a catch with nothing in it.** `artUri` returned
null on any throw and said nothing, and every row came back coverless with the
database, the res overlay and the APK all checking out — the binary XML was
dumped out of the APK to prove the root was in there. It cost an afternoon and
two wrong diagnoses. One `Log.w` in the catch named both real bugs in a single
run: `JSONObject.optString` returns the four-character string `"null"` for a
JSON null rather than `""`, so the tabs were handing `File("null")` to the
provider (`isNull` is checked first now); and the canonical-path substitution
above, which is visible ONLY in that exception's text — `readlink -f` on the
device does not show it, and believing it cost the first wrong answer. The
warning latches to one line per process, because two hundred identical traces
would bury the tree logging it sits beside.

The prober reports each row's `iconUri` now. A tree walk could say a row
arrived but never whether it would be drawn with anything, which is the one
thing this round changes, and `art:""` against a real `content://` is the whole
difference.

Verified on the Pixel 6a through Android Auto's own binder with the app cold:
both albums come back carrying
`content://com.soulstream.app.fileprovider/ss_library_canonical/...cover.jpg`.
The car screen itself is still the Desktop Head Unit's to show.
2026-09-15 11:11:35 -05:00
KeelrLeek 515b3da71f android auto: the tree under the root — four tabs read cold out of library.db
Round forty-nine put SoulStream in the car with an empty root. This fills it.
`onGetChildren` answers the four tabs Kyle picked — Recent · Albums · Artists ·
Saved — and everything under them: albums with their artist and track count,
artists grouped the way the app groups them, an album's tracks in disc and
track order, and every local file under Saved. `src-tauri/src/auto_browse.rs`
is one JNI export over `library.db`, and `AutoNative` in the plugin is the
Kotlin half.

**It reads read-only and it never calls `Library::open`.** That constructor
runs `PRAGMA journal_mode = WAL` and a `CREATE TABLE IF NOT EXISTS` batch —
writes, both of them — and a service that takes a write lock on the library to
draw a list is a bug waiting for a long drive. The path is derived the way
`config::download_dir` derives it, but without it: that function takes a
`tauri::AppHandle` and there is no Tauri in this process, so `config.json` is
parsed read-only for `download_dir` and the default is `<dataDir>/Library`,
with the database under `.soulstream/` as the portability rule requires.

**`AutoNative` calls `System.loadLibrary("app_lib")` itself, and that is the
round's other half.** When Auto binds a cold app there is no Activity, so
nothing has loaded the Rust side and none of it exists. Loading it here also
runs `JNI_OnLoad`, which publishes the JavaVM and the app Context to
`ndk-context` — the same hand-off the audio backend needs, arriving before
there is any audio, which is what round fifty-one stands on. `loadLibrary` is
idempotent, so it costs nothing when the app is already up.

Two things a head unit taught that no unit test would have. **The root must not
depend on the database.** The four tabs are a constant, and gating the whole
dispatch on a connection meant one unreadable library cost the car its entire
structure — it read as "Media isn't available for this list", which names
neither the tabs nor the reason. A library that cannot be read should cost the
ROWS, never the shape. And **`log::warn!` goes nowhere in a cold service
process**: nothing has run Tauri, so no `android_logger` is installed, and the
first failure here was completely silent. It writes to the platform log
directly now, which is the only reason the second failure had a cause attached.

`open_read_only` falls back to a read-write handle it immediately sets
`query_only` on. A read-only connection to a WAL-mode database needs the `-shm`
beside it and cannot create one, and the mode lives in the header, so it does
not go away when the sidecars do. This has NOT been observed to bite — the one
failure that looked like it was SELinux, below — but the fallback is cheap and
the connection still cannot change a row. `CREATE` is in neither flag set, so a
missing database stays an error rather than becoming an empty one.

Verified twice. The prober walks it cold on the phone AVD: 12 nodes, 139
leaves, 2.26 s, with the album tracklists in the right order and both artists
carrying the right albums. And it is on an Android Automotive head unit —
`ss_car`, API 34 — showing the four tabs, eleven Recent rows and five albums
with their counts, which is the first time any of this work has been seen
rendered by a car rather than described.

**Not done, and deliberately not guessed at.** There is no artwork: the plan
called for cover art through the existing FileProvider via a `files-path`
entry, but the library lives under the app's DATA dir and `files-path` maps to
`getFilesDir()`, which does not reach it — so the mechanism needs deciding, not
patching. And the rows are all lists; the grid Kyle chose for albums is a
content-style hint that is not set yet. Both are presentation over a tree that
is now correct, and both want the car in front of them.
2026-09-15 10:05:39 -05:00
KeelrLeek a44b4422fd android: the same SIGPIPE race, in the two focus checks it has not lost yet
deploy.sh and dev.sh both ask which app is on screen with
`echo "$FOCUS" | grep -qi`, under the same `set -o pipefail`. $FOCUS is one
line, so echo has always finished writing before grep -q exits and the race
has never been lost — but it is the identical shape that made verify-icon.sh
report a missing file in an APK that contained it. Herestrings, so there is no
writer to kill.
2026-09-15 07:48:09 -05:00
KeelrLeek c36ec439ce android: the icon gate answered fail / pass / fail to the same APK
Round 44's verify-icon.sh had never run inside deploy.sh — only by hand — and
it does two things there that it cannot do standalone.

deploy.sh exports MSYS_NO_PATHCONV=1 for its adb calls, and that switch is
global, so the APK path reached aapt2 as /f/Coding/... Windows binaries cannot
open an MSYS path, and aapt2's answer is "failed opening zip: I/O error",
which reads exactly like a corrupt APK rather than like a path. The four
aapt2 calls now get cygpath -w explicitly; unzip and cmp keep the MSYS form,
because they want that one.

The second is worse, because it is intermittent. Every membership test was
`echo "$VAR" | grep -q PATTERN`, under the `set -o pipefail` at the top.
grep -q exits the moment it matches; echo is still pushing 70 KB of unzip
listing into a pipe that just closed, takes SIGPIPE, exits 141 — and pipefail
promotes the writer's status to the pipeline, so a check that FOUND what it
was looking for reports failure. Whether it fires depends on where the match
falls in the stream, so three runs against one unchanged APK gave fail, pass,
fail, each naming a different missing file. Six of those, now herestrings:
no writer process, nothing to kill. Eight runs green afterwards.

Worth saying plainly: the APK was correct the entire time. The gate was
wrong, and a gate that is green at random is worse than no gate, because the
failure it invents points at the build.
2026-09-15 07:43:54 -05:00
KeelrLeek da4abeb8c9 android: the launcher's ground is the desktop's, not a flat colour under it
The icon round left the adaptive background as one `#FF05080B` and a note that
a gradient was being mocked. The mock settles it: the falcon covers about a
quarter of a circle-masked tile, so the ground is three quarters of what
anyone sees, and concept 04's glow survives every mask — the visible top-left
corner goes from `#05080B` to `#0E3543`. So `@color/ic_launcher_background`
becomes `@drawable/ic_launcher_ground`, a vector carrying the desktop icon's
own linear surface and the radial glow over it, every number
`src-tauri/icons/icon.svg`'s multiplied by 108/1024 and nothing else. The
colour resource stays where it is, unreferenced, because its filename is the
only thing stopping tauri's own `#fff` from colliding with it.

A vector and not five PNGs on evidence. The kit's glow has a uniform scale, so
the SVG ellipse is a circle and its rotation is a no-op, which is exactly what
`android:type="radial"` can carry; two numbers there and no vector could have
said it. The linear axis is 51.1° off horizontal, which rules out a
`<layer-list>` of shapes as well, whose angle must be a multiple of 45. It was
compiled and linked with the SDK's own aapt2 over the real merged res tree and
every stop read back out of the APK. The name is `ic_launcher_ground` and not
`ic_launcher_background` on purpose: the generated project still carries
Android Studio's template drawable of that name, so under the other name an
overlay copy that failed would silently link the green robot — under this one
it is a hard link error, which was tested too.

`verify-icon.sh` follows the layer. It does not ask whether the file is in the
APK; it asks what the adaptive icon's `<background>` resolves to, through the
resource table, in both build types, and then reads concept 04's six stops back
out of what it found — which catches a stale APK as well as a lost overlay.
Passing on a debug APK and on one put through `--shorten-resource-paths`,
failing on a stale one, and saying NOT CHECKED rather than guessing when aapt2
is absent. What none of it can see is a pixel: banding on the OLED, the glow's
weight under the Pixel's mask and how the falcon reads on a lit ground are a
launcher screenshot on the phone, and ANDROID.md says so instead of implying
the check covers it.

One slot the ground reaches is not the launcher at all. `MediaHub.appIcon()`
draws whatever `getApplicationIcon()` returns into a 512 px bitmap and hands
it to the lock-screen card as the large icon for any track whose cover has
not been decoded yet, so the card and the shade carry this ground as well —
the quickest place for Kyle to look at it, and the only reader of the
launcher icon inside the app. The walk reads that card's title and the
session's playback state and never its icon, so no step changes meaning. The
Android 12+ splash very likely changes as well, and the other way from what
the docs' one-line version of the rule suggests: the platform drops an
adaptive background only when it is a single colour close to
`windowSplashScreenBackground`, which round 44's flat `#05080B` was, so the
falcon has been drawing there alone and enlarged; a gradient is not one
colour, so it is kept and the whole icon is drawn. Likely, not proven — that
one is a screenshot too.
2026-09-15 07:22:02 -05:00
KeelrLeek d42645d2a0 android: the icon where it moves — the glyph, the lock screen and a splash that is not white
Both notification glyphs are the same falcon now, the logo kit's monochrome
path verbatim inside a group that re-frames it for a 24dp box — the playback
card's and the transfers notice's, because a note beside an arrow does not
read as one app, and the transfers one keeps the tray bar it always had so
the falcon dives into it. The fill stays white on transparent: the shade
composites a small icon from its alpha channel alone and tints it itself, so
a coloured export would arrive as a white blob.

The lock screen falls back to the app's own icon instead of a grey hole.
`MediaHub.art` is null from the moment a track changes until the frontend has
finished decoding its cover, and null forever for a track with none, so
`appIcon()` decodes the launcher icon ONCE — through the PackageManager,
because a library module's R has no `ic_launcher` — and hands it to both the
card and `METADATA_KEY_ALBUM_ART`. The app manifest lives in gitignored
`gen/` and declares no category, so the media-session plugin's own library
manifest contributes `android:appCategory="audio"` through the merger, and a
`Theme.App.Starting` on the brand ground replaces Android 12+'s white flash —
in `values-v31` and `values-night-v31` both, because the qualifier order puts
night mode ahead of platform version and a splash written only into
`values-v31` is silently ignored on a dark-mode phone.

The walk gains `media-card`: after the session block, the card is still
posted on the `playback` channel with the playing track's title on it, and
`media-keys` below it still gets the transport back — which is what a
drawable the shade cannot inflate would actually break. The glyph itself and
the splash are screenshots; `dumpsys notification` prints a resource id and
never a pixel, and the splash is gone before the walk's first frame.
2026-09-15 07:22:01 -05:00
KeelrLeek 4ebc38d5f3 android: the falcon on the launcher — a real adaptive icon, with the monochrome layer
The phone has been wearing tauri's logo. `tauri android init` writes the
generated project's `res/` from its own template and never reads
`src-tauri/icons/android/`, which has carried the falcon since 50afa6a, and the
detour build runs cargo and Gradle directly — so nothing ever copied it across:
the two dominant colours in the packaged `mipmap-xxxhdpi/ic_launcher.png` are
`#24c8db` and `#ffc131`, tauri's own brand cyan and amber. `src-tauri/android/res/`
is the app module's res now, tracked, and `build.sh` and `dev.sh` copy the whole
tree into `gen/android` before Gradle, because that project is regenerated and
gitignored and an asset that only lives there is an asset that disappears.

It is a real adaptive icon: the kit's foreground and its **monochrome** layer at
five densities, the legacy mipmaps under it, and the background as one
`#FF05080B` colour resource rather than five identical flat PNGs, so the gradient
being mocked separately replaces one file. The kit's own XML could not ship — it
points every layer at `@drawable/` while shipping them as PNGs under `mipmap-*`,
and in the generated project two of those three references resolve to Android
Studio's template robot rather than failing — so the overlay's XML says
`@mipmap/` and `@color/`, and its colour file takes the same name tauri's does,
because two files declaring one colour is a hard aapt2 error.

Losing the copy would be silent — the APK still builds, still installs, and wears
the old logo with nothing in the log — so `verify-icon.sh` opens every APK both
scripts make and reads the resources back out: the two `mipmap-anydpi-v26` XMLs
present, and four PNG names at five densities byte for byte from the overlay. A
release APK keeps neither the bytes nor the names — AGP re-encodes every PNG and
shortens every resource path — so that path asks `aapt2 dump resources` what each
name points at and compares pixel dimensions instead; 72×72 at hdpi still tells
the kit's icon from tauri's 49×49. No walk step: a launcher icon is not reachable
from inside the app, and the masking and the themed layer want a minute with the
phone's home screen.
2026-09-15 07:22:00 -05:00
KeelrLeek 07dad65ca5 android: the keep rules, and the release build that proves them
Every APK this port has produced is `assembleArm64Debug`, and the generated
project turns R8 on for `release` and only for `release` — while tauri finds a
plugin class by string, files every `@Command` under `commands[method.name]`,
and lets Jackson fill every `@InvokeArg` field by field. Minification breaks
exactly that, and it breaks it without a word: the app compiles, installs,
launches, shows Home, and then a tap on Library folder answers "No command
pickFolder found for plugin ..." in logcat and nothing on screen. So each
plugin module now carries its own `consumer-rules.pro` naming its own
reflected surface — the shape tauri-android and tauri-plugin-dialog already
use in this build, and one that travels with the plugin into a regenerated
`gen/android`, which a rule written into that gitignored tree would not.

What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`,
copied into the generated project by the build script because the app's own
Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn`
pair for the two `java.beans` classes jackson-databind references and Android
does not have (R8 turns a missing class into a build error, so without them
there may be no APK at all), and a keep for the four `@Command` methods
declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes
annotated `@TauriPlugin` and the members they declare; the base class carries
no such annotation, so nothing names `registerListener` — which is what the
lock-screen card's buttons and the download notice's Cancel and Open ride on.

`build.sh` grows `--release`: the release profile, `--features tauri/devtools`
so the walk can still attach at all, `assembleArm64Release`, and a zipalign and
sign with the same debug key the phone already trusts, so the APK installs over
the app rather than forcing an uninstall that would take Kyle's settings with
it. With no argument the script is what it has always been, cargo line for
cargo line, so deploy.sh and ship.sh keep the path they run every twenty
minutes. And it has been built. Not on the phone and not as arm64 — as **x86_64**, on an
emulator, because that is what this machine can run — but the flavour is the
only difference: the same Gradle, the same R8, the same rules, the same
zipalign and apksigner. `minifyX86_64ReleaseWithR8` succeeded and wrote **no**
`missing_rules.txt` at all, which is the `-dontwarn` pair doing its job;
`lintVitalX86_64Release` passed, so the `-x lintVital` advice is for a failure
that has not happened. The signed APK went over the debug app with
`adb install -r` and left its data alone, exactly as the same-key argument
above says it must.

Then the minified app was walked: Home, the record, playback, the queue, the
sheets, the lyrics, the drags, the export — **131 of 131, no exceptions, no
console errors, every step as expected**, and nothing in logcat about a missing
command or class. That is the failure this round exists to prevent, looked for
and not found.

What it does not cover is the hands-only list in ANDROID.md — the pickers, the
notification buttons, the share sheet — which no walk reaches, and the four
`@Command` methods on `Plugin` itself remain a precaution rather than a
diagnosed bug. The document says so, command by command.
2026-09-14 11:09:33 -05:00
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00
KeelrLeek defd8c5e6d android: a playlist file, picked
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`:
the document sheet with the caller's own filter, answered with a path Rust
can open. A document already on this phone hands its path over as it lies —
the document sibling of the same volume:relative id the folder picker
resolves, plus the Downloads provider's raw: — so a 300 MB pick copies
nothing; one that has no path is copied into cacheDir/picked under its own
name, and only as far as the caller allows, because a playlist is kilobytes
and a DJ set is not. Those copies are swept at the start of every pick,
which pick_image's never were.

The import sheet has its fifth door, on round 24's screen, wait card and
review, and the note that apologised for its absence says the one true thing
about it instead. `playlist_import_probe` took a path and a name the way
`playlist_import_folder` took a path, and the name is the load-bearing half:
the parser picks its format off the extension and titles the draft from the
stem, so a cache copy under an invented name would have read every playlist
as plain text. The mock walk drives the door and both of the picker's
refusals through round 31's fail door; the phone walk opens the real sheet
and dismisses it with BACK, reading nothing and writing nothing.

The brief asked for a second consumer — sending a file in a chat — and there
is nothing behind it: `community_pick_file` is the persona layer's three
native dialogs, `soulseek-client` has `say_room` and `message_user` and
nothing that sends a file, and both frontends' thread composers are text
only. Nothing was invented for it. The plugin command is general enough — a
caller's own filter, a zero-copy path, a bounded copy — that the one reader
still waiting, the local set listener the phone puts off by design, needs no
further plugin change. Playlist export is not on that list and never was:
writing a document out is the share plugin's errand, not a picker's. The
Kotlin is unbuilt — nothing on this machine compiles it.
2026-09-14 02:55:56 -05:00
KeelrLeek 04c87e0db1 android: a playlist, out of the phone
The desktop has had Export since playlists v2 and the phone had no door onto
it for one honest reason: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
storage plugin grew a `create_document` whose Kotlin writes the bytes through
the ContentResolver — a plugin function called from Rust, not a command, so
the file never passes through the WebView and nothing was added to the ACL —
and `playlist_export` now builds the file above the platform fork with the
same `playlist_formats` writers, so the phone rebuilds no format and the two
exports cannot drift. The door is the playlist's own sheet, on the page and
on the shelf card, with the desktop's five profiles in the desktop's order; a
dismissed sheet says nothing, because nothing happened. The mock walk drives
it three ways — a file written where the sheet pointed, a sheet dismissed, a
refusal read back as a sentence — and the phone walk opens the real sheet and
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
nothing in this tree compiles it, though it is written against
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
against a guess at them.
2026-09-14 02:38:44 -05:00
KeelrLeek 052e15302b android: what the phone says when things go wrong
Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
2026-09-14 01:23:26 -05:00
KeelrLeek 5738c2d7bf android: the downloads say so in the shade
A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
2026-09-14 00:44:11 -05:00
KeelrLeek 1737c5e1b6 android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo
Popular is asked on its own as well as with the page, so the section can
paint before the full page is built and an artist whose page came back
with none still gets one. A catalog record's rows that the album's own
artist anchor found no count for — a feature's guest, a compilation's
stranger — get the deferred Last.fm pass by their own artist id. The
artist page's ⋯ is its own sheet now: choose a hero photo from this phone,
or put the catalog's own back; set_artist_hero is pick_artist_hero's
install step split out and shared, so the desktop's dialog keeps its door.
You gains the desktop's Refresh artist portraits, and a data: URL is the
image itself in the art gate — a chosen hero comes back from Rust as one,
and the thumbnail fetch used to throw it away.
2026-09-13 23:46:07 -05:00
KeelrLeek c737809325 android: a folder of audio files, and a paste of track links
The paste door now reads itself the way the desktop's does: one track link
in the paste and every line is looked up on its own public page
(playlist_import_track_links), which is the way past Spotify's 100-track
public ceiling — the wait screen counts them as they arrive. A fourth door
imports a folder of audio files off this phone, picked with the storage
plugin's own document-tree picker; playlist_import_folder grew a path
argument for it, because on Android it had no dialog to open and always
answered nothing. A YouTube playlist link needed no door at all — the link
one already dispatches it.
2026-09-13 23:32:00 -05:00
KeelrLeek b50577fc81 android: a playlist's cover from a photo on the phone
The storage plugin gains pick_image — the document picker limited to
images, the picked content copied into the app's cache so the Rust side
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
install step split out and shared (squared, the master cover, the row's
revision moved). A playlist's sheet offers Choose cover photo and Remove
custom cover; the mock walk exercises both.
2026-09-13 21:20:11 -05:00
KeelrLeek 60303c8a95 android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down
Without a drag: a playlist row's sheet moves it up, down or to the top and
persists the whole order in one call (playlist_set_order, as the desktop's
drop does); a queue row's sheet moves it up or down, never past the track
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
walk, the embedded build installed, the phone walk, the commit, each step
gated on the one before.
2026-09-13 20:55:50 -05:00
KeelrLeek 6e39dc43f3 android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.

The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.

Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.

The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
2026-09-13 17:56:52 -05:00
KeelrLeek bb828ff2dc android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
2026-09-13 09:30:06 -05:00
KeelrLeek 972dd103cb release: 1.8.2-alpha
release / windows (push) Canceled after 0s
2026-09-10 07:19:18 -05:00
KeelrLeek 12fff9f6ad setup, updater: the installer window was a dev build with nothing inside it, and latest never matched a prerelease
Tauri decides dev-vs-production from the `custom-protocol` feature, not from
--release, and both workflows build the setup exe with a plain `cargo build`.
So cfg(dev) stayed on, the frontend was never embedded, and the window a
tester opened loaded devUrl: "localhost refused to connect" on a blank
800x580. The feature is a default of the crate now, with a build.rs guard
that fails a release build without it, and build.rs tracks setup/dist so a
changed window cannot be left out of the binary it is supposed to be in.

Behind that, the next wall: /releases/latest answers only with full
releases, and every release here is an alpha, so it 404s. The setup app read
that as a refused key and the app's Stable channel read it as a token that
cannot see the repository — both have been broken since the first alpha.
Each now lists /releases and takes the newest that is neither a draft nor
the rolling nightly, which the list really does put first.

And check_saved_key threw the saved key away on any failure at all, a
dropped connection included. The entry it deletes is the app's own updater
token, so only a refusal clears it now.

The release notes name the file to hand a tester: six assets on the page and
nothing saying which, while the bare NSIS installer is the biggest of them
and installs with no key gate and no signature check.
2026-09-10 07:19:15 -05:00
KeelrLeek 3cfff93036 release: 1.8.1-alpha
release / windows (push) Canceled after 0s
2026-09-09 18:10:29 -05:00
KeelrLeek fac6fc33dd release: 1.8.0-alpha
release / windows (push) Canceled after 0s
2026-09-09 16:16:22 -05:00
KeelrLeek cd2391198e first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again 2026-09-09 16:16:19 -05:00
KeelrLeek 07810f7543 release: 1.7.1-alpha 2026-09-09 12:13:10 -05:00
KeelrLeek aaacfc9ff5 holdings: a dash-led edition tail normalises like a bracketed one, so the catalogue's "Pinkerton - Deluxe Edition" finds the member who holds Pinkerton 2026-09-09 12:12:55 -05:00
KeelrLeek a98c750908 holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline 2026-09-08 12:10:49 -05:00
KeelrLeek 8285b97cbe shared playlists: a follow with news says so through a toast, and a follower's rows keep the owner's catalog ids 2026-09-08 09:48:06 -05:00
KeelrLeek 594a33a302 freenet node: the update exit is watched from a thread for as long as the check takes, not for two seconds inside the start call
The supervisor that applies the node's update-wanted exit (code 42)
watched a fresh child for two seconds and then let go. The exit is the
result of a network check the node makes at startup, and it takes as long
as the network takes: on a fresh install today a pinned 0.2.132 exited
several seconds after launch, after the supervisor had already returned,
and the layer read "node absent" with only node.log saying why — the
exact failure the supervisor was written against, back again.

`start_node_inner` now puts the child in `freenet_node` itself and leaves
`watch_for_update` polling it from its own thread for 90 s, under the
lock rather than owning the child — the child belongs to the state, and
the Stop button takes it. An update is applied and the peer restarted
while the lock is held, so nothing reads the dead child as "stopped" and
starts a second peer meanwhile; any other exit is logged and the slot
emptied; an emptied slot ends the watch. The first boot is the one launch
the watcher cannot cover (its child never reaches the state), so that loop
handles code 42 itself, once, with its clock restarted.

Seen live on this machine: the other-exit branch caught a spawn that died
on a busy port (code 43) within half a second and cleared the slot; a peer
killed by hand and started again from the app was connected two seconds
later. The exit-42 branch itself is not reproducible here any more — the
binary is on the latest release — and stands on the same code path.
2026-09-06 08:17:55 -05:00
KeelrLeek f67a0a30b4 shared playlists: importing a share follows it, so the owner's next revision reads as "2 new tracks since you imported"
Importing a shared playlist was a one-time snapshot: the owner's next
revision could only arrive as a whole second copy. A copy now remembers
WHICH revision it was made from, and the app reads the pointer for it
every ten minutes — no new contract, the same pointer and revision reads
the share card already does.

The ledger gains `following`, keyed `owner:uuid` — the one key a database
cannot hand out twice. A follow records the local playlist, the synced
revision (hash + seq) and what the last check saw: the current revision,
the entries it has that the synced one never had (in the owner's order),
and how many synced entries are gone. The diff is by occurrence id, the
proposal board's rule: a reorder is not news, a removal is counted, a
duplicate stays its own row. `import` stages the fetched revision so
`follow` records exactly what the copy was made from — a pointer read of
its own could already name a newer one.

Update appends the new rows through the same resolve path an import uses
(library first, Soulseek stream otherwise) and never removes or reorders:
the copy is the follower's to edit, and a track the owner dropped is
reported, not taken away. It runs under the ledger lock end to end, so a
check landing meanwhile merges against the new synced revision or not at
all. Checks read the network WITHOUT the lock — a pointer read is a ring
search — and merge each result under it afterwards, dropped if the follow
moved on.

Deleting the local copy ends the follow, in `forget_playlist` and the
startup reconcile both: the row id goes back to SQLite, and the next
playlist created must not start taking someone else's updates. Seen live:
the re-import after a delete took row id 1 again and followed cleanly.

Surfaces: an Inbox card when a check finds news ("2 new tracks since you
imported it · 1 removed", Add 2 tracks / stop following), and a Following
row in the playlist's Share sheet (up to date / N new / withdrawn by the
owner, Check now, Stop following). The poll rides the inbox watch: its own
ten-minute interval, plus one pass when the adapter connects.

Live, two instances on this machine: import + follow 582 ms; the owner's
revision 2 (one dropped, two added) read back as exactly "2 new, 1 removed"
in 2.7 s; Add appended two stream rows in 46 ms and left the dropped track
in place; a withdraw read as withdrawn in 57 ms; delete took the follow
with it; re-follow and unfollow both left the copy alone. One pointer read
after a republish timed out at 120 s and arrived a second later — the same
first-read-after-a-change lag the board has.

Tests: the diff (order, removal, a duplicate occurrence) and the ledger
reading files written before follows existed. App 152 · 6 ignored.
2026-09-06 08:16:45 -05:00
KeelrLeek df06f849e0 spaces: a room carries what its members are playing, sealed in the window its chat already uses 2026-09-04 12:20:27 -05:00
KeelrLeek 4ca9ab043b shared playlists: a deleted playlist takes its share with it, so the next one does not go out wearing its name 2026-09-04 11:06:34 -05:00
KeelrLeek 0ff65fd9b5 community: the board counts your pending proposals under the key you wear there 2026-09-04 09:56:39 -05:00
KeelrLeek 06baa5a805 spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in 2026-09-04 09:56:39 -05:00
KeelrLeek d6102101af spaces: a rotated key goes to the person behind the room key, and the epoch it belongs to is found by number 2026-09-03 16:01:40 -05:00
KeelrLeek 7b7f48ebc6 community: declare what you like on soulseek, so the similar-users shelf finally has something to rank on 2026-09-03 15:05:23 -05:00
KeelrLeek 8093adc89e a room-key proposal signs the hash the verifier checks, and the ping's pill shortens a raw id 2026-09-03 13:52:04 -05:00
KeelrLeek 6f7452c803 a second instance on one machine: its own data, its own peer port, and a join that says itself again when it was never seated 2026-09-03 13:39:06 -05:00
KeelrLeek 67e52697b2 spaces: a member wears a key minted for that room alone, so a roster stops naming personas 2026-09-03 13:06:32 -05:00
KeelrLeek fd216ecd3d community: a proposal on your board arrives as a toast, carried sealed through the inbox 2026-09-03 12:16:33 -05:00
KeelrLeek 4d03cf849b community: every ledger takes its write lock, so the inbox check can no longer land on top of a friend accept 2026-09-03 11:51:35 -05:00
KeelrLeek 07227a90a7 the freenet layer: the app supervises its own peer, so a new release no longer quietly ends it 2026-09-03 11:12:41 -05:00
KeelrLeek 733e3b57a8 community: one writer at a time per ledger, and an inbox publish that bumps from what the network really holds 2026-09-03 11:12:41 -05:00
KeelrLeek 0846b39d45 community: the library overlap shelf, their share folders against your albums on this device after a browse 2026-09-02 17:08:25 -05:00
KeelrLeek d7bc92fda7 release: 1.7.0-alpha 2026-09-02 16:38:43 -05:00
KeelrLeek bf81792c49 the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it 2026-09-02 16:33:38 -05:00
KeelrLeek 8b511b20e7 community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private 2026-09-02 15:50:18 -05:00
KeelrLeek 4378c4c0cc community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one 2026-09-02 15:26:30 -05:00
KeelrLeek 13c44f731a community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door 2026-09-02 15:02:06 -05:00