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
49 changes: 49 additions & 0 deletions docs/en/troubleshooting/wayland.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
49 changes: 49 additions & 0 deletions docs/troubleshooting/wayland.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 实现。
Expand Down
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
171 changes: 171 additions & 0 deletions scripts/niri-island-center.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading