android/handoff-2026-09-14
76
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bb7f1f1b2d |
android auto: the card says what the engine is doing, and a skip no longer leaves the old song playing
Reaching a row whose bytes are not on the phone read as the player going to the next track and freezing — the title changed, the position sat at 0:00, nothing said why — and after a skip the old song kept sounding under the new title until the fetch delivered, because start_at notified the card and spawned the resolver without stopping the engine (only the natural boundary was silent). The engine stops before it looks now: silence, the title, a spinner. The card is built buffering from its first frame (headlessTrack carries `resolving`), and the engine narrates on the artist line — "Signing in to Soulseek…", "Searching Soulseek…", "45 copies · connecting to <peer>…", "Streaming from <peer>…", "<peer> isn't sending · trying another copy…", "No copy found · trying the next track…" — through headlessState, kept in NowPlayingArgs.status and shown by SoulStreamPlayer while buffering; the engine's own Playing event clears it. The first tap's reply builds a fresh state after the JNI call the engine narrated inside, so it carries the line over rather than showing the artist for a beat. Verified on the head unit with a three-row test playlist: a cold tap reads "Searching Soulseek…" at 0:00 and plays at 0:14 with its cover; a skip into a row not fetched yet reads "Searching Soulseek…", then "Streaming from nouvellevag0…", then the artist again at 0:07, with the prefetch fetching the row after it. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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 | ||
|
|
06baa5a805 | spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in | ||
|
|
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 | ||
|
|
67e52697b2 | spaces: a member wears a key minted for that room alone, so a roster stops naming personas | ||
|
|
bf81792c49 | the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it | ||
|
|
8b511b20e7 | community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private | ||
|
|
4378c4c0cc | community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one | ||
|
|
13c44f731a | community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door | ||
|
|
984738135a | community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending | ||
|
|
6ed6f5b8f8 | community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers | ||
|
|
5160d97a5f | community: the drawer asks a peer for its card and the server for interests, and discover gets the similar-users shelf | ||
|
|
6dce4e2ac7 | the freenet layer: adapter, persona and profile services, the contracts bundled as commitments | ||
|
|
63a0357651 |
the soulseek hub: the whole Qt client as one area, wired to the wire
Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.
THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.
THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.
soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.
THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).
And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.
Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
|
||
|
|
50414dae5c | playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises | ||
|
|
1dd8573808 | dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice | ||
|
|
df3d15553a | playlists + lyrics: setlists with pages of their own, and the words timed to the voice | ||
|
|
c4ff6269a3 |
import: records named by their own tags, and covers resampled where the pixels are
Two threads from one session, both of them the same shape: something was being decided from the wrong source. THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers, years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches nothing anywhere; "Mirrored" matches instantly. Across the reference library 501 of 515 records carry a tag album and 315 disagree with their folder, and no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The record's own claim now leads and the folder is the fallback, in one place (src/lib/import-title.js) that apply and the picker both read — apply already resolved it this way, and only the picker did not. The verdict judge had the same split: it compared candidates against the folder too, so an exact answer scored as mere containment and a clean match wore a warning. Identification also stops searching when searching cannot work. A folder named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so after Deezer and MusicBrainz come back empty the artist's own catalogue is ranked by title similarity. Scoring rather than stripping is the point: 1989, 1984 and 2001 are real albums, and any cleaner aggressive enough to rescue Paul's Boutique would have destroyed them. Words split on whitespace with punctuation folded inside them, so "Paul's" stays one token; pressing vocabulary is ignored for scoring only; coverage is measured from both sides. 15 assertions pin the cases that would break it. Cached verdicts now carry the engine that produced them. A cached match is good forever, a cached "nothing found" only as good as the thing that looked — and nothing ever re-asked. Negatives from an older engine, or from a query that has since changed, are dropped and asked again. Soulseek was asked the same wrong question, with the artist said twice: "battles battles - mirrored - 2007". The server matches on ALL terms, so every leftover token narrows the search — that copy was only found because the peer happened to have 2007 in its path. The ladder now leads with the record's name and keeps the folder as the narrower rung behind it. AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate above hasFreeSlot, and queue depth — captured on every candidate — was never read at all. A peer with no free slot and 277 people ahead beat a free peer with a queue of two, because its files reported a bitrate and the others reported none, and `?? 0` scores "didn't say" as the worst possible answer. Bitrate is now ignored between two fully-lossless copies, where it describes the material rather than the quality. And nothing ever gave up. Track 1 is requested alone so it is not queued behind eleven of our own files, the rest follow once the peer proves it will serve us, and a copy that makes no progress for 45s is abandoned for the next ranked one — cancelling the transfers it leaves, which used to trickle on forever. A refusal falls through to the next peer instead of ending the record. "Try another peer" is reachable while one is loading, which it never was. RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already filed by comparing the destination's size to its source — but apply itself rewrites that file, embedding the chosen cover, so it is 582 KB larger from the moment it is written and the sizes can never match again. Every run laid down another " (n)" copy with its own library row: three runs left three of everything, 2.16 GB. A file at the destination that this run did not write is now simply an earlier import; within one run a repeated name is still a collision, which is what `kept` is for. ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read art from whichever folder the audio came from, which for a kept found copy is the pen — and a downloaded album often carries no picture, so three records imported with no cover while their own files held a 1500px one. It also only ever looked at tags, never at the Folder.jpg the review had just offered as "yours". COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed 2000px masters to paint 132px tiles — a 15x single-step reduction that reads as aliasing on photographic covers, and carries megabytes of base64 to draw a thumbnail. Artist portraits already had this exact fix; album covers never got it. getArt is now bounded by default and getArtMaster is the deliberate act, because ~60 places paint a cover and only two want real pixels: with the polarity the other way round, every surface anyone forgets is silently wrong. The fullscreen viewer also framed non-square art inside a square element, so its rounded corners and shadow wrapped 96px of nothing; it takes the artwork's own ratio now, bounded on both axes. Smaller, from the same passes: the preview play button is a real transport rather than a restart, and leaving a record silences its own preview without touching music started elsewhere; bucket chips navigate with per-lens memory instead of overwriting the saved resume position; Next counts from the record's real place in a queue that re-sorts under it, which was skipping one record for every one reviewed under a filter; a cover URL with nothing behind it is shown as the empty answer it is rather than a broken image in a pickable frame; write_tags returns early when there is nothing to change, instead of rewriting the whole file to write values it already had; and the import window has a Start over, which says what it costs before it costs it. |
||
|
|
e7d0971de2 |
import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the window, the picker, the audition pen, apply, and the review that stands between them. The fold is the new part. A release filed as a container of parts arrived as N albums, because the scan groups audio by its immediate parent and the container holds none of its own: Analord's eleven volumes were eleven records, and six more releases split the same way. Records now fold into the release above them, and apply is handed an explicit part list — never a recursive walk, which is what let one stray file swallow 288 descendants and duplicate 9 GB. Where releases live is read off the collection rather than assumed, so a genre-nested library moves the answer instead of breaking; below a dominant majority at one depth the pass refuses entirely, because no folding beats wrong folding. Three vetoes independent of depth catch a misread level. Every fold is listed in the review with a way out. research/import/fold-audit.mjs imports the shipping module, so the audited code is the running code: 9 folds, 0 swallows across four library shapes, and none of the 148 artist folders touched. It was that harness that killed the tempting version of this rule — matching a part against its container folded 40 artist folders, Mac Demarco's 8 albums into one, because album folders carry the artist's name. The regression test found a live bug on the way: parts number from 01 each, so flattening two discs collided, and "same name, same size" read the second file as already-imported and dropped it while still counting it. That reuse now also requires that this run has not already written the path. Also here: the picker's accent crossfades between records instead of cutting through the app's blue (ownership moved to the window, which survives the per-record re-key), the fullscreen viewer wears the album's accent instead of the one it left behind in the portal, Skip defers a record without deciding it, and Keep mine is gone — with the cards picked field by field it was Next under another name. |
||
|
|
185c29fc33 |
library: the page lit by its own records, and portraits that prove out
The expander reserves once on the page root instead of standing min-heights on the grid; shelf heads carry artist portraits keyed by path; every record tints its own card, expander and glow from its cover; the artwork opens the shared viewer; the banner wash reads the playing record and actually paints (isolation, the artist page's own fix); descriptions animate on both pages via partAnim. Portrait resolution rejects placeholder rows and never stores a silhouette (pick_artist_portrait, 3 tests); repair re-resolves the missing. |
||
|
|
f55052506a |
library: three views of the collection, and what every file actually is
Kyle picked this off an interactive prototype (research/library/) over a long evening of passes, and the settings he landed on are baked in rather than left as choices: records no catalogue knows about are ordinary citizens with no marks at all, artwork is generated from a record's own name when there is none, the albums you do not have are one collapsed line that opens into full-size dimmed cards, an artist's shelf splits by release type, the toolbar is a floating card, the index rail is always on, the List view's column header is welded to that card, the entrance is a wave of records, and cards are the artist page's own 150px. The artists accordion is gone. In its place: three views (Shelves, Grid, List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen sorts that are independent of the grouping, a live filter that moves every number in the header, and a stat row that is derived rather than written down. A library page has to say what the files ARE, and library_albums only carries id/artist/title/year/art/count/added. So the library crate gained album_stats: one pass over tracks per call, aggregating length, bytes, average bitrate, bit depth, sample rate and the dominant file extension per album, plus the newest play that met a seconds floor the caller passes (the same 45s the recents list uses — under it a play is a skip, and a page that sorts by "never played first" must not count skips as listening). Aggregated in Rust rather than SQL because the dominant format needs the path's extension counted per album, which SQLite cannot do without string gymnastics, and a library is a few thousand rows. Filing a record as an album, an EP, a single, a live set, a compilation, a soundtrack, a remix record or bonus material is not a new opinion: localReleaseType.js carries CatalogArtist's own title heuristics verbatim — the regexes are the tuned artifact — reduced to the branches whose evidence a LOCAL record can carry. A file on disk has no MusicBrainz release group, no editorial lead sentence and usually no record_type, so the MB and description-hint branches are absent because their inputs cannot exist. Under it sits the floor the prototype settled on: when the classifier lands on "other" and the row carries no provider type, the two facts a file always has decide — three tracks or under ten minutes is a single, seven or under twenty-five an EP — with the session/demo check re-run inside it so a five-track practice tape stays bonus material. It is a copy, and it is marked as one at every function; unifying it with the artist page is follow-up work, not something to smuggle into this. Clicking a record opens CatalogArtist's inline expander, class for class, with its numbers unchanged: the 460ms slide whose height is read live per frame and kept monotonic (a captured height animates to a stale number once the tracklist lands), the staggered waterfall that fades each part to its OWN resting opacity, per-row entrances at min(i*16, 180), and a same-row switch that keeps both panels alive while the stage's height eases between them. What differs is the content, because a library knows different facts: where the artist page shows Last.fm plays and listeners, the rail shows size on disk and bitrate, where it offers Save an owned record offers its folder, and each track carries what its file is. Three things about the chrome are measured rather than typed, and each one is a bug the prototype found first. The bar's height is read off the bar, because a typed 50px parks anything pinned below it behind a bar whose controls have wrapped; the bar's own minimum is a constant, because publishing a measurement into the property that decides it is a ratchet. The index rail's 34px gutter is reserved whenever the rail is on, drawn or not, so no click on Group can change the width of the content column. And the table's column template lives on the page rather than on the rows, because a 1fr column floors at min-content and RECORD is wider than an ellipsized title, so identical declarations on two elements resolve to different widths; columns drop by tier from the page's measured width so a record's name is never squeezed out. The entrance is timed by POSITION, not by index. Indexing the document spends the whole stagger on sections nobody is looking at — 38 shelves means every visible one hits the cap and they all arrive in the same frame, which has no relative motion in it and reads as no animation at all. Delay comes from how far down and slightly across the pane a thing sits, normalized to the topmost thing that is both moving and on screen, and the delays are pulled forward over the next few frames as content-visibility geometry lands (only forward, except while a thing is still hidden, where a correction is invisible). The hero, the toolbar and the column header never move: they are the page's furniture, and dealing them in animated the whole window instead of the part that changed. A rail click corrects until its target stops moving. content-visibility makes the height of everything unpainted an estimate, so one scroll to a section overshoots — measured on the prototype at up to 1143px, with the page's own height moving 2260px during the jump. Two bugs fixed on the way, both the same mistake: `>>` on an unsigned 32-bit hash goes negative above 2^31, so a generated track name indexed past its word list and a generated cover's second hue produced an invalid gradient. Both are `>>>`. Deliberately not here. There is no Import button because there is no backend command to import a folder — Rescan re-reads the library and its stats, and a real importer is a backend feature. The "N of M released" count and the missing-albums line appear only for artists you FOLLOW: a follow carries an anchored artist id, and matching a local artist to a provider by name is exactly what the identity rules forbid, so an unfollowed shelf says what it holds and nothing more. Verified against the running app on real files: 7 albums, 72 tracks, 1.8 GB on disk off the new command, and Maladroit opening to 13 tracks, 33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under the card it belongs to and its row landing 10px under the pinned bar. Verified headless against the mock library at scale: all eighteen view-by-grouping combinations at one content width, the toolbar pinned with zero drift, the joined header's columns matching the rows on both edges, the expander opening from a card and from a row, morphing in place within a row and closing without leaving its reserve behind, and the wave moving 74 things with 34 distinct arrival times across 391ms while the chrome stayed still. |
||
|
|
d397b78887 |
news: about the artist, and each story only once
Kyle reported duplicate articles and a few unrelated ones. Measuring the
live feed found it far worse than the handful he saw: 84 items across
seven follows, hand-labelled, 26 junk — and 24 of the 26 came from two
artists. toe returned 0 of 12 genuine, Pond 0 of 12. Round Pond, Goose
Pond Colony, Inlet Pond County Park, "Amanda M. Pond", peep-toe mules, a
funeral-home obituary. Not one item about the Australian band.
The largest single cause was not the query. parse_news_rss kept the first
twelve items and sorted by date afterwards, so it discarded ranks 13-100
of Google's relevance order — and every genuine toe item sits at rank
15-88. Nothing downstream could recover what was thrown away there. All
100 are parsed now; the cap is 40 and applies after filtering and dedup,
because the frontend's band asks for bandCols * 3 and twelve starved a
wide window.
news_relevance is the precision mechanism. Hard gates first: the name
must appear as a whole token run, every occurrence must not abut a hyphen
(peep-toe, head-to-toe, Chorn-Pond), and a canonically capitalised name
in a sentence-case headline must appear cased. Then document-level
signals, then occurrence-level signals computed for every occurrence with
the maximum kept — and the decision is a total AND a separate floor on
the occurrence subscore. That second clause is the whole filter: at a
single combined threshold every false positive scored at threshold from
document signals alone. "Every song on the 'Tip Toe' soundtrack" is a
music-press host plus a quoted title plus a music noun and carries zero
evidence at the name. Publisher reputation must never admit an item on
its own.
Tiers come from a short explicit dictionary, not from name length and not
from a frequency table. Length demoted Weezer to the strict path and cost
it four genuine items; corpus rank sent Wand from 16 kept to 45 because
the feed floods with "Wistoria: Wand and Sword", and took toe's precision
from 1.00 to 0.70.
dedup_news collapses several outlets' wording of one report — containment
rather than Jaccard, because headline lengths are wildly asymmetric and
Jaccard's union denominator punishes exactly that; artist tokens retained,
because stripping them starves a six-token headline to two; leader
clustering, because union-find chained 68 of 93 King Gizzard items into
one "story"; a 2-day window with unparseable dates non-mergeable, since
rfc2822_secs returns 0 on failure and two zeroes always satisfy a window.
The survivor carries an others count for the outlets it stands for.
That still left the other kind of repetition: many different reports about
one record, similar to each other but not to the leader that absorbed the
rest. Six of the visible twelve were the same Alien Metal announcement.
The fix is not a lower dedup threshold — lexical similarity merges
unrelated stories long before it catches rewrites — but a ceiling:
news_story_key names the album or single a headline is about, and
news_story_quota keeps at most two per work, applied after the date sort
so the survivors are a story's newest. Headlines naming no work are never
dropped, so a feed can shrink but can never be emptied.
Two things that were quietly broken. News URLs never qualified in
disk_cache_path, so the entire disk branch in artist_news was unreachable
and every restart refetched every follow against a rate-limited endpoint.
And rfind on a set of dashes plus one lands mid-character on a 3-byte
en-dash: the slice panics, and a panic inside an async Tauri command never
resolves its promise, so the News band hung forever — but only for artists
whose name sits mid-headline.
The host now comes from the source element's url attribute; its text is a
display name ("GamesRadar+") and is not a host. Both new fields are
serde(default), because cached items are read back through an .ok() that
swallows a schema mismatch: a required field would silently invalidate
every cached entry and fire a cold refetch for every follow at once.
Measured live: toe 12 items (0 real) to 5 (5 genuine), Pond 12 (0 real) to
7 (7 genuine), 32 duplicate clusters collapsed covering 71 redundant
reports, and no story takes more than two of the twelve visible slots.
Derivation, the falsified alternatives and a what-not-to-do section are in
research/news/PLAN.md.
|
||
|
|
8ef730848a |
news: headlines about the artists you follow, anchored to the artist
No per-artist music news API exists — every product that does this aggregates and filters, so this does too. Google News RSS is the source: free, keyless, no pagination, and no contract at all, which is why it gets the most conservative pacer in the file (4 requests per 10 seconds) and a 6-hour cache on disk as well as in memory. Nothing user-facing waits on it; the home page asks after it is already drawn. The query is the QUOTED artist name plus the word "music", and the quoting is the whole design. Measured 2026-08-04 against the real feed: a bare ambiguous name returned 0 of 10 relevant items — searching "Girls" gives Indigo Girls, The Deli Girls and a school production of Mean Girls — while the same name with a corroborating token returned 8 of 8, and an unambiguous one 10 of 10. That is the identity-anchor rule the rest of the app already lives by, applied to a provider that only speaks in names: never trust the name alone. The parser is deliberately not an XML library. One provider, four flat tags inside <item> blocks, exactly the judgment the Bing scrape made. What it does handle is what Google actually emits: entity-encoded titles including numeric forms, a " - Publication" suffix on every headline (stripped when the tail matches the <source> tag, so the title reads as a headline rather than a citation), and RFC 2822 pubDates converted to unix seconds by a hand-rolled civil-date conversion — nothing else in the workspace needs a date crate for one field. Freshness and relevance stay OUT of here. The 45-day window and the review filter are display policy and belong to the surface that shows them; this returns what the feed said, newest first, capped at a dozen. |
||
|
|
520e2ad4e4 |
follow: the artist page's heart becomes a real thing, stored and celebrated
Following an artist now writes a row: followed_artists (artist_id, name, portrait, followed_at) beside the playlists in the library db, reached through follow_artist / unfollow_artist / followed_artists, each emitting soulseek:follows-changed. Re-following refreshes the name and portrait — identity improves as pages are visited and a later portrait is usually the better one — but keeps the original followed_at, so "following since" stays honest. Name and portrait are stored on purpose rather than looked up: the home page's "new releases by artists you follow" can then draw a card from the row alone, with no catalog request per artist. The store is the UI truth and the chip never keeps its own copy. It is optimistic — the chip flips at the click and reverts if the write fails, rather than leaving the screen telling a lie. The chip itself (FB4) leads the stat row and wears the accent as ground with dark text, the Play pill's own trick, so its on-state is legible across the room. The lead slot is a fixed 14x14 in which the +, the equalizer bars and the check swap places, so nothing about the chip's box moves except the word — and the word morphs between its measured widths instead of snapping. Clicking runs the Encore: the + collapses, three bars dance three beats with a note rising per beat, they bow, and the check springs in as "Following" grows into place. The db write and the toast fire at the CLICK; the ceremony is presentation only, so navigating away mid-show still follows. Unfollow is an undo — the shrink morphs, nothing celebrates, and the toast says so. Every effect is template-owned with scoped keyframes. The first build spawned the glow and the notes imperatively and referenced -global- keyframes, which silently did not resolve: the glow ran no animation and sat at full opacity, the notes held opacity 0 and never appeared. The bars worked throughout for exactly the reason the others did not. The row around it is shorter too, at Kyle's call: the library and listening chips (progress meter, the disk popover, format, hours, last played, top track, genre) come out, leaving the identity trio and the socials. With nothing left to shed, the socials' scroll-driven fade and their layout drop at the park boundary come out with them, and the divider's clear space moves from the follow heart by name to whichever chip now closes the run. The JS that fed the removed chips stays — it is cheap, local, and the numbers still have a home coming. |
||
|
|
4c9a6e312a |
library: one record, one recents row, however it was played
A saved album that also streams showed up twice in the sidebar's recently-played — once as the library album, once as the catalog album — because a play row carries album_id only when the played FILE was a library track, so streamed plays of the same record grouped separately under their catalog id. The plays table already holds the bridge: any play that carried BOTH ids (a local track played with the catalog page on screen) pins that catalog album to that library album, by exact ids, never by name. recent_albums now folds linked catalog plays into the library row — which also keeps the row's timestamp honest when the newest listen was the streamed one — and drops them from the catalog arm. Deleting the album from the library dissolves the link, and its catalog plays stand alone again. Regression test reproduces the exact two-Origins shape: dual-id play plus a newer streamed play must come back as one library row wearing the streamed play's timestamp, sorted above an older unrelated record. |
||
|
|
b33d403634 |
player bar: the name sets the width, and the waveform stops flattering the clamp
The source chips are gone — the rail already tells that story — and with them the measured slot, its effects, and the bar's only container query. The now-playing cluster hugs the song name with no cap, giving ground only once the scrubber hits its 260px floor; the artist's 55% ceiling applies only when an album sits beside it, so a lone loading artist no longer renders as two letters in a twelve-pixel box. The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms per bucket — at 400, every display bar was ~2s of audio averaged twice), and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp rule, which flat-topped 53.8% of the bars on a real decode and was propped up by per-bar noise in the rail — deleted; the lane shows the record, not a hash of the bar index. Bars fold with a mean+peak blend and stretch across the track's own p05–p97, so a loud master stops pinning to the ceiling and a quiet one stops vanishing. The playbar copies the rail's fade verbatim: whole bars blend accent→grey with cuts landing in the 4px gap, and the one bar under the playhead crossfades toward whatever its neighbours are — the wash while hovering, the muted grey otherwise. The redraw gate watches that blend instead of a 0.75px playhead move, so both lanes now step on every position event. Loading is drawn, not implied: the solid fallback bar is gone and the lane is always the bars, always seekable. Empty, it rests as the rail's dots; a swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a streaming track's bars stand up left→right on a 40ms cadence, each rising over 2s. The rise frontier is the waveform's OWN coverage, reported by stream_waveform beside the peaks — the old contract zero-padded partial peaks to full length and let the UI fold them against a fresher byte count, which stretched the whole shape between refreshes. Where the bars end is how much of the track has actually loaded; cached and local tracks arrive at coverage 1 and pop in complete. The first partial is also requested eagerly (fresh refresh clock per track, 700ms retries until peaks exist), closing most of the audio-before-lane gap. |
||
|
|
3aaec30dc6 |
metadata: a lost probe race no longer writes an image host off for five minutes
The image-host liveness probe has a 3-second budget so a page never waits on it — but losing that race was recorded as "the host is down" and cached for the full five-minute TTL, turning one bad sample into square portraits on every artist page in the window. Measured on the machine that reported it: all 213 cached assets.fanart.tv urls answered in ~130ms while the machine's own first DNS resolver ate 1-3.6s of the budget before a byte was sent — and the A record's TTL is 300s, exactly the probe TTL, so every probe replayed that lottery on an expired name. The fast verdict still stands for the page that paid for it — it falls through to the next provider immediately, as before — but a background confirmation with a 12-second budget now decides whether the verdict persists, and the warning reports what was measured (timeout, transport error chain, or 5xx) instead of asserting an outage it cannot see. A regression test stalls a local socket exactly once and proves the host comes back without waiting out the TTL; the fanart_probe_live harness prints the reqwest error chain four ways for the next time a provider is suspected on this machine. |
||
|
|
dc918c98ef |
metadata: per-track listeners, and an artist profile the search hero can ask for
CatalogTrack gains artist_id — Deezer sends it on every search row and it was simply unparsed — and rank, its 0-1,000,000 popularity figure. Together they let a listener count be resolved for the PERFORMING artist of each row rather than for the query's top artist, so a "panda bear" search shows Daft Punk's "Doin' it Right (feat. Panda Bear)" at its real 464,046. Resolution rides the existing deezer_linked_mbid -> lastfm_listener_ranks chain behind a new catalog_track_listeners command, deliberately kept separate from catalog_search: that one is typed against per keystroke and must never wait on MusicBrainz's 1 req/s gate. catalog_artist_profile backs the hero's middle third — MusicBrainz tags off the cached artist document plus one Last.fm artist.getinfo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eba29824c9 | sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane |