From cf4d02e9961ac116b1911628ab5cbfb907d7394d Mon Sep 17 00:00:00 2001 From: Cleboost Date: Mon, 30 Jun 2025 02:11:02 +0200 Subject: [PATCH 1/5] Add Windows taskbar playback controls integration Introduces a TaskbarManager for Windows that adds playback control buttons (play/pause, next, previous) to the taskbar thumbnail toolbar. Updates playback controller to initialize and update these buttons based on playback state, and adds a new PLAY_PAUSE_OR_RESUME command for unified play/pause handling. Updates dependencies to include the windows crate and related packages. --- Cargo.lock | 73 ++++- psst-gui/Cargo.toml | 10 + psst-gui/src/cmd.rs | 1 + psst-gui/src/controller/mod.rs | 1 + psst-gui/src/controller/playback.rs | 43 +++ psst-gui/src/controller/taskbar.rs | 477 ++++++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 psst-gui/src/controller/taskbar.rs diff --git a/Cargo.lock b/Cargo.lock index 866b7f3b..e5bef027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2332,7 +2332,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.10", + "socket2 0.5.9", "tokio", "tower-service", "tracing", @@ -3909,6 +3909,7 @@ dependencies = [ "time-humanize", "ureq 3.0.11", "url", + "windows 0.58.0", "winres", ] @@ -5911,6 +5912,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.1" @@ -5943,17 +5954,30 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.0", + "windows-interface 0.59.1", "windows-link", "windows-result 0.3.2", - "windows-strings", + "windows-strings 0.4.0", ] [[package]] @@ -5966,6 +5990,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "windows-implement" version = "0.60.0" @@ -5977,6 +6012,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "windows-interface" version = "0.59.1" @@ -6013,6 +6059,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.2" @@ -6022,6 +6077,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.0" diff --git a/psst-gui/Cargo.toml b/psst-gui/Cargo.toml index 769d5572..37140859 100644 --- a/psst-gui/Cargo.toml +++ b/psst-gui/Cargo.toml @@ -53,10 +53,20 @@ raw-window-handle = "0.5.2" # Must stay compatible with Druid souvlaki = { version = "0.8.2", default-features = false, features = ["use_zbus"] } sanitize_html = "0.9.0" rustfm-scrobble = "1.1.1" + [target.'cfg(windows)'.build-dependencies] winres = { version = "0.1.12" } image = { version = "0.25.6" } +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58.0", features = [ + "Win32_UI_Shell", + "Win32_Foundation", + "Win32_System_Com", + "Win32_Graphics_Gdi", + "Win32_UI_WindowsAndMessaging", +] } + [package.metadata.bundle] name = "Psst" identifier = "com.jpochyla.psst" diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 9e897e79..d254f48f 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -51,6 +51,7 @@ pub const PLAY_TRACKS: Selector = Selector::new("app.play-track pub const PLAY_PREVIOUS: Selector = Selector::new("app.play-previous"); pub const PLAY_PAUSE: Selector = Selector::new("app.play-pause"); pub const PLAY_RESUME: Selector = Selector::new("app.play-resume"); +pub const PLAY_PAUSE_OR_RESUME: Selector = Selector::new("app.play-pause-or-resume"); pub const PLAY_NEXT: Selector = Selector::new("app.play-next"); pub const PLAY_STOP: Selector = Selector::new("app.play-stop"); pub const ADD_TO_QUEUE: Selector<(QueueEntry, PlaybackItem)> = Selector::new("app.add-to-queue"); diff --git a/psst-gui/src/controller/mod.rs b/psst-gui/src/controller/mod.rs index ea3dc830..322ceebb 100644 --- a/psst-gui/src/controller/mod.rs +++ b/psst-gui/src/controller/mod.rs @@ -12,6 +12,7 @@ mod on_update; mod playback; mod session; mod sort; +mod taskbar; pub use after_delay::AfterDelay; pub use alert_cleanup::AlertCleanupController; diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index 9913b9a1..1ca2218e 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -25,6 +25,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ cmd, + controller::taskbar::TaskbarManager, data::Nav, data::{ AppState, Config, NowPlaying, Playable, Playback, PlaybackOrigin, PlaybackState, @@ -38,6 +39,9 @@ pub struct PlaybackController { thread: Option>, output: Option, media_controls: Option, + taskbar_manager: Option, + taskbar_buttons_initialized: bool, + last_taskbar_state: Option, has_scrobbled: bool, scrobbler: Option, startup: bool, @@ -75,6 +79,9 @@ impl PlaybackController { thread: None, output: None, media_controls: None, + taskbar_manager: None, + taskbar_buttons_initialized: false, + last_taskbar_state: None, has_scrobbled: false, scrobbler: None, startup: true, @@ -104,6 +111,9 @@ impl PlaybackController { .map_err(|err| log::error!("failed to connect to media control interface: {:?}", err)) .ok(); + self.taskbar_manager = TaskbarManager::new(window, event_sink.clone(), widget_id) + .map_err(|err| log::error!("failed to initialize taskbar manager: {:?}", err)) + .ok(); self.sender = Some(player.sender()); self.thread = Some(thread::spawn(move || { Self::service_events(player, event_sink, widget_id); @@ -222,6 +232,33 @@ impl PlaybackController { }) .unwrap_or_default(); } + + self.update_taskbar_buttons(playback.state); + } + + fn setup_taskbar_buttons_on_first_play(&mut self, playback_state: PlaybackState) { + if !self.taskbar_buttons_initialized { + if let Some(taskbar_manager) = &self.taskbar_manager { + if let Err(e) = taskbar_manager.setup_buttons(playback_state) { + log::error!("Failed to setup taskbar buttons: {:?}", e); + } else { + self.taskbar_buttons_initialized = true; + } + } + } + } + + fn update_taskbar_buttons(&mut self, playback_state: PlaybackState) { + if Some(playback_state) != self.last_taskbar_state { + if self.taskbar_buttons_initialized { + if let Some(taskbar_manager) = &self.taskbar_manager { + if let Err(e) = taskbar_manager.update_play_pause_button(playback_state) { + log::error!("Failed to update taskbar buttons: {:?}", e); + } + } + } + self.last_taskbar_state = Some(playback_state); + } } fn update_media_control_metadata(&mut self, playback: &Playback) { @@ -447,6 +484,7 @@ where if let Some(queued) = data.queued_entry(*item) { data.start_playback(queued.item, queued.origin, progress.to_owned()); + self.setup_taskbar_buttons_on_first_play(PlaybackState::Playing); self.update_media_control_playback(&data.playback); self.update_media_control_metadata(&data.playback); if let Some(now_playing) = &data.playback.now_playing { @@ -495,6 +533,7 @@ where }) .collect(); + self.setup_taskbar_buttons_on_first_play(PlaybackState::Loading); self.play(&data.playback.queue, payload.position); ctx.set_handled(); } @@ -506,6 +545,10 @@ where self.resume(); ctx.set_handled(); } + Event::Command(cmd) if cmd.is(cmd::PLAY_PAUSE_OR_RESUME) => { + self.pause_or_resume(); + ctx.set_handled(); + } Event::Command(cmd) if cmd.is(cmd::PLAY_PREVIOUS) => { self.previous(); ctx.set_handled(); diff --git a/psst-gui/src/controller/taskbar.rs b/psst-gui/src/controller/taskbar.rs new file mode 100644 index 00000000..379bd5db --- /dev/null +++ b/psst-gui/src/controller/taskbar.rs @@ -0,0 +1,477 @@ +//! Windows Taskbar Thumbnail Toolbar integration +//! +//! This module provides functionality to display playback control buttons (https://github.com/jpochyla/psst/issues/659) +//! (play/pause, next, previous) in the Windows taskbar thumbnail preview. + +#[cfg(windows)] +use std::{ + collections::HashMap, + sync::{Arc, Mutex, OnceLock}, +}; + +#[cfg(windows)] +use druid::{ExtEventSink, Target, WidgetId}; + +#[cfg(windows)] +use windows::{ + core::*, Win32::Foundation::*, Win32::System::Com::*, Win32::UI::Shell::*, + Win32::UI::WindowsAndMessaging::*, +}; + +#[cfg(windows)] +use raw_window_handle::{HasRawWindowHandle, RawWindowHandle, Win32WindowHandle}; + +#[cfg(windows)] +use crate::{cmd, data::PlaybackState}; + +#[cfg(windows)] +const WM_COMMAND_OFFSET: u32 = 0x8000; +#[cfg(windows)] +const CMD_PREVIOUS: u32 = WM_COMMAND_OFFSET + 1; // Premier bouton +#[cfg(windows)] +const CMD_PLAY_PAUSE: u32 = WM_COMMAND_OFFSET + 2; // Bouton du milieu +#[cfg(windows)] +const CMD_NEXT: u32 = WM_COMMAND_OFFSET + 3; // Dernier bouton + +#[cfg(windows)] +static TASKBAR_CALLBACKS: OnceLock>>> = + OnceLock::new(); + +#[cfg(windows)] +pub struct TaskbarManager { + hwnd: HWND, + taskbar_list: Option, + event_sink: ExtEventSink, + widget_id: WidgetId, + is_initialized: bool, +} + +#[cfg(not(windows))] +pub struct TaskbarManager; + +impl TaskbarManager { + #[cfg(windows)] + pub fn new( + window_handle: &dyn HasRawWindowHandle, + event_sink: ExtEventSink, + widget_id: WidgetId, + ) -> Result { + let hwnd = match window_handle.raw_window_handle() { + RawWindowHandle::Win32(Win32WindowHandle { hwnd, .. }) => HWND(hwnd), + _ => { + log::error!("Failed to extract Win32 window handle"); + return Err(windows::core::Error::from_win32()); + } + }; + + let mut manager = Self { + hwnd, + taskbar_list: None, + event_sink, + widget_id, + is_initialized: false, + }; + + match manager.initialize() { + Ok(_) => Ok(manager), + Err(e) => { + log::error!("Failed to initialize TaskbarManager: {:?}", e); + Err(e) + } + } + } + + #[cfg(not(windows))] + pub fn new( + _window_handle: &dyn HasRawWindowHandle, + _event_sink: ExtEventSink, + _widget_id: WidgetId, + ) -> Result { + Ok(Self) + } + + #[cfg(windows)] + fn initialize(&mut self) -> Result<()> { + unsafe { + let com_result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if !com_result.is_ok() { + log::warn!( + "COM initialization failed or already initialized: {:?}", + com_result + ); + } + + let taskbar_list: ITaskbarList3 = + match CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER) { + Ok(tl) => tl, + Err(e) => { + log::error!("Failed to create ITaskbarList3: {:?}", e); + return Err(e.into()); + } + }; + + match taskbar_list.HrInit() { + Ok(_) => {} + Err(e) => { + log::error!("Failed to initialize ITaskbarList3: {:?}", e); + return Err(e.into()); + } + } + + self.taskbar_list = Some(taskbar_list); + + let callbacks = TASKBAR_CALLBACKS.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))); + callbacks.lock().unwrap().insert( + self.hwnd.0 as isize, + (self.event_sink.clone(), self.widget_id), + ); + + self.subclass_window()?; + self.is_initialized = true; + } + + Ok(()) + } + + #[cfg(not(windows))] + fn initialize(&mut self) -> Result<()> { + Ok(()) + } + + #[cfg(windows)] + fn subclass_window(&self) -> Result<()> { + unsafe { + let result = SetWindowSubclass(self.hwnd, Some(taskbar_subclass_proc), 1, 0); + + if result.as_bool() { + Ok(()) + } else { + log::error!("Failed to subclass window"); + Err(windows::core::Error::from_win32()) + } + } + } + + #[cfg(windows)] + pub fn setup_buttons(&self, playback_state: PlaybackState) -> Result<()> { + if !self.is_initialized { + log::warn!("Taskbar manager not initialized, skipping button setup"); + return Ok(()); + } + + let taskbar_list = match &self.taskbar_list { + Some(tl) => tl, + None => { + log::error!("ITaskbarList3 interface is None"); + return Err(windows::core::Error::from_win32()); + } + }; + + unsafe { + let play_icon = taskbar_icons::create_play_icon(); + let pause_icon = taskbar_icons::create_pause_icon(); + let prev_icon = taskbar_icons::create_previous_icon(); + let next_icon = taskbar_icons::create_next_icon(); + + let (play_pause_icon, play_pause_tooltip) = match playback_state { + PlaybackState::Playing => (pause_icon, "Pause"), + _ => (play_icon, "Play"), + }; + + let mut buttons = [ + THUMBBUTTON { + iId: CMD_PREVIOUS, + hIcon: prev_icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, + dwFlags: THBF_ENABLED, + ..Default::default() + }, + THUMBBUTTON { + iId: CMD_PLAY_PAUSE, + hIcon: play_pause_icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, + dwFlags: THBF_ENABLED, + ..Default::default() + }, + THUMBBUTTON { + iId: CMD_NEXT, + hIcon: next_icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, + dwFlags: THBF_ENABLED, + ..Default::default() + }, + ]; + + self.set_button_tooltip(&mut buttons[0], "Previous")?; + self.set_button_tooltip(&mut buttons[1], play_pause_tooltip)?; + self.set_button_tooltip(&mut buttons[2], "Next")?; + + let result = taskbar_list.ThumbBarAddButtons(self.hwnd, &buttons); + + match result { + Ok(_) => {} + Err(e) => { + log::error!( + "Failed to add taskbar buttons: HRESULT 0x{:08x}", + e.code().0 + ); + return Err(e.into()); + } + } + } + + Ok(()) + } + + #[cfg(not(windows))] + pub fn setup_buttons(&self, _playback_state: PlaybackState) -> Result<()> { + Ok(()) + } + + #[cfg(windows)] + pub fn update_play_pause_button(&self, playback_state: PlaybackState) -> Result<()> { + if !self.is_initialized { + log::warn!("Taskbar manager not initialized, skipping button update"); + return Ok(()); + } + + let taskbar_list = match &self.taskbar_list { + Some(tl) => tl, + None => { + log::error!("ITaskbarList3 interface is None during update"); + return Err(windows::core::Error::from_win32()); + } + }; + + unsafe { + let (icon, tooltip) = match playback_state { + PlaybackState::Playing => { + let pause_icon = taskbar_icons::create_pause_icon(); + (pause_icon, "Pause") + } + _ => { + let play_icon = taskbar_icons::create_play_icon(); + (play_icon, "Play") + } + }; + + let mut button = THUMBBUTTON { + iId: CMD_PLAY_PAUSE, + hIcon: icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP, + dwFlags: THBF_ENABLED, + ..Default::default() + }; + + self.set_button_tooltip(&mut button, tooltip)?; + + match taskbar_list.ThumbBarUpdateButtons(self.hwnd, &[button]) { + Ok(_) => {} + Err(e) => { + log::error!("Failed to update play/pause button: {:?}", e); + return Err(e.into()); + } + } + } + + Ok(()) + } + + #[cfg(not(windows))] + pub fn update_play_pause_button(&self, _playback_state: PlaybackState) -> Result<()> { + Ok(()) + } + + #[cfg(windows)] + fn set_button_tooltip(&self, button: &mut THUMBBUTTON, tooltip: &str) -> Result<()> { + let tooltip_bytes = tooltip.as_bytes(); + let len = tooltip_bytes.len().min(259); + + button.szTip.fill(0); + + for (i, &byte) in tooltip_bytes.iter().take(len).enumerate() { + button.szTip[i] = byte as u16; + } + + Ok(()) + } +} + +impl Drop for TaskbarManager { + #[cfg(windows)] + fn drop(&mut self) { + if self.is_initialized { + unsafe { + let _ = RemoveWindowSubclass(self.hwnd, Some(taskbar_subclass_proc), 1); + + if let Some(callbacks) = TASKBAR_CALLBACKS.get() { + callbacks.lock().unwrap().remove(&(self.hwnd.0 as isize)); + } + + CoUninitialize(); + } + } + } + + #[cfg(not(windows))] + fn drop(&mut self) {} +} + +#[cfg(windows)] +unsafe extern "system" fn taskbar_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _uid_subclass: usize, + _dw_ref_data: usize, +) -> LRESULT { + if msg == WM_COMMAND { + let command_id = (wparam.0 & 0xFFFF) as u32; + + if let Some(callbacks) = TASKBAR_CALLBACKS.get() { + if let Some((event_sink, widget_id)) = + callbacks.lock().unwrap().get(&(hwnd.0 as isize)).cloned() + { + match command_id { + CMD_PLAY_PAUSE => { + if let Err(e) = event_sink.submit_command( + cmd::PLAY_PAUSE_OR_RESUME, + (), + Target::Widget(widget_id), + ) { + log::error!("Failed to submit play/pause command: {:?}", e); + } + } + CMD_PREVIOUS => { + if let Err(e) = event_sink.submit_command( + cmd::PLAY_PREVIOUS, + (), + Target::Widget(widget_id), + ) { + log::error!("Failed to submit previous command: {:?}", e); + } + } + CMD_NEXT => { + if let Err(e) = + event_sink.submit_command(cmd::PLAY_NEXT, (), Target::Widget(widget_id)) + { + log::error!("Failed to submit next command: {:?}", e); + } + } + _ => {} + } + return LRESULT(0); + } + } + } + + DefSubclassProc(hwnd, msg, wparam, lparam) +} + +#[cfg(windows)] +mod taskbar_icons { + use windows::Win32::{Foundation::*, Graphics::Gdi::*, UI::WindowsAndMessaging::*}; + + const SZ: i32 = 32; + unsafe fn new_argb_bitmap() -> (HBITMAP, *mut u32) { + let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); + let hdc = GetDC(HWND(std::ptr::null_mut())); + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: core::mem::size_of::() as u32, + biWidth: SZ, + biHeight: -SZ, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let hbm = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) + .expect("CreateDIBSection"); + ReleaseDC(HWND(std::ptr::null_mut()), hdc); + (hbm, bits.cast::()) + } + + unsafe fn new_and_mask() -> HBITMAP { + CreateBitmap(SZ, SZ, 1, 1, None) + } + + fn inside(px: f32, py: f32, poly: &[(i32, i32)]) -> bool { + let mut inside = false; + let mut j = poly.len() - 1; + for (i, &(xi, yi)) in poly.iter().enumerate() { + let (xj, yj) = (poly[j].0, poly[j].1); + if ((yi > py as i32) != (yj > py as i32)) + && (px < (xj - xi) as f32 * (py - yi as f32) / (yj - yi) as f32 + xi as f32) + { + inside = !inside; + } + j = i; + } + inside + } + + unsafe fn rasterise_polys(bits: *mut u32, polys: &[&[(i32, i32)]]) { + for y in 0..SZ { + for x in 0..SZ { + let opaque = polys + .iter() + .any(|poly| inside(x as f32 + 0.5, y as f32 + 0.5, poly)); + let p = bits.add((y * SZ + x) as usize); + if opaque { + *p = 0xFFFFFFFF; + } else { + *p = 0x00000000; + } + } + } + } + + unsafe fn icon_from_polys(polys: &[&[(i32, i32)]]) -> HICON { + let (hbm_color, bits) = new_argb_bitmap(); + rasterise_polys(bits, polys); + + let hbm_mask = new_and_mask(); + let icon_info = ICONINFO { + fIcon: TRUE, + xHotspot: 0, + yHotspot: 0, + hbmMask: hbm_mask, + hbmColor: hbm_color, + }; + let hicon = CreateIconIndirect(&icon_info).expect("CreateIconIndirect"); + + let _ = DeleteObject(hbm_mask); + let _ = DeleteObject(hbm_color); + hicon + } + + pub fn create_play_icon() -> HICON { + unsafe { icon_from_polys(&[&[(22, 16), (10, 7), (10, 25)]]) } + } + + pub fn create_pause_icon() -> HICON { + let bar_l = &[(11, 7), (14, 7), (14, 25), (11, 25)]; + let bar_r = &[(18, 7), (21, 7), (21, 25), (18, 25)]; + unsafe { icon_from_polys(&[bar_l, bar_r]) } + } + + pub fn create_next_icon() -> HICON { + let bar = &[(6, 7), (10, 7), (10, 25), (6, 25)]; + let tri = &[(20, 16), (12, 7), (12, 25)]; + unsafe { icon_from_polys(&[bar, tri]) } + } + + pub fn create_previous_icon() -> HICON { + let tri = &[(12, 16), (20, 7), (20, 25)]; + let bar = &[(22, 7), (26, 7), (26, 25), (22, 25)]; + unsafe { icon_from_polys(&[tri, bar]) } + } +} From a38c87b28aaff263877523159bcb47c26bc0bb64 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Mon, 30 Jun 2025 12:53:51 +0200 Subject: [PATCH 2/5] Remove the explanation header --- psst-gui/src/controller/taskbar.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/psst-gui/src/controller/taskbar.rs b/psst-gui/src/controller/taskbar.rs index 379bd5db..972dd33f 100644 --- a/psst-gui/src/controller/taskbar.rs +++ b/psst-gui/src/controller/taskbar.rs @@ -1,8 +1,3 @@ -//! Windows Taskbar Thumbnail Toolbar integration -//! -//! This module provides functionality to display playback control buttons (https://github.com/jpochyla/psst/issues/659) -//! (play/pause, next, previous) in the Windows taskbar thumbnail preview. - #[cfg(windows)] use std::{ collections::HashMap, From 802898516ee47bb230d699bdf2fcfd1930af7258 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Mon, 7 Jul 2025 01:27:36 +0200 Subject: [PATCH 3/5] Improve taskbar button initialization and updates Adds a dedicated INITIALIZE_TASKBAR command and defers taskbar button setup until after widget initialization. Refactors taskbar button update logic to update all buttons and enable/disable them based on playback state, improving reliability and consistency of taskbar controls. --- psst-gui/src/cmd.rs | 1 + psst-gui/src/controller/playback.rs | 21 +++++-- psst-gui/src/controller/taskbar.rs | 92 ++++++++++++++++++----------- 3 files changed, 74 insertions(+), 40 deletions(-) diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index d254f48f..1b85810c 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -58,6 +58,7 @@ pub const ADD_TO_QUEUE: Selector<(QueueEntry, PlaybackItem)> = Selector::new("ap pub const PLAY_QUEUE_BEHAVIOR: Selector = Selector::new("app.play-queue-behavior"); pub const PLAY_SEEK: Selector = Selector::new("app.play-seek"); pub const SKIP_TO_POSITION: Selector = Selector::new("app.skip-to-position"); +pub const INITIALIZE_TASKBAR: Selector = Selector::new("app.initialize-taskbar"); // Sorting control pub const SORT_BY_DATE_ADDED: Selector = Selector::new("app.sort-by-date-added"); diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index 1ca2218e..92f33073 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -7,7 +7,7 @@ use crossbeam_channel::Sender; use druid::{ im::Vector, widget::{prelude::*, Controller}, - Code, ExtEventSink, InternalLifeCycle, KbKey, WindowHandle, + Code, ExtEventSink, InternalLifeCycle, KbKey, Target, WindowHandle, }; use psst_core::{ audio::{normalize::NormalizationLevel, output::DefaultAudioOutput}, @@ -252,7 +252,7 @@ impl PlaybackController { if Some(playback_state) != self.last_taskbar_state { if self.taskbar_buttons_initialized { if let Some(taskbar_manager) = &self.taskbar_manager { - if let Err(e) = taskbar_manager.update_play_pause_button(playback_state) { + if let Err(e) = taskbar_manager.update_all_buttons(playback_state) { log::error!("Failed to update taskbar buttons: {:?}", e); } } @@ -484,7 +484,6 @@ where if let Some(queued) = data.queued_entry(*item) { data.start_playback(queued.item, queued.origin, progress.to_owned()); - self.setup_taskbar_buttons_on_first_play(PlaybackState::Playing); self.update_media_control_playback(&data.playback); self.update_media_control_metadata(&data.playback); if let Some(now_playing) = &data.playback.now_playing { @@ -533,7 +532,6 @@ where }) .collect(); - self.setup_taskbar_buttons_on_first_play(PlaybackState::Loading); self.play(&data.playback.queue, payload.position); ctx.set_handled(); } @@ -590,6 +588,10 @@ where self.seek(Duration::from_millis(*location)); ctx.set_handled(); } + Event::Command(cmd) if cmd.is(cmd::INITIALIZE_TASKBAR) => { + self.setup_taskbar_buttons_on_first_play(PlaybackState::Stopped); + ctx.set_handled(); + } // Keyboard shortcuts. Event::KeyDown(key) if key.code == Code::Space => { self.pause_or_resume(); @@ -645,6 +647,17 @@ where self.set_volume(data.playback.volume); self.set_queue_behavior(data.playback.queue_behavior); + let event_sink = ctx.get_external_handle(); + let widget_id = ctx.widget_id(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = event_sink.submit_command( + cmd::INITIALIZE_TASKBAR, + (), + Target::Widget(widget_id), + ); + }); + // Request focus so we can receive keyboard events. ctx.submit_command(cmd::SET_FOCUS.to(ctx.widget_id())); } diff --git a/psst-gui/src/controller/taskbar.rs b/psst-gui/src/controller/taskbar.rs index 972dd33f..fd333082 100644 --- a/psst-gui/src/controller/taskbar.rs +++ b/psst-gui/src/controller/taskbar.rs @@ -22,11 +22,11 @@ use crate::{cmd, data::PlaybackState}; #[cfg(windows)] const WM_COMMAND_OFFSET: u32 = 0x8000; #[cfg(windows)] -const CMD_PREVIOUS: u32 = WM_COMMAND_OFFSET + 1; // Premier bouton +const CMD_PREVIOUS: u32 = WM_COMMAND_OFFSET + 1; #[cfg(windows)] -const CMD_PLAY_PAUSE: u32 = WM_COMMAND_OFFSET + 2; // Bouton du milieu +const CMD_PLAY_PAUSE: u32 = WM_COMMAND_OFFSET + 2; #[cfg(windows)] -const CMD_NEXT: u32 = WM_COMMAND_OFFSET + 3; // Dernier bouton +const CMD_NEXT: u32 = WM_COMMAND_OFFSET + 3; #[cfg(windows)] static TASKBAR_CALLBACKS: OnceLock>>> = @@ -90,10 +90,7 @@ impl TaskbarManager { unsafe { let com_result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); if !com_result.is_ok() { - log::warn!( - "COM initialization failed or already initialized: {:?}", - com_result - ); + log::warn!("COM initialization failed or already initialized: {:?}", com_result); } let taskbar_list: ITaskbarList3 = @@ -173,13 +170,16 @@ impl TaskbarManager { _ => (play_icon, "Play"), }; + let buttons_enabled = playback_state != PlaybackState::Stopped; + let button_flags = if buttons_enabled { THBF_ENABLED } else { THBF_DISABLED }; + let mut buttons = [ THUMBBUTTON { iId: CMD_PREVIOUS, hIcon: prev_icon, szTip: [0; 260], dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, - dwFlags: THBF_ENABLED, + dwFlags: button_flags, ..Default::default() }, THUMBBUTTON { @@ -187,7 +187,7 @@ impl TaskbarManager { hIcon: play_pause_icon, szTip: [0; 260], dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, - dwFlags: THBF_ENABLED, + dwFlags: button_flags, ..Default::default() }, THUMBBUTTON { @@ -195,7 +195,7 @@ impl TaskbarManager { hIcon: next_icon, szTip: [0; 260], dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, - dwFlags: THBF_ENABLED, + dwFlags: button_flags, ..Default::default() }, ]; @@ -209,10 +209,7 @@ impl TaskbarManager { match result { Ok(_) => {} Err(e) => { - log::error!( - "Failed to add taskbar buttons: HRESULT 0x{:08x}", - e.code().0 - ); + log::error!("Failed to add taskbar buttons: HRESULT 0x{:08x}", e.code().0); return Err(e.into()); } } @@ -227,7 +224,7 @@ impl TaskbarManager { } #[cfg(windows)] - pub fn update_play_pause_button(&self, playback_state: PlaybackState) -> Result<()> { + pub fn update_all_buttons(&self, playback_state: PlaybackState) -> Result<()> { if !self.is_initialized { log::warn!("Taskbar manager not initialized, skipping button update"); return Ok(()); @@ -242,32 +239,54 @@ impl TaskbarManager { }; unsafe { - let (icon, tooltip) = match playback_state { - PlaybackState::Playing => { - let pause_icon = taskbar_icons::create_pause_icon(); - (pause_icon, "Pause") - } - _ => { - let play_icon = taskbar_icons::create_play_icon(); - (play_icon, "Play") - } - }; + let play_icon = taskbar_icons::create_play_icon(); + let pause_icon = taskbar_icons::create_pause_icon(); + let prev_icon = taskbar_icons::create_previous_icon(); + let next_icon = taskbar_icons::create_next_icon(); - let mut button = THUMBBUTTON { - iId: CMD_PLAY_PAUSE, - hIcon: icon, - szTip: [0; 260], - dwMask: THB_ICON | THB_TOOLTIP, - dwFlags: THBF_ENABLED, - ..Default::default() + let (play_pause_icon, play_pause_tooltip) = match playback_state { + PlaybackState::Playing => (pause_icon, "Pause"), + _ => (play_icon, "Play"), }; - self.set_button_tooltip(&mut button, tooltip)?; + let buttons_enabled = playback_state != PlaybackState::Stopped; + let button_flags = if buttons_enabled { THBF_ENABLED } else { THBF_DISABLED }; - match taskbar_list.ThumbBarUpdateButtons(self.hwnd, &[button]) { + let mut buttons = [ + THUMBBUTTON { + iId: CMD_PREVIOUS, + hIcon: prev_icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, + dwFlags: button_flags, + ..Default::default() + }, + THUMBBUTTON { + iId: CMD_PLAY_PAUSE, + hIcon: play_pause_icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, + dwFlags: button_flags, + ..Default::default() + }, + THUMBBUTTON { + iId: CMD_NEXT, + hIcon: next_icon, + szTip: [0; 260], + dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS, + dwFlags: button_flags, + ..Default::default() + }, + ]; + + self.set_button_tooltip(&mut buttons[0], "Previous")?; + self.set_button_tooltip(&mut buttons[1], play_pause_tooltip)?; + self.set_button_tooltip(&mut buttons[2], "Next")?; + + match taskbar_list.ThumbBarUpdateButtons(self.hwnd, &buttons) { Ok(_) => {} Err(e) => { - log::error!("Failed to update play/pause button: {:?}", e); + log::error!("Failed to update all taskbar buttons: {:?}", e); return Err(e.into()); } } @@ -277,7 +296,7 @@ impl TaskbarManager { } #[cfg(not(windows))] - pub fn update_play_pause_button(&self, _playback_state: PlaybackState) -> Result<()> { + pub fn update_all_buttons(&self, _playback_state: PlaybackState) -> Result<()> { Ok(()) } @@ -373,6 +392,7 @@ mod taskbar_icons { use windows::Win32::{Foundation::*, Graphics::Gdi::*, UI::WindowsAndMessaging::*}; const SZ: i32 = 32; + unsafe fn new_argb_bitmap() -> (HBITMAP, *mut u32) { let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); let hdc = GetDC(HWND(std::ptr::null_mut())); From a8bb7bc2ca10d8cd05c1c5339a631bee66a3ee2b Mon Sep 17 00:00:00 2001 From: Jackson Goode <54308792+jacksongoode@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:05:25 +0900 Subject: [PATCH 4/5] Linting --- psst-gui/src/controller/playback.rs | 2 +- psst-gui/src/controller/taskbar.rs | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index 92f33073..01aef31a 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -255,7 +255,7 @@ impl PlaybackController { if let Err(e) = taskbar_manager.update_all_buttons(playback_state) { log::error!("Failed to update taskbar buttons: {:?}", e); } - } + } } self.last_taskbar_state = Some(playback_state); } diff --git a/psst-gui/src/controller/taskbar.rs b/psst-gui/src/controller/taskbar.rs index fd333082..4c093b76 100644 --- a/psst-gui/src/controller/taskbar.rs +++ b/psst-gui/src/controller/taskbar.rs @@ -90,7 +90,10 @@ impl TaskbarManager { unsafe { let com_result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); if !com_result.is_ok() { - log::warn!("COM initialization failed or already initialized: {:?}", com_result); + log::warn!( + "COM initialization failed or already initialized: {:?}", + com_result + ); } let taskbar_list: ITaskbarList3 = @@ -171,7 +174,11 @@ impl TaskbarManager { }; let buttons_enabled = playback_state != PlaybackState::Stopped; - let button_flags = if buttons_enabled { THBF_ENABLED } else { THBF_DISABLED }; + let button_flags = if buttons_enabled { + THBF_ENABLED + } else { + THBF_DISABLED + }; let mut buttons = [ THUMBBUTTON { @@ -209,7 +216,10 @@ impl TaskbarManager { match result { Ok(_) => {} Err(e) => { - log::error!("Failed to add taskbar buttons: HRESULT 0x{:08x}", e.code().0); + log::error!( + "Failed to add taskbar buttons: HRESULT 0x{:08x}", + e.code().0 + ); return Err(e.into()); } } @@ -250,7 +260,11 @@ impl TaskbarManager { }; let buttons_enabled = playback_state != PlaybackState::Stopped; - let button_flags = if buttons_enabled { THBF_ENABLED } else { THBF_DISABLED }; + let button_flags = if buttons_enabled { + THBF_ENABLED + } else { + THBF_DISABLED + }; let mut buttons = [ THUMBBUTTON { From e679649409ab8a85b18fc31a1471294f04927b35 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Tue, 25 Nov 2025 20:46:13 +0100 Subject: [PATCH 5/5] fix(taskbar): add missing imports and fix Result types for non-Windows - Add missing imports (HasRawWindowHandle, ExtEventSink, WidgetId, PlaybackState) for non-Windows version - Fix Result types to use std::result::Result<(), ()> in non-Windows implementations - Add allow(dead_code) for initialize method in non-Windows stub --- psst-gui/src/controller/taskbar.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/psst-gui/src/controller/taskbar.rs b/psst-gui/src/controller/taskbar.rs index 4c093b76..ff7ae24e 100644 --- a/psst-gui/src/controller/taskbar.rs +++ b/psst-gui/src/controller/taskbar.rs @@ -41,6 +41,15 @@ pub struct TaskbarManager { is_initialized: bool, } +#[cfg(not(windows))] +use druid::{ExtEventSink, WidgetId}; + +#[cfg(not(windows))] +use raw_window_handle::HasRawWindowHandle; + +#[cfg(not(windows))] +use crate::data::PlaybackState; + #[cfg(not(windows))] pub struct TaskbarManager; @@ -81,7 +90,7 @@ impl TaskbarManager { _window_handle: &dyn HasRawWindowHandle, _event_sink: ExtEventSink, _widget_id: WidgetId, - ) -> Result { + ) -> std::result::Result { Ok(Self) } @@ -129,7 +138,8 @@ impl TaskbarManager { } #[cfg(not(windows))] - fn initialize(&mut self) -> Result<()> { + #[allow(dead_code)] + fn initialize(&mut self) -> std::result::Result<(), ()> { Ok(()) } @@ -229,7 +239,7 @@ impl TaskbarManager { } #[cfg(not(windows))] - pub fn setup_buttons(&self, _playback_state: PlaybackState) -> Result<()> { + pub fn setup_buttons(&self, _playback_state: PlaybackState) -> std::result::Result<(), ()> { Ok(()) } @@ -310,7 +320,7 @@ impl TaskbarManager { } #[cfg(not(windows))] - pub fn update_all_buttons(&self, _playback_state: PlaybackState) -> Result<()> { + pub fn update_all_buttons(&self, _playback_state: PlaybackState) -> std::result::Result<(), ()> { Ok(()) }