//! 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::commands::{apply_accent, Accent, GLASS_ON};
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, PostMessageW, RegisterWindowMessageW, HICON, HTBOTTOM,
HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, ICONINFO,
KillTimer, SetTimer, SC_SIZE, WM_APP, WM_COMMAND, WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE,
WM_NCLBUTTONDOWN, WM_SETTINGCHANGE, WM_SIZE, WM_SIZING, WM_SYSCOMMAND, WM_TIMER,
};
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);
/// Private message, posted to ourselves — see the WM_RESIZE_DRAG arm in
/// subclass_proc for why the emit cannot happen inline.
const WM_RESIZE_DRAG: u32 = WM_APP + 0x11;
/// Set between WM_NCLBUTTONDOWN-on-a-border (or SC_SIZE) and
/// WM_EXITSIZEMOVE: "the modal loop we are about to enter is a RESIZE".
static SIZING_LATCH: AtomicBool = AtomicBool::new(false);
/// Set while a resize drag is actually in flight, so the enter/exit pair
/// is emitted exactly once each however the messages interleave.
static IN_SIZE_DRAG: AtomicBool = AtomicBool::new(false);
/// Set for the length of ANY modal move/size loop (move drags included),
/// so the WM_SIZE re-assert below can tell a discrete size change from
/// the per-frame WM_SIZE stream inside a drag.
static IN_MODAL: AtomicBool = AtomicBool::new(false);
/// One-shot timer for the post-gesture accent re-assert; any stable value.
const REAPPLY_TIMER_ID: usize = 0x50_1A_12;
/// Tells the frontend to ground the glass canvas for the length of a live
/// resize drag. Windows drops the accent-policy acrylic inside the modal
/// size loop while the window stays transparent, so the desktop punches
/// through razor-sharp; see armGlassResizeGuard in src/lib/glass.js.
#[derive(Clone, serde::Serialize)]
pub struct ResizeDrag {
pub active: bool,
/// "drag" for the modal move/size loop, "discrete" for a
/// maximize/restore/snap/DPI size jump — the frontend grounds them
/// under separate policies (see armGlassResizeGuard in glass.js).
pub kind: &'static str,
}
thread_local! {
/// UI-thread only, by the apartment rule in the module docs.
static TASKBAR: RefCell> = const { RefCell::new(None) };
/// The button icons outlive every call that shows them — the shell
/// keeps referencing an HICON after ThumbBarAddButtons returns, so
/// these are held for the process's life and replaced (with the old
/// ones destroyed) only when the system theme flips.
static ICONS: RefCell > = const { RefCell::new(None) };
}
struct Icons {
prev: HICON,
play: HICON,
pause: HICON,
next: HICON,
/// The theme these were drawn for, so a WM_SETTINGCHANGE that is not
/// a theme change does not needlessly rebuild them.
light: bool,
}
impl Icons {
fn destroy(&self) {
for icon in [self.prev, self.play, self.pause, self.next] {
unsafe { let _ = DestroyIcon(icon); }
}
}
}
pub fn init(app: &tauri::AppHandle) {
let Some(window) = app.get_webview_window("main") else {
log::warn!("thumbbar: no main window; taskbar transport buttons will be absent");
return;
};
let Ok(hwnd) = window.hwnd() else {
log::warn!("thumbbar: no HWND; taskbar transport buttons will be absent");
return;
};
// Same version-independent handle crossing as smtc.rs: tauri and this
// crate may resolve to different `windows` releases.
let hwnd = HWND(hwnd.0 as isize as *mut core::ffi::c_void);
// Leaked on purpose. The subclass lives as long as the window, which
// is as long as the process, and a handle behind a raw pointer must
// not be freed while the window procedure can still reach it.
let handle: &'static tauri::AppHandle = Box::leak(Box::new(app.clone()));
let ok = unsafe {
SetWindowSubclass(
hwnd,
Some(subclass_proc),
SUBCLASS_ID,
handle as *const _ as usize,
)
};
if !ok.as_bool() {
log::warn!("thumbbar: could not subclass the window; no taskbar transport buttons");
return;
}
// The window is already on screen by the time setup runs, so the
// shell has very likely broadcast TaskbarButtonCreated already and
// this subclass missed it. Try straight away — we are on the UI thread
// here, which is the only place ITaskbarList3 may be touched. If the
// taskbar button genuinely does not exist yet this fails harmlessly
// and either the message or the first playback retry picks it up.
add_buttons(hwnd);
}
/// Called from the player's event pump when playback starts or stops.
/// Cheap and idempotent: only a real change reaches the shell, and the
/// hop to the UI thread only happens when there is something to say.
pub fn set_playing(app: &tauri::AppHandle, playing: bool) {
if PLAYING.swap(playing, Ordering::Relaxed) == playing {
return;
}
let handle = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(window) = handle.get_webview_window("main") {
if let Ok(hwnd) = window.hwnd() {
let hwnd = HWND(hwnd.0 as isize as *mut core::ffi::c_void);
// Doubles as the retry: if the buttons never went up —
// the shell had not made the taskbar button yet when we
// started — the first thing the user plays puts them there.
if ADDED.load(Ordering::Relaxed) {
update_buttons(hwnd);
} else {
add_buttons(hwnd);
}
}
}
});
}
unsafe extern "system" fn subclass_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
_id: usize,
refdata: usize,
) -> LRESULT {
// Registered once by the shell; the value is stable per session, so
// resolving it lazily here costs one call.
let created = unsafe { RegisterWindowMessageW(windows::core::w!("TaskbarButtonCreated")) };
if msg != 0 && msg == created {
add_buttons(hwnd);
} else if msg == WM_COMMAND && (wparam.0 >> 16) as u32 == THBN_CLICKED {
let app = unsafe { &*(refdata as *const tauri::AppHandle) };
let button = match (wparam.0 & 0xFFFF) as u32 {
ID_PREV => Some("previous"),
// One button for both states, so it says "toggle" and lets the
// frontend read the status it actually has — the alternative
// is trusting this side's idea of what is playing, which is a
// frame behind by construction.
ID_TOGGLE => Some("toggle"),
ID_NEXT => Some("next"),
_ => None,
};
if let Some(button) = button {
let _ = app.emit("soulseek:media-button", MediaButton { button });
}
} else if msg == WM_NCLBUTTONDOWN {
// Latch WHAT KIND of non-client drag is starting. WM_ENTERSIZEMOVE
// on its own cannot tell a sizing drag from moving the window by
// its titlebar — data-tauri-drag-region calls StartDragging, which
// enters the very same modal loop — and only the sizing one is
// known to cost the acrylic. wparam IS the hit-test code here.
let ht = (wparam.0 & 0xFFFF) as u32;
SIZING_LATCH.store(
matches!(
ht,
HTLEFT
| HTRIGHT
| HTTOP
| HTTOPLEFT
| HTTOPRIGHT
| HTBOTTOM
| HTBOTTOMLEFT
| HTBOTTOMRIGHT
),
Ordering::Relaxed,
);
} else if msg == WM_SYSCOMMAND && (wparam.0 & 0xFFF0) == SC_SIZE as usize {
// Keyboard resize (Alt+Space, Size) enters the same loop with no
// click, so there is no hit-test to latch from.
SIZING_LATCH.store(true, Ordering::Relaxed);
} else if msg == WM_ENTERSIZEMOVE {
IN_MODAL.store(true, Ordering::Relaxed);
// DWM suspends the ACRYLIC accent inside a live RESIZE loop but
// keeps compositing the plain BLURBEHIND one, so a resize swaps
// down to Blur for the gesture instead of going see-through.
// A MOVE loop is different: DWM composites the acrylic straight
// through one — measured 2026-08-31 on this machine, stationary
// and at mouse-speed motion over a striped backdrop — while the
// Blur fallback ignores the gradient tint and skips the acrylic
// dimming, landing ~15% brighter over a bright backdrop. That
// brightness step for the whole of every titlebar drag is what
// "the window gets light when I drag it" was. So only a latched
// RESIZE parks the accent at Blur; a move keeps the acrylic. If
// the latch ever misses a real resize, the WM_SIZING arm below
// swaps the accent one mouse-move later.
if GLASS_ON.load(Ordering::Relaxed) && SIZING_LATCH.load(Ordering::Relaxed) {
let _ = apply_accent(hwnd.0, Accent::Blur);
}
// Arm on the LOOP, not on WM_SIZING. Press a border and hold still
// and Windows sends no WM_SIZING at all, yet the acrylic is
// already gone — keying on movement would leave exactly that case
// see-through, which is the artifact the guard exists to hide.
if SIZING_LATCH.load(Ordering::Relaxed) && !IN_SIZE_DRAG.swap(true, Ordering::Relaxed) {
unsafe {
let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(1), LPARAM(0));
}
}
} else if msg == WM_SIZING {
// Belt and braces for the latch above. If WM_NCLBUTTONDOWN never
// reached us carrying a sizing hit-test — this window is
// undecorated and tao does its own NCHITTEST, and the exact path
// has never been confirmed by a real human drag — a WM_SIZING is
// still proof we are inside a live resize, because Windows sends
// it nowhere else. A titlebar MOVE drag never sends it, so this
// cannot arm on a move. It arms one mouse-move later than the
// latch does, which is why the latch is still the primary.
if !IN_SIZE_DRAG.swap(true, Ordering::Relaxed) {
// The latch missed, so ENTERSIZEMOVE kept the acrylic; a
// WM_SIZING is proof this loop is a resize after all, so the
// accent parks at Blur here instead.
if GLASS_ON.load(Ordering::Relaxed) {
let _ = apply_accent(hwnd.0, Accent::Blur);
}
unsafe {
let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(1), LPARAM(0));
}
}
} else if msg == WM_EXITSIZEMOVE {
IN_MODAL.store(false, Ordering::Relaxed);
if GLASS_ON.load(Ordering::Relaxed) {
let _ = apply_accent(hwnd.0, Accent::Acrylic);
}
SIZING_LATCH.store(false, Ordering::Relaxed);
if IN_SIZE_DRAG.swap(false, Ordering::Relaxed) {
unsafe {
let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(0), LPARAM(0));
}
}
} else if msg == WM_RESIZE_DRAG {
// POSTED to ourselves, never emitted inline. Emitting from inside
// DefWindowProc's modal size loop would reenter the wry runtime's
// window store while that loop holds it; posting lands the emit at
// the top of the loop's own pump with nothing borrowed. The loop
// does pump posted messages, so the arm still reaches the page
// during the drag rather than after it.
let app = unsafe { &*(refdata as *const tauri::AppHandle) };
let _ = app.emit(
"soulseek:resize-drag",
ResizeDrag {
active: wparam.0 != 0,
kind: if lparam.0 == 1 { "discrete" } else { "drag" },
},
);
} else if msg == WM_SIZE {
// A DISCRETE size change: maximize (wparam 2), restore/snap/DPI
// (0). Kyle photographed the desktop punching through a maximized
// glass window (2026-08-28): the acrylic does not reliably
// survive the transition, so it is re-asserted — once now, once
// more after the animation settles (the immediate one can land
// while DWM is still mid-transition and be dropped again).
// Gated out inside a modal loop, where WM_SIZE streams per frame
// and the accent is deliberately parked at Blur.
if !IN_MODAL.load(Ordering::Relaxed)
&& GLASS_ON.load(Ordering::Relaxed)
&& (wparam.0 == 2 || wparam.0 == 0)
{
let _ = apply_accent(hwnd.0, Accent::Acrylic);
unsafe {
// Ground the canvas for the zoom animation plus the accent
// recomposition after it; the timer's arm below both
// re-asserts and releases. 650ms: recovery measured clean
// at ~613ms after the maximize call with the animation on
// (2026-08-28 vis_max, 43ms/frame), and WM_SIZE lands
// ~60-100ms after the call — the release then trails the
// last punch-through frame by a comfortable ~170ms.
let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(1), LPARAM(1));
let _ = SetTimer(Some(hwnd), REAPPLY_TIMER_ID, 650, None);
}
}
} else if msg == WM_TIMER && wparam.0 == REAPPLY_TIMER_ID {
unsafe {
let _ = KillTimer(Some(hwnd), REAPPLY_TIMER_ID);
}
if GLASS_ON.load(Ordering::Relaxed) {
let _ = apply_accent(hwnd.0, Accent::Acrylic);
}
unsafe {
let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(0), LPARAM(1));
}
} else if msg == WM_SETTINGCHANGE {
// Covers the light/dark flip: the glyphs are drawn for a
// background they no longer sit on, so redraw them.
if ADDED.load(Ordering::Relaxed) && system_uses_light_theme() != icons_are_light() {
update_buttons(hwnd);
}
}
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
}
fn taskbar() -> Option {
TASKBAR.with(|slot| {
let mut slot = slot.borrow_mut();
if slot.is_none() {
let created: Option =
unsafe { CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER).ok() };
if let Some(list) = &created {
if unsafe { list.HrInit() }.is_err() {
return None;
}
}
*slot = created;
}
slot.clone()
})
}
fn buttons() -> Option<[THUMBBUTTON; 3]> {
let light = system_uses_light_theme();
let (prev, play, pause, next) = ICONS.with(|slot| {
let mut slot = slot.borrow_mut();
if slot.as_ref().map_or(true, |i| i.light != light) {
if let Some(old) = slot.take() {
old.destroy();
}
*slot = build_icons(light);
}
slot.as_ref().map(|i| (i.prev, i.play, i.pause, i.next))
})?;
let playing = PLAYING.load(Ordering::Relaxed);
Some([
button(ID_PREV, prev, "Previous"),
button(
ID_TOGGLE,
if playing { pause } else { play },
if playing { "Pause" } else { "Play" },
),
button(ID_NEXT, next, "Next"),
])
}
fn button(id: u32, icon: HICON, tip: &str) -> THUMBBUTTON {
let mut b = THUMBBUTTON {
dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS,
iId: id,
hIcon: icon,
dwFlags: THBF_ENABLED,
..Default::default()
};
// szTip is a fixed 260-wide buffer, and the struct arrived zeroed, so
// writing the text without a terminator still leaves one behind it.
for (slot, ch) in b.szTip.iter_mut().zip(tip.encode_utf16()) {
*slot = ch;
}
b
}
fn add_buttons(hwnd: HWND) {
let (Some(list), Some(buttons)) = (taskbar(), buttons()) else {
return;
};
match unsafe { list.ThumbBarAddButtons(hwnd, &buttons) } {
Ok(()) => {
ADDED.store(true, Ordering::Relaxed);
log::info!("thumbbar: transport buttons added to the taskbar preview");
}
Err(e) => log::warn!("thumbbar: ThumbBarAddButtons failed ({e})"),
}
}
fn update_buttons(hwnd: HWND) {
let (Some(list), Some(buttons)) = (taskbar(), buttons()) else {
return;
};
if let Err(e) = unsafe { list.ThumbBarUpdateButtons(hwnd, &buttons) } {
log::debug!("thumbbar: ThumbBarUpdateButtons failed ({e})");
}
}
fn icons_are_light() -> bool {
ICONS.with(|slot| slot.borrow().as_ref().is_some_and(|i| i.light))
}
/// The thumbnail flyout follows the SYSTEM theme, not the app's, so this
/// reads SystemUsesLightTheme rather than AppsUseLightTheme. Absent (older
/// builds, or a policy-managed machine) means dark, which is the default
/// the flyout has always had.
fn system_uses_light_theme() -> bool {
let mut value: u32 = 0;
let mut size = std::mem::size_of::() as u32;
let status = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
windows::core::w!("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"),
windows::core::w!("SystemUsesLightTheme"),
RRF_RT_REG_DWORD,
None,
Some(&mut value as *mut u32 as *mut core::ffi::c_void),
Some(&mut size),
)
};
status.is_ok() && value != 0
}
// ---- the glyphs ----
//
// Drawn rather than shipped as assets: they are four flat shapes, and a
// .ico per glyph per theme per DPI is four files to keep in step with a
// transport that already draws itself in the app.
const ICON_PX: i32 = 16;
/// Coverage is sampled on a 4x4 grid per pixel, which is what turns a
/// hard-edged triangle into one that does not look like a staircase at
/// 16px. Cheap: this runs four times per theme change.
const SUPERSAMPLE: i32 = 4;
fn build_icons(light: bool) -> Option {
Some(Icons {
prev: draw(light, &|x, y| skip(x, y, true))?,
play: draw(light, &|x, y| triangle(x, y, 0.30, 0.80))?,
pause: draw(light, &|x, y| {
y >= 0.18 && y <= 0.82 && ((0.30..=0.44).contains(&x) || (0.56..=0.70).contains(&x))
})?,
next: draw(light, &|x, y| skip(x, y, false))?,
light,
})
}
/// A filled triangle pointing right, spanning `left`..`right` horizontally
/// and tapering to a point at the vertical centre.
fn triangle(x: f32, y: f32, left: f32, right: f32) -> bool {
if x < left || x > right {
return false;
}
let t = (x - left) / (right - left);
let half = 0.32 * (1.0 - t);
(y - 0.5).abs() <= half
}
/// Previous/next: a triangle with a bar closing it off. `back` mirrors the
/// whole glyph horizontally, so the two are one shape.
fn skip(x: f32, y: f32, back: bool) -> bool {
let x = if back { 1.0 - x } else { x };
let bar = (0.70..=0.80).contains(&x) && (y - 0.5).abs() <= 0.32;
bar || triangle(x, y, 0.22, 0.70)
}
fn draw(light: bool, shape: &dyn Fn(f32, f32) -> bool) -> Option {
// Premultiplied BGRA, top-down (the negative height): the glyph is a
// single colour at varying alpha, so premultiplying is just scaling
// the colour by the same coverage.
let (r, g, b) = if light { (0x1a, 0x1a, 0x1c) } else { (0xff, 0xff, 0xff) };
let mut header: BITMAPINFO = unsafe { std::mem::zeroed() };
header.bmiHeader = BITMAPINFOHEADER {
biSize: std::mem::size_of::() as u32,
biWidth: ICON_PX,
biHeight: -ICON_PX,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
};
let mut bits: *mut core::ffi::c_void = std::ptr::null_mut();
let color = unsafe {
CreateDIBSection(None, &header, DIB_RGB_COLORS, &mut bits, None, 0).ok()?
};
if bits.is_null() {
unsafe { let _ = DeleteObject(HGDIOBJ(color.0)); }
return None;
}
let pixels = unsafe {
std::slice::from_raw_parts_mut(bits as *mut u8, (ICON_PX * ICON_PX * 4) as usize)
};
let step = 1.0 / SUPERSAMPLE as f32;
for py in 0..ICON_PX {
for px in 0..ICON_PX {
let mut hits = 0;
for sy in 0..SUPERSAMPLE {
for sx in 0..SUPERSAMPLE {
let fx = (px as f32 + (sx as f32 + 0.5) * step) / ICON_PX as f32;
let fy = (py as f32 + (sy as f32 + 0.5) * step) / ICON_PX as f32;
if shape(fx, fy) {
hits += 1;
}
}
}
let a = (hits as f32 / (SUPERSAMPLE * SUPERSAMPLE) as f32 * 255.0) as u32;
let i = ((py * ICON_PX + px) * 4) as usize;
pixels[i] = (b as u32 * a / 255) as u8;
pixels[i + 1] = (g as u32 * a / 255) as u8;
pixels[i + 2] = (r as u32 * a / 255) as u8;
pixels[i + 3] = a as u8;
}
}
// A 32bpp icon still needs a mask bitmap; an all-zero one means "the
// alpha channel decides", which is what the colour bitmap above says.
let mask = unsafe { CreateBitmap(ICON_PX, ICON_PX, 1, 1, None) };
let info = ICONINFO {
fIcon: BOOL(1),
xHotspot: 0,
yHotspot: 0,
hbmMask: mask,
hbmColor: color,
};
let icon = unsafe { CreateIconIndirect(&info) };
// CreateIconIndirect copies both bitmaps, so they are ours to release
// whether it succeeded or not.
unsafe {
let _ = DeleteObject(HGDIOBJ(color.0));
let _ = DeleteObject(HGDIOBJ(mask.0));
}
icon.ok()
}
}