forked from Keelrleek/SoulStream
63a0357651d730f6ab611161ccc6250087a729bd
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 | ||
|
|
4d6e69e324 |
release ownership: one Deezer id can be seven artists
There is exactly one Deezer artist called "Wand" (1213178) and it holds the LA psych band, a Brazilian funk MC, an Indonesian act, an Indian one, a 2020 house project and whoever released Hard Knox in 2009 — 46 of its 72 rows are somebody else's. Ranking search results cannot help (there is one candidate) and neither can credit role (every row is primary-credited to it). The Apple namesake filter that should have caught this switched itself off: APPLE_EXACT_ARTIST_CANDIDATE_LIMIT is 8 and Apple has TEN artists named Wand, so the candidate fetch failed closed and every catalogue came back empty. It fails closed for a good reason at page-load time — it picks the winner by overlap with the requested artist's own Deezer releases, which on a fused record are partly the co-tenants'. Circular evidence has to fail closed. So the answer is not to raise that cap but to stop asking the fused list. artist_release_ownership runs in the DEFERRED identity pass, where the linked MBID has already named the artist, and picks the Apple performer that reproduces THAT catalogue. Non-circular, so 20 candidates is safe. Evidence is MB release groups of every type, the verified Apple catalogue, and the label sets of both (MB at one sighting, Apple at two — Apple files two rafftheraff singles under the LA band). Pruning only runs where a row is provably carried by a different same-named Apple artist, so the gate opens on 4 of the 55 artists measured and stays shut on Wednesday's twenty namesakes, Jungle's sixteen and Roosevelt's fourteen. The same evidence repairs the opposite failure. The guest guard keeps an appears_on row only if MB credits it to the artist's own MBID, which assumes MB mirrors collaborations. It does for Watch the Throne, whose artist CREDIT names both. It does not when the collaboration is its own entity: VULTURES 1, VULTURES 2 and KIDS SEE GHOSTS appear nowhere in Ye's 315 release groups, and all three were being erased from his page. Apple's artistName credit, read as a list of principals, restores them without restoring guest spots — Estelle's "American Boy (feat. Kanye West)" is still hers. A fused record's PORTRAIT is wrong for the same reason its releases are: it is whichever co-tenant uploaded last, a real photograph of the wrong person, so deezerPortraitMissing cannot see it. The verdict is persisted, because the search dropdown is typed against per keystroke and can never afford the lookups that produced it. Its store is deliberately not in net-cache-v1 — prune_disk_cache deletes every json there by mtime, and a verdict is not a cached response. Measured live: Wand 72 -> 26, Hot Toddy 36 -> 16, Crazy P 68 -> 51, Turnover 30 -> 25, Kanye 50 -> 64; Weezer, Pond, Billy Cobb, JPEGMAFIA and Daft Punk untouched. The page's "wand." wordmark went with the co-tenants and should have: it was traced from the Indonesian single's cover. |
||
|
|
86e0d86604 |
fix: preview crossfade no longer strands the main volume (silent playback)
Clicking a track could play silently: the store showed status=playing with the position advancing and no error, but the sink was at volume 0. The open/close crossfade ramped the main player's USER VOLUME as its fade channel and left it at 0 on an ack timeout (or when a track change suppressed the close resume). User volume persists across tracks and a new Play doesn't reset it, so every later click played at 0 while the slider still read 0.7. Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink = volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close fades ramp fade_gain instead of set_volume (their volume arg is gone, JS callers too), so the user volume is never touched -- store and backend can't desync -- and a clicked track always resets fade_gain to 1, so it can never be stranded silent. fade_resume lands exactly at 1. Player::fade_gain() mirrors it into a report atomic; debug overlay shows main fade + this proved the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7). 39 app + 12 player + 85 JS tests pass. |
||
|
|
793f78370e |
feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP. |
||
|
|
d051f0db28 |
Phase 2C WIP: PreviewOverlay modal + root integration (reel centering bug)
Working: - PreviewOverlay.svelte mounted at app root; opens over a fixture manifest (window.__previewFixture(n) in dev), inert background, player bar hidden, focus trap, keyboard nav, F3 rail, T3 swaps, R1b capsule, accent extraction. - previewSession.svelte.js: store + service wiring (production + fixture). - Root integration in App.svelte; revision-gateway bumps + notePlaybackGeneration threaded through state.svelte.js player entry points; api.win guarded so plain vite browser mounts. - Launcher on the artist page builds a real artist manifest and opens the modal with a play-full starter. - 70 JS tests still green. Two bugs found and fixed during bring-up: - effect_update_depth_exceeded: bleed-crossfade and bump effects read AND wrote the same $state; wrapped writes in untrack(). This loop was breaking Svelte's scheduler globally (even ui.* stopped reacting). - accentFromImage now sets crossOrigin for remote covers. KNOWN BROKEN (handoff): reel vertical centering. The active card stays centred at index 0 but drifts by one stride per step. Root cause isolated — see TODO-Feature-Docs/scrollable-track-previews/HANDOFF-PHASE2C.md. |
||
|
|
8cb3cd593b |
Phase 2A+2B: native preview backend and renderer glue
Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
fetch_preview_media — dedicated client whose custom redirect policy
re-validates every hop against the media allowlist; bounded body read
(cap enforced on received bytes, not Content-Length's promise); Date
header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
mirroring lease.js); preview_load with start_paused via the
desired-paused latch; preview_control/dispose; player_pause_acked
exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.
JS (2B):
- api.js: preview command surface (opaque lease ids only).
- nativeTransport.js: stamps {sessionId, generation, sessionKey} onto
backend events; cleared context makes stragglers dispatch nothing.
- productionResolver.js: DTO → MediaLease, failure classes preserved.
- focusBridge.js: production coordinator binding with the documented
renderer-side generation invariant (buffering counter + gateway).
- service.js: the orchestrator — settle window, resolve→load→play under
focus, single capped retry, one forward lease prefetch with safe-window
expiry, stale re-checks before every transport command, close/handoff.
The service constructs its transport around its own dispatch — the
factory signature exists because a pre-built transport silently loses
the event wiring (caught by test).
70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
|
||
|
|
a667b4b28e |
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts. |