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> = 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() + } +}