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.
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 2a83615 had never once rendered. Its
classes are applied by classList.toggle inside an action, which the
compiler cannot see, so Svelte deleted the rules as unused — that warning
is a deletion notice, not advice. :global() on the mask class only, with
the anchor left scoped, and all four states now verify against a
narrowed box.
Panel shapes persist in one key, soulstream.panels.v1, holding
nowPlaying, sidebarCollapsed and playerExpanded together so the three
cannot end up half-migrated against each other; it is read per-key so an
older blob contributes what it has. showNowPlaying() exists because
openQueue assigned ui.nowPlaying directly and would have skipped the
write.
The content-hugging cluster made the whole bar jump between songs; the
capped-and-scrolling mockups read as either dead space or perpetual
motion. What stuck (namehover.html, 2026-08-03): the name box is a
CONSTANT 240px — meta width and NAME_BOX_PX, paired — so the transport,
the lane, everything right of the cover sits at the same pixel whatever
plays. Verified across a long library title and a short cached one:
meta 240, cluster 291, play button x 387, identical every time.
A name that fits simply sits in the box. A longer one rests under an
edge fade — a gradient mask, never an ellipsis; every truncation rule
the old layout needed (the 55% artist ceiling, the album-gives-way flex,
the ellipsis trio) is deleted, because the box owns clipping now. While
the pointer is over a clipped name it glides out at 40px/s, holds 1.1s,
glides back, repeats; it eases home the moment you leave, and the
gradient only ever sits on the side actually hiding text. Both lines —
title and artist · album — glide independently, and the tooltips still
carry the full words.
Two halves of one defect family. The meta line preferred the queue item's
artist/album fields as a PAIR whenever either existed — but a library play
(playAlbum) writes only `artist`, with the record's name living inside
`sub` ("artist — album"), so `album: null` was taken at face value and
every library album lost its name from the bar (and the morning comment
claiming local rows carry neither field was simply wrong). Each half now
falls back to the `sub` split on its own, here and in the rail's byline —
which also restores the album for continuation items whose `sub` is a
bare artist.
And the line no longer trickles in: the buffering branch used to render
the artist alone, so the album flashed in a beat after the title when
playback started. The status gate is replaced by a REAL distinction — a
resolver-phase track has no `path` (playQueueIndex always writes one), so
"finding best source…" still shows as a bare label, and the moment a real
queue item owns the transport the full clickable line renders, buffering
or not. Measured: the first frame carrying the new title already carries
artist · album; no artist-only frame exists.
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.
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.
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.
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.
88px becomes 62px and the two stacked rows become one. The 26px goes back to
the content area on every screen, and the scrubber stops capping at 560px while
the flanks stretch — a 1900px window used to put a small waveform adrift in the
middle of an empty bar.
The cover is 40x40 at 12px of padding, which lands its centre on x=40: the
column every sidebar cover and nav icon already shares, and which the sidebar
holds through its entire collapse. It was 56px at 16px, centre x=52 — twelve
pixels out of a column the app is otherwise strict about.
The waveform is drawn as discrete bars on the Now Playing rail's exact pitch, a
2px bar on a 4px gap with a 1px radius, both deriving their count from that one
pitch so neither can drift from the other. Mirrored here, where the lane is
short and wide; the rail stands its bars on the floor because it sits above a
caption. The standing white playhead went with it — at this pitch it read as a
seam through the bars, and the accent's own leading edge marks the position.
Artist and album share one line as "Artist · Album", the `.who` idiom from the
album page rather than a new one. They are read from the queue item's OWN
artist/album fields now: `sub` is a display convenience that the album
continuation fill writes as a bare artist while carrying the album right
alongside, so a record played straight through lost its album name from the
second track on. When the line must shrink the ALBUM shrinks — proportional
shrinking turned a short artist into "Po…", which saves nothing and reads as
nothing.
New: the source line. Who is sending this track, how much has arrived, how fast
and how long is left, then the format. Four states, derived exactly as
NowPlaying derives its status line — two surfaces disagreeing about whether a
track is cached would be worse than either being wrong. A library file reports
NOTHING, because it has no peer and no transfer and zeroes would read as a
stalled one. It sits in its own slot rather than inside the meta cluster:
sharing that budget let a live transfer readout crowd the artist and album down
to "Po… · Terrestria…". Its width is measured per STATE and never per value, so
a ticking percentage and a counting-down ETA move nothing.
The now-playing panel toggle is gone from the shelf — the panel carries its own
collapse control, and two ways to do one thing is one too many. What replaces it
is a single overflow menu, so landing lyrics, a mini player and output switching
later costs no width; those three are present and disabled, marked SOON, rather
than wired to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The preview backend never received a volume — clips played at the crate
default (0.7) regardless of the slider, even muted. The session now sets
the preview volume from the app store (mute → 0) before the first load and
mirrors it live while the modal is open; the debug overlay's PREVIEW row
exposes the backend's resting volume as proof.
The player-bar volume (and fallback seek) fill is now always the accent
color — matching the waveform scrubber's played region — stepping up to
the hover shade on hover instead of white-until-hover.
Also syncs package-lock to the soulstream name + alpha version.
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.