Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"./native/media-ctrl",
"./native/taskbar-lyric",
"./native/taskbar-thumbnail",
"./native/window-monitor",
"./native/opencc",
]
resolver = "3"
Expand Down
10 changes: 10 additions & 0 deletions docs/native.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -39,6 +40,14 @@ Windows 任务栏缩略图自定义(仅 Windows):
- 借助 DWM 的 iconic representation,把任务栏**悬停缩略图**与 **Peek 全尺寸预览**替换为指定图片(当前专辑封面),而非默认的实时窗口内容;
- 对主窗口子类化,被动应答 DWM 的位图请求,封面变化时通知 DWM 重新拉取。

### window-monitor

Windows 前台窗口全屏探测(仅 Windows):

- 读取前台窗口矩形,判断其是否完整覆盖所在显示器,用于得知「是否有其他应用正处于全屏」;
- 排除最大化窗口(最大化同样铺满屏幕,但不算全屏)、桌面 / 壁纸层 / 任务栏等系统窗口,并支持传入自身 HWND 以避免自我判定;
- 无状态设计,只做单次同步查询,不持有任何窗口句柄,轮询节奏由 JS 侧控制。

## 构建

构建原生模块需要 **Rust 工具链**(通过 [rustup](https://rustup.rs/) 安装)。
Expand All @@ -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 跳过原生构建
Expand Down
5 changes: 5 additions & 0 deletions electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ const config: Configuration = {
to: "native",
filter: ["*.node"],
},
{
from: "native/window-monitor",
to: "native",
filter: ["*.node"],
},
{
from: "native/opencc",
to: "native",
Expand Down
1 change: 1 addition & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
},
Expand Down
4 changes: 4 additions & 0 deletions electron/main/ipc/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
applyDesktopLyricLock,
applyDesktopLyricAlwaysOnTop,
applyDynamicIslandAlwaysOnTop,
applyDynamicIslandHideOnFullscreen,
applyDynamicIslandSnapCentered,
applyDynamicIslandNotchFusion,
applyDynamicIslandNonOcclusive,
Expand Down Expand Up @@ -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;
Expand Down
98 changes: 98 additions & 0 deletions electron/main/services/foreground.ts
Original file line number Diff line number Diff line change
@@ -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<WindowMonitorNative>("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);
},
};
};
88 changes: 86 additions & 2 deletions electron/main/window/dynamicIsland.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 等坑
Expand Down Expand Up @@ -478,6 +554,7 @@ export const saveDynamicIslandState = (): void => {
/** 创建灵动岛窗口,如果窗口已存在则显示并聚焦 */
export const createDynamicIslandWindow = (): BrowserWindow => {
if (dynamicIslandWindow && !dynamicIslandWindow.isDestroyed()) {
// 用户主动唤起(托盘 / 设置开关),即便当前处于全屏也尊重这次操作直接显示
dynamicIslandWindow.show();
dynamicIslandWindow.focus();
return dynamicIslandWindow;
Expand Down Expand Up @@ -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`,
Expand All @@ -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 });
Expand All @@ -587,6 +670,7 @@ export const createDynamicIslandWindow = (): BrowserWindow => {
store.set("windowStates.dynamicIsland.visible", true);

dynamicIslandWindow.on("closed", () => {
stopFullscreenWatch();
stopCursorPolling();
activeShapeWidth = null;
dynamicIslandWindow = null;
Expand Down
1 change: 1 addition & 0 deletions electron/main/window/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
toggleDynamicIslandWindow,
getDynamicIslandWindow,
applyDynamicIslandAlwaysOnTop,
applyDynamicIslandHideOnFullscreen,
applyDynamicIslandHeight,
applyDynamicIslandShape,
applyDynamicIslandWidth,
Expand Down
25 changes: 25 additions & 0 deletions native/window-monitor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions native/window-monitor/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
8 changes: 8 additions & 0 deletions native/window-monitor/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/**
* 判断当前前台窗口是否处于全屏
*
* `excludeHwnd` 用于排除自身窗口,避免灵动岛把自己判定成全屏而自我隐藏。
*/
export declare function isForegroundFullscreen(excludeHwnd?: number | null): boolean
14 changes: 14 additions & 0 deletions native/window-monitor/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading