Skip to content

Commit b1668f3

Browse files
authored
✨feat(mac/tray/lyric): 新增 macOS 状态栏歌词与托盘图标适配 (#843)
* ✨feat(mac/tray/lyric): 新增 macOS 状态栏歌词与托盘图标适配 - 新增 macOS 状态栏歌词功能:在 macOS 平台,歌词将显示在系统状态栏区域,并根据播放进度实时更新,同时通过 IPC 消息进>行控制和同步。 - 实现 macOS 风格托盘图标适配:引入 `getTrayIcon()` 和 `getMenuIcon()` 方法,支持 macOS 模板图像,实现托盘和菜单图标 随系统亮/暗模式自动切换,提供更好的视觉集成。 - 维护跨平台兼容性:通过 `isMac` 判断进行逻辑隔离,确保 Windows 及其他平台原有的任务栏歌词窗口功能不受影响。 ~ * 🐞fix(mac/tray/lyric): 修复审查 * 🦄refactor(mac/tray/lyric): 解耦 macOS 状态栏歌词至独立模块 - 将 macOS 状态栏歌词功能从 模块完全迁移至新增的 独立模块,显著提升模块化和可维护性。 - 现已专用于处理 Windows/Linux 平台的任务栏歌词逻辑,消除了平台间的耦合。 - 更新为根据操作系统平台动态加载 或 。 - 调整了 对 事件的响应逻辑,并移除了冗余的 监听器,确保托盘标题管理与歌词功能的独立控制。 - 歌词和播放状态数据(如 等)的底层 IPC 事件保持共享,无需修改渲染进程的数据发送逻辑。 * ✨feat(mac/tray/lyric): 为 macOS 状态栏歌词新增独立设置项 - 引入 macOS 专属状态栏歌词开关,并将其集成到 Electron Store 和 Pinia Store 中,实现独立配置。 - 在设置界面中为 macOS 平台添加了专属开关,以直观控制状态栏歌词的启用与禁用。 - 更新了主进程 IPC 逻辑 (),使其响应新的 macOS 状态栏歌词设置。 - 优化了托盘菜单歌词切换逻辑 (),使其根据平台智能路由 IPC 事件,并统一了切换提示文本。 - 实现了托盘菜单与设置界面开关状态的双向同步,确保用户操作的一致性。 * ✨feat(mac/tray/lyric): 优化状态栏歌词显示与 IPC 更新 - 统一了 macOS 歌词相关 IPC 的类型定义,将 移至 并导入 IPC payload 类型。 - 优化了 macOS 状态栏歌词更新逻辑: - 移除了歌词更新的 200ms 防抖延迟。 - 实现了下一行歌词提前 300ms 预显示,以提高流畅度。 - 引入了 50ms 间隔的歌词插值,以实现更平滑的进度更新,尤其是在主进程接收到渲染器防抖更新时。 - 为 macOS 状态栏歌词数据请求创建了专用的 IPC 通道 ,增强了关注点分离。 - 恢复了 中缺失的注释,以提高代码可读性和可维护性。 * 🐞fix(mac/tray/lyric): 响应审查建议,优化状态栏歌词同步并修复闪烁问题 - 修复了开启macOS状态栏歌词时,先快速闪烁logo和错误歌词文本的问题 - 移除了在启用歌词时立即清空歌词列表的逻辑 - 确保在新歌词数据到达后立即更新状态栏显示 - 优化了 setInterval 的使用方式: - 彻底废弃了 setInterval 内部的时间推移逻辑 - 转而完全依赖渲染进程通过 IPC 同步的精确时间来更新歌词进度 - 解决了 IPC 延迟导致的歌词抢跑问题 - 在主进程的 mac-statusbar:update-progress 处理器中引入了进度同步阈值 (100ms) - 只有当 IPC 传递的 currentTime 与主进程本地 macCurrentTime 差异超出阈值时才进行同步,以平滑歌词显示,避免微小跳动 - 修正了注释以提高代码可读性 - 更新了 ipcMain.on(taskbar:update-state) 处理器中的注释,使其更准确、清晰,并反映了当前逻辑 * ✨feat(mac/tray/lyric): 优化 macOS 状态栏歌词同步逻辑 - 引入 Date.now() 驱动的内部时间推进机制,减少 CPU 空转并提高同步健壮性: - 新增 macLastUpdateTime 变量,记录 macCurrentTime 上次更新时间戳。 - 修改 startInterpolation 函数的 setInterval 回调,通过计算 elapsedTime 累加到 macCurrentTime。 - 优化 IPC 进度同步为校准机制: - 在 mac-statusbar:update-progress IPC 事件处理器中,当 currentTime 与 macCurrentTime 差异超过阈值或非播放状态时,才进行时间校准。 - 确保 PROGRESS_SYNC_THRESHOLD_MS 阈值正常发挥作用,避免不必要的微小同步。 - 清理冗余调试日志,提升代码整洁度。
1 parent b68e105 commit b1668f3

15 files changed

Lines changed: 502 additions & 60 deletions

File tree

electron/main/ipc/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { isMac } from "../utils/config";
12
import initCacheIpc from "./ipc-cache";
23
import initFileIpc from "./ipc-file";
34
import initLyricIpc from "./ipc-lyric";
5+
import { initMacStatusBarIpc } from "./ipc-mac-statusbar";
46
import initMediaIpc from "./ipc-media";
57
import initMpvIpc from "./ipc-mpv";
68
import initProtocolIpc from "./ipc-protocol";
@@ -35,7 +37,11 @@ const initIpc = (): void => {
3537
initMediaIpc();
3638
initMpvIpc();
3739
initRendererLogIpc();
38-
initTaskbarIpc();
40+
if (isMac) {
41+
initMacStatusBarIpc();
42+
} else {
43+
initTaskbarIpc();
44+
}
3945
};
4046

4147
export default initIpc;
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { ipcMain } from "electron";
2+
import { useStore } from "../store";
3+
import { getMainTray } from "../tray";
4+
import mainWindow from "../windows/main-window";
5+
import { MacLyricLine } from "../../../src/types/lyric";
6+
import {
7+
UpdateLyricsPayload,
8+
UpdateProgressPayload,
9+
UpdateStatePayload,
10+
} from "../../../src/types/ipc";
11+
12+
13+
let macLyricLines: MacLyricLine[] = [];
14+
let macCurrentTime = 0;
15+
let macOffset = 0;
16+
let macIsPlaying = false;
17+
let macLastLyricIndex = -1; // 上一次显示的歌词行索引
18+
let interpolationTimer: NodeJS.Timeout | null = null; // 插值计时器
19+
let macLastUpdateTime: number = 0; // 上次更新 macCurrentTime 的时间戳
20+
21+
const LYRIC_UPDATE_INTERVAL = 50; // ms, 歌词更新频率
22+
const PROGRESS_SYNC_THRESHOLD_MS = 100; // ms, 进度同步阈值,如果误差超过此值才同步
23+
24+
/**
25+
* 停止插值计时器
26+
*/
27+
const stopInterpolation = () => {
28+
if (interpolationTimer) {
29+
clearInterval(interpolationTimer);
30+
interpolationTimer = null;
31+
}
32+
};
33+
34+
/**
35+
* 启动插值计时器
36+
*/
37+
const startInterpolation = (store: ReturnType<typeof useStore>) => {
38+
stopInterpolation(); // 先停止任何已存在的计时器
39+
macLastUpdateTime = Date.now(); // 在启动新的插值计时器时,重置 macLastUpdateTime
40+
interpolationTimer = setInterval(() => {
41+
const now = Date.now();
42+
const elapsedTime = now - macLastUpdateTime;
43+
macCurrentTime += elapsedTime;
44+
macLastUpdateTime = now;
45+
updateMacStatusBarLyric(store);
46+
}, LYRIC_UPDATE_INTERVAL);
47+
};
48+
49+
/**
50+
* 根据当前时间查找对应的歌词行索引
51+
*/
52+
const findCurrentLyricIndex = (
53+
currentTime: number,
54+
lyrics: MacLyricLine[],
55+
offset: number = 0,
56+
): number => {
57+
// 提前 300ms 显示下一行歌词,以看起来更舒服
58+
const targetTime = currentTime - offset + 300;
59+
let index = -1;
60+
61+
for (let i = lyrics.length - 1; i >= 0; i--) {
62+
if (lyrics[i].startTime <= targetTime) {
63+
index = i;
64+
break;
65+
}
66+
}
67+
68+
return index;
69+
};
70+
71+
/**
72+
* 更新 macOS 状态栏歌词(只在新行时才更新)
73+
*/
74+
const updateMacStatusBarLyric = (store: ReturnType<typeof useStore>) => {
75+
const tray = getMainTray();
76+
if (!tray) return;
77+
78+
const showWhenPaused = store.get("taskbar.showWhenPaused") ?? true;
79+
if (!macIsPlaying && !showWhenPaused) {
80+
// 如果不显示,则清空标题
81+
tray.setMacStatusBarLyricTitle("");
82+
return;
83+
}
84+
85+
// 如果歌词为空,则清空标题并返回
86+
if (macLyricLines.length === 0) {
87+
tray.setMacStatusBarLyricTitle("");
88+
return;
89+
}
90+
91+
const currentLyricIndex = findCurrentLyricIndex(macCurrentTime, macLyricLines, macOffset);
92+
93+
// 如果行索引没有变化,不更新
94+
if (currentLyricIndex === macLastLyricIndex) return;
95+
macLastLyricIndex = currentLyricIndex;
96+
97+
const currentLyric =
98+
currentLyricIndex !== -1
99+
? macLyricLines[currentLyricIndex].words
100+
.map((w) => w.word ?? "")
101+
.join("")
102+
.trim()
103+
: "";
104+
105+
tray.setMacStatusBarLyricTitle(currentLyric);
106+
};
107+
108+
export const initMacStatusBarIpc = () => {
109+
const store = useStore();
110+
111+
// 初始化时读取新的 macOS 专属设置
112+
const isMacosLyricEnabled = store.get("macos.statusBarLyric.enabled") ?? false;
113+
const tray = getMainTray();
114+
tray?.setMacStatusBarLyricShow(isMacosLyricEnabled); // 根据新设置初始化显示状态
115+
116+
// 新增 macOS 专属设置切换监听
117+
ipcMain.on("macos-lyric:toggle", (_event, show: boolean) => {
118+
store.set("macos.statusBarLyric.enabled", show); // 更新 store
119+
const tray = getMainTray();
120+
121+
// 触发 "mac-toggle-statusbar-lyric" 事件,让 ipc-tray 响应
122+
ipcMain.emit("mac-toggle-statusbar-lyric", null, show);
123+
124+
const mainWin = mainWindow.getWin(); // 获取主窗口实例
125+
if (mainWin && !mainWin.isDestroyed()) {
126+
// 发送更新给渲染进程,同步 Pinia store
127+
mainWin.webContents.send("setting:update-macos-lyric-enabled", show);
128+
if (show) {
129+
130+
mainWin.webContents.send("mac-statusbar:request-data"); // 请求新数据
131+
} else {
132+
tray?.setMacStatusBarLyricTitle(""); // 关闭时清空歌词
133+
stopInterpolation(); // 关闭时停止计时器
134+
}
135+
} else if (!show) {
136+
// 如果主窗口不可用且正在关闭,也清空歌词
137+
tray?.setMacStatusBarLyricTitle("");
138+
stopInterpolation(); // 关闭时停止计时器
139+
}
140+
});
141+
142+
ipcMain.on("taskbar:update-lyrics", (_event, lyrics: UpdateLyricsPayload) => {
143+
// 新歌词到达,更新数据并重置索引
144+
macLyricLines = lyrics.lines ?? [];
145+
macLastLyricIndex = -1;
146+
// 确保新歌词到达后立即更新状态栏显示
147+
const mainWin = mainWindow.getWin();
148+
if (mainWin && !mainWin.isDestroyed()) {
149+
updateMacStatusBarLyric(useStore());
150+
}
151+
});
152+
153+
// macOS 状态栏歌词专用进度更新
154+
ipcMain.on("mac-statusbar:update-progress", (_event, progress: UpdateProgressPayload) => {
155+
156+
// 进度到达,这是启动更新和插值的“门禁”
157+
if (progress.currentTime !== undefined) {
158+
const diff = Math.abs(progress.currentTime - macCurrentTime);
159+
160+
// 如果误差在阈值之内,并且当前正在播放,则不进行时间同步,让内部状态保持稳定
161+
// 否则,进行校准
162+
if (!(diff <= PROGRESS_SYNC_THRESHOLD_MS && macIsPlaying)) {
163+
macCurrentTime = progress.currentTime;
164+
macLastUpdateTime = Date.now(); // 校准时更新时间戳
165+
}
166+
}
167+
if (progress.offset !== undefined) {
168+
macOffset = progress.offset;
169+
}
170+
// 收到精确进度或误差较大同步后,立即更新一次歌词显示
171+
updateMacStatusBarLyric(store);
172+
// 如果此时是播放状态,确保插值器运行
173+
if (macIsPlaying) {
174+
startInterpolation(store);
175+
}
176+
});
177+
178+
ipcMain.on("taskbar:update-state", (_event, state: UpdateStatePayload) => {
179+
// 根据播放状态更新 macOS 状态栏歌词显示逻辑
180+
if (state.isPlaying !== undefined) {
181+
macIsPlaying = state.isPlaying;
182+
// 当歌曲暂停时:停止歌词更新计时器,并进行一次最终更新以显示当前歌词
183+
if (!macIsPlaying) {
184+
stopInterpolation();
185+
updateMacStatusBarLyric(store);
186+
}
187+
// 当歌曲开始播放时:不在这里直接启动歌词更新,而是等待 'mac-statusbar:update-progress' 事件
188+
// 该事件作为“门禁”,负责启动歌词更新的插值计时器,以确保与播放进度的同步
189+
}
190+
});
191+
192+
ipcMain.on("mac-statusbar:request-data", () => {
193+
// macOS 请求歌词数据,转发请求并等待响应
194+
const mainWin = mainWindow.getWin();
195+
if (mainWin && !mainWin.isDestroyed()) {
196+
mainWin.webContents.send("mac-statusbar:request-data");
197+
}
198+
});
199+
};

electron/main/ipc/ipc-taskbar.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import mainWindow from "../windows/main-window";
66
import taskbarLyricWindow from "../windows/taskbar-lyric-window";
77

88
const initTaskbarIpc = () => {
9+
// 在函数内部获取 store,确保在 app ready 事件之后
910
const store = useStore();
10-
1111
const envEnabled = store.get("taskbar.enabled");
12-
1312
const tray = getMainTray();
13+
1414
tray?.setTaskbarLyricShow(envEnabled);
1515

1616
if (envEnabled) {
@@ -20,8 +20,15 @@ const initTaskbarIpc = () => {
2020
ipcMain.on("taskbar:toggle", (_event, show: boolean) => {
2121
store.set("taskbar.enabled", show);
2222
const tray = getMainTray();
23+
2324
tray?.setTaskbarLyricShow(show);
2425

26+
const mainWin = mainWindow.getWin(); // 获取主窗口实例
27+
if (mainWin && !mainWin.isDestroyed()) {
28+
// 发送更新给渲染进程,同步 Pinia store
29+
mainWin.webContents.send("setting:update-taskbar-lyric-enabled", show);
30+
}
31+
2532
if (show) {
2633
taskbarLyricWindow.create();
2734
} else {

electron/main/ipc/ipc-tray.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { PlayModePayload } from "@shared";
22
import { ipcMain } from "electron";
33
import { getMainTray } from "../tray";
4-
import { appName } from "../utils/config";
4+
import { appName, isMac } from "../utils/config";
55
import lyricWindow from "../windows/lyric-window";
66

7+
// macOS 状态栏歌词开关状态
8+
let macStatusBarLyricEnabled = false;
9+
// 当前歌曲标题
10+
let currentSongTitle = appName;
11+
712
/**
813
* 托盘 IPC
914
*/
@@ -22,8 +27,11 @@ const initTrayIpc = (): void => {
2227
ipcMain.on("play-song-change", (_, options) => {
2328
let title = options?.title;
2429
if (!title) title = appName;
25-
// 更改标题
26-
tray?.setTitle(title);
30+
currentSongTitle = title;
31+
// 更改标题(仅在非 macOS 状态栏歌词模式下更新托盘标题)
32+
if (!isMac || !macStatusBarLyricEnabled) {
33+
tray?.setTitle(title);
34+
}
2735
tray?.setPlayName(title);
2836
});
2937

@@ -46,6 +54,17 @@ const initTrayIpc = (): void => {
4654
ipcMain.on("desktop-lyric:toggle-lock", (_, { lock }: { lock: boolean }) => {
4755
tray?.setDesktopLyricLock(lock);
4856
});
57+
58+
// macOS 状态栏歌词开关
59+
ipcMain.on("mac-toggle-statusbar-lyric", (_, show: boolean) => {
60+
if (!isMac) return;
61+
macStatusBarLyricEnabled = show;
62+
tray?.setMacStatusBarLyricShow(show);
63+
// 如果关闭,恢复显示歌曲标题
64+
if (!show) {
65+
tray?.setTitle(currentSongTitle);
66+
}
67+
});
4968
};
5069

5170
export default initTrayIpc;

electron/main/store/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export interface StoreType {
7373
downloadThreadCount?: number;
7474
/** 启用HTTP2下载 */
7575
enableDownloadHttp2?: boolean;
76+
/** macOS 专属设置 */
77+
macos: {
78+
/** 状态栏歌词 */
79+
statusBarLyric: {
80+
/** 是否启用 */
81+
enabled: boolean;
82+
};
83+
};
7684
/** 更新通道 */
7785
updateChannel?: "stable" | "nightly";
7886
}
@@ -106,6 +114,11 @@ export const useStore = () => {
106114
showWhenPaused: true,
107115
autoShrink: false,
108116
},
117+
macos: {
118+
statusBarLyric: {
119+
enabled: false,
120+
},
121+
},
109122
proxy: "",
110123
amllDbServer: defaultAMLLDbServer,
111124
cachePath: join(app.getPath("userData"), "DataCache"),

0 commit comments

Comments
 (0)