From f0c2bb5ed3fe967d45c26f1996641ddb1cf62b1e Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:08:26 -0500
Subject: [PATCH] taskbar: transport buttons under the window preview
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src-tauri/Cargo.toml | 7 +
src-tauri/src/lib.rs | 4 +
src-tauri/src/playback.rs | 3 +
src-tauri/src/thumbbar.rs | 422 ++++++++++++++++++++++++++++++++++++++
4 files changed, 436 insertions(+)
create mode 100644 src-tauri/src/thumbbar.rs
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 7c19a23..e530bb8 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -45,6 +45,13 @@ windows = { version = "0.61.3", features = [
"Media_Ocr",
"Storage_Streams",
"Win32_Foundation",
+ # Taskbar thumbnail toolbar — ITaskbarList3 + the window subclass that
+ # hears its clicks, and the GDI/registry bits that draw its icons.
+ "Win32_Graphics_Gdi",
+ "Win32_System_Com",
+ "Win32_System_Registry",
+ "Win32_UI_Shell",
+ "Win32_UI_WindowsAndMessaging",
"Win32_System_Performance",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 8dc5341..a511f4f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -10,6 +10,7 @@ mod playback;
mod preview;
mod smtc;
mod state;
+mod thumbbar;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -35,6 +36,9 @@ pub fn run() {
// transport state at the card immediately, and the card has to be
// registered by then or the first track's status is dropped.
smtc::init(app.handle());
+ // Only subclasses the window here; the buttons themselves go up
+ // when the shell says the taskbar button exists.
+ thumbbar::init(app.handle());
playback::init(app.handle());
playback::prune_cache(app.handle());
preview::init(app.handle());
diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs
index 99467d7..61797e2 100644
--- a/src-tauri/src/playback.rs
+++ b/src-tauri/src/playback.rs
@@ -274,6 +274,9 @@ pub fn init(app: &tauri::AppHandle) {
if let Some((status, position, duration)) = card {
crate::smtc::set_status(&handle, &status);
crate::smtc::set_timeline(&handle, position, duration);
+ // The taskbar preview's middle button wears play or pause
+ // off the same truth.
+ crate::thumbbar::set_playing(&handle, status == "playing");
}
let _ = handle.emit("soulseek:player", ui);
}
diff --git a/src-tauri/src/thumbbar.rs b/src-tauri/src/thumbbar.rs
new file mode 100644
index 0000000..4387066
--- /dev/null
+++ b/src-tauri/src/thumbbar.rs
@@ -0,0 +1,422 @@
+//! The taskbar thumbnail toolbar — transport buttons under the window preview.
+//!
+//! Hovering an app's taskbar button pops a preview, and an app may put up to
+//! seven buttons under it. Every media player uses them for the transport, and
+//! that is what this puts there: previous, play/pause, next.
+//!
+//! Three Win32 facts shape everything below.
+//!
+//! 1. **The buttons can only be added once per window**, and only after the
+//! shell has actually created the taskbar button — signalled by the
+//! registered `TaskbarButtonCreated` message. Adding them before that
+//! silently does nothing, which is why this waits for the message rather
+//! than running at startup like the other init hooks. Afterwards the set is
+//! changed with `ThumbBarUpdateButtons`, never added again.
+//! 2. **Clicks arrive as `WM_COMMAND`** on the window, so hearing them means
+//! subclassing the window Tauri owns. `SetWindowSubclass` is the polite way
+//! — it chains rather than replacing the window procedure, so wry keeps
+//! receiving everything it expects.
+//! 3. **`ITaskbarList3` is apartment-threaded.** It is created and used only
+//! on the UI thread; updates arriving from the player's event thread are
+//! marshalled with `run_on_main_thread`. That is also why it lives in a
+//! thread-local rather than in Tauri's shared state.
+//!
+//! Clicks are emitted as `soulseek:media-button` — the same event the OS media
+//! card sends (see smtc.rs), so the frontend's existing dispatch handles these
+//! with no extra code, and the two surfaces cannot drift apart.
+
+#[cfg(windows)]
+pub use imp::{init, set_playing};
+
+#[cfg(not(windows))]
+pub fn init(_app: &tauri::AppHandle) {}
+
+#[cfg(not(windows))]
+pub fn set_playing(_app: &tauri::AppHandle, _playing: bool) {}
+
+#[cfg(windows)]
+mod imp {
+ use crate::smtc::MediaButton;
+ use std::cell::RefCell;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use tauri::{Emitter, Manager};
+ use windows::core::BOOL;
+ use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
+ use windows::Win32::Graphics::Gdi::{
+ CreateBitmap, CreateDIBSection, DeleteObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB,
+ DIB_RGB_COLORS, HGDIOBJ,
+ };
+ use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
+ use windows::Win32::System::Registry::{
+ RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD,
+ };
+ use windows::Win32::UI::Shell::{
+ DefSubclassProc, ITaskbarList3, SetWindowSubclass, TaskbarList, THBF_ENABLED, THB_FLAGS,
+ THB_ICON, THB_TOOLTIP, THUMBBUTTON, THBN_CLICKED,
+ };
+ use windows::Win32::UI::WindowsAndMessaging::{
+ CreateIconIndirect, DestroyIcon, RegisterWindowMessageW, HICON, ICONINFO, WM_COMMAND,
+ WM_SETTINGCHANGE,
+ };
+
+ const ID_PREV: u32 = 1;
+ const ID_TOGGLE: u32 = 2;
+ const ID_NEXT: u32 = 3;
+ /// Any value, as long as it is ours and stable: it identifies this
+ /// subclass among any others on the same window.
+ const SUBCLASS_ID: usize = 0x50_1A_11;
+
+ /// Whether the transport is currently playing, so the middle button knows
+ /// which glyph it should be wearing. An atomic because it is written from
+ /// the player's event thread and read on the UI thread.
+ static PLAYING: AtomicBool = AtomicBool::new(false);
+ static ADDED: AtomicBool = AtomicBool::new(false);
+
+ thread_local! {
+ /// UI-thread only, by the apartment rule in the module docs.
+ static TASKBAR: RefCell