//! 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> = 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_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()
}
}