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