feat: mac添加音乐控制图标 , 托盘菜单项,更新播放状态和当前歌曲信息的逻辑

feat #105
This commit is contained in:
alger
2025-03-31 23:05:19 +08:00
parent e0d13057c3
commit 374a7a837d
13 changed files with 416 additions and 53 deletions

BIN
resources/icons/next.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

BIN
resources/icons/pause.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

BIN
resources/icons/play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

BIN
resources/icons/prev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -41,7 +41,12 @@ export default {
songCount: '{count} songs',
tray: {
show: 'Show',
quit: 'Quit'
quit: 'Quit',
playPause: 'Play/Pause',
prev: 'Previous',
next: 'Next',
pause: 'Pause',
play: 'Play'
},
language: 'Language'
};

View File

@@ -41,6 +41,11 @@ export default {
language: '语言',
tray: {
show: '显示',
quit: '退出'
quit: '退出',
playPause: '播放/暂停',
prev: '上一首',
next: '下一首',
pause: '暂停',
play: '播放'
}
};

View File

@@ -9,7 +9,7 @@ import { initializeConfig } from './modules/config';
import { initializeFileManager } from './modules/fileManager';
import { initializeFonts } from './modules/fonts';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeTray, updateTrayMenu } from './modules/tray';
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager } from './modules/window';
import { startMusicApi } from './server';
@@ -109,11 +109,21 @@ if (!isSingleInstance) {
// 更新主进程的语言设置
i18n.global.locale = locale;
// 更新托盘菜单
updateTrayMenu();
updateTrayMenu(mainWindow);
// 通知所有窗口语言已更改
mainWindow?.webContents.send('language-changed', locale);
});
// 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => {
updatePlayState(playing);
});
// 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => {
updateCurrentSong(song);
});
// 所有窗口关闭时的处理
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {

View File

@@ -1,82 +1,418 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron';
import {
app,
BrowserWindow,
Menu,
MenuItem,
MenuItemConstructorOptions,
nativeImage,
Tray
} from 'electron';
import { join } from 'path';
import type { Language } from '../../i18n/main';
import i18n from '../../i18n/main';
// 歌曲信息接口定义
interface SongInfo {
name: string;
song: {
artists: Array<{ name: string; [key: string]: any }>;
[key: string]: any;
};
[key: string]: any;
}
let tray: Tray | null = null;
// 为macOS状态栏添加控制图标
let playPauseTray: Tray | null = null;
let prevTray: Tray | null = null;
let nextTray: Tray | null = null;
let songTitleTray: Tray | null = null;
let isPlaying = false;
let currentSong: SongInfo | null = null;
const LANGUAGES: { label: string; value: Language }[] = [
{ label: '简体中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' }
];
// 更新播放状态
export function updatePlayState(playing: boolean) {
isPlaying = playing;
if (tray) {
updateTrayMenu(BrowserWindow.getAllWindows()[0]);
}
// 更新播放/暂停图标
updateStatusBarTray();
}
// 获取艺术家名称字符串
function getArtistString(song: SongInfo | null): string {
if (!song || !song.song || !song.song.artists) return '';
return song.song.artists.map((item) => item.name).join(' / ');
}
// 获取歌曲完整标题(歌曲名 - 艺术家)
function getSongTitle(song: SongInfo | null): string {
if (!song) return '未播放';
const artistStr = getArtistString(song);
return artistStr ? `${song.name} - ${artistStr}` : song.name;
}
// 更新当前播放的音乐信息
export function updateCurrentSong(song: SongInfo | null) {
currentSong = song;
if (tray) {
updateTrayMenu(BrowserWindow.getAllWindows()[0]);
}
// 更新状态栏歌曲信息
updateStatusBarTray();
}
// 确保 macOS 状态栏图标能正确显示
function getProperIconSize() {
// macOS 状态栏通常高度为22像素
const height = 18;
const width = 18;
return { width, height };
}
// 更新macOS状态栏图标
function updateStatusBarTray() {
if (process.platform !== 'darwin') return;
const iconSize = getProperIconSize();
// 更新歌曲标题显示
if (songTitleTray) {
if (currentSong) {
// 限制歌曲名显示长度,添加作者名
const songName = currentSong.name.slice(0, 10);
let title = songName;
const artistStr = getArtistString(currentSong);
// 如果有艺术家名称,添加到标题中
if (artistStr) {
title = `${songName} - ${artistStr.slice(0, 6)}${artistStr.length > 6 ? '..' : ''}`;
}
// 设置标题和提示
songTitleTray.setTitle(title, {
fontType: 'monospacedDigit' // 使用等宽字体以确保更好的可读性
});
// 完整信息放在tooltip中
const fullTitle = getSongTitle(currentSong);
songTitleTray.setToolTip(fullTitle);
console.log('更新状态栏歌曲显示:', title, '完整信息:', fullTitle);
} else {
songTitleTray.setTitle('未播放', {
fontType: 'monospacedDigit'
});
songTitleTray.setToolTip('未播放');
console.log('更新状态栏歌曲显示: 未播放');
}
}
// 更新播放/暂停图标
if (playPauseTray) {
// 使用PNG图标替代文本
const iconPath = join(
app.getAppPath(),
'resources/icons',
isPlaying ? 'pause.png' : 'play.png'
);
const icon = nativeImage.createFromPath(iconPath).resize(iconSize);
icon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
playPauseTray.setImage(icon);
playPauseTray.setToolTip(
isPlaying ? i18n.global.t('common.tray.pause') : i18n.global.t('common.tray.play')
);
}
}
// 导出更新菜单的函数
export function updateTrayMenu() {
export function updateTrayMenu(mainWindow: BrowserWindow) {
if (!tray) return;
// 创建一个上下文菜单
const contextMenu = Menu.buildFromTemplate([
{
label: i18n.global.t('common.tray.show'),
click: () => {
BrowserWindow.getAllWindows()[0]?.show();
}
},
{ type: 'separator' },
{
label: i18n.global.t('common.language'),
submenu: LANGUAGES.map(({ label, value }) => ({
// 如果是macOS设置TouchBar
if (process.platform === 'darwin') {
// macOS 上使用直接的控制按钮
const menu = new Menu();
// 当前播放的音乐信息
if (currentSong) {
menu.append(
new MenuItem({
label: getSongTitle(currentSong),
enabled: false,
type: 'normal'
})
);
menu.append(new MenuItem({ type: 'separator' }));
}
// 上一首、播放/暂停、下一首的菜单项
// 在macOS上临时使用文本菜单项替代图标确保基本功能正常
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.prev'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
}
})
);
menu.append(
new MenuItem({
label: i18n.global.t(isPlaying ? 'common.tray.pause' : 'common.tray.play'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
})
);
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.next'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
}
})
);
// 分隔符
menu.append(new MenuItem({ type: 'separator' }));
// 显示主窗口
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.show'),
type: 'normal',
click: () => {
mainWindow.show();
}
})
);
// 语言切换子菜单
const languageSubmenu = Menu.buildFromTemplate(
LANGUAGES.map(({ label, value }) => ({
label,
type: 'radio',
checked: i18n.global.locale === value,
click: () => {
// 更新主进程的语言设置
i18n.global.locale = value;
// 更新托盘菜单
updateTrayMenu();
// 直接通知主窗口
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
win.webContents.send('language-changed', value);
console.log('向窗口发送语言变更事件:', value);
}
updateTrayMenu(mainWindow);
mainWindow.webContents.send('language-changed', value);
}
}))
},
{ type: 'separator' },
{
label: i18n.global.t('common.tray.quit'),
click: () => {
app.quit();
}
}
]);
);
// 设置系统托盘图标的上下文菜单
tray.setContextMenu(contextMenu);
menu.append(
new MenuItem({
label: i18n.global.t('common.language'),
type: 'submenu',
submenu: languageSubmenu
})
);
// 退出按钮
menu.append(
new MenuItem({
label: i18n.global.t('common.tray.quit'),
type: 'normal',
click: () => {
app.quit();
}
})
);
tray.setContextMenu(menu);
} else {
// Windows 和 Linux 使用原来的菜单样式
const menuTemplate: MenuItemConstructorOptions[] = [
// 当前播放的音乐信息
...((currentSong
? [
{
label: getSongTitle(currentSong),
enabled: false,
type: 'normal'
},
{ type: 'separator' }
]
: []) as MenuItemConstructorOptions[]),
{
label: i18n.global.t('common.tray.show'),
type: 'normal',
click: () => {
mainWindow.show();
}
},
{ type: 'separator' },
{
label: i18n.global.t('common.tray.prev'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
}
},
{
label: i18n.global.t(isPlaying ? 'common.tray.pause' : 'common.tray.play'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
},
{
label: i18n.global.t('common.tray.next'),
type: 'normal',
click: () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
}
},
{ type: 'separator' },
{
label: i18n.global.t('common.language'),
type: 'submenu',
submenu: LANGUAGES.map(({ label, value }) => ({
label,
type: 'radio',
checked: i18n.global.locale === value,
click: () => {
i18n.global.locale = value;
updateTrayMenu(mainWindow);
mainWindow.webContents.send('language-changed', value);
}
}))
},
{ type: 'separator' },
{
label: i18n.global.t('common.tray.quit'),
type: 'normal',
click: () => {
app.quit();
}
}
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
}
}
// 初始化状态栏Tray
function initializeStatusBarTray(mainWindow: BrowserWindow) {
if (process.platform !== 'darwin') return;
const iconSize = getProperIconSize();
// 创建下一首按钮(调整顺序,先创建下一首按钮)
const nextIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'next.png'))
.resize(iconSize);
nextIcon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
nextTray = new Tray(nextIcon);
nextTray.setToolTip(i18n.global.t('common.tray.next'));
nextTray.on('click', () => {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
});
// 创建播放/暂停按钮
const playPauseIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', isPlaying ? 'pause.png' : 'play.png'))
.resize(iconSize);
playPauseIcon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
playPauseTray = new Tray(playPauseIcon);
playPauseTray.setToolTip(
isPlaying ? i18n.global.t('common.tray.pause') : i18n.global.t('common.tray.play')
);
playPauseTray.on('click', () => {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
});
// 创建上一首按钮(调整顺序,最后创建上一首按钮)
const prevIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'prev.png'))
.resize(iconSize);
prevIcon.setTemplateImage(true); // 设置为模板图片适合macOS深色/浅色模式
prevTray = new Tray(prevIcon);
prevTray.setToolTip(i18n.global.t('common.tray.prev'));
prevTray.on('click', () => {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
});
// 创建歌曲信息显示 - 需要使用特殊处理
const titleIcon = nativeImage
.createFromPath(join(app.getAppPath(), 'resources/icons', 'note.png'))
.resize({ width: 16, height: 16 });
titleIcon.setTemplateImage(true);
songTitleTray = new Tray(titleIcon);
// 初始化显示文本
const initialText = getSongTitle(currentSong);
// 在macOS上特别设置title来显示文本确保它能正确显示
songTitleTray.setTitle(initialText, {
fontType: 'monospacedDigit' // 使用等宽字体以确保更好的可读性
});
songTitleTray.setToolTip(initialText);
songTitleTray.on('click', () => {
mainWindow.show();
});
// 强制更新一次所有图标
updateStatusBarTray();
// 打印调试信息
console.log('状态栏初始化完成,歌曲显示标题:', initialText);
}
/**
* 初始化系统托盘
*/
export function initializeTray(iconPath: string, mainWindow: BrowserWindow) {
// 根据平台选择合适的图标
const iconSize = process.platform === 'darwin' ? 18 : 16;
const iconFile = process.platform === 'darwin' ? 'icon_16x16.png' : 'icon_16x16.png';
const trayIcon = nativeImage
.createFromPath(join(iconPath, 'icon_16x16.png'))
.resize({ width: 16, height: 16 });
.createFromPath(join(iconPath, iconFile))
.resize({ width: iconSize, height: iconSize });
tray = new Tray(trayIcon);
// 初始化菜单
updateTrayMenu();
// 设置托盘图标的提示文字
tray.setToolTip('Alger Music Player');
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
// 初始化菜单
updateTrayMenu(mainWindow);
// 初始化状态栏控制按钮 (macOS)
initializeStatusBarTray(mainWindow);
// 在 macOS 上,点击图标时显示菜单
if (process.platform === 'darwin') {
tray.on('click', () => {
if (tray) {
tray.popUpContextMenu();
}
});
} else {
// 在其他平台上,点击图标时切换窗口显示状态
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
}
return tray;
}

