13c44f731ab2129271d5f4282f53bc8565fa0e93
65
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 | ||
|
|
64a7561750 | community absorbs the hub: search, transfers and browse ride the same pills, one entry in the sidebar | ||
|
|
8c25f93afe | community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column | ||
|
|
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.
|
||
|
|
75a95405a8 |
the acrylic rides move drags: only a real resize parks the glass at blur
Kyle: "when clicking and dragging the window the window gets light." It did, by design that no longer had a reason: WM_ENTERSIZEMOVE parked the accent at plain BLURBEHIND for EVERY modal loop, move drags included, on the old assumption that a suspended acrylic punches through on moves the same way it does on resizes. Plain blur ignores the gradient tint and has none of the acrylic's material dimming — measured over a striped backdrop, every glass surface stepped ~15% brighter (band luma 45.5 -> 52.2) for the whole of every titlebar drag. And no compensation was possible: move loops emit no resize-drag event, the frontend never hears about them. Measured on this machine (research/glass-drag harness, SC_MOVE posted so no synthetic mouse goes near the UI, GUI_INMOVESIZE as ground truth): DWM composites the acrylic straight through a move loop. Re-asserted mid-loop it restored rest luma EXACTLY (45.5/31.8/29.4), stationary and through 28 frames of real mouse-speed motion — no punch-through, no trailing. So the swap is now gated on SIZING_LATCH: a titlebar move keeps the acrylic, a latched resize parks at Blur exactly as before (DWM genuinely suspends acrylic in live resizes — that half is load-bearing and stays). The WM_SIZING belt-and-braces arm swaps the accent too, so a resize the hit-test latch misses still degrades one mouse-move late instead of never. Verified after: entering a move loop changes nothing to the decimal; entering a resize loop still swaps (+6.7 band luma, the intended trade) and restores on release. |
||
|
|
15746893a9 |
restoreSession rebuilds the waveform and the cache badge from the backend, and a direct album arrival gets its artist portrait
Two bugs Kyle reported, both in what a session restore does NOT bring back. THE LANE AND THE STREAM BADGE. Kyle: after the machine sleeps, "the playbar waveform goes away and the track says streming even though its cashed". The trigger is not sleep — it is the frontend restarting with a track loaded, which WebView2 does on its own under memory pressure (and which a page reload does too). Neither survives it: the waveform is measured out of the audio bytes and was never re-requested, so the lane rested as dots while ALSO flagged complete; and streamReceived/streamTotal reset to 0, which is what the Now Playing header picks between "transferring", "Cached · size" and "Streaming from <peer>" with — both at zero fell through to the last, for a file that finished downloading sessions ago. His read was exactly right: it WAS cached and the label was lying. Both are now rebuilt from the BACKEND's truth rather than the snapshot. The restore fires the same final-quality waveform request the stream-completion path uses (it answers from whatever the cache holds — full, partial or nothing, each rendering honestly) and asks stream_cache_len for the real cached byte count, setting the counters only when the cache is COMPLETE so "Cached" is never claimed for a prefix. Local tracks get their waveform re-requested too; they had the same dotted-lane bug. Note the guard compares path+peer, not object identity: player.queue is a $state proxy, so the item read back is never === the snapshot object the closure holds. THE BREADCRUMB'S ARTIST PORTRAIT. Reaching an album directly (search -> album, skipping the artist page) left a bare glyph where the artist's picture belongs: trail() synthesises that crumb from the record's own artist NAME, and metadata::CatalogAlbum carries only the name — no id, no picture. openCatalogAlbum now resolves the artist seed after the page lands and re-records the crumb in place (same key), so trail() reads artistArt/artistId out of it. The resolver shares the album header's own cache and in-flight dedupe, so this is the lookup the page mounting already pays for. It also heals the crumb's CLICK, which had artistId null on every direct arrival. Verified live: reloading the app in its broken state restored a 2000-peak waveform and flipped the header to "Cached · 14.6 MB"; Daft Punk's portrait shows on a direct-from-search album arrival. 98/98 tests. |
||
|
|
73a677d35c |
one listener bar, five surfaces: the search page's pill wins and the heat wash is retired
Kyle asked for the popularity bars to match the search page "to keep a
consistent design across the board". They were two designs, and one of them had
quietly drifted into a third.
THE SEARCH PAGE'S PILL IS NOW THE APP-WIDE DESIGN: a visible
rgba(255,255,255,0.07) track, a solid var(--accent) fill scaled by the shared
curve, the full comma-formatted count standing centred on top, 17px tall with
a 4px radius. The album page, the artist page's open-in-place album panel, the
discography's record sections and the artist page's Popular list all draw it.
WHAT WENT AWAY IS THE HEAT WASH. Those three surfaces drove the fill's COLOUR
and ALPHA from the count (--t on the row, alpha ramping 0.30-0.85 via a double
color-mix, digits ramping with it), so length and colour told one story twice
and every page read as a different design. Length is the only encoding now, and
listenerHeat is deleted from listenerBars.js — nothing computes a temperature
any more. The dark-glass CONTRACT's deliberate exclusion for these bars ("their
alpha IS the data encoding") is moot and marked so.
THE DISCOGRAPHY HAD DRIFTED FURTHEST and is the reason this mattered: it
computed peak/heat inline with a LINEAR share, a 6% floor and abbreviated
labels ("12K"), against everyone else's COUNT_GAMMA 0.42 curve, browser-measured
max-content floor and full figures. It now imports listenerBars.js like the
rest, so no two surfaces can disagree about how big a hit is — the reason the
module exists.
THE POPULAR LIST joins them: it was plain right-aligned muted text, deliberately
not accent so the row's own `.now` accent stayed distinct (the ♫ rank and title
still carry that). Its column widened 62px -> 96px, the pill's width, and its
peak is taken over the WHOLE list rather than the visible five so the bars do
not re-scale on Show more.
Kept throughout: unknown counts render an em dash with NO bar (a floor-width
pill behind a dash reads as a measured tiny value), and a column drops entirely
when Last.fm knew nothing.
Verified live against the running app — computed styles on the album and artist
pages match the search bar value-for-value. 98/98 tests.
|
||
|
|
385b598170 |
glass survives the window gestures: the accent rides the modal loop and the maximize animation gets a bracket
Kyle reported the blur "being masked by just going opeque" on maximize, and separately the desktop punching through a maximized window. Both were the same transition, and the old guard covered neither honestly. THE GUARD'S SIGNAL IS NOW THE MODAL LOOP ITSELF. thumbbar.rs already subclasses the main window for the taskbar transport, so the size loop is three arms on that existing proc: WM_NCLBUTTONDOWN carrying a sizing hit-test (or SC_SIZE, the keyboard path) latches "this loop is a RESIZE", WM_ENTERSIZEMOVE arms, WM_EXITSIZEMOVE releases, and WM_SIZING arms as a second net because this window is undecorated and tao does its own NCHITTEST. Emitted as soulseek:resize-drag, POSTED to ourselves rather than emitted inline: emitting from inside DefWindowProc's modal loop reenters the wry runtime's window store while that loop holds it. The signal and the failure are then the same object, which is what the two previous attempts were not. The first grounded on any DOM `resize` — one event fires for a maximize, so the settled window sat flat black for ~70ms hiding glass that was already back. The second discriminated on event CADENCE, and that is the one worth remembering: on this 4-core laptop, re-rasterising every backdrop-filter at each new size pushes `resize` deliveries far apart, and measured 2026-08-28 events 150ms apart never grounded AT ALL — a real drag stayed see-through, shipping the exact artifact the guard exists to prevent. Cadence is not a proxy for the loop. DWM SUSPENDS THE ACRYLIC INSIDE THE LOOP but keeps compositing the plain BLURBEHIND, so the accent swaps down to Blur for the gesture and back on release (window_glass refactored into apply_accent + an Accent enum; GLASS_ON tracks the main window so the subclass can re-assert). The window stays glassy through a drag instead of going black — which is what Kyle actually wanted, and GROUND_DRAG is off on his verification by hand. THE MAXIMIZE ANIMATION SCALES A SNAPSHOT the accent cannot ride: measured at 43ms/frame, desktop icons read sharply THROUGH the maximized window until ~613ms after the call. Disabling the DWM zoom animation removed the punch-through outright and every frame of a ground-suppressed maximize showed the blur soft and present — but that also removed the animation, and Kyle chose the animation back with the canvas grounded across the recomposition. So transitions are forced ON, the subclass brackets the jump (active at WM_SIZE, a re-assert immediately and again at 650ms, release with the second), and GROUND_DISCRETE paints the canvas for exactly that bracket. Both policies are one-line JS constants in glass.js — flipping between "animation + dark bracket" and "instant snap + live glass" needs no rebuild. The DOM resize listener survives as the fallback for a build with no subclass, reverting to the original behaviour; it stands down for good the moment one native event is heard. Measured clean: 3 maximize/restore gestures, 3 brackets, canvas never opaque outside them; a 9-second modal loop held with zero resize events in it. 98/98 tests. |
||
|
|
547cee6649 |
dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a translucent black card nets 0.81 - darker than its own pane and inside the ~0.85 band already rejected once on the artist page. Solid mode's ladder is measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new --glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass (0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary .btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are NEVER redefined - they are consumed inside color-mix() and gradient stops where an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible chip; the album row's text-overflow fade works by BEING opaque), and they paint modal chrome that has to stay solid. 129 additive :global(:root.glass) declarations across 18 files, all 129 verified inside a glass block by walking the diff - solid mode byte-identical, confirmed structurally, by computed style with the class off, and visually. Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING the last ~19px of the listeners bar and nothing translucent can occlude, so .tcount masks out just before the buttons instead; Playlist's banner scrim and the discography's pinned ambience both landed on opaque --bg-base and now dissolve like the album banner; and the 0.9 black hero tint on Home/CatalogSearch/Discography paints straight onto the pane when there is no artwork, so :first-child (the {#if} renders no element) flips it to white. Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and the Settings copy for the mode now says so instead of claiming cards do. Measured with a known ground substituted behind the transparent window, since CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a white fill AND its backdrop-filter dropped, +30 for the bare pane beside it. RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live resize and the window stays transparent through it, so the desktop punches through sharp - measured by toggling window_glass alone, desktop icon labels readable through the titlebar. The blur cannot be kept (documented since Win10 2004, and the modern DWM backdrop was already rejected for the overlay-promotion blink), so armGlassResizeGuard grounds the canvas to --bg-sidebar for the length of the gesture. STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin bake moved to display resolution and off the ready task (was a same-task 4K WebGL readback plus software blur, 100-170ms per rotation AND per artist entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded; one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so all three periscopes stop cloning mid-gesture on ANY pane rather than only their own; bounded reclone with the querySelectorAll census deleted, the gate widened 250->1000ms, and the build/attach split across an idle callback and a rAF behind a generation guard; the hero rotation tick gated on scroll recency and its payload cache sized to the working set (2->4 entries, 24->32MiB, 32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED. Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were measured innocent - do not re-chase them. 98/98 tests throughout. |
||
|
|
6ba1864498 | dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes | ||
|
|
dc7ea16b22 | mini player + dark glass: eight shapes run by the hidden main as the one brain, dissolving covers and accents, drag from anywhere, and the space between the panes turned to real acrylic - with the webview flags that keep rest frames honest | ||
|
|
6a7a5c49c0 | overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them | ||
|
|
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. |
||
|
|
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. |
||
|
|
034eac9569 |
home: a landing page built from the app's own parts
Approved on research/home-redesign after four passes: the Desk layout, the Showcase polish, the welcome card, a 6-month feed window, news with reviews filtered, and all three of the sections I proposed. Nothing here is a new design. The release cards, their grid, the date chip and its accent-glass recipe, the upcoming wording and the missing play button on an unreleased record are the artist page's. The section heads, the featured card, the artist cards and the list rows are ArtistExtras'. The entrance is CatalogDiscography's edition deal with the same four approved numbers, and the arrival rule with it — a null from consumeScroll means a fresh arrival, which is the only thing that deals; coming Back is a return to something already read. Only the rails, the aside panels and the news list are new vocabulary, and those came off the mock verbatim. The styles are copies rather than imports for the same reason the discography page carries copies of the album page's: a page that reaches into another page's stylesheet couples them forever. The feed is what "new" honestly means. Deezer's release list merged with the MusicBrainz identity supplement — A ? of WHEN is a Panda Bear record that exists only in the supplement, and Weezer's upcoming album only carries its real name (Gold Album) there — then deduped per artist by cover and by base title, which collapses Weezer's BBC triplet and Röyksopp's four same-day remix drops to one card each. Upcoming first, then newest, with the age riding the artwork so the reader never has to work out whether "new" means this week or last spring. Jump back in is deliberately NOT deduped against it: a record can be new and also what you were just listening to, and it belongs in both. Everything reads caches. Assembling this feed cold costs one catalog_artist call per followed artist — 48 seconds for nine, measured — so home_warm walks the follows in the background through the same entry points the pages use and emits per artist as it goes. The page fills in progressively instead of waiting for the last one, and a follow change re-walks it. Three sections were mine to propose and are now in: the back catalogue (albums older than the window, one per artist first so a deep discography cannot own the row), the library rail, and Complete the set — you own two of sixteen Weezer albums and a new one lands in seventeen days. |
||
|
|
f6eb9ff02f |
media card: Windows knows SoulStream is a music player now
Registering with the System Media Transport Controls is what makes an app a media app to the OS rather than a window that happens to make noise. The card that the media-key overlay, the taskbar media flyout and the lock screen all draw now carries our cover, title, artist and album; the hardware media keys reach us with the window unfocused; and the transport buttons on that card drive this app. Verified against the OS's own session list: title/artist/album/albumArtist, type Music, a live scrubber, a present thumbnail, and play disabled while playing. Where each half of it comes from follows where the truth already lives. Status and position are pushed by the backend's player event pump, which already sees every transport change and every position tick — the card is driven by the same events the UI is, so it cannot drift from the window, and it costs no IPC at all. Metadata goes the other way, once per track, published by the player bar: the queue lives in the frontend and so does the derivation the bar displays, and publishing exactly what the bar shows means the two can never disagree. Deriving it a second time in Rust would guarantee that one day they do. Button presses are forwarded as `soulseek:media-button` rather than handled in Rust, because nextTrack knows about shuffle, repeat, the album fill and re-sourcing a track restored from the last session, and none of that is knowable from that side. They run the same functions the on-screen transport runs — the play/pause branch moved into togglePlayPause() so there is one implementation, not two that drift. Proven by driving the app from the OS session: next advanced the queue, pause and play moved the status, previous restarted the track on the >3s rule the app already had. Stop is deliberately disabled: this transport has no stop, only pause, and a dead button on the card is worse than a missing one. Buffering maps to Changing, which is what "finding best source…" honestly is. The thumbnail is the bar's own data URL decoded into an in-memory stream — no temp files — and it is explicitly cleared when a track has no cover, because the previous track's art outliving it is worse than none. The debug handle gains the local playback entry points beside the streaming ones it already had. That is not only convenience: verifying this end to end needs a track playing, and every run that reaches for Soulseek to get one costs a login the server starts ignoring after a burst. |
||
|
|
34313e6035 |
discography: the whole catalogue on a page of its own
"Show all" beside the artist page's Discography head opens every release as a stack of album-page heroes with their tracklists — the shapes Kyle picked off the mock: the search page's top-result card as a compact header, the album hero at --cover-scale 0.45 per record, a per-record accent sampled from its own cover, the year rail and the tab summary. The tab row does not stick and there is no Playing badge. Records the artist page has not fetched are shown rather than hidden, and the entrance is the W1 stagger — 300ms in, 55ms per row, 28px of lift, eight rows. It does not re-file the releases. catalog.discoTabs is the artist page's OWN computed tab list, published as it derives it and republished as the evidence improves. It has to work this way: the filing depends on the background description harvest that runs while you look at the artist page and on the MusicBrainz type hints from the deferred identity pass, neither of which is derivable from the payload — so a second classifier would read neither and would quietly file some releases differently from the page that linked to it. One list, computed once, handed over. If the tabs in hand belong to somebody else, opening a discography lands on the artist page instead, which republishes them and puts "Show all" one click away; an empty discography would be a lie about the catalogue. The page's CSS was extracted from src/ rather than retyped — CatalogAlbum's record section, CatalogSearch's header card and PlayerBar's menu, pulled through the same rule-matcher the mockup generator uses — so it inherits the app's measurements instead of approximating them. Verified against the running app: artwork 108px, record hero 114.3, header 147.5, header top 78, bar offset 0, ambience banner 0, rail centring error 0 and 12.8px of clearance at every scroll depth and pane width. Pond files 12+16+2+2 = 32 families on both pages. It is the one page exempt from BAR_VIEWS' padding-top rule, deliberately: its blurred ambience layer has to begin at the very top of the pane, and a padding there would start it halfway through the glass bar. It takes the inset on its header's margin instead, which lands in the same place. |
||
|
|
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. |
||
|
|
5786269592 |
player bar: it grows upward, the cursor is marked by the wave itself, and the shape you leave it in survives a restart
Expanding (mock E1+T1, cubic-bezier(.22,1,.36,1) over 350ms to 90px):
`.app.player-expanded` sets --player-height beside the sidebar's own rule
and grid-template-rows joins the transition, so the bar grows into the
content and its bottom edge is pinned without a rule saying so — the
grid's height is the window, the bar's track takes 28px and 1fr gives up
exactly that. Inside the bar four blocks are absolutely placed so the two
layouts can interpolate; flex cannot, because one row to a centred
two-row stack means changing flex-direction, which does not animate. Name
and extras sit at top 50% with a -50% translate and are centred at every
height in both states. --btn-w and --extras-w are measured once rather
than assumed: extras is 180px, not the 170 the layout was drafted with,
and it differs between a stream and a library track. The toggle takes the
download button's seat (the sidebar's .gicon, verbatim) and Save to
library moves into the ... menu under the same isStream gate. Measured:
name and extras vertical error 0 in both states, transport and lane
horizontal error 0 expanded, lane exactly 10px under the transport.
The 1px playhead tick is gone from all three painters. The bar under the
cursor lifts while its neighbours dip (A4): growing alone reads as
nothing, because the median bar is 21.3px of a 24px lane and 87% of bars
have no headroom left to grow into — the dip is what carries the
silhouette. The wash is enter/leave only, one shared presence ramp with
hard span membership, so the edge cannot trail the pointer: 0-bar lag
across five jumps in both directions, and moving inside the lane
schedules no frames at all. The settled painter keeps its cached-Path2D
grouped fills whenever no pointer is on the lane.
The clipped-name edge fade from
|
||
|
|
a949c24677 |
playback: streams try peers that can actually deliver, and give up on the
dead ones faster "Da Funk" — hundreds of sources — took over a minute to start. The quality-first score ranks a format tier at 1000 points and a free upload slot at 600, so busy lossless peers owned the entire failover list; each one queued the request and never sent a byte, and the flat 15s-per- candidate walk burned the minute before a live peer got its turn. For STREAMING, candidates are now stable-partitioned: peers with a free upload slot first, best quality within each half. Quality still decides among peers that can deliver immediately; a busy peer's better file is a save-time choice, and downloads keep the pure ranking. The album path and the background fill inherit the same order. The failover budget drops from 15s flat to 12s for the first candidate (an indirect handshake can honestly take ~10) and 8s for the rest — a peer that has not sent a byte in eight seconds is not about to. Worst case falls from ~60s of dead peers to ~36s, and the common case — an available peer ranked first — starts in seconds. |
||
|
|
cef1363acd |
playback: stop flooding the Soulseek server from the album fill
Clicking rapidly through a record's tracks kept dropping the server session and arming its reconnect throttle — a fresh login died inside ~90 seconds of a click-storm. Cached plays themselves were innocent (play_stream's cached branch never touches the network, preloads cache-check first); the traffic was the album FILL, which every play restarted immediately and which fires a server search for every remaining track the source memory does not know. Three protections, layered: The fill now starts only after the queue has sat still for four seconds, and a new play cancels the pending start — stress-clicking sends nothing at all, while normal listening fills the album four seconds into a track, hours before it matters. Searches are globally paced: never two inside 1.5 seconds, whoever asks. A user-triggered resolve still feels instant — the first search never waits — only bursts are spread. And a failed AUTOMATIC connect arms a three-minute backoff. Every dev-reload of the page runs autoConnect, and an auto-dial during an outage only extends the server's cooldown; the user's own login button still dials whenever pressed. |
||
|
|
e2cfed081c |
player bar: the track-change morph stops being a coin flip
Two independent fragilities each collapsed the shape→shape morph back to a grow-from-dots, which is why it only sometimes worked. The hold was 600ms against an arrival whose timing is DECODE time: the same track costs ~5ms once the session holds its waveform in memory but hundreds of ms to over a second on the first cold decode. 2000ms covers the cold case with room; fresh-stream searches take well past that, so the loading story still gets the lane. And the hold bailed on a bar-count mismatch — but the lane's width follows the song TITLE now, so switching between a long and a short name resizes the lane mid-gap and the guard threw the held shape away. Held and morphed heights are refitted to the new count instead (nearest neighbour — it is a moving start point, on screen for under a second). Four consecutive cached switches measured after the fix: minimum lane height 15.1 on every one (dots are ~2, the old dip hit ~5). Gapless playback is untouched — the hold and morph are pure painting, and the sink-append path never waits on the lane. |
||
|
|
0bbdb1bb0e |
player bar: track changes morph shape into shape, not up from the dots
The arrival morph honestly started from whatever the lane showed — and on a track change the lane always showed dots, because the waveform empties the moment the queue moves. Every switch therefore played the same grow-from-zero, when the picked motion was one waveform transforming into another. A track change now HOLDS the outgoing shape for ARRIVE_HOLD_MS (600ms), painted in unplayed grey since the new track owns the transport. A quick arrival — a local file, a cached stream — lands inside the hold and the morph runs directly from the old bars into the new ones; a slow stream load falls to the dots-and-swell story when the hold expires, exactly as before. The rail mirrors the hold with the same shared constant, and suppresses the loading swell while holding so the keyframes cannot fight the held heights. Measured across a cached track switch: the lane's mean height never left 16.9-17.4 (the dots sit at 2, the old dip hit 5.2) while the per-column fingerprint changed — the shape transformed without ever falling. |
||
|
|
1765de65cf |
player bar: a complete waveform arrives as one motion instead of popping
Chosen on waveform-arrive.html (M2, direct morph, 1000ms): when a track has its whole waveform at once — a local file, a fully-cached stream, or the final decode replacing a stream's progressive peaks — every bar eases from whatever the lane currently shows straight into the new shape, all together. From the resting dots that reads as a uniform grow; from a mid-sweep swell or the previous track's bars it is literally one waveform transforming into another. The streaming rise keeps its left-to-right story untouched; the morph fires only on a peaks swap at coverage 1, and never on a resize, which re-lays the same shape and stays instant. The canvas keeps a record of what it actually painted last frame — dots, swell, rise, morph or settled — so the morph always starts from the truth on screen, and the fade colours (accent, boundary blend, hover wash) stay live through it. The rail mirrors the motion in CSS: an `arriving` class carries the same 1000ms house-ease height transition, placed after the rise rule so a stream's final decode arrives on this ease too. Measured live on a cached start: bar heights walked 5.2 → 8.2 → 10.6 → 12.3 → 13.6 → 14.6 → 15.0 and settled at 15.1 at the 1000ms mark — the ease-out curve, frame by frame. |
||
|
|
5ac456102f |
playback: the album button and the background fill learn the cache too
The cached-source skip only covered single track clicks; pressing Play on an already-streamed album still resolved from scratch, and the background album fill searched for every remaining track it appended. Three closures: Play-album first asks the memory for every track of the record, and when all of them are still byte-complete in the cache it queues the whole album from memory — no search, no popup, playable offline. One unknown track falls through to the resolver as before. The resolver's own queue build now remembers EVERY track of the folder it picked, not just the one that plays — one album resolve teaches the whole record, so the next press of Play skips straight to the cache once the files have finished arriving. The background fill checks the memory before searching for each appended track, and remembers what it appends, so a record that has streamed before extends its queue instantly instead of running one 7-second search per remaining track. Verified live: the remembered track went from click to playing in 114ms with the resolver never activating. |
||
|
|
f55562f0ab |
playback: a cached track plays without asking Soulseek where it lives
Hopping between songs of an already-cached album still ran the full "finding best source…" resolve for audio sitting complete on disk — a second of popup between every click. The player now remembers the source each stream item was queued from (localStorage, LRU-capped at 400) and, before any search, asks the backend whether that exact file is byte-complete in the stream cache. If it is, the track plays those bytes immediately: no resolver, no popup, and no online requirement — cached audio is playable offline, so the check sits before requireOnline. The memory is a hint, never a promise. It is written eagerly when an item is queued to PLAY, because a source that never delivers leaves no complete cache file and the byte-complete probe rejects it later at no cost; eviction and half-finished transfers fall back to the normal resolver the same way. First play of a track records it, every play after that is instant. |
||
|
|
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. |
||
|
|
b173f60085 |
search: the results page, the typeahead, and a session that survives a reload
The page is the signed-off prototype built out: floating rounded hero, inline top-tracks panel, filter chips, two-column Songs|Albums beside Artists, and all seven states — results, searching, no results, one-kind-only, library-empty, provider-unreachable, first visit. MusicBrainz records are merged in silently behind the Deezer rows. The top result is ranked by POPULARITY, not by kind: each kind puts up its most popular exact match and the winner is whichever holds the largest share of its own kind's leader. A raw fans-vs-rank comparison was tried first and handed "panda bear" to an Owl City song. Two provenance traps are paid for here. Deezer's /search/album returns no release_date at all, so sorting newest-first ordered by PROVENANCE rather than by date; dates are backfilled from the artist overview, resolved PER ALBUM ARTIST because Deezer's artist search answers nothing for "weezer blue album". And the typeahead holds its keyboard selection as the row's KEY rather than its index, because the MusicBrainz supplement lands ~600ms later and re-orders the album group under the cursor. The hero's middle third is hidden on the width of the PANE, not the window. The sidebar and the now-playing rail take ~490px between them, so a 1504px window can leave the hero 1012px — a window-width media query cannot see that, and the "latest release" card ended up sheared down to 26px of itself. The budget is written out at the @container rule. Session restore rounds it off: reload or restart returns to the same place with the same track, everything but the transport coming from localStorage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8f56f4398 |
albums: ask the artist's own discography before deciding a record does not exist
"A ? of WHEN" opened as a bare local page — no plays, no listeners, no real release date — while the artist page's expander showed all three for the same record. The artist page was right and the resolve was wrong. Deezer's SEARCH index is not the discography. The artist page merges a MusicBrainz supplement on top of it, and MB-only records get a real id, a real release date and full Last.fm figures there; the Deezer search that openAlbumByName ran simply cannot see them. So the resolve now falls through to the artist's own album list before giving up. That list has to be the SUPPLEMENTED one. getCatalogArtistPage returns the raw Deezer page until the artist page has been visited and written the merged version back into the cache, so a cold click still missed. It now runs mergeArtistIdentity itself — the same pure merge the artist page and the Now Playing panel already share — instead of concluding the record is nowhere. Verified against the artist page's own numbers: id 5831190465731370, released 2026-07-10, 4,521 plays, 236 listeners, per-track 66/131/113/108 — identical to the expander. The local-composition path stays as the last resort for a record genuinely in neither catalogue, where it is still the only thing that can render at all. |
||
|
|
dde8c7510b |
albums: one page for every record, and streamed history rows that actually open
Three things were wrong with opening an album, and only one of them was the one that got reported. The reported half: clicking a local record in the sidebar's history went to the old in-library album view. Local albums carry no catalog identity of any kind -- `albums.mbid` is null and is not even in the DTO -- so there was nothing to route with. openAlbumByName already existed for exactly this (the now-playing links use it, and it requires BOTH artist and title to match normalized, which is the namesake guard); it now reports whether it resolved and caches settled answers, so a second click is instant. The unreported half, and the reason "half of them are not working": every STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a string, and `catalog_album` takes a u64 -- the invoke was rejected outright with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all failed silently on click. The id is coerced in checkedAlbumPayload now, where both other string sources (page seeds) pass through too. And the part that made the first half unfixable: a record no catalog carries had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount of resolving would ever open it. Local albums now render on the SAME page, composed from the library's own data -- its tracks, durations, bitrates and the cover already on disk -- under a namespaced `lib:` id that can never collide with a Deezer one. Playback stays local: Play and any row queue the files you already have rather than sending a record you own back through the resolver. Save is not offered (it is already saved), previews are absent (there is nothing to preview), and the attribution footer credits your library rather than claiming Deezer + MusicBrainz. With that, the old view has no callers and is deleted: the album-page block, its CSS, and the members that only it used. Nothing anywhere routes to it. Verified live: all six history rows open the new page, a Home card opens it, Play queues 14 local FLACs without streaming, and the back arrow returns. |
||
|
|
69f64c11eb |
previews: the glow goes, the eq steps, and every clip lands before you scroll to it
The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows. |
||
|
|
eba29824c9 | sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane | ||
|
|
606250c361 | resize: reads before writes everywhere, and an open album drags like a closed one | ||
|
|
35a18d9ca0 |
state: one identity merge, one description splitter, and a record named at click time
Three separate things the panel and the pages were doing differently. The MusicBrainz merge lived inside `applyArtistIdentity`, which only the artist page calls, so the Now Playing panel showed the raw provider list: My Bloody Valentine appeared with four releases because Loveless and Isn't Anything exist only as supplements, and Weezer filed differently because the panel never received `type_hints`. The merge is now `mergeArtistIdentity`, a pure function both surfaces run. `applyDiscoveryIdentity` deliberately does not write `catalog.artistPage` — that would overwrite whatever page the user is looking at — and is guarded on the normalized artist name so a track change mid-flight cannot land one artist's supplement on another's list. `streamSong` knew the record all along and never said so. Every caller passes `album` and `albumId`; the queue item was built with `sub: artist`, and the panel splits `sub` on the dash to find its album line, so a single track could never name the record it came from. `catalogRef` was also assigned AFTER the Soulseek resolve, so the panel could not start loading the record until a peer had been picked — that wait was the "why does it take time to show the rest of the album" delay, with the id in hand the whole time. It is now set before the resolve, and handed back if no source is found. `splitDescription` promotes a description's own first sentence to a lede. It lives here beside the other release-identity helpers because three surfaces need it and a copy in each is how they start disagreeing. The abbreviation guard is worth keeping: written as `Nov\.` the lookbehind consumed the very period it was meant to protect, and "released 4 Nov. 1991" split after "Nov.". |
||
|
|
9cb19f42af |
player: fill the album, report a cached track honestly, and stop double-appending
The album continuation appended exactly ONE track and then waited for the next track start to append the one after, so a song started from search or an artist page showed a queue of one or two entries however long the record was. It still resolves one at a time -- a track cannot be queued before a peer with it is found -- but it no longer stops after the first. My Name Is Jonas cold: 1 to all 10 in under 12 seconds, in album order. The resume anchor moved to the LAST queued track so a fill cut short by going offline picks up where it stopped instead of being locked out by the "next already queued" guard. An adversarial review of that change found the hole it opened, and it is fixed here rather than shipped: dropping the second clause of the bail-out was wrong. streamSong installs autoAlbum only AFTER waitForStreamStart, which polls the GLOBAL player status, so a second play issued during that buffering window nulls autoAlbum and streamSong then re-installs its own context over the top -- and the fill would go on appending one album's tracks to the other album's live queue. The anchor must also still BE in the queue, which survives our own appends and disappears exactly when the queue is replaced; and streamSong no longer installs a context it no longer owns. advanceGaplessly reported a pre-buffered track as zero-of-zero. streamTotal === 0 is the UI's "no byte counts yet" state, so a finished track showed the indeterminate "Streaming from <peer>" sweep with no further event ever coming to correct it. Gapless is only offered for a fully cached file, so its size IS its received count. The buffering handler mirrors the store rather than zeroing it for the same reason -- those counters are what a window restore copies back, and zeroing them turned a complete transfer back into the sweep on minimize and restore. tryAppendNext set its appendedNextPath guard AFTER its await -- check then act. Harmless with one caller; the moment the cached-play event gave it a second same-tick caller both passed the guard and appended the same source to the sink twice, which puts the panel a whole track ahead of the audio from the next boundary on. It claims the slot before awaiting and gives it back on failure. ui.nowPlayingDragging is here because App.svelte's grid column has to leave the 0.28s ease at the same instant the panel does. |
||
|
|
ae99757ddd |
hero: repaint on a resize instead of rebuilding the canvas for it
Collapsing the sidebar dropped frames only on the artist page. The cause was one observer: the app grid animates its columns for 280ms, a ResizeObserver fires on every frame of that, and this canvas answered each one with a full resize() -- 171-224ms of main thread per toggle, 10-12 frames over 16ms, worst frame 54ms. Muting that single observer took the worst frame to 4.3ms; muting every other observer changed nothing. Two independent per-frame stalls inside it. Reallocating the ~3200x1630 backing store was 115-205ms of the total, and the box moved about a pixel per frame, so every one of those reallocations bought an invisible sharpness change. gl.getError() after every draw is a synchronous round trip to the GPU process, and it stalls hardest exactly when the GPU is busy compositing the animation that provoked it. reflow() is the observer's path now: it repaints into the CURRENT store and matches the store to the box once, 140ms after the box settles. What makes that correct is taking the cover crop from the DISPLAYED box while outSize normalizes by the store -- the two may then disagree, because a crop chosen for a 1.80 box drawn into a 1.96 store is stretched by exactly the ratio the compositor undoes painting store into box. Geometrically exact at every intermediate width, no reallocation. The error check is skipped on that path only; the settle pass that follows is checked, so a real failure still surfaces. After: 1.5-2.4ms per toggle across 46 calls, max 0.2ms each. No frame over 33ms, 0-2 over 16ms. The Now Playing collapse rides the same transition and improved with it. resize is no longer exposed: every caller outside this file is reacting to a box that may still be moving, and that is reflow's job. |
||
|
|
e334cd51c2 |
now playing: collapse to a rail, and rebuild the panel around the record
The right-hand panel disappeared when you closed it. It collapses to a rail
now, exactly as the left nav does -- same 0.28s curve, same crossfade clock
for its contents -- so the record, its progress and its transfer stay on
screen at 64px instead of sliding off.
THE RAIL carries the album as a full-height bleed behind a real glass bar
(the app's own recipe: rgba(20,20,24,.28), blur(12px) saturate(1.7), 46px),
with the expand chevron in that bar, a left-edge progress line, the cover,
an equaliser while playing, stacked icons badged with what is behind them
-- tracks left, releases -- and the title set vertically.
THE PANEL stops saying "Now playing", which was the only permanently
visible line it had and told you nothing. It names what you are playing
FROM, and carries the transfer as a hairline along the glass bar's bottom
edge -- the same object in both states, so a download reads identically
whether the panel is open or collapsed. The hairline fills linearly (it
tracks progress rather than easing past it), sweeps indeterminately while
there is no byte count to be honest about, and fades out when cached rather
than sitting full forever. Hovering the header drops the whole detail:
peer, transferred, rate, and saving what you are streaming. A library track
has no peer, so none of it appears -- zeroes would read as a stalled
transfer.
Below the record, one long undifferentiated scroll becomes four shallow
panes -- Queue, Lyrics, Artist, About -- behind a sticky segmented control.
Sticky because keeping your scroll position across a switch is worthless if
the control you just used has scrolled out of reach. Queue gains an up-next
card, library ticks, and per-track Last.fm listeners past 396px; Artist has
the artist as one full-width pill whose figures spread once there is room,
the same branded social circles the artist page draws, releases with Show
all, and fans-also-like; About carries the record's description and the
album page's RELEASED block verbatim, so a record reads the same in both
places. Lyrics is a placeholder -- there is no source in the app and
pretending otherwise would be worse than saying so.
The panel is drag-resizable from its left edge, 272-560, with a 10px magnet
at the default and a width tag while dragging; the width is remembered,
because one you set by hand and lost every launch is worse than not being
able to set it.
Five things this fixed on the way, four of them mine:
- `playerArtSource` resolved a "better" cover by ARTIST + TITLE. A name
cannot identify a record: every self-titled Weezer album is "Weezer", the
resolver returned whichever one the provider answered with, and all of
them played back showing the Green Album's art. The substitution is gone;
the record's own asset is rewritten to its 1000x1000 variant instead --
a real quality win, no identity risk, and more than the panel needs even
at its widest. Verified: Blue and Green now resolve to their own hashes.
- The panel body was torn down 300ms after closing, an optimisation from
when it slid off-screen. The rail shows that artwork, so it blanked the
rail's cover and its bleed.
- An inline --nowplaying-width would have outranked the collapse rule and
made the panel un-collapsible; the dragged width goes through a
--np-open-width indirection instead.
- The segmented control was an opaque strip painted straight over the album
bleed, reading as a grey band across the artwork. It is glass now.
- The waveform was a picture of a scrubber with no way to seek, while the
player bar carried the only one. It seeks now, on the same semantics
(75ms throttle dragging, exact seek on release), and the second time
reads remaining rather than total -- the total is already implied by the
bar's length.
`soulseek:stream` reports {received, total, done} and no rate, unlike the
download path's speed_bps, so the speed behind the hairline is measured
here: a rolling 1.5s window, because a per-event delta reads as a number
flickering between 0 and several MB/s rather than a speed. There is
deliberately no "queued at peer" state -- the streaming path reports no
queue place, and the panel will not claim to know one.
`discovery` keeps the artist page's socials rather than discarding them;
they were already being fetched. 29 CSS rules for the removed sections are
gone with them.
Verified in the running app over CDP: collapse to exactly 64px and back,
rail contents and bleed at blur(17px), all four panes, sticky segments,
social chips carrying the artist page's own brand tints, the release grid
going to three columns past 430px and the pill's stats spreading past
372px, and About reading "Released May 9, 1994 - 10 tracks - 41 min / DGC -
Alternative Rock". 85 JS tests pass, production build clean with no unused
selectors. The drag gesture itself is not CDP-drivable and wants a
hands-on check.
|
||
|
|
98d11a2228 |
previews: the album button opens the record the previewed track came from
The third action in the preview modal's control row has been a disabled stub since the modal landed -- there was nowhere for it to go until the album page existed. It goes there now. The target is the item's own provider `albumId`, never a title match, so a top-tracks preview lands on the exact record the modal's metadata line is already naming: Elephant Gym's "Light" opens Angle, and AC/DC's "Highway to Hell" the album rather than any of the compilations carrying it. The manifest has carried `albumId` and an `actions.openAlbum` flag from the start; all 20 rows of a Deezer top-track payload declare one. The navigation lands on the previewed TRACK, not just the record -- the album page's existing pending-track flash does the scrolling and the highlight, the same landing the now-playing song link already gets. Three details that are not obvious: - The overlay must not import state.svelte.js (the module comment on previewSession explains the cycle), so the navigator is injected by the page exactly as `playFull` already is. - Both ids are read out of previewUi and the snapshot BEFORE the close, not after: closePreview() nulls the callback and drops the snapshot the active item came from. And the close happens first, because the session holds the audio focus and navigating under an open dialog would land the new page behind the scrim. - contracts.js freezes every manifest id as a String, while `catalog_album` takes a u64 -- a JSON string fails to deserialize -- and the album page's flash compares track ids with ===. Both cross back to numbers in openAlbumFromPreviewItem, once, rather than at each call site. An album session is already standing on the record it would open, so the button greys out there with "You're already on this album" rather than re-navigating to the page underneath. The decision is made from the manifest's own contextKey, so it holds for any future surface that opens an album-kind session. The album page still supplies the navigator, so a row that ever declares a different album goes somewhere instead of being silently dead. Verified in the running app over CDP: artist top-tracks preview -> click -> lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number, matching track found on the page, .trow.flash present, modal gone, and the glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album session the button is disabled and a click changes nothing. 85 JS tests pass. |
||
|
|
122157e061 |
listener bars: a bar is never narrower than the number standing on it
Two faults in the tracklist's listener column, both visible on any record with one runaway hit. The bar was a LINEAR share of the album's biggest song, so a single outlier took the whole column and pressed everything else into a sliver. Weezer's Green Album is the case: Island In The Sun has 11.9x the listeners of that record's quietest track, so nine of the ten bars lived inside the bottom 8% of a 168px column and stopped being tellable apart. And the fill was absolutely positioned with the number floating on top of it, so a short bar left its own digits hanging off the end onto the row background. Six of the ten rows on the Blue Album did this. The share is now raised to a power below 1 (0.42). That is strictly monotonic and still anchored to the PEAK, so the ordering survives and a genuinely flat record still reads flat -- a min/max normalisation against the album's own quietest track was rejected for exactly that reason, since it would stretch Maladroit to look as varied as Green Album. Measured in the running app, shortest bar of a 168px column: album top:bottom before after Weezer (Green Album) 11.9x 8% 38% Make Believe 6.7x 15% 45% Weezer (Blue Album) 3.9x 26% 56% Weezer (White Album) 2.3x 43% 70% Maladroit 2.2x 46% 72% The flat records move least, which is the point: the curve spends its lift on the albums that were squashed and barely touches the ones that were not. For the second fault the number moves INSIDE the fill, and the fill gets `min-width: max-content`. The floor is then per-row and measured by the browser in the real font -- nothing in JS could do that honestly -- and the width handed over is only ever a lower bound. Across the four albums above, numbers hanging off their bar went from 6 to 0 and every bar stayed monotonic in its count. Green Album lands at 38% rather than the 35% the curve alone gives, because the text floor takes over on its shortest row. A track Last.fm has no count for keeps its dash and now gets no paint at all; a floor-width pill behind an em dash would read as a measured tiny value rather than an absent one. The scale moves to src/lib/listenerBars.js because the album page and the artist page's open-in-place album panel draw this identical column, and a second copy would quietly disagree with the first about how big a record's hit was -- the same reason editions.js was lifted out of the artist page. Both call sites verified over CDP; 85 JS tests pass. |
||
|
|
7a517dca59 |
navigation: one glass bar for every page, with a history and a path in it
The glass bar was copied into two views and each carried its own contextual
back link — "Results" on the artist page, the artist's name on the album page.
It is a shared component now (src/components/GlassBar.svelte), and it carries
the app's navigation: back/forward arrows, and the trail to where you are.
**History and the trail are different things, and the file keeps them apart.**
History is a SEQUENCE — every place visited, in order — and it drives the
arrows. The trail is a HIERARCHY, derived from the current place, never from
history. That is why switching between two editions of a record adds a history
step but does not add a crumb: a breadcrumb built out of history would read
`Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits
inside the original. It does not.
Crumbs improve in place. An artist page opens on an id and learns its name and
portrait a moment later, so the crumb starts as a placeholder and is refreshed
through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for
a row in some list can never rewrite where you are. Search results are the
journey's root rather than a bare "Search" — the crumb reads “weezer”, which
is the thing you actually want to get back to.
Arriving crumbs slide in from the right; only genuinely new ones animate, so an
ancestor that was already there does not re-run its entrance when the tail
changes. Chosen from four motions mocked against a real navigation stack.
Every navigation now goes through a recorder. A bare `ui.view = "…"` is a
destination the back arrow can never return to, so the sidebar routes through
`goToView` and the catalog openers record on arrival.
**Scroll position is remembered.** Going back returns you to the paragraph you
left; arriving somewhere new starts at its top. The difference is exactly
whether the move came from the arrows/trail or from a fresh click. Two details
that mattered: the offset is captured at the START of a navigation, because the
scroll pane outlives the view swap and by the time a page has been replaced its
scrollTop already belongs to its successor; and restoring is not one assignment
— the artist page paints a skeleton, then an overview, then the full
discography, so a single `scrollTop = 900` clamps to whatever height exists in
that frame and lands near the top. It re-applies until the target is reachable
and gives up the instant the reader touches wheel, key or trackpad. The album
page never reset its scroll at all, so a record opened from half-way down an
artist page began half-way down; it does now.
**The artwork lightbox serves any cover.** It was hardwired to the playing
queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor),
so Now Playing, the artist page's expander rail and the album hero all open the
same viewer with their own `{src, local, title, subtitle}`. Each passes the
UPGRADED cover rather than the sized variant it paints — the point of the
viewer is the master resolution.
Hovering artwork offers it: the whole element lifts under a light wash with the
expand mark centred. `transform`, not a width change — on the album page the
copy column is sized to end level with the artwork, so a reflowing hover would
twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion
160ms, --art-wash) live in global.css rather than being typed into three files.
Now Playing's corner expand disc is gone: one gesture for "open this artwork"
everywhere instead of a disc in one place and a lift in the others.
On the artist page the expander's album NAME now opens that record's page.
Also removed: the album page's scroll-triggered title takeover. The breadcrumb
names the record permanently, which made it redundant — the build was already
flagging its CSS as dead.
**And the fix this session actually turned on.** Kyle reported the album
tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636
listeners beside Tired Of Sex at 606,324. Switching the column from plays to
listeners changed nothing, because both were wrong for the same reason.
`artist.gettoptracks` returns a popular song many times over — once as itself,
then again for every "(Album Version)", live cut and remaster — listeners
descending. The fold meant to keep the first entry, and its comment said so,
but it was written `if by_key.insert(k, v).is_some() { continue }`.
`HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard
correctly skipped the ranked push while letting every later duplicate win the
map the album tracklist reads. The last re-listing — the most obscure pressing
— is what reached the screen.
Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642
listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919.
My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle
810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex",
"Across the Sea" — were ever right, which is why the column looked plausible in
places and absurd in others. Twenty-four entries for "The Good Life", twenty
for "El Scorcho".
The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real
figures. It also repairs Popular's plays and the album totals, which read the
same map. The tracklist column shows listeners in both places it appears — the
album page and the artist page's expander — because leaving one on plays would
have let one record disagree with itself.
152 Rust tests and 85 JS tests pass.
|
||
|
|
ea9a040d09 |
album page: the record gets a page of its own, under the same glass bar
The album page was a back link, a 150px cover and a list of titles — a
placeholder beside the artist page it is reached from. It is now built from the
same parts: the frosted glass header, a hero carrying the record's own art and
palette, the play-count bars, and the edition cards.
**The glass bar is the artist page's, not a copy of it.** Same 46px, same
rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with
the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is
meant to be the header every page navigates by, so it had to be the same
object rather than something that resembles it.
It gains one thing here: the record's cover, name and a play button fade into
the bar once the title itself has gone under it, so the bar always says where
you are. That watches the TITLE, not the identity block — watching the block
meant waiting for the artwork to clear too, about 240px of scroll on a page
that only has 199px of it, so it would never have fired on a ten-track album.
**The hero is one proportional block.** `--cover-scale` sizes the artwork
(282px) and everything beside it is `em` off that one root, so type, gaps, the
artist portrait, the pills and the stat divider all move by the same factor.
Two earlier attempts are worth naming because they both looked wrong:
- damping the type (0.6 + 0.4·s) made the words visibly lag the artwork;
- `space-between` filled the height but opened the gaps faster than the type
grew, so the block came apart as it got bigger.
The gap is 1.8em, which is what the five rows need to fill the cover exactly,
and being an em it keeps that fit at any size. Measured live: the label's top
and the buttons' bottom both sit 0px from the artwork's edges. The hero's own
height derives from the artwork (`--cover + 68px`), so the 24px under the glass
bar holds at every scale instead of being a second number that drifts.
**Editions come from src/lib/editions.js — the artist page's own grouping.**
`releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out
of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages
fold a record's pressings the same way. Two implementations would have drifted
invisibly: each page would show a plausible edition list and disagree about
which pressings are one album.
The album payload has no siblings, so the family is rebuilt from the artist's
discography. It reads the FULL page rather than the overview — the overview is
the artist page's first-paint subset and both dropped the anniversary edition
and carried no track counts, so the rows read "1994" where the discography
reads "1994 · 50 tracks". Cached per artist, and already warm whenever this
page was reached from theirs. Live, the Blue Album now lists Original 10 /
Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly.
One deliberate narrowing: the artist page buckets releases by category before
grouping, and that categorisation leans on page state this view does not load
(harvested description hints, MusicBrainz type hints). So a sibling is accepted
only when the backend LINKED it (`edition_group`, a verified ordered core-track
match) or it declares the same record type. That can only ever show fewer
editions than the discography, never promote a separate live album or
compilation into this record's edition list.
**Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a
`keepCurrent` option that holds the page already on screen instead of clearing
to the loading state; the incoming cover is decoded before the swap so the
artwork crossfades rather than blinking out; and the parts are re-dealt on the
same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind →
title → meta → stats → actions → tracklist → description → editions → credits.
Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without
lifting, because sliding a 42px blur reads as a wobble, and every part animates
to its OWN resting opacity — the banner rests at 0.62 and animating it to 1
would have run the colour wash half again too bright for the whole swap. A
failed load under `keepCurrent` clears anyway, so a dead edition surfaces the
error instead of looking like the click did nothing.
**The byline shows the artist, not the album cover again.** The album payload
carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads
and fills the same cache `openArtistByName` uses: the click that navigates
there has usually already paid for it, in-flight lookups are deduped so the
page and its own back button cannot fire the same search twice, and a transport
failure stays retryable rather than being cached as "no artist". A neutral dot
holds the space until it lands, so the line never reflows.
Shuffle and Preview join Play and Save. Shuffle is an action on this record,
not a toggle — it always turns shuffle on rather than flipping a global and
silently starting the album in order — and stays lit while it is on so that
state is visible from here. Preview opens the same modal the artist page does;
`buildAlbumManifest` already existed in the preview layer with no caller, which
is what this surface was for. It reports partial availability the same way, and
the manifest is a snapshot so an edition swap cannot rewrite an open session.
Also: plays bars, the whole-track/listener totals, the editorial description
and a released/label line, all off data `catalog_album` was already returning
and the page was throwing away. The plays column hides below three tracks — a
bar measured against itself is not a comparison, and on a single it read as a
rating. Track counts are pluralised, so a single says "1 track".
No add-to-playlist button. `add_to_playlist` requires a file path per track, so
a catalog album can only be added once it is in the library; that is a playlist
decision, not something to slip in behind an icon.
150 Rust tests and 85 JS tests pass.
|
||
|
|
644a0192a5 |
discography: give mixtapes a tab, and stop the provider spelling it twice
Death Grips' "Exmilitary" was missing from the app entirely. Deezer never
carried it, and MusicBrainz has it but types the release group Mixtape/Street,
which the studio-album supplement excluded along with every other secondary
type. The same exclusion hid Chance the Rapper's "Acid Rap" and "Coloring
Book" — records that outscrobble everything he has released as an album.
They cannot simply join Albums. MusicBrainz files 118 Mixtape/Street groups for
Gucci Mane and 51 for Lil Wayne, which buries a 15-record discography, and no
significance gate separates the canon from the ephemera: measured on Last.fm,
Exmilitary sits at 19% of Death Grips' biggest record while Gucci Mane's
"Chicken Talk" sits at 20% of his. The two are indistinguishable by weight
because both are real. So the answer is filing, not filtering — a tab of their
own, where volume costs nothing and nothing has to be hidden. It sits beside
Albums rather than down with the archival tabs because for the artists who have
any, the mixtape is usually the canon. Empty tabs are already dropped, so
Queen and Radiohead never see it.
A SEPARATE query rather than a relaxed filter, and the page cap is the reason.
MusicBrainz returns 100 items, and asking one query for both — via
`-secondarytype:live AND -secondarytype:compilation AND …`, the only form its
Lucene actually honours, since a bare
`(-secondarytype:* OR secondarytype:"Mixtape/Street")` returns either nothing
or mixtapes only — came back with Gucci Mane's 87 mixtapes and just 13 of his
15 studio albums. The mixtapes ate the page and pushed two real records off it.
Split, the album supplement is byte-for-byte what it was, and the cost is one
request on the DEFERRED identity pass, disk cached for a day like every other
MusicBrainz call.
The tab then exposed a dedup hole that had been there all along.
`normText` DELETES bracket contents, which is correct for the peer file
matching it was written for and wrong for release identity. MusicBrainz writes
a qualifier after a colon where Deezer puts it in parentheses, so
"World War 3 (Lean)" reduced to "worldwar3" while MB's "World War 3: Lean"
reduced to "worldwar3lean". They could never collide, so the supplement
injected a second card for a record already on the page — and with mixtapes now
filed apart, the same work appeared in Albums AND Mixtapes. Ten such pairs on
Gucci Mane, among them "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part
Five", "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" / "Trap House
Four".
The identity merge gets its own key, which keeps the qualifier and folds the
three things the providers genuinely disagree about: the separator, the words
introducing a number, and whether that number is a digit, a word or a roman
numeral. Single-letter romans are deliberately NOT folded — "X", "V" and "I"
are real album titles and reading them as 10, 5 and 1 would merge unrelated
records.
And a matched mixtape contributes its TYPE, not a card. The Deezer row keeps
the slot because it is the playable one — real album id, real tracklist — and
MusicBrainz's verdict re-files it, exactly as `type_hints` already re-files a
mislabelled compilation. That is what moves "World War 3 (Lean)" out of Albums
instead of leaving one copy in each tab. It is also why Gucci Mane's Albums
count falls 146 -> 73: those rows were ALREADY being swallowed by the old
title check and left sitting in Albums untyped, so this does not hide them, it
finally classifies them.
Every row sharing a key adopts together, not just the first. A provider
sometimes carries one record twice under its own spellings ("Brick Factory 2"
beside "Brick Factory, Vol. 2"); typing one of them and not the other put one
copy in Mixtapes and left the other in Albums, which is a worse page than
leaving both alone.
Measured live: Gucci Mane 252 -> 241 rows with cross-tab duplicates 10 -> 0,
Death Grips one mixtape and it is Exmilitary, Queen and Radiohead unchanged
with no Mixtapes tab rendered. 75 Rust tests and 85 JS tests pass.
Still open, and older than this change: Deezer carries "Brick Factory 2" and
"Brick Factory, Vol. 2" as two rows, and "Trap House 3" beside "Trap House
III". This key would collapse them, but that means deleting a row the page
currently shows, which wants measuring across a range of artists first.
|
||
|
|
388ad7d910 |
artist page: open an album in place, and show what the numbers are worth
The discography grid gains an expander: click a record and it opens between the rows, with a rail carrying the art, the totals and the editions, and a track list beside it. Three strands landed on this file across as many sessions and they share the same DOM -- the row template the plays column widens is the one the expander draws, and the keying that attribution needs is what makes the cover crossfade possible -- so they arrive together. **Keeping the grid still while a panel grows inside it.** An expander opening mid-grid moves every row below it, and the naive version fights its own animation: the reserve is measured against a layout that is still collapsing. So each expander other than the one being kept is treated as mid-outro and discounted from the measurement, the grid's `min-height` glides to the new reserve rather than snapping, and a same-row switch keeps BOTH panels alive -- `panelLeave` takes the outgoing one out of flow so the two overlap instead of stacking, and the stage eases between their heights so the box never collapses to nothing on the way. `.xp-stage` exists to be the containing block for that. **The loading state only exists if the load is slow.** A warm open resolves in tens of milliseconds and used to flash a line of text on its way past, so nothing renders until the load has outlasted a 250ms grace -- and when it does render it is placeholder rows drawn in the track grid's exact geometry, so the real list replaces them in place rather than reflowing. **Cover tier follows DPR, not window size.** The rail art is a FIXED 209px -- the rail column is 250px regardless of monitor -- so a 4K display does not make the box bigger; only device pixel ratio does. 209px needs 418 device pixels at DPR 2, which the 500 rendition covers, and side by side at DPR 2 the 500 and 1000 sources are indistinguishable at actual size. At DPR 3 the 500 is visibly soft. So 500 below that line and 1000 above it, rather than paying 3.6x the bytes (148KB -> 501KB per cover) on every display to fix the top one. Coverart Archive has no 1000 rendition; its next step is 1200. **Popular rows are keyed on `origin_track_id ?? id`.** This is what lets the backend hand a row its original album without destroying it: the corrected row is REUSED rather than torn down and rebuilt, which keeps its save state and is the whole reason the cover can crossfade instead of popping. `nowPlayingTrack` matches either id, so a track that is playing stays marked as playing across the correction. The crossfade itself needs the proxied art SYNCHRONOUSLY -- one microtask is still a frame with the raw CDN url in `src`, which is a network request for a file already held -- hence `peekCatalogArt`, which returns the resolved data URL or null and never a promise. The replay recipe goes to v4 for the same reason it went to v3: a v3 replay carries Deezer's licensed attribution on every popular row -- Bohemian Rhapsody on the 2018 movie soundtrack, wearing that poster -- and painting a known-wrong album name instantly is worse than one ordinary load. **Plays, as a heat block rather than a bare number.** Bar length and colour both scale with the count, tinted with the OPEN album's accent so the column reads as this record's own. The header and the rows share one grid template, so the columns line up whether or not Last.fm knew the record -- and when it knew nothing the whole column, header included, stays out of the layout entirely. An unknown track is a dash, never a zero. Album totals sit between the meta line and the Play row, badged when they were summed from the tracklist because the provider's own figure could not cover its biggest track. The listener count in the Popular list needed the row wider: break-even was 833px for the column plus its gap, up from 760px, and the rest is air, so title and album each end up ~38px wider than before rather than narrower. That column is muted and deliberately NOT accent -- the row already spends the accent on `.now`, and a coloured column on every row drowns the one that means "playing". |