Commit Graph
13 Commits
Author SHA1 Message Date
KeelrLeek 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.
2026-08-04 19:08:58 -05:00
KeelrLeek 8ef730848a news: headlines about the artists you follow, anchored to the artist
No per-artist music news API exists — every product that does this
aggregates and filters, so this does too. Google News RSS is the source:
free, keyless, no pagination, and no contract at all, which is why it
gets the most conservative pacer in the file (4 requests per 10 seconds)
and a 6-hour cache on disk as well as in memory. Nothing user-facing
waits on it; the home page asks after it is already drawn.

The query is the QUOTED artist name plus the word "music", and the
quoting is the whole design. Measured 2026-08-04 against the real feed: a
bare ambiguous name returned 0 of 10 relevant items — searching "Girls"
gives Indigo Girls, The Deli Girls and a school production of Mean Girls
— while the same name with a corroborating token returned 8 of 8, and an
unambiguous one 10 of 10. That is the identity-anchor rule the rest of
the app already lives by, applied to a provider that only speaks in
names: never trust the name alone.

The parser is deliberately not an XML library. One provider, four flat
tags inside <item> blocks, exactly the judgment the Bing scrape made.
What it does handle is what Google actually emits: entity-encoded titles
including numeric forms, a " - Publication" suffix on every headline
(stripped when the tail matches the <source> tag, so the title reads as a
headline rather than a citation), and RFC 2822 pubDates converted to unix
seconds by a hand-rolled civil-date conversion — nothing else in the
workspace needs a date crate for one field.

Freshness and relevance stay OUT of here. The 45-day window and the
review filter are display policy and belong to the surface that shows
them; this returns what the feed said, newest first, capped at a dozen.
2026-08-04 19:08:35 -05:00
KeelrLeek f0c2bb5ed3 taskbar: transport buttons under the window preview
Hovering the taskbar button pops a preview, and an app may put buttons
under it. Every media player uses them for the transport, and now so do
we: previous, play/pause, next, with the middle one swapping its glyph as
playback starts and stops.

Clicks are emitted as `soulseek:media-button` — the same event the OS
media card sends — so the frontend's existing dispatch handles them with
no new code and the two surfaces cannot drift apart. Verified by posting
the exact WM_COMMAND/THBN_CLICKED the shell posts: next advanced the
queue, toggle paused, toggle resumed.

Three Win32 facts shaped the implementation. The buttons can only be
added ONCE per window and only after the shell has created the taskbar
button, which it announces with a registered message — but Tauri shows
the window before the setup hook runs, so that announcement is usually
already gone by the time we could subclass, and the first build put no
buttons up at all. They are added directly at init now, with the first
playback as a retry for the case where the taskbar button genuinely does
not exist yet. Clicks arrive as WM_COMMAND, so hearing them means
subclassing Tauri's window — SetWindowSubclass chains rather than
replacing the window procedure, so wry still receives everything it
expects. And ITaskbarList3 is apartment-threaded, so it lives in a
thread-local touched only on the UI thread, with updates from the
player's event thread marshalled through run_on_main_thread.

The glyphs are drawn at runtime rather than shipped: they are four flat
shapes, and a .ico per glyph per theme would be files to keep in step
with a transport that already draws itself in the app. Coverage is
sampled on a 4x4 grid per pixel, which is what stops a 16px triangle
looking like a staircase. They follow SystemUsesLightTheme — the
thumbnail flyout rides the system theme, not the app's — and are rebuilt
on WM_SETTINGCHANGE when that flips, so they do not end up dark-on-dark.
2026-08-04 16:08:26 -05:00
KeelrLeek 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.
2026-08-04 16:08:10 -05:00
KeelrLeek 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.
2026-08-04 14:43:27 -05:00
KeelrLeek 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.
2026-08-03 16:08:51 -05:00
KeelrLeekandClaude Opus 5 dc918c98ef metadata: per-track listeners, and an artist profile the search hero can ask for
CatalogTrack gains artist_id — Deezer sends it on every search row and it was
simply unparsed — and rank, its 0-1,000,000 popularity figure. Together they
let a listener count be resolved for the PERFORMING artist of each row rather
than for the query's top artist, so a "panda bear" search shows Daft Punk's
"Doin' it Right (feat. Panda Bear)" at its real 464,046.

Resolution rides the existing deezer_linked_mbid -> lastfm_listener_ranks chain
behind a new catalog_track_listeners command, deliberately kept separate from
catalog_search: that one is typed against per keystroke and must never wait on
MusicBrainz's 1 req/s gate.

catalog_artist_profile backs the hero's middle third — MusicBrainz tags off the
cached artist document plus one Last.fm artist.getinfo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:03:03 -05:00
KeelrLeek 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.
2026-08-01 07:11:17 -05:00
KeelrLeek eba29824c9 sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane 2026-07-31 18:21:04 -05:00
KeelrLeek 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.
2026-07-26 05:58:17 -05:00
KeelrLeek 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.
2026-07-23 16:05:14 -05:00
KeelrLeek 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.
2026-07-22 18:45:29 -05:00
KeelrLeek 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.
2026-07-22 17:57:00 -05:00