View File

@@ -20,6 +20,7 @@ declare global {
removeDownloadListeners: () => void;
onLanguageChanged: (callback: (locale: string) => void) => void;
invoke: (channel: string, ...args: any[]) => Promise<any>;
sendSong: (data: any) => void;
};
$message: any;
}

View File

@@ -11,6 +11,7 @@ const api = {
restart: () => ipcRenderer.send('restart'),
openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
sendSong: (data) => ipcRenderer.send('update-current-song', data),
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id),
// 歌词窗口关闭事件
onLyricWindowClosed: (callback: () => void) => {

View File

@@ -11,6 +11,7 @@
</template>
<script setup lang="ts">
import { cloneDeep } from 'lodash';
import { darkTheme, lightTheme } from 'naive-ui';
import { computed, nextTick, watch } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -90,6 +91,7 @@ onMounted(async () => {
// 使用 nextTick 确保 DOM 更新后再初始化
await nextTick();
initAudioListeners();
window.api.sendSong(cloneDeep(playerStore.playMusic));
}
});
</script>

View File

@@ -1,3 +1,4 @@
import { cloneDeep } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
@@ -394,6 +395,7 @@ const setupAudioListeners = () => {
// 监听播放
audioService.on('play', () => {
playerStore.setPlayMusic(true);
window.api.sendSong(cloneDeep(playerStore.playMusic));
clearInterval();
interval = window.setInterval(() => {
try {

View File

@@ -353,14 +353,15 @@ export const usePlayerStore = defineStore('player', () => {
const setIsPlay = (value: boolean) => {
isPlay.value = value;
play.value = value;
localStorage.setItem('isPlaying', value.toString());
// 通知主进程播放状态变化
window.electron?.ipcRenderer.send('update-play-state', value);
};
const setPlayMusic = async (value: boolean | SongResult) => {
if (typeof value === 'boolean') {
play.value = value;
isPlay.value = value;
localStorage.setItem('isPlaying', value.toString());
setIsPlay(value);
} else {
await handlePlayMusic(value);
play.value = true;