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.
This commit is contained in:
KeelrLeek committed 2026-08-04 16:08:10 -05:00
1 parent f72b12a892
commit f6eb9ff02f
7 files changed
+439 -7

No files matched your search

+29 -7
View File
@@ -13,7 +13,7 @@
getArt,
getCatalogArt,
playerArtSource,
resumePlayback,
togglePlayPause,
togglePlayerExpanded,
showNowPlaying,
saveStream,
@@ -296,6 +296,31 @@
return () => (active = false);
});
/* The OS media card (Windows SMTC — src-tauri/src/smtc.rs). Published from
HERE, not from the store: what Windows shows should be what this bar
shows, and this is where that is derived — `metaParts` reconciles the
item's own artist/album fields against the `sub` split, and `artUrl` is
the cover after the local/catalog fork. Deriving either a second time in
Rust would be a second set of rules to keep in step.
Status and position are NOT sent: the backend's player event pump
already has both and drives the card itself, for no IPC. */
let lastCard = "";
$effect(() => {
const title = player.track?.title ?? "";
const [artist, album] = metaParts;
const art = artUrl;
if (!title) return;
// Fires twice per track — once when the title lands, again when the
// cover finishes resolving — and that is deliberate: the card should
// not wait on artwork to say what is playing. Everything past those is
// a re-render carrying the same content, dropped here rather than
// crossing IPC with a few hundred KB of base64 cover for nothing.
const key = `${title}${artist}${album}${art ?? ""}`;
if (key === lastCard) return;
lastCard = key;
void api.smtcMetadata(title, artist, album, art);
});
// Waveform canvas + hover state.
let waveWrap = $state(null);
let waveCanvas = $state(null);
@@ -598,12 +623,9 @@
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function toggle() {
if (player.status === "playing") api.playerControl("pause");
// Not a bare `resume`: a track restored from the last session has no
// audio loaded behind it and has to be re-sourced from the queue.
else if (player.status === "paused") resumePlayback();
}
// Shared with the OS media card and the hardware media keys, so a media
// key cannot end up doing something subtly different from this button.
const toggle = togglePlayPause;
function seekTarget(e, el) {
if (!player.duration || idle) return;
+10
View File
@@ -111,6 +111,16 @@ export const followedArtists = () => invoke("followed_artists");
export const followArtist = (artistId, name, portrait) =>
invoke("follow_artist", { artistId, name, portrait: portrait ?? null });
export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId });
// The OS media card (Windows SMTC). Only the metadata crosses IPC and only on
// a track change — status and position are pushed by the backend's own player
// event pump, which already has both. See src-tauri/src/smtc.rs.
export const smtcMetadata = (title, artist, album, art) =>
invoke("smtc_metadata", {
title: title ?? "",
artist: artist ?? "",
album: album ?? "",
art: art ?? null,
});
// The WebView blocks target="_blank"; external links open via the backend.
export const openExternal = (url) => invoke("open_external", { url });
export const albumArt = (path) => invoke("album_art", { path });
+43
View File
@@ -5391,6 +5391,34 @@ export function initEvents() {
api.onEvent("soulseek:playlists-changed", refreshPlaylists);
// The OS media card and the hardware media keys, arriving through Windows'
// SMTC (src-tauri/src/smtc.rs). They run the SAME functions the on-screen
// transport runs — the queue, shuffle, repeat and the re-source of a
// restored track all live here, and a second implementation over there
// would be a second set of rules to keep in step. `toggle` is what a
// single play/pause key sends; `play`/`pause` come from the card's
// separate buttons, and both are safe to send at any status because
// togglePlayPause reads the real one.
api.onEvent("soulseek:media-button", (ev) => {
switch (ev?.button) {
case "play":
if (player.status !== "playing") togglePlayPause();
break;
case "pause":
if (player.status === "playing") togglePlayPause();
break;
case "toggle":
togglePlayPause();
break;
case "next":
nextTrack(false);
break;
case "previous":
prevTrack();
break;
}
});
// The backend batches search responses (~150ms ticks) so a broad search
// can't flood the IPC bridge or trigger a reactive flush per response.
api.onEvent("soulseek:search-results", (batch) => {
@@ -6254,6 +6282,16 @@ export function resumePlayback() {
playQueueIndex(index);
}
/** The play/pause button's whole behaviour, in one place — the player bar's
* control and the OS media card's both call this, so a media key can never do
* something subtly different from clicking the button. Not a bare `resume`:
* a track restored from the last session has no audio loaded behind it and
* has to be re-sourced from the queue, which resumePlayback handles. */
export function togglePlayPause() {
if (player.status === "playing") api.playerControl("pause");
else if (player.status === "paused") resumePlayback();
}
/** Consumed by the player-event handler the first time a restored track
* reaches `playing`. */
export function takeResumeSeek() {
@@ -6357,6 +6395,11 @@ if (import.meta.env.DEV) {
app, ui, player, catalog, resolver, nav,
libraryStore, recentsStore,
streamCatalogAlbum, streamSong, openCatalogArtist,
// The local counterparts of the two above, plus the transport. Lets a
// probe drive playback end to end without Soulseek — which matters
// beyond convenience: the server ignores the handshake after a burst
// of reconnects, so a verification run that streams costs a login.
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
};