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