From fa6b1f41869d4a12332e952c990a129038515160 Mon Sep 17 00:00:00 2001 From: dtotaku Date: Thu, 22 Oct 2026 15:36:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=81=B5=E5=8A=A8=E5=B2=9B=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=85=B6=E4=BB=96=E5=BA=94=E7=94=A8=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E9=9A=90=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 window-monitor 原生模块,通过 Win32 读取前台窗口矩形,判断其是否 完整覆盖所在显示器,据此在他人全屏(游戏 / 视频 / 演示 / 浏览器全屏) 时隐藏灵动岛,退出全屏后恢复显示与置顶,避免常驻 topmost 层级压在全屏 内容之上。 - native/window-monitor:无状态单次查询,排除最大化窗口与桌面类窗口 (最大化同样铺满屏幕但不算全屏),支持传入自身 HWND 避免自我判定 - services/foreground.ts:300ms 轮询,仅在状态变化时回调,非 Windows 平台降级为始终显示 - dynamicIsland:全屏隐藏、退出后 showInactive 恢复(不抢焦点); ready-to-show 时若已处于全屏则跳过显示,避免"先闪一下再消失" - 设置项 dynamicIsland.hideOnFullscreen,默认开启,仅 Windows 显示 --- Cargo.lock | 10 ++ Cargo.toml | 1 + docs/native.md | 10 ++ electron-builder.config.ts | 5 + electron.vite.config.ts | 1 + electron/main/ipc/config.ts | 4 + electron/main/services/foreground.ts | 98 ++++++++++++++++++++ electron/main/window/dynamicIsland.ts | 88 +++++++++++++++++- electron/main/window/index.ts | 1 + native/window-monitor/Cargo.toml | 25 +++++ native/window-monitor/build.rs | 3 + native/window-monitor/index.d.ts | 8 ++ native/window-monitor/package.json | 14 +++ native/window-monitor/src/lib.rs | 111 +++++++++++++++++++++++ scripts/build-native.ts | 4 + shared/defaults/settings.ts | 1 + shared/types/settings.ts | 2 + src/i18n/locales/en-US.json | 4 + src/i18n/locales/zh-CN.json | 4 + src/settings/categories/externalLyric.ts | 15 ++- tsconfig.node.json | 1 + windows/dynamic-island/App.vue | 1 + 22 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 electron/main/services/foreground.ts create mode 100644 native/window-monitor/Cargo.toml create mode 100644 native/window-monitor/build.rs create mode 100644 native/window-monitor/index.d.ts create mode 100644 native/window-monitor/package.json create mode 100644 native/window-monitor/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1b2f2b251..e4e57191e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4228,6 +4228,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window-monitor" +version = "0.1.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "windows", +] + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 656de3419..96b98defc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "./native/media-ctrl", "./native/taskbar-lyric", "./native/taskbar-thumbnail", + "./native/window-monitor", "./native/opencc", ] resolver = "3" diff --git a/docs/native.md b/docs/native.md index f5c64b31f..6892675ab 100644 --- a/docs/native.md +++ b/docs/native.md @@ -10,6 +10,7 @@ SPlayer-Next 将性能敏感的能力下沉到 **Rust**,通过 [NAPI-RS](https | `media-ctrl` | 系统媒体控制(Windows SMTC / Linux MPRIS / macOS Now Playing)与 Discord RPC | | `taskbar-lyric` | Windows 任务栏歌词文字渲染 | | `taskbar-thumbnail` | Windows 任务栏悬停缩略图 / Peek 预览替换为专辑封面 | +| `window-monitor` | Windows 前台窗口全屏探测(供灵动岛全屏隐藏使用) | ### audio-engine @@ -39,6 +40,14 @@ Windows 任务栏缩略图自定义(仅 Windows): - 借助 DWM 的 iconic representation,把任务栏**悬停缩略图**与 **Peek 全尺寸预览**替换为指定图片(当前专辑封面),而非默认的实时窗口内容; - 对主窗口子类化,被动应答 DWM 的位图请求,封面变化时通知 DWM 重新拉取。 +### window-monitor + +Windows 前台窗口全屏探测(仅 Windows): + +- 读取前台窗口矩形,判断其是否完整覆盖所在显示器,用于得知「是否有其他应用正处于全屏」; +- 排除最大化窗口(最大化同样铺满屏幕,但不算全屏)、桌面 / 壁纸层 / 任务栏等系统窗口,并支持传入自身 HWND 以避免自我判定; +- 无状态设计,只做单次同步查询,不持有任何窗口句柄,轮询节奏由 JS 侧控制。 + ## 构建 构建原生模块需要 **Rust 工具链**(通过 [rustup](https://rustup.rs/) 安装)。 @@ -60,6 +69,7 @@ NAPI-RS 会自动生成各模块的 `index.d.ts` 类型声明,主进程通过 @splayer/media-ctrl → native/media-ctrl @splayer/taskbar-lyric → native/taskbar-lyric @splayer/taskbar-thumbnail → native/taskbar-thumbnail +@splayer/window-monitor → native/window-monitor ``` ::: tip 跳过原生构建 diff --git a/electron-builder.config.ts b/electron-builder.config.ts index a551a60a9..0e2b57c19 100644 --- a/electron-builder.config.ts +++ b/electron-builder.config.ts @@ -97,6 +97,11 @@ const config: Configuration = { to: "native", filter: ["*.node"], }, + { + from: "native/window-monitor", + to: "native", + filter: ["*.node"], + }, { from: "native/opencc", to: "native", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d0cde087c..3ab4dbc82 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ "@splayer/media-ctrl": resolve(__dirname, "native/media-ctrl"), "@splayer/taskbar-lyric": resolve(__dirname, "native/taskbar-lyric"), "@splayer/taskbar-thumbnail": resolve(__dirname, "native/taskbar-thumbnail"), + "@splayer/window-monitor": resolve(__dirname, "native/window-monitor"), "@splayer/opencc": resolve(__dirname, "native/opencc"), }, }, diff --git a/electron/main/ipc/config.ts b/electron/main/ipc/config.ts index a950cc7a6..5caf9bdeb 100644 --- a/electron/main/ipc/config.ts +++ b/electron/main/ipc/config.ts @@ -22,6 +22,7 @@ import { applyDesktopLyricLock, applyDesktopLyricAlwaysOnTop, applyDynamicIslandAlwaysOnTop, + applyDynamicIslandHideOnFullscreen, applyDynamicIslandSnapCentered, applyDynamicIslandNotchFusion, applyDynamicIslandNonOcclusive, @@ -109,6 +110,9 @@ const applyConfigChange = (keyPath: string, value: unknown): void => { case "dynamicIsland.alwaysOnTop": applyDynamicIslandAlwaysOnTop(value as boolean); break; + case "dynamicIsland.hideOnFullscreen": + applyDynamicIslandHideOnFullscreen(value as boolean); + break; case "dynamicIsland.snapCentered": applyDynamicIslandSnapCentered(value as boolean); break; diff --git a/electron/main/services/foreground.ts b/electron/main/services/foreground.ts new file mode 100644 index 000000000..8ad441665 --- /dev/null +++ b/electron/main/services/foreground.ts @@ -0,0 +1,98 @@ +import { isWin } from "@main/utils/config"; +import { nativeLog } from "@main/utils/logger"; +import { loadNativeModule } from "@main/utils/nativeLoader"; + +/** + * 前台窗口全屏探测服务 + * + * 依赖 window-monitor 原生模块(仅 Windows 构建):读取前台窗口矩形, + * 判断其是否完整覆盖所在显示器,从而得知"是否有应用正处于全屏"。 + * + * 灵动岛据此在他人全屏(游戏 / 视频 / 演示 / 浏览器 F11)时隐藏, + * 退出全屏后恢复,避免常驻 topmost 层级压在全屏内容之上。 + * + * 非 Windows 平台没有对应实现,查询恒返回 false,即保持"始终显示"的原有行为。 + */ + +/** 前台全屏轮询间隔(ms):单次查询只有几个 Win32 调用,开销可忽略,取较短间隔保证切换跟手 */ +const POLL_INTERVAL_MS = 300; + +type WindowMonitorNative = typeof import("@splayer/window-monitor"); + +let native: WindowMonitorNative | null = null; +/** 加载失败也标记为已尝试,避免每次轮询都重复 require 触发异常开销 */ +let loadAttempted = false; + +/** 懒加载原生模块,缺失时静默降级 */ +const load = (): WindowMonitorNative | null => { + if (loadAttempted) return native; + loadAttempted = true; + native = loadNativeModule("window-monitor.node", "window-monitor"); + return native; +}; + +/** + * 查询当前前台窗口是否处于全屏 + * @param excludeHwnd - 需要排除的窗口句柄(自身窗口),避免把自己判定成全屏 + */ +export const isForegroundFullscreen = (excludeHwnd: number | null = null): boolean => { + if (!isWin) return false; + const mod = load(); + if (!mod) return false; + try { + return mod.isForegroundFullscreen(excludeHwnd); + } catch (error) { + nativeLog.warn("前台全屏探测失败", error); + return false; + } +}; + +export interface ForegroundFullscreenWatcher { + /** 最近一次判定结果,供窗口显示前查询,避免全屏时先显示再隐藏造成闪烁 */ + isFullscreen: () => boolean; + /** 停止轮询 */ + stop: () => void; +} + +/** + * 启动前台全屏轮询,仅在状态变化时回调 + * @param options.getExcludeHwnd - 每次轮询时取需要排除的窗口句柄 + * @param options.onChange - 全屏状态发生变化时触发 + */ +export const watchForegroundFullscreen = (options: { + getExcludeHwnd: () => number | null; + onChange: (fullscreen: boolean) => void; +}): ForegroundFullscreenWatcher => { + let last = false; + let stopped = false; + + const tick = (): void => { + if (stopped) return; + // 轮询回调里通常会操作窗口,而窗口可能在两次轮询之间被销毁; + // 这里兜底避免定时器抛出未捕获异常,也避免状态卡死 + try { + const current = isForegroundFullscreen(options.getExcludeHwnd()); + if (current === last) return; + last = current; + options.onChange(current); + } catch (error) { + nativeLog.warn("前台全屏轮询失败", error); + } + }; + + // 首次判定同步完成且不触发回调,调用方可直接读取 isFullscreen() 决定初始是否显示 + try { + last = isForegroundFullscreen(options.getExcludeHwnd()); + } catch { + last = false; + } + const timer = setInterval(tick, POLL_INTERVAL_MS); + + return { + isFullscreen: () => last, + stop: () => { + stopped = true; + clearInterval(timer); + }, + }; +}; diff --git a/electron/main/window/dynamicIsland.ts b/electron/main/window/dynamicIsland.ts index 4b285b176..c1ff52c39 100644 --- a/electron/main/window/dynamicIsland.ts +++ b/electron/main/window/dynamicIsland.ts @@ -4,8 +4,12 @@ import { is } from "@electron-toolkit/utils"; import { createWindow } from "./create"; import { store } from "@main/store"; import { broadcast } from "@main/utils/broadcast"; -import { isMac } from "@main/utils/config"; +import { isMac, isWin } from "@main/utils/config"; import { setTrayDynamicIsland } from "@main/services/tray"; +import { + watchForegroundFullscreen, + type ForegroundFullscreenWatcher, +} from "@main/services/foreground"; import { isAppQuitting } from "@main/utils/lifecycle"; import { DYNAMIC_ISLAND_BASE_HEIGHT } from "@shared/defaults/settings"; @@ -172,6 +176,78 @@ export const applyDynamicIslandAlwaysOnTop = (alwaysOnTop: boolean): void => { win.setAlwaysOnTop(alwaysOnTop, "screen-saver"); }; +/** 读取窗口 HWND 数值,供原生模块排除自身窗口;失败返回 null */ +const getHwndPtr = (win: BrowserWindow): number | null => { + try { + const big = win.getNativeWindowHandle().readBigUInt64LE(0); + if (big > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(big); + } catch { + return null; + } +}; + +/** 前台全屏监听句柄 */ +let fullscreenWatcher: ForegroundFullscreenWatcher | null = null; + +/** 停止前台全屏监听 */ +const stopFullscreenWatch = (): void => { + fullscreenWatcher?.stop(); + fullscreenWatcher = null; +}; + +/** + * 启动前台全屏监听 + * + * 其他应用进入全屏(游戏 / 视频 / 演示 / 浏览器 F11)时隐藏灵动岛, + * 退出全屏后恢复显示并重新置顶。 + * + * 之所以用 hide 而不是取消置顶:全屏窗口本身也是 topmost 竞争的输家, + * 只要灵动岛还在屏幕上就会被压住或反过来压住全屏内容,隐藏是唯一干净的做法。 + */ +const startFullscreenWatch = (): void => { + stopFullscreenWatch(); + if (!isWin) return; + if (!store.get("dynamicIsland.hideOnFullscreen")) return; + fullscreenWatcher = watchForegroundFullscreen({ + // 灵动岛被点击后会成为前台窗口,必须排除,否则会把自己判定成全屏而自我隐藏 + getExcludeHwnd: () => + dynamicIslandWindow && !dynamicIslandWindow.isDestroyed() + ? getHwndPtr(dynamicIslandWindow) + : null, + onChange: (fullscreen) => { + if (!dynamicIslandWindow || dynamicIslandWindow.isDestroyed()) return; + if (fullscreen) { + dynamicIslandWindow.hide(); + return; + } + // showInactive 不抢焦点,保持灵动岛非侵入定位 + dynamicIslandWindow.showInactive(); + dynamicIslandWindow.setAlwaysOnTop(store.get("dynamicIsland").alwaysOnTop, "screen-saver"); + }, + }); +}; + +/** + * 应用"全屏时隐藏"配置:关闭时立即恢复显示,开启时立刻接管当前全屏状态 + * @param enabled 是否启用全屏隐藏 + */ +export const applyDynamicIslandHideOnFullscreen = (enabled: boolean): void => { + const win = getDynamicIslandWindow(); + if (!win || win.isDestroyed()) return; + if (!enabled) { + stopFullscreenWatch(); + if (!win.isVisible()) win.showInactive(); + return; + } + startFullscreenWatch(); + if (fullscreenWatcher?.isFullscreen()) { + win.hide(); + } else if (!win.isVisible()) { + win.showInactive(); + } +}; + /** * 光标位置轮询:用 OS 级 screen.getCursorScreenPoint() 判断鼠标是否在窗口内 * 不依赖 DOM 鼠标事件,避免 setIgnoreMouseEvents 穿透时事件漏发、opacity=0 不触发 leave 等坑 @@ -478,6 +554,7 @@ export const saveDynamicIslandState = (): void => { /** 创建灵动岛窗口,如果窗口已存在则显示并聚焦 */ export const createDynamicIslandWindow = (): BrowserWindow => { if (dynamicIslandWindow && !dynamicIslandWindow.isDestroyed()) { + // 用户主动唤起(托盘 / 设置开关),即便当前处于全屏也尊重这次操作直接显示 dynamicIslandWindow.show(); dynamicIslandWindow.focus(); return dynamicIslandWindow; @@ -555,6 +632,9 @@ export const createDynamicIslandWindow = (): BrowserWindow => { }, }); + // 窗口实例就绪即开始监听前台全屏,保证 ready-to-show 时能读到初始判定 + startFullscreenWatch(); + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { dynamicIslandWindow.loadURL( `${process.env["ELECTRON_RENDERER_URL"]}/windows/dynamic-island/index.html`, @@ -574,7 +654,10 @@ export const createDynamicIslandWindow = (): BrowserWindow => { dynamicIslandWindow.once("ready-to-show", () => { if (!dynamicIslandWindow) return; - dynamicIslandWindow.show(); + // 创建时已有应用处于全屏则不显示,避免"先闪一下再消失" + if (!fullscreenWatcher?.isFullscreen()) { + dynamicIslandWindow.show(); + } dynamicIslandWindow.setAlwaysOnTop(config.alwaysOnTop, "screen-saver"); if (config.nonOcclusive) { dynamicIslandWindow.setIgnoreMouseEvents(true, { forward: true }); @@ -587,6 +670,7 @@ export const createDynamicIslandWindow = (): BrowserWindow => { store.set("windowStates.dynamicIsland.visible", true); dynamicIslandWindow.on("closed", () => { + stopFullscreenWatch(); stopCursorPolling(); activeShapeWidth = null; dynamicIslandWindow = null; diff --git a/electron/main/window/index.ts b/electron/main/window/index.ts index 9e946ef78..7170131a2 100644 --- a/electron/main/window/index.ts +++ b/electron/main/window/index.ts @@ -37,6 +37,7 @@ export { toggleDynamicIslandWindow, getDynamicIslandWindow, applyDynamicIslandAlwaysOnTop, + applyDynamicIslandHideOnFullscreen, applyDynamicIslandHeight, applyDynamicIslandShape, applyDynamicIslandWidth, diff --git a/native/window-monitor/Cargo.toml b/native/window-monitor/Cargo.toml new file mode 100644 index 000000000..19c260ecf --- /dev/null +++ b/native/window-monitor/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "window-monitor" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { workspace = true, features = ["napi9"] } +napi-derive = { workspace = true } + +[dependencies.windows] +workspace = true +features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_WindowsAndMessaging", +] + +[build-dependencies] +napi-build = { workspace = true } + +[lints] +workspace = true diff --git a/native/window-monitor/build.rs b/native/window-monitor/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/native/window-monitor/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/native/window-monitor/index.d.ts b/native/window-monitor/index.d.ts new file mode 100644 index 000000000..c269dd5bd --- /dev/null +++ b/native/window-monitor/index.d.ts @@ -0,0 +1,8 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +/** + * 判断当前前台窗口是否处于全屏 + * + * `excludeHwnd` 用于排除自身窗口,避免灵动岛把自己判定成全屏而自我隐藏。 + */ +export declare function isForegroundFullscreen(excludeHwnd?: number | null): boolean diff --git a/native/window-monitor/package.json b/native/window-monitor/package.json new file mode 100644 index 000000000..4a0c28ebe --- /dev/null +++ b/native/window-monitor/package.json @@ -0,0 +1,14 @@ +{ + "name": "window-monitor", + "version": "0.1.0", + "main": "window-monitor.node", + "types": "index.d.ts", + "napi": { + "binaryName": "window-monitor", + "triples": {} + }, + "scripts": { + "build": "napi build --release --no-const-enum", + "build:debug": "napi build --no-const-enum" + } +} diff --git a/native/window-monitor/src/lib.rs b/native/window-monitor/src/lib.rs new file mode 100644 index 000000000..42a24801c --- /dev/null +++ b/native/window-monitor/src/lib.rs @@ -0,0 +1,111 @@ +//! 前台窗口全屏探测原生模块 +//! +//! 通过 Win32 读取前台窗口矩形,判断其是否完整覆盖所在显示器, +//! 用于"是否有其他应用正处于全屏"这一判断。 +//! +//! 灵动岛据此在他人全屏(游戏 / 视频 / 演示 / 浏览器 F11)时自动隐藏, +//! 退出全屏后再恢复显示与置顶,避免常驻 topmost 层级压在全屏内容之上。 +//! +//! 设计取舍:只做单次同步查询,不持有任何窗口句柄与全局状态。 +//! 轮询节奏由 JS 侧控制,模块本身无状态,避免长期持有 HWND 造成悬垂引用。 + +use napi_derive::napi; +use windows::Win32::Foundation::{HWND, RECT}; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + GWL_STYLE, GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, IsIconic, + IsWindowVisible, WS_MAXIMIZE, +}; + +/// 全屏判定容差(像素) +/// +/// 部分全屏窗口的矩形会比显示器略大(如 -8 起始),或因为 DPI 取整差 1~2 px, +/// 留一点余量避免漏判;但不宜过大,否则会把接近满屏的普通窗口误判成全屏。 +const FULLSCREEN_TOLERANCE: i32 = 2; + +/// 需要排除的窗口类名 +/// +/// 这些窗口铺满屏幕但并不代表用户在全屏使用应用:桌面、壁纸层、任务栏。 +const EXCLUDED_CLASSES: [&str; 3] = ["Progman", "WorkerW", "Shell_TrayWnd"]; + +/// 读取窗口类名,失败时返回空字符串 +fn class_name(hwnd: HWND) -> String { + let mut buf = [0u16; 256]; + let len = unsafe { GetClassNameW(hwnd, &mut buf) }; + if len <= 0 { + return String::new(); + } + String::from_utf16_lossy(&buf[..len as usize]) +} + +/// 判断 `outer` 是否完整覆盖 `inner`(允许容差) +fn covers(outer: &RECT, inner: &RECT) -> bool { + outer.left <= inner.left + FULLSCREEN_TOLERANCE + && outer.top <= inner.top + FULLSCREEN_TOLERANCE + && outer.right >= inner.right - FULLSCREEN_TOLERANCE + && outer.bottom >= inner.bottom - FULLSCREEN_TOLERANCE +} + +/// 窗口是否处于最大化状态 +/// +/// 最大化窗口的矩形同样会覆盖整个显示器(甚至超出屏幕边界), +/// 若不排除会把"最大化"误判成"全屏",导致灵动岛在用户只是最大化窗口时就消失。 +fn is_maximized(hwnd: HWND) -> bool { + let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) }; + (style as u32) & WS_MAXIMIZE.0 != 0 +} + +/// 判断当前前台窗口是否处于全屏 +/// +/// `exclude_hwnd` 用于排除自身窗口:灵动岛被点击后会成为前台窗口, +/// 不排除会导致"自己把自己判定成全屏"而自我隐藏。 +/// +/// 返回 `false` 表示当前没有应用处于全屏(或无法判定)。 +#[napi] +pub fn is_foreground_fullscreen(exclude_hwnd: Option) -> bool { + unsafe { + let foreground = GetForegroundWindow(); + if foreground.0.is_null() { + return false; + } + if exclude_hwnd.is_some_and(|exclude| foreground.0 as isize as i64 == exclude) { + return false; + } + // 最小化 / 不可见的窗口不可能是全屏 + if IsIconic(foreground).as_bool() || !IsWindowVisible(foreground).as_bool() { + return false; + } + // 最大化不算全屏 + if is_maximized(foreground) { + return false; + } + let class = class_name(foreground); + if EXCLUDED_CLASSES + .iter() + .any(|excluded| class.eq_ignore_ascii_case(excluded)) + { + return false; + } + + let mut window_rect = RECT::default(); + if GetWindowRect(foreground, &mut window_rect).is_err() { + return false; + } + + let monitor = MonitorFromWindow(foreground, MONITOR_DEFAULTTONEAREST); + if monitor.0.is_null() { + return false; + } + let mut monitor_info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if !GetMonitorInfoW(monitor, &mut monitor_info).as_bool() { + return false; + } + + covers(&window_rect, &monitor_info.rcMonitor) + } +} diff --git a/scripts/build-native.ts b/scripts/build-native.ts index ac500eca0..9fa3ab400 100644 --- a/scripts/build-native.ts +++ b/scripts/build-native.ts @@ -25,6 +25,10 @@ const modules: NativeModule[] = [ name: "taskbar-thumbnail", enabled: process.platform === "win32", }, + { + name: "window-monitor", + enabled: process.platform === "win32", + }, { name: "opencc", }, diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index adf5cb641..ceb0e4380 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -72,6 +72,7 @@ export const defaultSystemConfig: SystemConfig = { unplayedColor: "rgba(255, 255, 255, 0.5)", backgroundColor: "rgba(0, 0, 0, 1)", alwaysOnTop: true, + hideOnFullscreen: true, snapCentered: true, notchFusion: false, nonOcclusive: false, diff --git a/shared/types/settings.ts b/shared/types/settings.ts index a77425683..c99874b76 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -166,6 +166,8 @@ export interface DynamicIslandSettings { backgroundColor: string; /** 窗口置顶 */ alwaysOnTop: boolean; + /** 其他应用全屏时自动隐藏(仅 Windows) */ + hideOnFullscreen: boolean; /** 吸附时是否居中 */ snapCentered: boolean; /** macOS 刘海融合 */ diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index a387aab94..1310e1175 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -1447,6 +1447,10 @@ "label": "Always On Top", "description": "Keep the Dynamic Island window above others" }, + "dynamicIslandHideOnFullscreen": { + "label": "Hide on Fullscreen", + "description": "Hide the island when another app goes fullscreen (games, video, browser fullscreen) and restore it on exit" + }, "dynamicIslandNotchFusion": { "label": "Notch Fusion", "description": "Expand the island from the physical MacBook notch" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b81d472ba..1c57cccc2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1447,6 +1447,10 @@ "label": "窗口置顶", "description": "灵动岛窗口始终保持在最前" }, + "dynamicIslandHideOnFullscreen": { + "label": "全屏时隐藏", + "description": "其他应用进入全屏(游戏、视频、浏览器全屏)时自动隐藏灵动岛,退出全屏后恢复显示并置顶" + }, "dynamicIslandNotchFusion": { "label": "刘海融合", "description": "在 MacBook 刘海屏上让灵动岛从物理刘海向下扩展" diff --git a/src/settings/categories/externalLyric.ts b/src/settings/categories/externalLyric.ts index baf8a815a..795fd08a4 100644 --- a/src/settings/categories/externalLyric.ts +++ b/src/settings/categories/externalLyric.ts @@ -1,6 +1,6 @@ import type { SettingCategory, SettingSection } from "@/types/settings-schema"; import { useSettingsStore } from "@/stores/settings"; -import { isMac } from "@/utils/config"; +import { isMac, isWin } from "@/utils/config"; import IconLucideMonitor from "~icons/lucide/monitor"; const desktopLyricSection: SettingSection = { @@ -225,6 +225,19 @@ const dynamicIslandSection: SettingSection = { binding: { store: "settings", path: "system.dynamicIsland.alwaysOnTop" }, defaultValue: true, }, + ...(isWin + ? [ + { + key: "dynamicIslandHideOnFullscreen", + type: "switch" as const, + binding: { + store: "settings" as const, + path: "system.dynamicIsland.hideOnFullscreen", + }, + defaultValue: true, + }, + ] + : []), ...(isMac ? [ { diff --git a/tsconfig.node.json b/tsconfig.node.json index cb018bd51..b0319a823 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,6 +19,7 @@ "@splayer/media-ctrl": ["./native/media-ctrl"], "@splayer/taskbar-lyric": ["./native/taskbar-lyric"], "@splayer/taskbar-thumbnail": ["./native/taskbar-thumbnail"], + "@splayer/window-monitor": ["./native/window-monitor"], "@splayer/opencc": ["./native/opencc"] } } diff --git a/windows/dynamic-island/App.vue b/windows/dynamic-island/App.vue index 6f6ef0841..5eda87cef 100644 --- a/windows/dynamic-island/App.vue +++ b/windows/dynamic-island/App.vue @@ -21,6 +21,7 @@ const config = reactive({ unplayedColor: "rgba(255, 255, 255, 0.5)", backgroundColor: "rgba(0, 0, 0, 1)", alwaysOnTop: true, + hideOnFullscreen: true, snapCentered: true, notchFusion: false, nonOcclusive: false,