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.
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.
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.
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.