9f7661afaba5f7cb8c94230cc61293a5bb6332d3
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9f7661afab |
android auto: the car's queue works like the page's, and every row gets its cover
"Needs the app" is gone. It named what the cold service could not do — the resolver and the Soulseek session live in the page, and a car that starts the app cold has no page — and Kyle's answer to the label was the right one: a tap should find a copy and play it, in the background, and a playlist should keep going through rows that are not here exactly as the page keeps going. headless_slsk.rs is a Soulseek session for a process with no page: the stored account, one search (artist + simplified title, the server's excluded phrases stripped), copies ranked by rules close to the page's songCandidates — the title in the filename, streamable formats, a duration within eight per cent, lossless over 320 over the rest, the artist in the path, a free upload slot before anything else — and a download into the same stream cache the page uses. Its own tokio runtime: a car-started process has no Tauri one. headless.rs is the page's queue in Rust. resolve_playable queues EVERY row of a playlist in order, a row not here with its remembered peer file and no path. The engine starts a row from its file when it is whole, from the fetch already filling its cache file when one is, and otherwise finds it on the network right then (the remembered peer first, then the search), playing the bytes as they land; a peer that sends nothing for fifteen seconds is dropped for the next copy, twice, and when a row has no copy anywhere the queue moves on — only the last row's failure reaches the card. The next three rows are fetched ahead of time, four seconds after a start so nothing competes with the track that is starting, and when the immediate next is whole before the current one ends it is appended to the sink and the boundary is gapless — the page's preloadLocal and preloadCachedStream in one. Fetches are keyed by cache file, not index, so a shuffle cannot start the same file from the same peer twice. The card shows the title at once with a spinner (STATE_BUFFERING through headlessState), then the track; every source is remembered in the mirror, so the row is "here" next time. One session per process: when the app opens over it, login_inner ADOPTS the client and reads its events through subscribe() instead of logging in again — two logins from one address in quick succession is what the server throttles — and the fetches in flight become the page's own preloads, so the row the car was fetching ahead lands gaplessly under the page too. Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have no catalog cover, and the car drew the app icon over them. In the cold process the cover embedded in the file is extracted — the desktop's own tagger::extract_art, keyed by the audio path under <library>/.soulstream/stream-art/by-file/, a miss remembered as an empty file — for browse rows, the card and the Continue row, and a row fetched cold gets the same once 200 kB of it have landed. On the phone, coverart.svelte.js is the desktop's playlist backfill as a background walk over every playlist: the file before the catalog (the row's own cached copy, persisted as art_path), then the ONE strict ladder the import review uses (findCatalogTrack, persisted as art_url + track_id) — never a loose name lookup. After boot, on every playlist change, every six hours, the open page's rows first, a miss remembered for a day and only when the catalog answered. The player does the same for the track that is playing (ensureStreamArt: embedded, then catalog), makes the URL the entry's art key and hands it to the walk to persist. The page's own hole is closed too: a streamed row with no cover got no art key at all, and where it had one the extractor's FILE PATH was stored where a data URL belongs; such rows get a "stream:" key and the file is read as a thumbnail, and a stream row's art_path now draws on the phone. A car that starts the app cold has no page to walk for it, so the cold process asks the catalog itself. cover_hunt.rs is catalogMatch.js in Rust — the same Deezer field queries through the metadata crate, a match only when the artist agrees, the base title agrees, the version qualifiers are compatible and a known duration does not contradict; the two are meant to stay in step — fed by every browse and every queue built from a playlist. A blank stream row is answered from what was asked before (stream-art/catalog.json, a miss for a day and only when the catalog answered), else queued for one thread that looks rows up one at a time behind the client's own Deezer pacing, writes the answer on the row (art_url and the track id, only ever filling a NULL; the library is WAL and the page's writer is another connection), and re-lists the playlist on the car. The track that is playing gets the same, through an entry that moves only the card's art and never its position. The cold Soulseek search is the page's two-rung ladder as well: artist plus the simplified title, then the title alone with the artist required in the peer's path; the hunt's last rung is the page's last rung too, the artist's whole shelf flattened to tracks, for the records the search index buries — spent only on the track that is playing, bounded to forty seconds, and the cover memory carries the matcher's version so a "no" from an older matcher is no answer once a rung is added. Two things the head unit found in the seam. media3's default onSubscribe answers "not supported", so nobody was ever subscribed and the re-list notice reached nobody; LibraryTree accepts subscriptions now, and covers land on the car's list within seconds of being found. And the Soulseek client spawns a download's transfer task with tokio::spawn, which needs a runtime on the calling thread — the engine's resolver thread is not a Tauri command, the spawn panicked, the thread died without a word, and the card sat on BUFFERING; every client call from an engine thread runs under the cold runtime's enter() now. The phone's artist page never ran the MusicBrainz pass. It built its discography from Deezer's list alone, so the releases Deezer's artist listing omits — EPs, and whole albums — never appeared, nor the authoritative release types or the guest-appearance and co-tenant arbitration. The desktop's identity merge is a plain module now (lib/artistIdentity.js, moved out of state.svelte.js verbatim, the desktop importing it back), and the phone runs it after the page is on screen — one implementation for both surfaces. What the restarted head unit found, all in the seam between a car session and the page — and one bug older than this branch. The pump's calls into Kotlin never worked. "auto advanced to" has not appeared in a single log while cold queues advanced through whole albums under a card stuck on track one, and the new end-of-queue notice did not fire either. FindClass on the self-attached pump thread resolves through the system class loader, which cannot see com.soulstream.mediasession .AutoNative; the lookup failed and the exception was cleared, silently, every time. The class is taken from the object Kotlin hands every JNI entry — on a Java thread, through the app's own loader — and kept as a global reference; every pump call goes through call_auto_native. Autoplay on connection worked unprompted and resumed Starboy at 3:50 of 3:50: the page writes the position one last time when a track runs out, so a resume point can sit at the end. resume_queue treats a position within two seconds of the duration as "the next item from the top", keeps every entry (one not here is found on reach, like any other), and the headless engine reports end-of-queue to Kotlin, which turns the card to paused. A seek on the page reached the resume point only at the pump's next five-second flush; seekSecs writes the session at once. Opening the app over a cold car session handed the page the right queue and the page called it a paused session that needed re-sourcing: the transport snapshot it reads is built only from events after the page subscribes, and an engine already playing sends no Playing. The bar said paused, the card said PAUSED, the audio kept going. playback::init seeds the snapshot from the engine's own progress when it adopts a running engine, so the page follows what is sounding. preview::init opened two more output streams at every launch for the desktop's hover previews, which the phone never calls — three live AAudio tracks on our uid with one sounding. It is gated off on Android. |
||
|
|
5ddb6d4d59 |
tooling: gradle's temp dir on the work machine, and the walk's transient toast
Gradle's AF_UNIX pipes die anywhere under the user profile on the work
machine ("Unable to establish loopback connection" → "Invalid argument:
connect"), and every gradlew in build.sh, dev.sh and the prober went
through it. env.sh now points TMP/TEMP at C:\gradle-tmp when that directory
exists; a machine without it is untouched.
The mock walk's artist-hero-cleared step asserted a toast — a transient the
probe landed after, twice, with the toast plainly in the still. The
expectation also accepts the hero itself being gone.
Also: commands.rs carried a real NUL byte inside b"dwmapi.dll\x00", which
made grep call the file binary; it is the \0 escape now.
|
||
|
|
6f789687e4 |
android: keep a playlist offline
"Keep offline" on a playlist's More sheet: every streamed row fetched into the stream cache in the background and pinned there, so the whole list plays with the app shut — in a car, in a pocket — the way a downloaded playlist does in any other player. Not the library: "Download all tracks" files rows as albums, which is Save; this keeps the playlist a playlist of streamed rows that are simply already here. One pass at a time, one row at a time: the remembered peer file first, else the resolver's ladder over the network, then the same preload_stream the player uses for the next track, polled until whole, then pinned in the stream-source mirror — and the cache pruner leaves a pinned file alone, or the retention window would quietly undo the switch. The row being played right now is skipped (a second transfer of a file a peer is already streaming to us deadlocks both). The fetch rides the downloads notice in the shade, which is also what keeps the process alive once the app leaves the screen; Android 12+ lets that service start only from the foreground, so a pass that begins on screen keeps going in a pocket. The list lives in config.json (`offline_playlists`); the header says "Kept offline · N of M here" once a pass has looked. Verified on the phone: the shade read "Keeping 29 tracks offline", eleven rows were done in the first forty seconds, and the car's playlist row went from "8 of 40 here" to "10 of 40 here" two minutes later. |
||
|
|
17942fc04b |
android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.
A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.
The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.
The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.
Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.
Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.
The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.
The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.
Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
|
||
|
|
8f46f9c663 |
android auto: a catalog row is handed to the app, not failed at
Every entry in a streamed playlist is `kind='stream'` with no file on this phone — only a catalog id. A service-only process cannot turn that into audio: the resolver is hundreds of lines of shared JS that exist while a WebView does and not otherwise. So the car tapped those rows and nothing happened, which is the worst of the three possible answers. Now the process asks whether the APP can do it instead. Rust, finding nothing local, reports WHICH playlist and entry were meant rather than just failing — a catalog row is not a broken row, it is one this process cannot do alone, and that difference is what decides between an error and a hand-off. Kotlin carries it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded, which is only while a WebView exists: the same fact is both the channel and the test for "is the app warm". And the JS plays it with `playFrom(playlist, items, item)` — the exact call the half-sheet makes when the same row is tapped in the app, so the queue, the provenance and the failure message are the app's own. Nothing about the car is special once it arrives. Cold, it says so instead of pretending: the focus is handed back rather than held for silence, and the log names the row. A phone in a pocket genuinely cannot play a Deezer id, and a tap that quietly does nothing is worse than one that stops. Two things the same change fixed on the way past. A streamed entry has no cover FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their sleeves and a remote uri now goes to the host untouched instead of through a FileProvider whose root check it could never pass. And Recent, playlists and an artist's albums join Albums as GRIDS — all four are things known by the picture, which is what the grid is for. `probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at all: the script force-stops SoulStream, which is precisely the app the hand-off needs alive. **Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm answers `auto hand-off to the app` and the event reaches the JS with no console error. **Not proven:** that the audio then starts. Those Deezer ids have to go through the resolver exactly as they do when tapped in the app, and if the app cannot play them the car will not either — which is correct behaviour and indistinguishable, from here, from a bridge that silently failed. It wants checking with the app in front of you. |
||
|
|
54245a33b5 |
android auto: Recent means albums and playlists, and is never empty over a full library
"Recent" answered with TRACKS out of the `plays` table, and on a phone whose
history had just been wiped that was "No items" sitting over a full library —
the first thing a driver sees, saying the app has nothing.
Three changes, each about what the word means in a car.
**Albums, not tracks.** "Recent" in a car is "put that back on", and what
anyone means by that is the record. A list of single songs plays one and stops.
`plays` records what was played by NAME rather than by track id, so getting
back to an album means matching the pair again; anything played that has since
left the library drops out of the join, which is right for a row whose promise
is that tapping it works.
**Playlists, which were absent entirely.** They are in the database, they are
local, and they play from cold exactly as albums do — they were missing only
because the four root slots were spent, and Recent is somewhere they can live
without taking one. Counted out of `playlist_items` rather than
`playlist_tracks`: it is the richer table and the one that knows whether an
entry is a local file or something that needs the resolver. A playlist that is
part catalog says so on the row — "Playlist · 4 of 9 here" — so the gap is
declared before the tap rather than discovered after it, and tapping plays the
local entries in order. Entries that need the phone are shown rather than
hidden, so the car's list matches what is on the phone, and marked unplayable
rather than pretending.
**An unplayed library is not an empty shelf.** With no history at all, Recent
falls back to the newest albums, which is a defensible reading of the word and
a great deal better than an empty tab on a fresh install.
`ss:playlist:<id>` and `ss:pitem:<id>` join the tree and `resolve_playable`, so
a playlist or one of its tracks plays with the rest behind it.
Two fixes to the instrument that found this: `probe.sh` was passing
`--es play ""` when no query was given, which `am start` rejects outright
("Argument expected after play") — it is conditional now. And a splice left
`fn track_node(fn track_node(` in the source, which rustc reports as an
unclosed delimiter at the END of the file, pointing 400 lines away from the
mistake.
|
||
|
|
062e1845e0 |
android auto: one engine per process, actually — init was still spawning its own
`headless.rs` was written around a guarantee its own doc comment made and the code did not keep: `playback::init` still called `Player::spawn_with_device` directly. So a car could start a cold process, play a record into it, and the moment the app's Activity came up the process built a SECOND player — two AAudio streams out of one app, playing over each other. That is precisely the failure the module exists to prevent, and nothing would have reported it but Kyle's ears. On Android `init` now takes the process's engine instead. The event receiver is single-consumer and the headless pump already owns it, so the UI SUBSCRIBES to the pump rather than reading the engine directly; and the fallback queue is cleared on the way in, because from that point the JS decides what plays next and two things advancing one engine would skip a track each time. The desktop path is untouched — it still spawns its own, because there is nothing else in that process to share with. Also here: a log line on the transport's routing decision (`auto playWhenReady=… headless=…` and `auto transport …`), added while chasing a pause that turned out to be working. It is kept because the question "did this button go to Rust or to a WebView that is not there" has now come up three times and is invisible without it. The pause was real, and the measurement was not. "One active AAudio track after a pause" proves nothing — a paused sink still shows as active. The honest test is that the session reports PAUSED(2) and the position stops moving, which it does: 24873 ms, unchanged five seconds later. |
||
|
|
f59a2d773e |
android auto: the position the engine is actually at, and search by voice
**Pausing looked like the track restarting, and it was not only a look.** The card carried the position it was BUILT with — zero, on the headless path, because nothing wrote it again. media3 extrapolates a position while playing, so the bar moved and seemed right, but every state rebuild snapped back to that stale zero. Worse than cosmetic: media3 then BELIEVED the player was at zero, and reconciled on resume by seeking there, so the track really did start again. The same staleness made a seek look like it had done nothing — the engine moved and the next rebuild reported zero. `getState()` asks the engine now, on every rebuild, through `positionMs()` and `sounding()`. One JNI call and always true, instead of a number remembered from the moment playback began. A seek moves the reported position immediately rather than waiting for the engine's next event, so the scrubber does not lag a beat behind and read as snapping back. **Search, which is the half of round fifty-two that needed no decisions.** `onSearch` answers the count and `onGetSearchResult` the rows; both run the same couple of LIKEs over the library, because there is no search index in this schema and a few hundred albums do not need one. Albums rank before tracks: "play Discovery" almost always means the record, and whoever wanted the song would have said more — which matters because a voice command takes the first result. Voice comes in by a different door and it is implemented too. "Play X" arrives at `onAddMediaItems` as an item with NO media id and a `searchQuery` instead; Google's own samples disagree about whether Auto uses that or `onSearch`, so both are answered. An empty query is "play some music" and gets Recent rather than an error. The prober gained a `search:` prefix for it — `probe.sh 2 false "search:discovery"` issues `playFromSearch` through the session token, which is the path a spoken sentence actually takes. It answers `auto voice 'discovery' n=1`, `auto play ss:album:2 queued=14`, and the controller reports "One More Time" playing: a sentence turned into a record, in a process with no Activity. Still library-only, and that is now the whole remaining question. Playlists are local and would play from cold exactly as albums do, blocked only on which four root slots to spend. Catalog and Soulseek rows cannot resolve without the WebView, but can whenever the app is warm — browsable always, an honest error only when cold. |
||
|
|
4d75395411 |
android auto: the car's own controls, the covers, and four tabs with faces
Everything here came from putting the thing on a real head unit and looking at it, which is the only reason any of it was found. **The transport did nothing.** Pause, seek, next and previous all routed through `MediaHub.act`, which is a round trip out to the WebView — and a car that started a cold process has no WebView, so every button drew, moved, and sent a message to nobody. `AutoNative.driving()` reports whether the headless queue is what is playing, and when it is, the transport goes straight to the engine instead: pause/resume, a real seek, and next/previous that move the fallback queue and push a card update after them. A normal phone session is untouched and still goes through the JS, which owns shuffle, repeat, `alts` failover and the real queue. That is the THIRD instance of one bug: the headless path silently skipping something the WebView path does for free. Audio focus was the first (a live AAudio track, muted, audible to nobody), the card was the second (STATE_NONE over playing music), the transport is this one. Anything the JS does on the way to making a sound has to have an answer here too, and the failure is always silence rather than an error. **The covers, and the colour.** The playback screen was showing the app icon, because nothing filled the card's art. Android Auto washes that screen with a colour it extracts from whatever artwork it is handed — so every record was tinted the falcon's blue. The engine already knows the album's cover path, so it now travels with the track and the card is built from it. Random Access Memories comes up gold. The app derives an accent per cover and the car cannot be told to, but it turns out the car does it anyway, for free, given art. **Four tabs with icons**, which is what every other media app in the car has: a root browsable item's artwork is drawn as its tab icon, and ours had none, so they rendered as bare words. `android.resource://` URIs rather than the FileProvider — these are our own resources, identical on every install, and granting a per-connection URI for an icon that never changes would be wrong. **The grid, in the right place.** The hint was set on each node's own `LibraryParams` when returning its children; Auto reads it from the ROOT as a default and from the PARENT ITEM as an override. So the albums drew as a list with the grid hint set correctly and delivered nowhere. The root declares `CONTENT_STYLE_SUPPORTED` now and the `ss:albums` tab carries the override, along with each artist, whose children are also sleeves. **Shuffle** is a `CommandButton` on `setMediaButtonPreferences` — both spellings exist in 1.10.1 and `setCustomLayout` is the older one. It uses the PREDEFINED `ICON_SHUFFLE_ON` rather than a drawable of ours: a custom resource the host does not recognise is dropped silently, which is what happened the first time, and `ICON_SHUFFLE_OFF` names the state and is drawn as a circular arrow that reads as repeat. It reorders only what is AHEAD, so the current song does not restart under the driver's hands and "previous" still goes back to what was actually heard. The accent is `#31C0CB` in a car theme named by `com.google.android.gms.car.application.theme`. Its parent is a FRAMEWORK theme because the style is resolved inside the car's process, where AppCompat and Material are not guaranteed to exist. Proven on the Pixel 6a through a real Android Auto session, driven from the DHU: the four tabs render with their icons, Albums is a grid of full covers, the playback screen shows the album art with the colour pulled from it, and a media `next` moves the queue and lands the card on "The Game of Love". **What this does not touch, and it is the next question.** Everything in the tree is a local file. Playlists are in the database, are local, and would play from cold exactly as albums do — they are absent only because Auto gives four root slots and four are taken, which is a trade to make rather than a limit. Catalog and Soulseek rows genuinely cannot play from a cold process, but they CAN whenever the app is warm, through the same `MediaHub.act` round trip the transport just stopped depending on — browsable always, honest error only when there is no WebView. That is a better answer than the design's "never" and it is where this goes next. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
e1bf456743 |
android auto: the car can see the app, and the root it opens is empty
Android Auto reaches a media app through `MediaBrowserService`, so `MediaService` becomes a `MediaLibraryService`, one intent filter carries three actions — media3's `MediaLibraryService` and `MediaSessionService`, plus the legacy `android.media.browse.MediaBrowserService` that Auto actually binds — and `res/xml/automotive_app_desc.xml` with the `com.google.android.gms.car.application` meta-data declares the app to the car. Auto lists SoulStream, opens it, and gets a browsable root. There is nothing under it: `onGetChildren` is left un-overridden so the default answers `ERROR_NOT_SUPPORTED` at once, and round fifty fills the tree. `onGetLibraryRoot` answers out of `Futures.immediateFuture` — no database, no I/O, nothing deferred — because `MediaLibraryServiceLegacyStub.onGetRoot` blocks the binder thread serving the host on that future with NO timeout and runs the callback on the main handler. A root that waits on anything is a head unit that gives up. The round carries a mitigation it cannot be split from. androidx/media #3158 — open since 2026-04-03, confirmed again 2026-09-04, reproduced on media3 1.11.0 — has a host that binds a `MediaLibraryService` with nothing playing drive the service round `onCreate` → `onBind` → `onGetSession` → `onConnect` → `onDestroy` at about thirty cycles a second, deterministically after a force-stop with no existing binding. That is exactly how Auto connects and exactly what the prober does. media3 will not act before an Auto-side fix lands, so the loop cannot be stopped from here; it can only be made to cost nothing. The session, the player, the notification provider, the audio-focus request and the becoming-noisy receiver move into `MediaHub` for the life of the PROCESS and `onCreate` allocates and registers nothing — round forty-eight's version would have built a platform session and a legacy browser stub thirty times a second, registered and unregistered a receiver sixty times a second, and abandoned audio focus thirty times a second while music was playing. Three consequences, each a trap for the next round. `onDestroy` calls `removeSession` and never `release()`: `removeSession` drops the session from that instance's map and clears its listener, which is what stops a destroyed service being called back, while `markReleased()` would latch `released` and leave a process-lifetime player that never updates the card again. The session is built with `MediaLibrarySession.Builder(applicationContext, …)` rather than the `(MediaLibraryService, …)` overload, because `MediaSessionServiceLegacyStub.initialize()` does `attachToBaseContext(sessionImpl.getContext())` — a session built against the Service pins a destroyed Service as the base context of a live `MediaBrowserServiceCompat`. And nothing releases the session any more, so it and its platform `MediaSessionCompat` live until the process dies; the walk's `one-session` check is what keeps that honest. `onCreate` logs `auto service created n=…` and `onDestroy` logs `auto service destroyed`, which are the only way to count the loop on a real phone. And the instrument. `tools/auto-prober/` is a throwaway app that binds the same way Auto does, with Auto's own root hints, walks whatever tree comes back, and logs one JSON object per event under `SoulAutoProbe`. It is framework-only (`android.media.browse.MediaBrowser`, not androidx, not media3) because that is the path Auto itself takes into a media3 app, and a prober built out of the library it is probing would not prove that path works. It lives outside `src-tauri/gen/android/` because `tauri android init` would delete it, and borrows the generated project's Gradle wrapper rather than shipping one, so a run downloads nothing. With SoulStream force-stopped there is no WebView, so no CDP, so `smoke.mjs` is blind to every bit of this — the prober is the only eye the project has on Auto without a head unit. The number it exists for is `root`: milliseconds from `connect()` to `onConnected()` with the process cold. Two corrections to the plan, argued in the files. `adb shell pm dump` is `dumpsys package` and prints NO application meta-data, so the Auto declaration cannot be a hard walk assertion — the two that ARE hard are the intent filters (`auto-browse`, `auto-library`), which catch the `TAURI_ANDROID_PROJECT_PATH` trap completely because the filters and the meta-data live in one library manifest and are lost together; the declaration is read instead by the prober, through `PackageManager.GET_META_DATA` and the app's own `Resources`, and echoed back by the walk as the advisory `auto-desc`. And the prober module compiles at jvmTarget 1.8 like every other module here, not 17 — JDK 17 is what RUNS Gradle. No Rust, no frontend, no cargo. The round was authored where none of it could be compiled and it has now been built and run: the Kotlin compiles, the APK installs, and the prober binds the app cold. `desc` resolves the meta-data to a real `xml/automotive_app_desc`; `filter` shows BOTH the legacy `android.media.browse.MediaBrowserService` and media3's own resolving to `MediaService`; `root` comes back at ~2.2 s with `ss:root` and a session token, of which the callback itself is about nine milliseconds and the rest is the cold process starting; `children` answers `n=-1` with nothing in it, which is the un-overridden default doing its job; and `auto service created n=1 reused=false` — #3158 does not loop here. One fix the first run earned. The prober read the descriptor's `name` out of the android namespace, but Google's `<uses name="media"/>` is a BARE attribute with no namespace, so it came back `[""]` — a real declaration and a dangling resource id reported identically, which is the single thing the event exists to tell apart. It reads the null namespace now and keeps ANDROID_NS as a fallback; the second run says `uses:["media"]`. None of this proves the car screen: the Desktop Head Unit or a real head unit is the only thing that shows what Auto draws, how it groups a tree, or whether a tap plays. This proves the app answers. |
||
|
|
3a8ccd4640 |
android: the lock-screen card, moved to media3 before anything is built on it
The card Kyle sees every day was a hand-built MediaStyle notification over a MediaSessionCompat, on `androidx.media`, deprecated since May. Android Auto needs a media3 session to browse, so the choice was to write the new thing against a deprecated library or to move the old thing first; this moves the old thing and adds no Auto. `MediaService` is a `MediaSessionService` and a new `SoulStreamPlayer` is the `SimpleBasePlayer` media3 asks for when the engine lives somewhere else — rodio, in Rust, behind a WebView round trip — so `getState()` is a pure read of the last `NowPlayingArgs` off the bridge, queue window and all, and every button routes back out through `MediaHub.act`. Three corrections to the plan, each the kind that only shows up on a phone. media3 **1.10.1**, not 1.11.0, whose POM drags kotlin-stdlib 2.2.10 into a project that compiles with Kotlin 1.9.25. The audio-focus and becoming-noisy blocks STAY hand-rolled: `setAudioAttributes(…, handleAudioFocus)` is an ExoPlayer convenience that lands in a `@ForOverride` method which throws, and deleting them would have lost "another app takes the output" and "headphones out" with nothing failing anywhere. And the service is BOUND, never started — `MediaSessionService.onStartCommand` returns `START_STICKY` without promoting for any intent that is not media3's own, so a `startForegroundService` would arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13 bug in a new coat. Four more things are load-bearing and silent when they break: a playlist that must never be empty while a track exists, `COMMAND_GET_TIMELINE` with `COMMAND_GET_CURRENT_MEDIA_ITEM`, `STATE_READY` while paused, and both spellings of next and previous. Each ends in a card that is simply never posted, with nothing in any log. The window carries `queueBase` now, for a stable media-item uid as it slides, and `repeat`, whose numbering is swapped between the app and media3, so that what a controller is told about repeat is the truth and media3's own view of the end of the queue agrees with the `hasNext` the next button is advertised on. The 2026-09-13 bug has a second door, and `onStartCommand` is what shuts it. media3 hands the framework a media-button `PendingIntent` built with `PendingIntent.getForegroundService`, unconditionally, because the receiver the legacy stub resolves is now this service — there being no `MediaButtonReceiver` left for it to find. So the SYSTEM starts this service in the foreground on every media KEY, while media3 promotes only for a session it calls user-engaged. A key into a process built for that key, a key on a card paused past the ten-minute tail that does not start playback, any key with the bar cleared: none of them qualify, and each would take the app down ten seconds later. (The card's own buttons are milder — only play/pause, and only while paused, is a foreground start.) So `onStartCommand` promotes first, the way this service always did — through media3's own `onUpdateNotification` when there is a card to post, and otherwise with a plain notification of its own, posted and withdrawn in the same breath. `Service`'s `startForeground` is final, so the split is by what media3 can be asked to do, not by watching it. The walk gains `one-card` and `one-session` — the only checks that tell a landed migration from a doubled one — plus an advisory `media-plumbing` line; the logged window grew `base` and `rep`, which are the only readback either field has anywhere on the device and which `queue-window` now asserts; and the paused-restore wait goes to 25 s, which ANDROID.md had already claimed in one place and contradicted in another. ANDROID.md also says plainly what the walk cannot prove and Kyle has to try himself: headphones out, another app taking focus, the card's actual look, and a Bluetooth headset's own buttons. The Kotlin is unbuilt — no Kotlin compiler here — and was read against javap of media3 1.10.1 and the real sources of `SimpleBasePlayer`, `MediaSessionService`, `MediaNotificationManager` and `DefaultMediaNotificationProvider`; the manifest and resources compile and link under aapt2, and the Rust type-checks. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
afcd3cc7f3 |
android: the media mirror grows a queue window the card never shows
The Kotlin side of the mirror has always known about exactly one track, and media3's `SimpleBasePlayer.getState()` — the thing an Android Auto session is built on — wants a playlist and an index into it. So the data lands a round before the migration that needs it, where it cannot break anything: `NowPlaying` and `NowPlayingArgs` carry a bounded `queue` of at most twenty-one entries, the current item and ten either side, each with its id, title, artist, album, duration and art key, plus `queueIndex`, where the current one sits within that window rather than within the whole queue. `src/mobile/media.svelte.js` fills them from `player.queue` and `player.qi`; `MediaService` ignores every bit of it, so the card is byte for byte the card it was. Ten either side because one covers the card's own prev and next and nothing else while a queue list — which is what Auto asks for — wants a screenful plus a scroll both ways, and it is a cap rather than a length: a two-thousand-track queue crosses the bridge as twenty-one entries exactly like a twenty-one-track one, measured at two kilobytes of JSON for twenty-one short titles and six with long ones and real cover paths, against the thirty to eighty of base64 cover that already ride the same call once per record. Nothing renders it, which is the whole difficulty in checking it — and both hops of this bridge fail silently, because serde drops an unknown field and tauri's Jackson mapper has `FAIL_ON_UNKNOWN_PROPERTIES` off, so a key spelled snake_case where camelCase was wanted arrives as nothing at all with no error anywhere. `MediaSessionPlugin.update` logs one line per update, and the phone walk holds that line against `__ss.card` — the mirror media.svelte.js now keeps of what it last sent, the shape xfernote has kept of the shade since it was written — field for field, through a next and a previous. The art-key count is compared as well as the size and the index, since that is the only camelCase field an entry has. The check arms at queue index one rather than at wherever the walk left off, which buys a row on either side — `prev()` seeks to zero at the top of a queue and `next()` stops at the end — a track that has only just started, so `prev()` is still a step rather than the seek it becomes past five seconds, and a window whose own index is the queue index, because nothing is clamped off the front while the index is under ten. Each of the three readings has to be a fresh one at both ends, the push counter up and a new line in the log, since a `prev()` that lands back where it started reads exactly like a stale line otherwise; and a queue too short to arm, or repeat-one, under which `next()` seeks instead of stepping, fails the check rather than skipping it. The plugin's `consumer-rules.pro` keeps the new `@InvokeArg` class by name beside the one it already keeps; without that line a release build would fill `NowPlayingArgs` and leave every queue entry null, quietly. Rust and Kotlin both changed, so this one cannot go down `dev.sh` — it needs the full `deploy.sh` detour. And everything the walk already asserts about the card is untouched, which is the real check on this round. |
||
|
|
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. |
||
|
|
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
|
||
|
|
fbc6a0268b |
android auto: the UI is four tabs and a colour, not a screen — the design, decided
An earlier pass in this repo mocked a car UI from imagination: a tab strip, a header with search and queue icons, a bottom mini-player, a big-art playback view with a linear progress bar. None of those exist. Google's line is flat — "You don't draw your user interface directly on Android Auto; the framework has two well-defined UIs that are created automatically" — and the reference frames confirm it. The bottom bar is the SYSTEM's (home, notifications, Assistant). Progress is a RING around play/pause. The artwork sits small at the left behind a blur of itself. About THREE rows fit on screen, not eight. So the artifact that was needed was never a layout. It is the short list of slots the app actually fills, and this commit decides them: tabs Recent · Albums · Artists · Saved style albums grid, artists and tracks list actions Shuffle · Save, 2 of the 6 allowed reach amber dot on rows that need the app; Save shows a tick when down accent brand teal #31C0CB Playlists loses its tab because the phone holds exactly one, and an empty tab is a dead quarter of the only four we get. Saved takes it because it is the set that plays with the app shut, which is the one thing genuinely different about this app in a car. Grid-vs-list is not density — the car fits three either way — it is what identifies the row: a record by its sleeve, an artist by name, a track only by text. Save is the highest-value button on the screen: hear it, save it, it plays cold on the way home. The dot marks the RISKY row rather than the safe one, and thins out on its own as more music is saved. Auto allows one accent pair, set at build time, and it cannot follow the artwork the way the app does everywhere else — so it has to be the constant, the falcon's teal. None of this can ship before round 48. The card is MediaSessionCompat today, Auto needs a MediaLibraryService, and two live sessions is not a safe intermediate. Order is unchanged: 44 → 45 → 46 → 47 → 48, soak, then 49–52 build the table above. The mockup that settles it is research/auto-mockup (gitignored): both real libraries — the handset's three albums and the desktop's 418 — inside the real template, at the three DHU resolutions, with an overlay that outlines which pixels are the car's and which are ours. |
||
|
|
c1a2608bdd |
android: three the audit found — bars translucent over nothing, and two insets the WebView does not report
A pass over every screen the app can reach, at rest and scrolled, shot from the DEVICE so the Android status bar is in the frame. Three of what it found are nothing to do with the page's top edge and predate it; they are here on their own. **The bars are translucent over nothing.** Glass — the backdrop blur behind the chrome — is off by default on the phone, deliberately: perf.mjs measured three blurred bands taking a 60 Hz scroll to 30. But the bars kept the alphas that were chosen to sit ON that blur, so what shipped was the middle state, 92% and 94% over no backdrop at all. The audit reads "Random Access Memories", "Alive 2007", "Latest release" and "Touch (feat. Paul Williams) 8:17" straight through the navigation bar. The base values are the no-glass case — `html[data-glass="blur"]` overrides them — so the base is what had to be opaque; the blurred variants keep their alphas, because there they have something to be translucent against. The device now answers `rgb(16, 16, 19)` for the nav and `rgb(28, 28, 32)` for the mini bar, no alpha on either. **The bottom inset is reported as zero, and the gesture pill is drawn anyway.** The WebView answers 51px for the top and 0 for the bottom, while the page runs the full height under Android's gesture handle — the nav measured y 855→915 of a 915 viewport — so its labels sat exactly where the pill lands, straight through the word "Library". **And in landscape the top goes to zero too.** The cutout's inset correctly moves to the side (portrait top=51 left=0; landscape top=0 left=51), but the status bar is still drawn across the top there, so the clock sat on "MONDAY, SEPTEMBER 14" and the battery icons sat on the floating ⋯. Both are floors rather than replacements: `max(env(...), 18px)` and `max(env(...), 24px)`, 24 being Android's own status bar height. A device that reports its insets properly is untouched — portrait's 51 wins on its own — and only one that reports nothing gets the floor, which every Android with a gesture pill or a status bar needs. Everything along the bottom is already anchored to `--inset-bottom`, so the mini bar, `.endpad`, the composer, the sheets and the player frame all move in step with it. What the pass did NOT find, having looked: the chip rows clipped at the right edge of the sleep-timer and sort sheets are not broken — `scrollWidth 458` in a `clientWidth 412` with `overflow-x: auto`, so they are meant to be swiped, though nothing on screen says so. The player's three faces, the artwork viewer, the track sheet, the page overflow and Community's four tabs all render correctly. The harness is in research/ (gitignored): shots.mjs walks every reachable screen and shoots the device frame at several offsets, contact.mjs tiles a pass into one sheet, clip.mjs records a GIF of the app scrolling, fadetry.mjs previews a CSS value live without a build, and selectors.mjs checks in one minute what the full walk takes twenty to cover. Two things it learned the hard way are written into it: every overlay sits on top of whatever page is behind it, and a pass once came back as twenty-five photographs of the same player sheet; and every tab keeps its own stack, so go(tab) shows that stack's top and only resets when the tab is already active — a pass shot Discography and labelled it You. |
||
|
|
1f9b30c51f |
android: the page ends at the clock — the bars go, the hero keeps the top edge
The bar was anonymous until you scrolled, then faded in carrying the title the page already had in 27px type two centimetres below it. Kyle's rule for what replaces it: the page is cut off where it meets the Android status bar and dissolves there, and — the part that decides the whole shape of this — **backgrounds may pass under the clock, words may not**. A mask on the scroller cannot tell a photograph from a sentence. It takes both. So the artist hero is painted twice: the page's own copy inside the masked scroller, cut away in that band with everything else, and a second copy behind the scroller (heroart.svelte.js), unmasked, showing through exactly where the mask took the first one away. Same image, same box, same focal point, same scale, offset by the same scroll — one picture to the eye, and only the name dissolves. The second copy carries `overflow: hidden` for the same reason .hero does: without it the 1.04 scale renders 268px in a 258px box and five pixels of photograph appear under the hero's foot, below the black the scrim fades to. That also retires an attempt that did not survive contact. The mask first stepped aside for the hero on a scroll-driven `--fade-on`, which meant rebuilding a mask-image on the scroller every frame; measured at 60 Hz median and p90 either way, but it is work for nothing, and the flip that replaced it put the artist's name out of existence in one step. With the photograph protected by being OUTSIDE the mask, neither is needed: the mask is a constant and nothing recomputes it. No page has a bar now. A tab page's ⋯ — which is the app's own menu, the queue and the sleep timer and Settings — moved beside that page's own large title in `.phead`; Search and You grew the row they did not have, Community kept the one it had. A pushed page floats a back chevron and a ⋯ over the page instead, on flat discs with no backdrop blur, because the bar's own note records what three blurred bands did to a 60 Hz scroll on the Pixel 6a and these would have been a fourth. Three numbers now come from one place. `--chrome-d` and `--chrome-gap` are the disc and its margin; `.screen.has-chrome` reserves exactly that much at the top of a pushed page, and `--fade-top` reaches exactly the chrome's foot. They were independent for one build and it showed immediately: the page began underneath its own buttons, so "Transfers" sat behind the back chevron before you had scrolled at all, and the fade stopped 17px short of the discs so they stood on crisp text and a record's floating ⋯ landed on a track row's own ⋯. The walk followed. Nineteen steps reached a page's overflow through `.gbar .iconbtn`, which now matches nothing: the four on a tab page go to `.phead .iconbtn.muted`, the rest to `.fchrome.more`, and the album's back chevron to `.fchrome.back`. The `title` field read the bar's own text, which is a worse source than it looks — it only existed when chrome happened to be drawn. The screen carries its meta as data attributes now and the three probes read those, spelled exactly as `.gb-t`'s textContent was (the title, then the kicker when it differs) so every expectation written against it still holds. Checked on the device rather than argued: no `.gbar` and no `.gb-t` anywhere, `.phead .iconbtn.muted` present on all four tab pages and opening "SoulStream · Queue · Sleep timer · Settings", both `.fchrome` controls present on a record, and the title probe answering "Random Access MemoriesDaft Punk". One thing this leaves, and it is a real cost rather than an oversight: a tab page's ⋯ scrolls away with its header. The bar used to carry it the whole way down, so on Home the sleep timer now wants a scroll back to the top. |
||
|
|
cdb861fcbd |
android: no bar arrives over the page — the tab pages lose their top banner
The glass bar was anonymous until you scrolled, then faded in carrying the title the page already had in 27px type two centimetres below it. On Home that meant "Good morning" over "Good morning", half-transparent, while the content it covered kept moving underneath. Kyle's word: remove them. So a tab page has no bar at all. `barOn`/`titled` and the scroll trigger they read are gone; `showBar` is `!root`, and the element is not rendered on Home, Search, Library, Community or You. Nothing fades in over what you are reading. The bar was not only decoration, which is why this is more than a deletion: its ⋯ is the page overflow, and on a tab page that overflow is the app's own menu — the queue, the sleep timer, Settings. That moves to where the page's title already is, in `.phead`, beside Home's identicon and at the end of Library's existing row of actions. Search and You grow the row they did not have. Community already carried its own ⋯ and keeps it. A pushed page still has a bar, because it carries the back chevron and a title that is not repeated anywhere else on the screen. It is static now: rendered only when there is somewhere to go back to, on from the first frame and titled from the first frame. The opacity-0 default, the 180ms cross-fade, and the `.gb-t` rise the title used to arrive on all come out of the stylesheet — nothing is left that can catch the eye mid-scroll. The walk reached that overflow through `.gbar .iconbtn` on a tab page, which is now a selector that matches nothing there: `home-more` on both step lists and the two sleep-timer steps go to `.phead .iconbtn.muted` instead. The other `.gbar` steps are untouched on purpose — the playlist export sheet is a PUSHED page and still has its bar, and `library-ambience` is the album page's back chevron, not an overflow. Checked on the device rather than argued: on Home `.gbar` is absent, the ⋯ is present, and tapping it opens the half sheet on "SoulStream · Queue · Sleep timer off · Settings"; on a pushed record the bar is there with its two buttons, its title, and a computed opacity of exactly 1. One thing this leaves: the bar was also what backed the OS status bar once a page scrolled under it. The clock and the battery now sit on the content. That is fine on the ground and on dark artwork, and it will not be over a white sleeve — a short scrim with no title and no buttons would fix it without bringing a banner back, and is deliberately not in this commit. |
||
|
|
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. |
||
|
|
9c3273e6af |
android: the waits the walk could not see, and why they are off by default
The adaptive waits went in unmeasured — the comparison was still running when work stopped. Measured against round 42's 255 step verdicts they are not green: six steps that were `ok` at baseline fail — `artist-lanes-page`, `net-playing`, `net-sheet`, `np-more`, `np-copied`, `np-saved`. They were not six faults. `net-playing` threw `Cannot read properties of null (reading 'click')` because the network row it wanted had not arrived, and every step after it then worked the wrong track: `np-copied` and `np-saved` were hunting for `Copy to a folder` on a LOCAL track's sheet, which does not have it. The reading asked for three things and got one. `resolver.active` is real. **`net.busy` and `lib.loading` are not fields on those stores** — `net` publishes `searching`, `responses`, `files` and `groups`, `lib` publishes `loaded` — so both read `undefined` on every poll, joined a constant into the fingerprint, and left a search in flight indistinguishable from a settled page. The catalog was not read at all, which is `artist-lanes-page`: an artist lookup sits in `catalog.artists[name].status` until it turns `ready`, and the mock holds that page for four seconds on purpose. The names are corrected, the catalog's loading entries are counted, and the counters that move as results land are in the reading, so partial progress registers as progress. The second half matters more than the names. **A stable fingerprint is not a settled page.** A search that has sent nothing back yet holds every counter still, so "two identical readings 90 ms apart" was satisfied in the middle of the work it existed to wait for. Work in flight is its own leading field now, and while the resolver is active, a search is running or any catalog entry is loading, a still page does not count as a settled one. An unreadable page reads as busy for the same reason. The declared wait was always the ceiling and still is. That fixed four of the six. What it did not fix is the reason this is now **off by default**: across three full walks the catalog and network cluster fails a *different subset* each time — one run lost `net-arrived`, `np-copied` and `np-saved`; the next got all three back and lost `net-playing`, `cat-album-row` and the three that hang off `net-playing`. A gate that is green at random is worse than a slow one, and every expectation in this file was written against the declared waits. So `settle()` sleeps the wait it was given unless **`SS_ADAPTIVE=1`** asks otherwise, and the walk prints which mode it ran in so no log is ambiguous about it. Turning that variable on and off is also the A/B the comparison needed and never had. With it on the saving is real — 162 of 414 declared seconds on the mock walk, 95 of 224 on a device, where the walk went 131 of 131 with no exceptions and no console errors. It can have the default when it is green twice running. Three steps carry `hold: true` either way, because they wait on the mock's own clock rather than on the app: a streamed file handed over on a seven-second timer, and the two writes whose only evidence is the notice that follows them. |
||
|
|
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. |
||
|
|
867fd42972 |
android: turned over, for real — the walk rotates the phone, and puts it back
Round twenty-seven wrote the landscape layout and proved it at 915×411 in
headless Chrome; no real device had ever been turned, and the phone walk
refuses a `{ view: [...] }` step because a device's orientation is not the
step list's to set. That refusal stays. The turn goes in the phone tail
instead, right after `background` has proved playback is live with the app
off screen, and it asserts the four things the mock physically cannot show:
`screen.orientation.angle` is 90 or 270 — an override without
`screenOrientation` leaves the angle at 0, so this one is only ever a real
phone — the WebView RESIZED as well (a separate clause, because a WebView
that never got the configuration change looks exactly like a display that
never rotated), `--nav-h` computed to 48px so the phone-sized landscape query
really fired, and the activity was not recreated: the same window token, the
same `performance.timeOrigin`, the same pid, no `AndroidRuntime` fatal — and
those last two are read tolerantly, because `pidof` exits 1 when the process
is GONE and `adb()` throws on a non-zero exit, so the plain read threw the
whole report away at exactly the moment it mattered. Then the stack, the
track still playing and past where it was (a track that rolled over during
the turn is still playing, which is all the clause claims), nothing clipped,
and the player sheet sideways in two columns with every control on screen.
The safe-area insets are printed, not asserted. Nobody had ever measured
them here — `MainActivity` calls androidx's `enableEdgeToEdge()` and nothing
in the generated Kotlin touches insets, so `env()` comes straight out of
Chromium's window-inset mapping — and asserting a number nobody has measured
is how a round fails on a correct build. What is asserted is the plumbing, on
both edges, because a hole-punch is only on one of them: whatever `env()`
says, the glass bar, the mini bar, the screen and the sheet spend it.
The phone is Kyle's, so the two rotation settings are read before either is
written and put back from four places — the block's own finally, main's
finally, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK — because a failed
clause only walks on but a WebView that stops answering throws clean out of
the loop, and Ctrl-C runs no exit handler at all. The latch clears itself
before it writes, a failed restore hands it back for the next caller, and the
skip paths disarm rather than restore, so a phone that could not be read, one
already sideways and `--no-rotate` all leave it untouched. Nothing else on the
device is written.
One thing beyond the turn: the `{ adb }` step kept its stdout. `am start`
prints "Error: Activity not started, unable to resolve Intent" on stdout and
exits 0, so `share-import` — the step that exists to prove the share intent
resolves — could not fail for its own reason. It can now.
|
||
|
|
33f32cb4c9 |
android: the long session — what the app is still holding after an hour
Every walk this project has ever run is minutes long, and nothing in the port
had been looked at for a leak. Driving the real bundle lap after lap says two
useful things. The first is that the obvious answer is wrong: the naive
add-minus-remove listener counter climbs about twelve a lap, and every one of
those is a `.screen` element that `{#key nav.seq}` threw away — count only
what is still attached to window, document or a connected element and it is
flat, lap for lap, and so are the intervals. The second is that exactly two
things grow without a limit. `art.svelte.js` kept every cover it had ever
fetched for the WebView's lifetime — one browse of a 114-cover library takes
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
stranded a 360 px data URL under a key nothing could ever read again.
Both are bounded at the desktop's own numbers rather than at invented ones:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
`state.svelte.js`, oldest first. Two rules the desktop does not need are
added, because its callers ask through `getArt(path)` while the phone's
templates index the map: the entry that just landed is never dropped, and
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
re-arms only on a path prop change, so an evicted cover that is still mounted
would stay a disc for good. Built with the cap forced to 8 and scrolled
through eighteen screens of a 140-card Library grid, the map sat at 61 —
spared, every one of them, for being painted — and not one cover on screen
lost its image; at the shipped 512 a whole library walk runs the trim zero
times. The playlist covers need no policy at all: the new revision drops the
old ones, which are unreachable by construction.
The verification is the round. `artTally()` on the handle reports the map and
its side tables, which are module-private; seventeen probe fields carry that,
the catalog's ten session maps, the queue, the transfers and the DOM on every
step of both walks; and seven mock steps install a live listener and timer
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
which the first-run block's reloads would throw away — drive two warm laps, a
baseline and nine more, assert the census, and take the registry back out so
nothing appended after this block inherits a patched platform. Installed that
late it can only see what is registered from that moment on, so its absolute
counts are small and are not asserted as a census; what is asserted is the
delta across the nine laps, and that it fires was proved by planting a
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
and the intervals moved by 0, the art map grew 17 against a headroom of 80
and the catalog 5 against 24, and the DOM ended smaller than it started.
`tallyTimers` is a ceiling and not a delta, and says so: every track start
schedules a 47-second recents refresh, so a walk this fast keeps a handful
pending, and making it sharp would cost a fifty-second quiet period. The
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
as left alone on purpose — evicting a catalog entry is not safe while
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
the registry is never installed there, those three fields read −1, and
`artTally()` is a pure read.
|
||
|
|
9bfbcfc96c |
android: the phone walk waits for the app, not just for the WebView
A walk always follows a fresh install, so the app is always cold: the shell mounts, the library loads, Soulseek logs in and the share index is built. Attaching only proves the WebView answers, and the walk used to begin its first step there — against a page that had not finished mounting, which reads as a broken feature rather than a slow boot. It now waits for the handle it drives, and the attach itself gets two more tries when the devtools socket outlasts its own forty seconds of patience. |
||
|
|
0ce8c63cb6 |
android: what the device has actually been tested for — the map, and the steps it could close
Most of this port is proven against an object in memory and only some of it against the phone, and nobody had written down which was which. Writing it down found four phone steps — `library-ambience`, `queue`, `lyrics` and `community` — named and screenshotted with no entry in `PHONE_EXPECT` at all, and the driver reads a missing key as a pass, so all four had been green since the rounds that wrote them; they have assertions now, tolerant of Kyle's own library and of nothing else. Thirty-three more steps move onto the device: everything that can be proven there without downloading, joining, sending, signing in or writing to his library. The library's shape and its sort (nav state, not config), a local track's sheet off a real path, Search's recents out of this WebView's own storage, a queue row's sheet opened and not tapped, the walk's own playlist with a row removed and put back by Undo — the first round trip that stash has made through real SQLite — the import review's Keep switch restored before the draft is created, the sleep timer set and put out by the next step, the stream-cache chip and the Wi-Fi-only guard read first and restored last, and a port the core has to refuse. ANDROID.md carries the map beside Verifying: what the device walk proves (121 named steps, up from 88, plus the seven things after the loop that no mock can do), what stays mock-only and why — needs `__ssMock`, needs a fresh install, needs a viewport the phone walk refuses to set, needs the dev flavour, or would download, join, or destroy his data — which six of the new steps are conditional on what his own library happens to hold, and what still needs a person. No product code in this round: four inserts in the phone half of smoke.mjs, one in the doc. |
||
|
|
a8adce087f |
android: the phone walk waits for the WebView instead of giving up on it
A launch leaves a window where the app is up, focused and has a pid while the devtools socket the walk attaches to does not exist yet — on a loaded machine it has been as long as ten seconds. attachPhone tried once and reported "is SoulStream on screen?" about an app that plainly was. It now tries for fifteen seconds, re-reading the pid each time in case the app restarted underneath it. |
||
|
|
537bfdd5af |
android: the faces the walk had never seen — six failure states, read back in their own words
Round thirty-one gave the phone honest error states and shipped six of them unobserved, because neither `loadHome` nor `loadLyrics` was reachable from the `__ss` handle and both answer a second ask with nothing. The handle carries the two stores and their two loaders now, and a block of mock steps arms each failure with the mock's own weather, drives the face, and asserts the sentence a person reads as well as the absence of the Rust string behind it: Home's catalog line, the Gaps line and its second tap, a catalog record's stub, the artist page's two different answers, a playlist that would not read, the lyrics lookup that failed, a refused transfer, and the library toast's Try again, pressed for the first time. It sits before the first-run block, which reloads and empties the mock's follows; every failure is disarmed by the step that proves the face heals, and a last step asserts nothing was left armed. The phone walk gets a safe step of its own — a terminal transfer row written into the store and taken away, nothing fetched and nothing reaching the shade. ANDROID.md now names what the walk still cannot see: three screens that draw a raw backend string, six error faces with no expectation, and the history mirror printed on every step and asserted on none. |
||
|
|
f2cde4ec7c |
android: the whole discography, on a page of its own
The artist page already filed every release the desktop's way, but it only ever showed one category at a time and it showed all of it: no slice anywhere in that grid, so an artist with 140 releases drew 140 cards. There is a page now — All in front of the categories, a sort, a grouping by year, and the editions opened in place under their own record, which is the part a flat list loses — and the grid behind it stops at twelve with the way down under it. It is the same classifier twice over the same cached albums, which the desktop cannot do because its filing waits on a background description harvest; the three things its own page has and this one does not — a tracklist per record, an accent sampled per cover, the scroll-led banner — are 140 calls and 140 decodes over mobile data for rows that are one tap away, and ANDROID.md says so rather than leaving them looking forgotten. The list caps at sixty with the rest a button away, before the grouping, so the last year on screen can be a part of itself; under a name order By year is not offered, because the years would repeat out of sequence. The mock gains a 140-record artist so both caps are walked rather than argued about. |
||
|
|
1258b2b78d |
android: the rows go where you put them — a real drag on the playlist and the queue
The phone stood in for the desktop's drag with Move up / Move down / Move to the top on a row's sheet, which is fine but is not what a finger is for. Press and hold a row for 320ms now and it lifts, the rows below make room, and letting go lands it — on the playlist page and on the player sheet's queue, through one action in src/mobile/reorder.js. The Move rows stay: they are the accessible path round 28 named to a screen reader, and they are the only way to reorder while the filter is on. Release a press without having moved it and the row's sheet opens exactly as a long press always opened it, which is what keeps the queue's sheet reachable at all — a queue row has no ⋯, so `oncontextmenu` is its only door to Play now, Move to next and Remove from queue. Most of the work is in not fighting the page. Nothing carries `touch-action: none`, because a flick that starts on a row is how the list is scrolled and `touch-action` is latched at touchstart anyway; the scroll is refused instead by a non-passive `touchmove` listener added at the lift, which works precisely because the finger has been still for 320ms and the browser has not started one. A press that moves more than 8px before then is a scroll and the gesture lets go of it. Click and contextmenu guards sit on the container in the capture phase while a lift is live, or a released drag would fire the row's own click and play the track. Rows are measured once at the lift in the scroller's content space, so the edge pull — within 64px of either edge — leaves the snapshot true, and each displaced row moves by the exact difference between its top and its neighbour's rather than by one assumed row height, because the queue face puts album separators between rows. No new mutation and no new command: `moveItem` and `moveInQueue` each take a numeric target now as well as "up" / "down" / "top", so there is still exactly one call to `playlist_set_order` carrying the whole id order, and the existing queue guards were already the right guards. The drop is optimistic and snaps back with the error toast if the backend says no. Two refusals cover the middle of a gesture — the playlist drops nothing if a refetch has replaced its rows, and the queue drops nothing if `player.qi` has moved — and dragging is off entirely while the playlist's filter has anything in it, because a filtered list's visible order is not the stored order. Both walks drive it with real pointer events and assert the order out of the store: a drag, a drag back, a refusal under the filter, a press that still opens the sheet, and a drop the backend is armed to reject. |
||
|
|
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. |
||
|
|
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. |
||
|
|
95c2bfeeba |
android: the first run — a folder to keep music in, and an account to reach the network
Nobody had seen this app on a phone that has just installed it. On Android the library root is never missing — config.rs falls back to the app's own sandbox, a real folder nothing else can see — so "no folder yet" cannot be a null check, and setup_done has never been written by the phone, so every phone in existence reads as unstamped, Kyle's included. The tour therefore opens on three clauses, not one: no flag, no saved account, and no records, read only after the library and the credentials have both answered. Two cards, skippable, and no password anywhere in it — the Soulseek step goes to the card on Community and stops. Every screen that is empty because of those two things now says so with the row that fixes it: Home (whose copy predated the folder picker), Library → Albums, Library → Artists, which had no empty state at all, and Search, where a flat sentence about signing in became the row that does it. A row goes away because the config moved, so the two doors onto the same folder tell each other — You re-reads when the tour moves it, the tour's store is told when You's own picker does — and the all-files row re-reads the grant when the system screen hands the app back, the way You already did. You → This phone opens the tour again as a preview that reads the config and writes nothing, which is the door the phone walk drives on Kyle's own device. The mock can answer as a fresh install now — an empty database, no account, no permission, the sandbox root — so the whole thing is walked end to end, and two commands that had no mock at all (player_control, open_external) got one on the way past. It is the mock walk's first reload, which turned up two things a reload does not clear by itself: the player writes the session back on pagehide, so the fresh block clears it from a pagehide listener of its own, and the viewport emulation lives on the DevTools session, so the harness hands it back after the re-attach. |
||
|
|
46cf6a98fe |
android: the sweep — what thirty rounds left behind, and the byte that hid a file from grep
Nothing here changes what the phone does, and the walks are untouched on
purpose: they are the guard, not the subject. Out go nine imports no file
ever names again, two things nothing in src, tests, crates or src-tauri
has ever referenced — `forgetAccount` in the Soulseek store, whose Sign
out row calls `disconnect`, and `isLossless` in util.js — and two CSS
rules no markup can reach, `.cardpad` and `.iconbtn.liked`; `stamp` and
`fmtCount` had each been written twice word for word, so both bodies move
into util.js unchanged and four screens gain a name on an import line they
already had. The byte is in player.svelte.js, where the play-history key
joins artist and title on a NUL that was written into the source as the
character itself rather than as the escape catalog.svelte.js already uses
— that made the file non-text, so every grep of it since has answered
"Binary file matches" and shown nothing. One line is added rather than
removed: `.mini .mini-lane { width: auto }` reads exactly like the strays
and is the only thing keeping the lane from coming out 32px wider than the
strip it was cut for, so it now says so — and `.feat`, declared twice in
shell.css with different values, is left alone, because untangling that
moves pixels and this round may not. A tenth dead import is missing from
that first list on purpose: Sheet.svelte's `import * as api` is a namespace
import, the one shape Rollup cannot shake, so its reason was the bundle
rather than drift and it went out with the round before.
|
||
|
|
01bab007f7 |
android: the boot chain goes out in one round, and a census that holds it there
Thirty rounds of onMount had never been read end to end, and read end to end it was a queue: two dozen commands with a critical path ten deep, because Tauri's `listen` is an invoke like any other. loadLibrary asked for five independent SQLite reads one behind the other, bindSlsk registered eight listeners one behind the other, and watchLibrary and the player's bindEvents did two each. None of them depended on anything before them. They go out in one round now — the same commands, critical path 10 to 3, and ten of them used to leave in the shell's own first turn where twenty-three do now — and in a rig that runs the real store modules with the bridge instrumented, lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip swept rather than measured; the phone's own numbers are unproven until the walk prints bootMs and bootLibMs. The behaviour is meant to be identical. loadLibrary's only movable part was lib.error, which has two writers and used to be decided by the order they were awaited in; the two messages are now carried out of the round and applied in that same order, so a failing library still shows the library's message. Every combination of which of the five reads fails, over six latency shapes, comes out identical to the old code. bindSlsk still does not return until every handler is up, so connectStored cannot let a fast connected state land with nobody listening, and bindEvents still hands back the transport's unlisten and still rejects only when the transport is the one that failed. src/lib/boot.js is the project's first launch instrument — one counter every invoke and every listen passes, sealed at the first frame the shell paints and never re-opened, so a probe landing a second and a half later still reads the launch. perf.mjs measures frames; nothing measured the launch. The mock walk's Home step now holds a budget against it and both walks print bootCalls, bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no clock in it: a call that waits on the one before it can only issue its first in the shell's own turn, so a chain put back in a queue falls under the floor whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there at all, which is the census itself being broken rather than the app. One import line goes with it: Sheet.svelte's `import * as api`, the only one of the ten dead imports this round turned up whose reason is the bundle at all. Rollup already drops an unreferenced NAMED import, so a dead name on an import line is hygiene rather than bytes; a namespace import is the one shape tree-shaking cannot see through, and this one was dead. It frees nothing on its own — twenty-nine other files in the phone's graph hold the same module the same way, so api.js is pinned into the chunk whatever Sheet.svelte does — but it is the only one of the ten that belongs to a round about weight. The nine that are drift go out with the sweep in the round after this one. Two things were looked at and left alone, both written down in ANDROID.md rather than half-done. Home's per-artist catalog walk is the deepest chain in the app, and that serial walk is the Deezer and MusicBrainz pacing, not an oversight. And the follows really are read twice at launch, but the obvious guard does not work: Home's effect calls loadHome with force the first time they land, so the guard would never fire. One small local read, left for a round that can do it properly. |
||
|
|
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. |
||
|
|
44eda8dff5 |
android: the room can measure the song, and a cold artist page stops waiting on one call
The tap-to-sync room draws the recording's own vocal band (lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the head, and one button lays every line across the stretches it says are sung — the desktop's own generator, shared and unit-tested, which refuses rather than guesses when there is too little voice to read. The result is labelled an estimate everywhere it shows, the cursor goes back to the top so the taps correct it, and one more tap drops the whole thing back to an empty set, because a rough fill stamps every line at once and leaves Undo nothing to take back. The artist page now fires the hero seed and the overview beside the full page, the way the desktop stages them: a hero Kyle picked on this phone comes back off the file with no network at all, the discography paints from the overview's first page until the exhaustive pass lands with the classified one, and both lanes are dropped with the page when a hero changes. ANDROID.md's "Not done yet" is rewritten as the true map of the port — what is absent by design and why, what is waiting on Rust rather than on a file picker, and one bullet about the artwork viewer that had been done for rounds. |
||
|
|
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. |
||
|
|
4fc6e9c630 |
android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
Every control on the phone has a name now, in the app's own words, and the desktop's word where it already has one; the nav is a tab set whose badge count is part of the tab's name — "You, 3 transfers" rather than a stray 3 read out before it. The two sheets are dialogs with somewhere sensible for the focus to land, and the half sheet's grab handle is finally a real Close — the first dismissal a screen reader could reach, because the scrim never was one. A sheet that is down is inert rather than sitting off-screen in the accessibility tree with the hidden mini bar and the nav under it, and one live region is mounted for the life of the app so that an undo offer is actually announced; switches say on or off, chips say pressed, and the waveform is a slider where it can seek. Nothing visible changed. The walk counts the controls with no name, with a junk name, in a hidden layer, or with unsaid state — all four held at zero on ten screens, and the nameless count on a room and on both prompts besides, which is where the six fields named here actually live. |
||
|
|
9df162360e |
android: the phone turned sideways — the player beside itself, the chrome cut down
The phone UI was drawn portrait-first and had never been looked at in
landscape. The player sheet was the casualty: its cover is a square sized by
width, so at 915×411 it came out 879px tall and the title, the scrubber, the
transport and the face buttons all sat below the bottom of a sheet that does
not scroll — a corner of the artwork and no way to pause. Sideways the sheet
is two columns now, the cover sized by height beside the controls, with a
lyrics or queue face taking the cover's column; the nav and the mini bar come
down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has
always been rather than 300px of a 411px screen, the record head puts the
cover beside the title, and the shell reads the side insets the cutout and the
gesture pill take when the phone is on its side. The walk turns the phone over
and back on its own: `{ view: [w, h] }` is a step kind now, and the steps
assert what used to be off the screen.
|
||
|
|
aff29aba5c |
android: a streamed file copied into a folder, the listening port, the dev build's diagnostics
The now-playing sheet's streamed track can be copied into a folder picked on the phone, as the file it is — the cache written out, nothing downloaded again, no library row (copy_stream_to_folder, which the desktop has had a command for and no UI). You → Sharing carries the listening port, what peers dial when they download from you: unset is the client's own 50300, the app asks the router to forward it over UPnP and falls back to the server relaying, and a change applies on the next connect. The dev flavour gets the desktop's debug overlay as a screen under This phone — CPU, memory, the network, the stream and its loudness, what the pre-buffer is fetching, the size of the play history — behind devBuild in util.js, which is Vite's own DEV flag, so the embedded build the phone keeps has no row to tap. |
||
|
|
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. |
||
|
|
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. |
||
|
|
24c6857847 |
android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy
The editor the desktop drives with a mouse is better under a thumb: the song plays, the line to time sits large in the middle of the screen with the one behind and the one ahead faint around it, and a tap anywhere stamps it. One layer the back gesture peels, and a set left mid-tap is offered back by the row that opened it; Save writes the track's own document, lines never tapped left out of it and the button saying how many are kept. The lyrics sheet also gains the per-track auto-sync switch and the desktop's reduced-vocals copy — the same title, artist and duration, so the words keep following it, and one row puts the original back. |
||
|
|
e693dfb868 |
android: your own Soulseek interests, and who the server says is like you
Community's fourth tab is Taste: the likes and hates this client publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set), added and taken back as chips, and the people the server ranks as close to them (slsk_similar_users) — a row each into the peer sheet the app already has. Every declaration asks the server again and the chips stay quiet until it has answered, the way the desktop does, because the ranking only moves when its input does. The lists live in taste.svelte.js, read once per account when the tab is opened; the mock walk declares a like, watches the match grow, declares a hate and takes the like back. |
||
|
|
7eae0ee945 |
android: a name of your own for anyone on the network
The desktop's local contact metadata on the phone: a peer's sheet ends in Name and notes — a petname, a group, a private note and the Favorite and Trusted marks, kept in the app's own config and published nowhere. The petname stands in for the username on the buddy list, in the chat list, on a thread's bar, in the peer sheet, in a room's roster and on a room line, and the real username stays with it — on the line beneath, or beside the name in a room — so a stranger is never quietly turned into someone you know. Favourites sit at the top of the buddy list, and clearing a person's details drops the entry rather than storing an empty one — the backend's own rule. |
||
|
|
f792d7955d |
android: a peer in full — their card, their interests, and what they have that you don't
The peer sheet's new "About this user" row opens a screen of its own: the card their client serves (their own words, their picture, the upload slot and the queue), the likes and hates they declared, and the comparison this phone makes of their share against the library. The comparison is the point of opening a stranger — every record they have by an artist in your library that you don't, each row opening the search that finds it, and a way to forget the whole comparison again. Nothing is uploaded to work it out. Their picture opens in the artwork viewer, which now takes a data: key beside a library path and an https URL. |
||
|
|
694a29eb0d |
android: downloads on Wi-Fi only
One switch the desktop never needed, off by default: on mobile data every Save the phone can start — a record, a track, a folder, a file, a playlist's bulk, the now-playing Save — waits, with the reason on the bar; streaming is never held back. The walk flips the switch, makes the connection cellular and sees a download refused. |
||
|
|
3c8989dfa6 |
android: the hub's filters on the network results, pasted lyrics
The raw results take the Soulseek hub's filters — a free slot only, FLAC or MP3 320+ — as chips above the folders. The lyrics sheet has the desktop's paste door: plain text, or LRC with timestamps, kept for the track. |
||
|
|
59be9f6c3e |
android: the nav's badges — transfers in flight on You, unread chats on Community
The desktop's title-bar download ring and its unread indicator, on the phone's nav: a count on You while Save is fetching, a count on Community when a chat has unread lines. |
||
|
|
f8c3b90665 |
android: hours on the library's count line, By format, Jump back in on the empty search
The desktop Library summary's hours on the phone's count line; a By format order from the file stats (lossless by depth and rate first, then by bitrate, the badge as the card's line); the empty Search page's Jump back in — the last records played — as the desktop's empty search shows them. |
||
|
|
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. |
||
|
|
d95c3f48d1 |
android: the room list filtered by name, the rest on demand
Soulseek lists hundreds of rooms; the phone showed the first 80 and no way to the rest. Community → Rooms now has a filter by name and extends the list 200 at a time. |
||
|
|
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. |
||
|
|
991d52d0cc |
android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm
The desktop Library's per-record file stats on the phone (library_album_stats, one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards, the size on the list rows and the record page. The artist page's own listening (artist_play_stats): the time played as a chip, the most played track under "On this phone". The whole-library thumbnail warm a moment after boot, at the cards' edge, one decode at a time behind the tiles' gate. |
||
|
|
26928b706d |
android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
The playlist repair (Find a local match: the library searched by the row's name, a pick relinks it through playlist_relink_item; an unknown row takes the track's identity). A peer's sheet on the raw results (play or save the folder, browse their files, message, buddy) and the Browse screen — the hub's Browse tab: a user's whole share with sizes and totals, a folder played or saved whole; a share can hold thousands of folders, so it filters and renders 80 at a time. "Look up a user" on Community → Buddies. The sheets the phone lacked: a queue row's (Play now, Move to next, Remove from queue), a catalog song's, a catalog card's and the record page's own, a raw file's (in the results and in Browse), a room name's, a buddy's, the now-playing Save (the desktop's Save menu for a streamed track or record). The artwork viewer with pinch zoom, one layer the back gesture peels. Search's recent searches and its spelling suggestion from the names this phone knows. You → Streaming, Downloads, Sharing, Lyrics, Album artwork, and Clear play history; a record's Re-fetch the cover. The lyrics tools on the sheet's face: the offset nudged and kept per track, another LRCLIB candidate chosen, the lookup forgotten and done again (lyrics.svelte.js, shared by the face and the lyrics sheet). The harness: every CDP call times out and the reload step re-attaches (a WebView that stopped answering hung a walk for ten minutes); the audio check counts SoulStream's own tracks' underruns from the mixer dump instead of the thread-wide counters that any app's notification moves — the afternoon's "dropouts" were other apps' sounds, and the browse of a 49,544-file share measured at zero; __settle resets every tab to its root through the nav; an adb step kind; the walks cover all of the above on the mock and on the phone. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
972dd103cb |
release: 1.8.2-alpha
release / windows (push) Canceled after 0s
|
||
|
|
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. |
||
|
|
3cfff93036 |
release: 1.8.1-alpha
release / windows (push) Canceled after 0s
|
||
|
|
f8508da081 | setup: the installer is our own window — the alpha key is the gate, the release comes down with it and is verified before the silent install, and the in-app tour asks for the key too | ||
|
|
8093835482 |
release workflow: the version step is a block scalar, so its quotes reach bash whole
release / windows (push) Canceled after 0s
|
||
|
|
fac6fc33dd |
release: 1.8.0-alpha
release / windows (push) Canceled after 0s
|
||
|
|
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 | ||
|
|
07810f7543 | release: 1.7.1-alpha | ||
|
|
4ea1582afc | library: the toolbar is a solid card under dark glass, because a backdrop blur over the frost lets titles through | ||
|
|
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 | ||
|
|
1c66602b89 | who has: the sheet closes when the page scrolls under it, instead of floating beside a card that has moved | ||
|
|
67b7444f82 | settings: each section heading carries the same icon as its index entry | ||
|
|
48ae01c753 | settings: Appearance gets a real icon — "display" was a CSS property that had leaked into my list of icon names | ||
|
|
a0528c6d3f | settings: each index entry carries its section's icon, lit in the accent when it is the one on screen | ||
|
|
07ac3d3631 | settings: the long lists use the app's own chooser — the sort menu's popover under a pill — instead of native selects the webview draws with the OS's text | ||
|
|
7e291cfccc | settings: one long page of section cards behind a sticky index, segments and switches for the choices, and the Networks card moved in from Community | ||
|
|
6c44f3e2ab | community: a chat thread stretches with the pane too — no inner width cap remains | ||
|
|
f006c1cd5c | community: the page fills the pane edge to edge instead of sitting in a centred 1520px column | ||
|
|
b8e6fb7396 | who has this: a gap card says who holds it, the sheet and the open album name them, a Space grows a Holdings tab, and Get it is the hub's own grab or a wait for their presence | ||
|
|
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 | ||
|
|
08ca09586a | community: Discover ranks the shelves it already has into one, and every card still says where it came from | ||
|
|
8285b97cbe | shared playlists: a follow with news says so through a toast, and a follower's rows keep the owner's catalog ids | ||
|
|
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. |
||
|
|
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.
|
||
|
|
df06f849e0 | spaces: a room carries what its members are playing, sealed in the window its chat already uses | ||
|
|
4ca9ab043b | shared playlists: a deleted playlist takes its share with it, so the next one does not go out wearing its name | ||
|
|
0ff65fd9b5 | community: the board counts your pending proposals under the key you wear there | ||
|
|
06baa5a805 | spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in | ||
|
|
d6102101af | spaces: a rotated key goes to the person behind the room key, and the epoch it belongs to is found by number | ||
|
|
7b7f48ebc6 | community: declare what you like on soulseek, so the similar-users shelf finally has something to rank on | ||
|
|
8093adc89e | a room-key proposal signs the hash the verifier checks, and the ping's pill shortens a raw id | ||
|
|
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 | ||
|
|
67e52697b2 | spaces: a member wears a key minted for that room alone, so a roster stops naming personas | ||
|
|
b3c121fa94 | community: the inbox check belongs to the app, not to the page that happened to be open | ||
|
|
fd216ecd3d | community: a proposal on your board arrives as a toast, carried sealed through the inbox | ||
|
|
4d03cf849b | community: every ledger takes its write lock, so the inbox check can no longer land on top of a friend accept | ||
|
|
07227a90a7 | the freenet layer: the app supervises its own peer, so a new release no longer quietly ends it |