From d3e9dd17ab089319f36dfe66153ababeea524bd3 Mon Sep 17 00:00:00 2001 From: OMSociety Date: Sun, 8 Nov 2026 04:52:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(taskbar-lyric):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E6=BB=91=E5=8A=A8=E6=9C=9F=E9=80=90=E5=B8=A7?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E5=B9=B6=E8=AE=A9=E5=87=BA=E5=BA=95=E7=BC=98?= =?UTF-8?q?=E5=94=A4=E9=86=92=E5=B8=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/window/taskbarLyric.ts | 39 ++- native/taskbar-lyric/index.d.ts | 8 + native/taskbar-lyric/src/lib.rs | 26 ++ native/taskbar-lyric/src/service.rs | 24 +- native/taskbar-lyric/src/strategy/win11.rs | 302 +++++++++++++++++---- 5 files changed, 328 insertions(+), 71 deletions(-) diff --git a/electron/main/window/taskbarLyric.ts b/electron/main/window/taskbarLyric.ts index 3c9182d37..39ff4a610 100644 --- a/electron/main/window/taskbarLyric.ts +++ b/electron/main/window/taskbarLyric.ts @@ -19,7 +19,10 @@ import type { } from "@splayer/taskbar-lyric"; import { taskbarLog } from "@main/utils/logger"; -type TaskbarLyricNative = typeof import("@splayer/taskbar-lyric"); +type TaskbarLyricNative = typeof import("@splayer/taskbar-lyric") & { + /** 直接移动窗口位置(保持宽高不变) */ + moveWindow?: (hwndPtr: number, x: number, y: number) => boolean; +}; const REG_SUBKEY_EXPLORER_ADVANCED = "Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced"; @@ -142,9 +145,18 @@ const pickSpace = (layout: JsTaskbarLayout): PickedSpace | null => { /** 首次 applyLayout 成功后才 show 窗口——避免初始 3000x200 大窗口闪现 */ let firstLayoutDone = false; -/** 空间不足时安静隐藏窗口,避免残留旧位置的歌词残影 */ -const hideIfVisible = (win: BrowserWindow): void => { - if (win.isVisible()) win.hide(); +/** + * 收起态保持窗口可见,只把有效区域裁到远端 1×1 + * + * hide/show 会让 Chromium 重新合成首帧,展开瞬间歌词内容先出现在偏移位置、 + * 约 100ms 后才滑回原位;窗口保持可见时合成面持续有效,展开只恢复形状即可 + * 直接呈现正确内容。1×1 区域落在窗口远端,不遮挡任务栏底缘的唤醒带 + * @param win - 歌词窗口 + */ +const parkWindow = (win: BrowserWindow): void => { + if (!firstLayoutDone || !win.isVisible()) return; + const { width } = win.getBounds(); + win.setShape([{ x: Math.max(0, width - 1), y: 0, width: 1, height: 1 }]); }; /** Rust 布局回调:把物理像素空间转为 DIP,<阈值则隐藏,≥阈值则 setBounds 并 show */ @@ -154,12 +166,12 @@ const applyLayout = (layout: JsTaskbarLayout): void => { const picked = pickSpace(layout); if (!picked) { - hideIfVisible(win); + parkWindow(win); return; } const { rect, anchor } = picked; if (rect.width <= 0 || rect.height <= 0) { - hideIfVisible(win); + parkWindow(win); return; } @@ -175,7 +187,7 @@ const applyLayout = (layout: JsTaskbarLayout): void => { const availHeight = Math.round(rect.height / dpi); if (availWidth < MIN_LYRIC_WIDTH_DIP) { - hideIfVisible(win); + parkWindow(win); return; } @@ -191,7 +203,14 @@ const applyLayout = (layout: JsTaskbarLayout): void => { height: availHeight, anchor, }; - win.setBounds({ x: windowX, y: availY, width: windowWidth, height: availHeight }); + const hwndPtr = Number(win.getNativeWindowHandle().readBigUInt64LE(0)); + // Electron 的 setBounds 移动 owner 窗口不生效,交由 native 直接 SetWindowPos; + // 布局矩形与 SetWindowPos 同为物理像素,无需 DIP 换算 + const moved = + !!nativeModule && + typeof nativeModule.moveWindow === "function" && + nativeModule.moveWindow(hwndPtr, rect.x, rect.y); + if (!moved) win.setBounds({ x: windowX, y: availY, width: windowWidth, height: availHeight }); applyContentShape(); if (!firstLayoutDone) { @@ -336,6 +355,10 @@ export const createTaskbarLyricWindow = (): BrowserWindow | null => { const hwndPtr = Number(hwndPtrBigInt); taskbarLog.info(`嵌入窗口 hwnd=${hwndPtr}`); svc.embedWindowByPtr(hwndPtr); + // 首帧先以 1×1 形状显示窗口:Chromium 对首次 show 的合成可能使用旧内容位置, + // 先让窗口在不可见形状下完成首帧合成,之后应用真实形状时内容位置才是准确的 + win.setShape([{ x: INITIAL_WIDTH - 1, y: 0, width: 1, height: 1 }]); + win.showInactive(); svc.update(resolveLyricWidth()); startWatchers(mod); taskbarCreatedWatcher = tryStart( diff --git a/native/taskbar-lyric/index.d.ts b/native/taskbar-lyric/index.d.ts index 7b25f155d..3b4ff16b8 100644 --- a/native/taskbar-lyric/index.d.ts +++ b/native/taskbar-lyric/index.d.ts @@ -64,3 +64,11 @@ export interface JsTaskbarLayout { space: JsAvailableSpace extra: JsExtraLayoutInfo } + +/** + * 直接移动窗口位置,保持宽高不变 + * + * Electron 对嵌入任务栏的透明无边框窗口,setBounds 的 move 部分在 compositor surface + * 已建立后不会真正移动 HWND;动画跟随需要直接调 SetWindowPos + */ +export declare function moveWindow(hwndPtr: number, x: number, y: number): boolean diff --git a/native/taskbar-lyric/src/lib.rs b/native/taskbar-lyric/src/lib.rs index e2738c184..c02c6e780 100644 --- a/native/taskbar-lyric/src/lib.rs +++ b/native/taskbar-lyric/src/lib.rs @@ -103,6 +103,32 @@ pub(crate) fn take_valid_hwnd(hwnd_ptr: usize) -> Option { } } +/// 直接移动窗口位置,保持宽高不变 +/// +/// Electron 对嵌入任务栏的透明无边框窗口,setBounds 的 move 部分在 compositor surface +/// 已建立后不会真正移动 HWND;动画跟随需要直接调 SetWindowPos +#[napi] +pub fn move_window(hwnd_ptr: f64, x: i32, y: i32) -> bool { + use windows::Win32::UI::WindowsAndMessaging::{ + SWP_NOACTIVATE, SWP_NOSIZE, SWP_NOZORDER, SetWindowPos, + }; + let Some(hwnd) = take_valid_hwnd(hwnd_ptr as usize) else { + return false; + }; + unsafe { + SetWindowPos( + hwnd, + None, + x, + y, + 0, + 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + ) + .is_ok() + } +} + // --- NAPI Watcher 绑定 --- #[napi(js_name = "UiaWatcher")] diff --git a/native/taskbar-lyric/src/service.rs b/native/taskbar-lyric/src/service.rs index 7d1057900..c61fe47d6 100644 --- a/native/taskbar-lyric/src/service.rs +++ b/native/taskbar-lyric/src/service.rs @@ -125,13 +125,15 @@ fn worker_loop(rx: &Receiver, tsfn: &LayoutTsfn) { } TaskbarCommand::Update { width } => { - let mut final_width = width; + let mut widths = vec![width]; let mut stop_signal = false; let mut reinit_requested = false; while let Ok(next_msg) = rx.try_recv() { match next_msg { - TaskbarCommand::Update { width: w } => final_width = w, + TaskbarCommand::Update { width: w } => { + widths.push(w); + } TaskbarCommand::Embed { hwnd_ptr } => { if let Some(hwnd) = take_valid_hwnd(hwnd_ptr) { last_hwnd = Some(hwnd_ptr); @@ -154,13 +156,27 @@ fn worker_loop(rx: &Receiver, tsfn: &LayoutTsfn) { break; } - last_width = final_width; + last_width = *widths.last().unwrap_or(&last_width); if reinit_requested { do_reinit(&mut strategy, last_hwnd); + // 重建后历史帧对应的旧布局已无意义,只按最新宽度重放一次 + let last = widths.pop().unwrap_or(last_width); + widths.clear(); + widths.push(last); } - if !run_update_with_retry(&mut strategy, final_width, tsfn, rx) { + // 自动隐藏的滑动动画由 LOCATIONCHANGE 每帧驱动,每个 Update 都对应一帧位置; + // 合并成最后一帧会让歌词在动画期间钉在原地、等动画结束才跳到终点 + let mut stop = false; + for w in widths { + last_width = w; + if !run_update_with_retry(&mut strategy, w, tsfn, rx) { + stop = true; + break; + } + } + if stop { break; } } diff --git a/native/taskbar-lyric/src/strategy/win11.rs b/native/taskbar-lyric/src/strategy/win11.rs index 653dd93cb..2e4549363 100644 --- a/native/taskbar-lyric/src/strategy/win11.rs +++ b/native/taskbar-lyric/src/strategy/win11.rs @@ -1,6 +1,10 @@ +use std::mem::size_of; +use std::time::{Duration, Instant}; + use windows::{ Win32::{ Foundation::{HWND, RECT}, + Graphics::Gdi::{GetMonitorInfoW, MONITORINFO, MonitorFromWindow, MONITOR_DEFAULTTONEAREST}, UI::WindowsAndMessaging::{FindWindowExW, GetWindowRect}, }, core::w, @@ -11,13 +15,57 @@ use crate::{ AvailableSpace, ExtraLayoutInfo, LayoutParams, Rect, SystemType, TaskbarLayout, TaskbarStrategy, }, - uia::TaskbarScanner, + uia::{TaskbarContentBounds, TaskbarScanner}, utils::{BRIDGE_CLASS, check_registry_value, find_taskbar_hwnd, read_system_uses_light_theme}, }; +/// 视为任务栏完整可见的相交面积占比;低于该值说明处于自动隐藏的滑动/隐藏状态 +const FULL_VISIBLE_RATIO: f32 = 0.99; + +/// 自动隐藏收起后,任务栏在屏幕边缘仅保留约 2px 的鼠标唤醒触发带; +/// 可见厚度不超过该阈值时将空间宽高置 0 让 JS 端收起歌词窗口,避免窗口盖住唤醒带导致无法唤出任务栏 +const AUTOHIDE_COLLAPSED_MAX_PX: i32 = 4; + +/// 任务栏矩形不变时缓存的重扫周期 +/// +/// UIA 整树扫描单次耗时上百毫秒;稳态下每次 Update 都重扫会占满 worker, +/// 滑动动画的帧只能排队等待。横向边界只随任务栏内部布局变化,用周期重扫兜底 +const SCAN_REFRESH_INTERVAL: Duration = Duration::from_millis(2000); + +/// 任务栏内容横向边界相对任务栏左边缘的偏移 +/// +/// 自动隐藏的滑动动画由 LOCATIONCHANGE 每帧驱动,任务栏矩形每帧都在平移;动画期间 +/// UIA 扫描慢且在树抖动期容易失败,复用上次完整可见时扫描到的横向边界、只跟随当前 +/// 矩形位置,歌词才能按帧贴住任务栏滑出/滑回 +#[derive(Debug, Clone, Copy)] +struct ScanCache { + content_left: i32, + content_width: i32, + widgets_left: i32, + widgets_width: i32, +} + +impl ScanCache { + fn from_bounds(bounds: &TaskbarContentBounds, tb_rect: RECT) -> Self { + Self { + content_left: bounds.content.x - tb_rect.left, + content_width: bounds.content.width, + widgets_left: bounds.widgets.x - tb_rect.left, + widgets_width: bounds.widgets.width, + } + } +} + pub struct Win11Strategy { h_taskbar: HWND, scanner: Option, + /// 最近一次完整可见时扫描到的横向边界;滑动/隐藏期间作为布局缓存 + scan_cache: Option, + /// 最近一次布局使用的任务栏矩形,用于识别滑动动画中的逐帧位移 + last_tb_rect: Option, + /// 最近一次 UIA 扫描对应的任务栏矩形与时间;矩形未变且缓存新鲜时跳过重扫 + last_scan_rect: Option, + last_scan_at: Option, } impl Win11Strategy { @@ -25,12 +73,145 @@ impl Win11Strategy { Self { h_taskbar: HWND::default(), scanner: None, + scan_cache: None, + last_tb_rect: None, + last_scan_rect: None, + last_scan_at: None, } } fn is_taskbar_center_align() -> bool { check_registry_value("TaskbarAl", |val| val == 1, true) } + + /// 任务栏与所属显示器相交的(面积占比, 可见高度px) + fn monitor_visibility(hwnd: HWND, tb_rect: RECT) -> (f32, i32) { + let tb_h = (tb_rect.bottom - tb_rect.top).max(1); + let tb_w = (tb_rect.right - tb_rect.left).max(1); + // SAFETY: 两个 API 对失效句柄/显示器返回失败或默认值,不产生越界访问 + unsafe { + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + if !GetMonitorInfoW(monitor, &raw mut info).as_bool() { + return (1.0, tb_h); + } + let monitor_rect = info.rcMonitor; + let width = + (tb_rect.right.min(monitor_rect.right) - tb_rect.left.max(monitor_rect.left)).max(0); + let height = + (tb_rect.bottom.min(monitor_rect.bottom) - tb_rect.top.max(monitor_rect.top)).max(0); + let full_area = (tb_w * tb_h).max(1); + ((width * height) as f32 / full_area as f32, height) + } + } + + /// 扫描结果合理性:内容区宽度为正且不超出任务栏宽度 + /// + /// 内容横向偏移是任务栏平移的不变量,任意可见状态下扫描都可用; + /// 但收起态 UIA 可能返回退化布局,宽度异常的结果不可信、不入缓存 + fn cacheable(scan: &ScanCache, tb_rect: RECT) -> bool { + let tb_width = tb_rect.right - tb_rect.left; + scan.content_width > 0 && scan.content_width <= tb_width + } + + /// 无缓存时补一次扫描建立缓存,仅成功且结果合理时生效 + /// + /// 应用启动时任务栏常处于收起态,不预建缓存的话首次展开动画期间只能走 UIA 慢路径, + /// 动画结束后才一次性跳变 + fn seed_cache(&mut self, tb_rect: RECT) { + if self.scanner.is_none() { + match TaskbarScanner::new() { + Ok(s) => self.scanner = Some(s), + Err(_) => return, + } + } + let Some(scanner) = self.scanner.as_ref() else { + return; + }; + if let Ok(bounds) = scanner.scan_taskbar(self.h_taskbar) { + let scan = ScanCache::from_bounds(&bounds, tb_rect); + if Self::cacheable(&scan, tb_rect) { + self.scan_cache = Some(scan); + self.last_scan_rect = Some(tb_rect); + self.last_scan_at = Some(Instant::now()); + } + } + } + + /// 任务栏完全收起时返回宽高为 0 的空布局,触发 JS 侧 parkWindow 停靠,让出屏幕底缘的唤醒触发带 + fn empty_layout(is_centered: bool) -> TaskbarLayout { + TaskbarLayout { + space: AvailableSpace::default(), + extra: ExtraLayoutInfo { + system_type: SystemType::Win11, + is_centered, + is_light: read_system_uses_light_theme(), + }, + } + } + + /// 用给定的横向边界与当前任务栏矩形组装布局,滑动动画期间只随矩形平移 + fn build_layout( + tb_rect: RECT, + scan: ScanCache, + tray_rect: Rect, + is_centered: bool, + ) -> TaskbarLayout { + let tb_height = tb_rect.bottom - tb_rect.top; + + let content_left = tb_rect.left + scan.content_left; + let content_right = content_left + scan.content_width; + let widgets_left = tb_rect.left + scan.widgets_left; + + let left_x = if scan.widgets_width > 0 && widgets_left < content_left { + widgets_left + scan.widgets_width + } else { + tb_rect.left + }; + + let left_width = (content_left - left_x).max(0); + + let left_space = Rect { + x: left_x, + y: tb_rect.top, + width: left_width, + height: tb_height, + }; + + let right_x = content_right; + + let right_right_edge = if scan.widgets_width > 0 && widgets_left > content_right { + widgets_left + } else if tray_rect.width > 0 { + tray_rect.x + } else { + tb_rect.right + }; + + let right_width = (right_right_edge - right_x).max(0); + + let right_space = Rect { + x: right_x, + y: tb_rect.top, + width: right_width, + height: tb_height, + }; + + TaskbarLayout { + space: AvailableSpace { + left: left_space, + right: right_space, + }, + extra: ExtraLayoutInfo { + system_type: SystemType::Win11, + is_centered, + is_light: read_system_uses_light_theme(), + }, + } + } } impl TaskbarStrategy for Win11Strategy { @@ -67,8 +248,22 @@ impl TaskbarStrategy for Win11Strategy { } let mut tb_rect = RECT::default(); - unsafe { - let _ = GetWindowRect(self.h_taskbar, &raw mut tb_rect); + if unsafe { GetWindowRect(self.h_taskbar, &raw mut tb_rect) }.is_err() { + return None; + } + + let is_centered = Self::is_taskbar_center_align(); + let (vis_ratio, vis_height) = Self::monitor_visibility(self.h_taskbar, tb_rect); + + // 任务栏已收起到底缘(只剩 ~2px 唤醒触发带):直接返回空布局让 JS hide 窗口, + // 避免歌词窗口停在 T=1150 盖住底缘 2px 导致鼠标无法唤出任务栏 + if vis_height <= AUTOHIDE_COLLAPSED_MAX_PX { + self.last_tb_rect = Some(tb_rect); + // 应用启动时任务栏常已处于收起态,趁机预建缓存,首次展开动画即可走快速路径 + if self.scan_cache.is_none() { + self.seed_cache(tb_rect); + } + return Some(Self::empty_layout(is_centered)); } let tray_rect = unsafe { @@ -88,77 +283,65 @@ impl TaskbarStrategy for Win11Strategy { } }; + // 滑动动画期间(矩形逐帧变化或未完整可见)不跑 UIA 扫描:扫描耗时超过帧间隔, + // 且树抖动期容易失败,会导致整段动画期间不 emit。复用上次完整可见时的横向边界, + // 只跟随当前矩形平移,歌词即可按帧贴住任务栏 + let sliding = self.last_tb_rect.is_some_and(|prev| prev != tb_rect); + if let Some(scan) = self.scan_cache + && (sliding || vis_ratio < FULL_VISIBLE_RATIO) + { + self.last_tb_rect = Some(tb_rect); + return Some(Self::build_layout(tb_rect, scan, tray_rect, is_centered)); + } + + // 矩形没变说明任务栏内部布局也没变:复用缓存直接布局,避免稳态下每次 Update + // 都跑上百毫秒的整树扫描、占住 worker 导致动画帧排队;内部布局变化由周期重扫兜底 + if let Some(scan) = self.scan_cache + && self.last_scan_rect.is_some_and(|prev| prev == tb_rect) + && self + .last_scan_at + .is_some_and(|at| at.elapsed() < SCAN_REFRESH_INTERVAL) + { + self.last_tb_rect = Some(tb_rect); + return Some(Self::build_layout(tb_rect, scan, tray_rect, is_centered)); + } + if self.scanner.is_none() { match TaskbarScanner::new() { Ok(s) => self.scanner = Some(s), Err(_) => { error!("Scanner 初始化失败"); - return None; + self.last_tb_rect = Some(tb_rect); + return self + .scan_cache + .map(|scan| Self::build_layout(tb_rect, scan, tray_rect, is_centered)); } } } - let uia_bounds = match self.scanner.as_ref()?.scan_taskbar(self.h_taskbar) { - Ok(bounds) => bounds, - Err(_) => { + let bounds = match self.scanner.as_ref().map(|s| s.scan_taskbar(self.h_taskbar)) { + Some(Ok(bounds)) => bounds, + _ => { error!("scan_taskbar 失败"); self.scanner = None; - return None; + self.last_tb_rect = Some(tb_rect); + return self + .scan_cache + .map(|scan| Self::build_layout(tb_rect, scan, tray_rect, is_centered)); } }; - let is_centered = Self::is_taskbar_center_align(); - let tb_height = tb_rect.bottom - tb_rect.top; - - let content_left = uia_bounds.content.x; - let content_right = uia_bounds.content.x + uia_bounds.content.width; - - let left_x = if uia_bounds.widgets.width > 0 && uia_bounds.widgets.x < content_left { - uia_bounds.widgets.x + uia_bounds.widgets.width - } else { - tb_rect.left - }; - - let left_width = (content_left - left_x).max(0); - - let left_space = Rect { - x: left_x, - y: tb_rect.top, - width: left_width, - height: tb_height, - }; - - let right_x = content_right; - - let right_right_edge = - if uia_bounds.widgets.width > 0 && uia_bounds.widgets.x > content_right { - uia_bounds.widgets.x - } else if tray_rect.width > 0 { - tray_rect.x - } else { - tb_rect.right - }; - - let right_width = (right_right_edge - right_x).max(0); - - let right_space = Rect { - x: right_x, - y: tb_rect.top, - width: right_width, - height: tb_height, - }; + let scan = ScanCache::from_bounds(&bounds, tb_rect); + // 扫描成功且结果合理即入缓存——内容横向偏移不随任务栏滑动变化, + // 收起/滑动中的扫描同样可信;退化结果由 cacheable 挡掉 + if Self::cacheable(&scan, tb_rect) { + self.scan_cache = Some(scan); + self.last_scan_rect = Some(tb_rect); + self.last_scan_at = Some(Instant::now()); + } + self.last_tb_rect = Some(tb_rect); - Some(TaskbarLayout { - space: AvailableSpace { - left: left_space, - right: right_space, - }, - extra: ExtraLayoutInfo { - system_type: SystemType::Win11, - is_centered, - is_light: read_system_uses_light_theme(), - }, - }) + Some(Self::build_layout(tb_rect, scan, tray_rect, is_centered)) } fn restore(&self) {} @@ -169,3 +352,4 @@ impl Drop for Win11Strategy { self.restore(); } } +