From 056e75b08c995124534b97a1da806d44f22a557f Mon Sep 17 00:00:00 2001 From: txdycyy Date: Sat, 3 Oct 2026 13:42:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8F=90=E4=BE=9B=20Niri=20=E7=81=B5?= =?UTF-8?q?=E5=8A=A8=E5=B2=9B=E5=8A=A8=E6=80=81=E5=B1=85=E4=B8=AD=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/troubleshooting/wayland.md | 49 ++++++++ docs/troubleshooting/wayland.md | 49 ++++++++ eslint.config.mjs | 4 + scripts/niri-island-center.mjs | 171 ++++++++++++++++++++++++++ scripts/niri-island-center.test.mjs | 179 ++++++++++++++++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 scripts/niri-island-center.mjs create mode 100644 scripts/niri-island-center.test.mjs diff --git a/docs/en/troubleshooting/wayland.md b/docs/en/troubleshooting/wayland.md index fc6a3cc80..8daf24c89 100644 --- a/docs/en/troubleshooting/wayland.md +++ b/docs/en/troubleshooting/wayland.md @@ -76,6 +76,55 @@ If normal mouse dragging does not work, enable **Settings → External Lyrics Click-through while locked is a known issue; Xwayland may help. +## Keep the Dynamic Island centered on Niri + +On Niri, changing the lyric window's width may not apply the new position requested by Electron. The optional repository helper [`scripts/niri-island-center.mjs`](https://github.com/SPlayer-Dev/SPlayer-Next/blob/dev/scripts/niri-island-center.mjs) keeps the Island at the **horizontal center of its own output**, preserving its vertical position. It is specific to Niri and does not change window behavior on KDE, GNOME, Windows, or macOS. + +Requirements: Node.js 22 or newer and Niri IPC with window `layout` information. Tested on Niri 26.04. Only floating windows with app ID `top.imsyy.splayer_next` and title `Dynamic Island` are matched; the main player and desktop lyric windows are excluded. + +Run from the repository directory: + +```bash +node scripts/niri-island-center.mjs +``` + +Keep the terminal open, enable the Island, and play a song. Press `Ctrl+C` to stop and restore the original positioning behavior. The helper does not modify player settings. + +If the Island does not float automatically, add this Niri rule: + +```kdl +window-rule { + match app-id=r"^top\.imsyy\.splayer_next$" title="^Dynamic Island$" + open-floating true +} +``` + +To start at login, copy the script to a permanent location: + +```bash +mkdir -p ~/.local/share/splayer-next +cp scripts/niri-island-center.mjs ~/.local/share/splayer-next/ +``` + +Add the following line to the Niri configuration, replacing `/home/your-user` with your home directory: + +```kdl +spawn-at-startup "node" "/home/your-user/.local/share/splayer-next/niri-island-center.mjs" +``` + +This takes effect at the next login; run the `node` command manually for the current session. Run only one instance. Stop the helper and remove the startup entry to uninstall it. + +The helper subscribes to Niri events without polling or spawning external commands. It reads current window, workspace, and output geometry for each correction and uses compositor logical coordinates for multiple outputs and scaling. Window sizes and mouse input regions are preserved. Horizontal dragging is recentered; vertical dragging still works. + +> [!NOTE] +> Niri 26.04 animates floating-window moves larger than 10 logical pixels. Corrections are split into consecutive small moves to avoid an additional spring animation during lyric transitions. This threshold is a Niri implementation detail; revalidate after upgrading Niri if transient movement returns. The helper does not fix Wayland click-through or cross-workspace stacking limitations. Missing layout information is skipped, and IPC connection errors stop the helper. + +Run the standalone tests with: + +```bash +node --test scripts/niri-island-center.test.mjs +``` + ## Global shortcuts On native Wayland, Electron registers global shortcuts through `xdg-desktop-portal`. New shortcuts should trigger a permission request when the app starts. KDE lists them under **System Settings → Keyboard → Shortcuts → SPlayer-Next**. diff --git a/docs/troubleshooting/wayland.md b/docs/troubleshooting/wayland.md index a2fe38c6a..6f4679533 100644 --- a/docs/troubleshooting/wayland.md +++ b/docs/troubleshooting/wayland.md @@ -119,6 +119,55 @@ window-rule { 锁定时鼠标穿透不生效是已知问题。可以尝试[使用 Xwayland](#使用-xwayland) +## Niri 下让灵动岛保持水平居中 + +Niri 下,歌词改变窗口宽度时,Electron 请求的新窗口坐标可能不生效。可以使用仓库中的可选脚本 [`scripts/niri-island-center.mjs`](https://github.com/SPlayer-Dev/SPlayer-Next/blob/dev/scripts/niri-island-center.mjs),由合成器将灵动岛保持在**所在显示器的水平中心**,保留纵向位置。它适用于 Niri,不适用于 KDE / GNOME,也不会修改 Windows、macOS 或其他桌面环境的窗口行为。 + +要求:Node.js 22 或更新版本,以及提供窗口 `layout` 信息的 Niri IPC。已在 Niri 26.04 上验证。脚本只匹配应用 ID `top.imsyy.splayer_next`、标题 `Dynamic Island` 的浮动窗口;不会移动播放器主窗口或桌面歌词窗口。 + +从仓库目录运行: + +```bash +node scripts/niri-island-center.mjs +``` + +保持终端运行,打开灵动岛并播放歌曲。按 `Ctrl+C` 停止,窗口即恢复原有定位行为。脚本不修改播放器设置。 + +若灵动岛没有自动浮动,在 Niri 配置中添加: + +```kdl +window-rule { + match app-id=r"^top\.imsyy\.splayer_next$" title="^Dynamic Island$" + open-floating true +} +``` + +需要登录后自动运行时,先将脚本复制到固定位置: + +```bash +mkdir -p ~/.local/share/splayer-next +cp scripts/niri-island-center.mjs ~/.local/share/splayer-next/ +``` + +在 Niri 配置中添加以下一行,将路径中的 `/home/your-user` 替换为自己的主目录: + +```kdl +spawn-at-startup "node" "/home/your-user/.local/share/splayer-next/niri-island-center.mjs" +``` + +该启动项在下次登录生效;当前会话仍可手动执行 `node` 命令。只运行一个实例。卸载时停止脚本并移除启动项即可。 + +脚本订阅 Niri 窗口事件,不定时轮询,不调用外部命令。每次修正都重新读取合成器的窗口、工作区和显示器信息,使用逻辑坐标处理多屏及缩放,并保持窗口原有宽高和鼠标区域。横向拖动后会重新居中,纵向拖动仍然有效。 + +> [!NOTE] +> Niri 26.04 对超过 10 个逻辑像素的浮动窗口位移添加动画。脚本将修正拆成连续的小步位移,避免歌词换行时叠加第二层弹簧动画。这个动画阈值属于 Niri 的实现细节,升级 Niri 后若再次出现瞬时晃动,需要重新验证。它不解决 Wayland 下的鼠标穿透或跨工作区置顶限制;IPC 缺少布局信息时跳过定位,连接错误时退出。 + +开发者可运行脚本的独立测试: + +```bash +node --test scripts/niri-island-center.test.mjs +``` + ## 全局快捷键 在原生 Wayland 下,Electron 的全局快捷键通过 `xdg-desktop-portal` 实现。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 84e36f846..6afa0e3e3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -64,5 +64,9 @@ export default defineConfig( "@typescript-eslint/no-require-imports": "off", }, }, + { + files: ["scripts/niri-island-center*.mjs"], + rules: { "@typescript-eslint/explicit-function-return-type": "off" }, + }, eslintConfigPrettier, ); diff --git a/scripts/niri-island-center.mjs b/scripts/niri-island-center.mjs new file mode 100644 index 000000000..66ea0287c --- /dev/null +++ b/scripts/niri-island-center.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +import { createConnection } from "node:net"; +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; + +const APP_ID = "top.imsyy.splayer_next"; +const TITLE = "Dynamic Island"; + +/** 只匹配灵动岛浮动窗口,避免移动同一应用的主窗口。 */ +export const isIsland = (window) => + window.app_id === APP_ID && window.title === TITLE && window.is_floating; + +/** 使用合成器的逻辑坐标居中,保留纵向位置,不混用 Electron 的缩放坐标。 */ +export function centerActions(windows, workspaces, outputs) { + const actions = []; + for (const window of windows) { + if (!isIsland(window)) continue; + const layout = window.layout; + const position = layout?.tile_pos_in_workspace_view; + const width = layout?.window_size?.[0]; + const offset = layout?.window_offset_in_tile?.[0]; + const workspace = workspaces.find((item) => item.id === window.workspace_id); + const outputWidth = outputs[workspace?.output]?.logical?.width; + // 旧版 IPC 缺少布局信息,或显示器正在拔插时不尝试定位。 + if (![position?.[0], width, offset, outputWidth].every(Number.isFinite)) continue; + if (width <= 0 || outputWidth <= 0) continue; + const delta = outputWidth / 2 - (position[0] + offset + width / 2); + // 容许整数坐标取整,避免奇数宽度或分数缩放导致反复移动。 + if (Math.abs(delta) < 1) continue; + // Niri 26.04 对不超过 10 个逻辑像素的移动不运行动画。 + // 同一 IPC 连接内连续发送小步位移,避免一次大幅回正产生第二层弹簧动画。 + let remaining = Math.round(delta); + while (remaining !== 0) { + const step = Math.sign(remaining) * Math.min(10, Math.abs(remaining)); + actions.push({ + Action: { + MoveFloatingWindow: { + id: window.id, + x: { AdjustFixed: step }, + y: { AdjustFixed: 0 }, + }, + }, + }); + remaining -= step; + } + } + return actions; +} + +/** 一个连接顺序读写请求,另一个连接订阅事件,不启动轮询或子进程。 */ +export async function startCentering(socketPath = process.env.NIRI_SOCKET) { + if (!socketPath) + throw new Error("NIRI_SOCKET is not set; run this helper inside a Niri session."); + const sockets = []; + const readers = []; + let stopped = false; + let dirty = false; + let running = false; + let targets = new Set(); + let resolveClosed; + let rejectClosed; + const closed = new Promise((resolve, reject) => { + resolveClosed = resolve; + rejectClosed = reject; + }); + // 初始化尚未返回控制器时,也要处理连接失败。 + closed.catch(() => {}); + + const stop = (error) => { + if (stopped) return; + stopped = true; + for (const reader of readers) reader.close(); + for (const socket of sockets) socket.destroy(); + if (error) rejectClosed(error); + else resolveClosed(); + }; + + const connect = async () => { + const socket = createConnection(socketPath); + sockets.push(socket); + socket.on("error", stop); + socket.on("close", () => stop()); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const reader = createInterface({ input: socket, crlfDelay: Infinity }); + readers.push(reader); + return { socket, lines: reader[Symbol.asyncIterator]() }; + }; + + try { + const commands = await connect(); + const request = async (message) => { + commands.socket.write(`${JSON.stringify(message)}\n`); + const line = await commands.lines.next(); + if (line.done) throw new Error("Niri IPC connection closed."); + const reply = JSON.parse(line.value); + if (reply.Err !== undefined) throw new Error(`Niri IPC: ${reply.Err}`); + return reply.Ok; + }; + + const reconcile = async () => { + if (running || stopped) return; + running = true; + try { + while (dirty && !stopped) { + dirty = false; + const { Windows: windows } = await request("Windows"); + targets = new Set(windows.filter(isIsland).map((window) => window.id)); + if (!targets.size) continue; + const { Workspaces: workspaces } = await request("Workspaces"); + const { Outputs: outputs } = await request("Outputs"); + for (const action of centerActions(windows, workspaces, outputs)) { + if (stopped) break; + await request(action); + } + } + } catch (error) { + if (!stopped) stop(error); + } finally { + running = false; + } + }; + + const events = await connect(); + events.socket.write('"EventStream"\n'); + const consume = async () => { + try { + for await (const line of events.lines) { + if (stopped) break; + const event = JSON.parse(line); + if (event.Err !== undefined) throw new Error(`Niri IPC: ${event.Err}`); + if ( + event.WindowsChanged || + event.WorkspacesChanged || + (event.WindowOpenedOrChanged && + (isIsland(event.WindowOpenedOrChanged.window) || + targets.has(event.WindowOpenedOrChanged.window.id))) || + (event.WindowClosed && targets.has(event.WindowClosed.id)) || + event.WindowLayoutsChanged?.changes.some(([id]) => targets.has(id)) + ) { + dirty = true; + void reconcile(); + } + } + stop(); + } catch (error) { + if (!stopped) stop(error); + } + }; + void consume(); + return { stop: () => stop(), closed }; + } catch (error) { + stop(error); + throw error; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const controller = await startCentering(); + process.once("SIGINT", controller.stop); + process.once("SIGTERM", controller.stop); + console.log("SPlayer Dynamic Island centering active for this Niri session."); + await controller.closed; + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/niri-island-center.test.mjs b/scripts/niri-island-center.test.mjs new file mode 100644 index 000000000..7c04b3771 --- /dev/null +++ b/scripts/niri-island-center.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:net"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { once } from "node:events"; +import { test } from "node:test"; +import { centerActions, startCentering } from "./niri-island-center.mjs"; + +const window = (x, width, extra = {}) => ({ + id: 7, + app_id: "top.imsyy.splayer_next", + title: "Dynamic Island", + is_floating: true, + workspace_id: 1, + layout: { + tile_pos_in_workspace_view: [x, 80], + window_size: [width, 60], + window_offset_in_tile: [0, 0], + }, + ...extra, +}); +const workspaces = [{ id: 1, output: "DP-1" }]; +const outputs = { "DP-1": { logical: { width: 2560, x: 0, y: 0, scale: 1 } } }; +const delta = (w, ws = workspaces, os = outputs) => + centerActions([w], ws, os).reduce( + (sum, action) => sum + action.Action.MoveFloatingWindow.x.AdjustFixed, + 0, + ); + +test("长短歌词按真实窗口宽度回到同一中心,保留纵向位置并指定窗口 ID", () => { + for (const width of [255, 711, 415, 1010, 400, 255]) { + const w = window(1025, width); + const actions = centerActions([w], workspaces, outputs); + for (const { + Action: { MoveFloatingWindow: action }, + } of actions) { + assert.equal(action.id, 7); + assert.deepEqual(action.y, { AdjustFixed: 0 }); + assert.ok(Math.abs(action.x.AdjustFixed) <= 10); + } + assert.ok(Math.abs(1025 + delta(w) + width / 2 - 1280) <= 0.5); + } +}); + +test("对已居中窗口和奇数宽度不重复发命令", () => { + assert.equal(delta(window(1080, 400)), 0); + assert.equal(delta(window(1080, 401)), 0); +}); + +test("多屏、负坐标和分数缩放使用窗口所属输出的逻辑宽度", () => { + const ws = [ + { id: 1, output: "left" }, + { id: 2, output: "right" }, + ]; + const os = { + left: { logical: { x: -1920, y: 0, width: 1536, scale: 1.25 } }, + right: { logical: { x: 0, y: 0, width: 1920, scale: 2 } }, + }; + assert.equal(delta(window(500, 400), ws, os), 68); + assert.equal(delta(window(500, 400, { workspace_id: 2 }), ws, os), 260); +}); + +test("边框偏移计入内容中心", () => { + const w = window(1080, 400); + w.layout.window_offset_in_tile = [4, 4]; + assert.equal(delta(w), -4); +}); + +test("不触碰主窗口、同名的其他应用或平铺窗口", () => { + for (const extra of [ + { title: "SPlayer-Next" }, + { app_id: "another-player" }, + { is_floating: false }, + ]) + assert.equal(delta(window(100, 400, extra)), 0); +}); + +test("旧版 IPC、不可见布局和拔掉的显示器安全跳过", () => { + assert.equal(delta(window(100, 400, { layout: undefined })), 0); + const w = window(100, 400); + w.layout.tile_pos_in_workspace_view = null; + assert.equal(delta(w), 0); + assert.equal(delta(window(100, 400), workspaces, {}), 0); + assert.equal(delta(window(100, 400), workspaces, { "DP-1": { logical: null } }), 0); +}); + +test("缺少 Niri 会话时不建立连接", async () => { + await assert.rejects(startCentering(""), /NIRI_SOCKET/); +}); + +async function withNiri(t, handler) { + const dir = await mkdtemp(join(tmpdir(), "splayer-niri-")); + const socketPath = join(dir, "ipc.sock"); + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + createInterface({ input: socket }).on("line", (line) => handler(JSON.parse(line), socket)); + }); + server.listen(socketPath); + await once(server, "listening"); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }); + return socketPath; +} + +test("快速连续换行合并请求,修正事件不形成循环,停止时断开连接", { timeout: 5000 }, async (t) => { + let current = window(1025, 255); + let stream; + let releaseWindows; + const actions = []; + let done; + const centered = new Promise((resolve) => { + done = resolve; + }); + const path = await withNiri(t, (request, socket) => { + if (request === "EventStream") { + stream = socket; + socket.write(`${JSON.stringify({ Ok: "Handled" })}\n`); + socket.write(`${JSON.stringify({ WindowsChanged: { windows: [current] } })}\n`); + } else if (request === "Windows") { + if (!releaseWindows) { + releaseWindows = () => socket.write(`${JSON.stringify({ Ok: { Windows: [current] } })}\n`); + current = window(1025, 711); + stream.write( + `${JSON.stringify({ WindowLayoutsChanged: { changes: [[7, current.layout]] } })}\n`, + ); + releaseWindows(); + } else socket.write(`${JSON.stringify({ Ok: { Windows: [current] } })}\n`); + } else if (request === "Workspaces") { + socket.write(`${JSON.stringify({ Ok: { Workspaces: workspaces } })}\n`); + } else if (request === "Outputs") { + socket.write(`${JSON.stringify({ Ok: { Outputs: outputs } })}\n`); + } else if (request.Action) { + actions.push(request.Action.MoveFloatingWindow); + current.layout.tile_pos_in_workspace_view[0] += + request.Action.MoveFloatingWindow.x.AdjustFixed; + socket.write(`${JSON.stringify({ Ok: "Handled" })}\n`); + stream.write( + `${JSON.stringify({ WindowLayoutsChanged: { changes: [[7, current.layout]] } })}\n`, + ); + if ( + Math.abs( + current.layout.tile_pos_in_workspace_view[0] + current.layout.window_size[0] / 2 - 1280, + ) <= 0.5 + ) + done(); + } + }); + const controller = await startCentering(path); + t.after(controller.stop); + await centered; + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(actions.length, 10); + assert.equal( + actions.reduce((sum, action) => sum + action.x.AdjustFixed, 0), + -100, + ); + controller.stop(); + await controller.closed; +}); + +test("不支持的 IPC 返回错误时停止,不持续发送失败命令", { timeout: 5000 }, async (t) => { + let requests = 0; + const path = await withNiri(t, (request, socket) => { + requests++; + if (request === "EventStream") { + socket.write(`${JSON.stringify({ Err: "unsupported request" })}\n`); + } + }); + const controller = await startCentering(path); + await assert.rejects(controller.closed, /unsupported request/); + assert.equal(requests, 1); +});