diff --git a/public/img/icons/pause.png b/public/img/icons/pause.png new file mode 100644 index 0000000..509d738 Binary files /dev/null and b/public/img/icons/pause.png differ diff --git a/public/img/icons/unlike.png b/public/img/icons/unlike.png new file mode 100644 index 0000000..a0afa24 Binary files /dev/null and b/public/img/icons/unlike.png differ diff --git a/src/background.js b/src/background.js index 4b7c86a..8a006bb 100644 --- a/src/background.js +++ b/src/background.js @@ -8,6 +8,13 @@ import { globalShortcut, nativeTheme, } from 'electron'; +import { + isWindows, + isMac, + isLinux, + isDevelopment, + isCreateTray, +} from '@/utils/platform'; import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; import { startNeteaseMusicApi } from './electron/services'; import { initIpcMain } from './electron/ipcMain.js'; @@ -18,6 +25,7 @@ import { createDockMenu } from './electron/dockMenu'; import { registerGlobalShortcut } from './electron/globalShortcut'; import { autoUpdater } from 'electron-updater'; import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; +import { EventEmitter } from 'events'; import express from 'express'; import expressProxy from 'express-http-proxy'; import Store from 'electron-store'; @@ -69,15 +77,10 @@ const closeOnLinux = (e, win, store) => { } }; -const isWindows = process.platform === 'win32'; -const isMac = process.platform === 'darwin'; -const isLinux = process.platform === 'linux'; -const isDevelopment = process.env.NODE_ENV === 'development'; - class Background { constructor() { this.window = null; - this.tray = null; + this.ypmTrayImpl = null; this.store = new Store({ windowWidth: { width: { type: 'number', default: 1440 }, @@ -324,8 +327,14 @@ class Background { }); this.handleWindowEvents(); + // create tray + if (isCreateTray) { + this.trayEventEmitter = new EventEmitter(); + this.ypmTrayImpl = createTray(this.window, this.trayEventEmitter); + } + // init ipcMain - initIpcMain(this.window, this.store); + initIpcMain(this.window, this.store, this.trayEventEmitter); // set proxy const proxyRules = this.store.get('proxy'); @@ -341,11 +350,6 @@ class Background { // create menu createMenu(this.window, this.store); - // create tray - if (isWindows || isLinux || isDevelopment) { - this.tray = createTray(this.window); - } - // create dock menu for macOS const createdDockMenu = createDockMenu(this.window); if (createDockMenu && app.dock) app.dock.setMenu(createdDockMenu); diff --git a/src/electron/ipcMain.js b/src/electron/ipcMain.js index 64e5168..9d0d42e 100644 --- a/src/electron/ipcMain.js +++ b/src/electron/ipcMain.js @@ -4,12 +4,73 @@ import { registerGlobalShortcut } from '@/electron/globalShortcut'; import cloneDeep from 'lodash/cloneDeep'; import shortcuts from '@/utils/shortcuts'; import { createMenu } from './menu'; +import { isCreateTray, isMac } from '@/utils/platform'; const clc = require('cli-color'); const log = text => { console.log(`${clc.blueBright('[ipcMain.js]')} ${text}`); }; +const exitAsk = (e, win) => { + e.preventDefault(); //阻止默认行为 + dialog + .showMessageBox({ + type: 'info', + title: 'Information', + cancelId: 2, + defaultId: 0, + message: '确定要关闭吗?', + buttons: ['最小化', '直接退出'], + }) + .then(result => { + if (result.response == 0) { + e.preventDefault(); //阻止默认行为 + win.minimize(); //调用 最小化实例方法 + } else if (result.response == 1) { + win = null; + //app.quit(); + app.exit(); //exit()直接关闭客户端,不会执行quit(); + } + }) + .catch(err => { + log(err); + }); +}; + +const exitAskWithoutMac = (e, win) => { + e.preventDefault(); //阻止默认行为 + dialog + .showMessageBox({ + type: 'info', + title: 'Information', + cancelId: 2, + defaultId: 0, + message: '确定要关闭吗?', + buttons: ['最小化到托盘', '直接退出'], + checkboxLabel: '记住我的选择', + }) + .then(result => { + if (result.checkboxChecked && result.response !== 2) { + win.webContents.send( + 'rememberCloseAppOption', + result.response === 0 ? 'minimizeToTray' : 'exit' + ); + } + + if (result.response === 0) { + e.preventDefault(); //阻止默认行为 + win.hide(); //调用 最小化实例方法 + } else if (result.response === 1) { + win = null; + //app.quit(); + app.exit(); //exit()直接关闭客户端,不会执行quit(); + } + }) + .catch(err => { + log(err); + }); +}; + const client = require('discord-rich-presence')('818936529484906596'); /** @@ -58,7 +119,7 @@ function parseSourceStringToList(sourceString) { return sourceString.split(',').map(s => s.trim()); } -export function initIpcMain(win, store) { +export function initIpcMain(win, store, trayEventEmitter) { ipcMain.handle('unblock-music', async (_, track, source) => { // 兼容 unblockneteasemusic 所使用的 api 字段 track.alias = track.alia || []; @@ -102,9 +163,9 @@ export function initIpcMain(win, store) { }); ipcMain.on('close', e => { - if (process.platform === 'darwin') { + if (isMac) { win.hide(); - exitAsk(e); + exitAsk(e, win); } else { let closeOpt = store.get('settings.closeAppOption'); if (closeOpt === 'exit') { @@ -115,7 +176,7 @@ export function initIpcMain(win, store) { e.preventDefault(); win.hide(); } else { - exitAskWithoutMac(e); + exitAskWithoutMac(e, win); } } }); @@ -214,63 +275,15 @@ export function initIpcMain(win, store) { registerGlobalShortcut(win, store); }); - const exitAsk = e => { - e.preventDefault(); //阻止默认行为 - dialog - .showMessageBox({ - type: 'info', - title: 'Information', - cancelId: 2, - defaultId: 0, - message: '确定要关闭吗?', - buttons: ['最小化', '直接退出'], - }) - .then(result => { - if (result.response == 0) { - e.preventDefault(); //阻止默认行为 - win.minimize(); //调用 最小化实例方法 - } else if (result.response == 1) { - win = null; - //app.quit(); - app.exit(); //exit()直接关闭客户端,不会执行quit(); - } - }) - .catch(err => { - log(err); - }); - }; - - const exitAskWithoutMac = e => { - e.preventDefault(); //阻止默认行为 - dialog - .showMessageBox({ - type: 'info', - title: 'Information', - cancelId: 2, - defaultId: 0, - message: '确定要关闭吗?', - buttons: ['最小化到托盘', '直接退出'], - checkboxLabel: '记住我的选择', - }) - .then(result => { - if (result.checkboxChecked && result.response !== 2) { - win.webContents.send( - 'rememberCloseAppOption', - result.response === 0 ? 'minimizeToTray' : 'exit' - ); - } - - if (result.response === 0) { - e.preventDefault(); //阻止默认行为 - win.hide(); //调用 最小化实例方法 - } else if (result.response === 1) { - win = null; - //app.quit(); - app.exit(); //exit()直接关闭客户端,不会执行quit(); - } - }) - .catch(err => { - log(err); - }); - }; + if (isCreateTray) { + ipcMain.on('updateTrayTooltip', (_, title) => { + trayEventEmitter.emit('updateTooltip', title); + }); + ipcMain.on('updateTrayPlayState', (_, isPlaying) => { + trayEventEmitter.emit('updatePlayState', isPlaying); + }); + ipcMain.on('updateTrayLikeState', (_, isLiked) => { + trayEventEmitter.emit('updateLikeState', isLiked); + }); + } } diff --git a/src/electron/tray.js b/src/electron/tray.js index b0ec291..6afa580 100644 --- a/src/electron/tray.js +++ b/src/electron/tray.js @@ -1,40 +1,30 @@ /* global __static */ import path from 'path'; import { app, nativeImage, Tray, Menu } from 'electron'; +import { isLinux } from '@/utils/platform'; -export function createTray(win) { - let icon = nativeImage - .createFromPath(path.join(__static, 'img/icons/menu@88.png')) - .resize({ - height: 20, - width: 20, - }); - - let contextMenu = Menu.buildFromTemplate([ - //setContextMenu破坏了预期的click行为 - //在linux下,鼠标左右键都会呼出contextMenu - //所以此处单独为linux添加一个 显示主面板 选项 - ...(process.platform === 'linux' - ? [ - { - label: '显示主面板', - click: () => { - win.show(); - }, - }, - { - type: 'separator', - }, - ] - : []), +function createMenuTemplate(win) { + return [ { - label: '播放/暂停', + label: '播放', icon: nativeImage.createFromPath( path.join(__static, 'img/icons/play.png') ), click: () => { win.webContents.send('play'); }, + id: 'play', + }, + { + label: '暂停', + icon: nativeImage.createFromPath( + path.join(__static, 'img/icons/pause.png') + ), + click: () => { + win.webContents.send('play'); + }, + id: 'pause', + visible: false, }, { label: '上一首', @@ -75,6 +65,19 @@ export function createTray(win) { click: () => { win.webContents.send('like'); }, + id: 'like', + }, + { + label: '取消喜欢', + icon: nativeImage.createFromPath( + path.join(__static, 'img/icons/unlike.png') + ), + accelerator: 'CmdOrCtrl+L', + click: () => { + win.webContents.send('like'); + }, + id: 'unlike', + visible: false, }, { label: '退出', @@ -86,28 +89,117 @@ export function createTray(win) { app.exit(); }, }, - ]); - let tray = new Tray(icon); - tray.setToolTip('YesPlayMusic'); + ]; +} + +// linux下托盘的实现方式比较迷惑 +// right-click无法在linux下使用 +// click在默认行为下会弹出一个contextMenu,里面的唯一选项才会调用click事件 +// setContextMenu应该是目前唯一能在linux下使用托盘菜单api +// 但是无法区分鼠标左右键 +class YPMTrayLinuxImpl { + constructor(tray, win, emitter) { + this.tray = tray; + this.win = win; + this.emitter = emitter; + this.template = undefined; + this.initTemplate(); + this.contextMenu = Menu.buildFromTemplate(this.template); + + this.tray.setContextMenu(this.contextMenu); + this.handleEvents(); + } - if (process.platform === 'linux') { - //linux下托盘的实现方式比较迷惑 - //right-click无法在linux下使用 - //click在默认行为下会弹出一个contextMenu,里面的唯一选项才会调用click事件 - //setContextMenu应该是目前唯一能在linux下使用托盘菜单api - //但是无法区分鼠标左右键 - - tray.setContextMenu(contextMenu); - } else { - //windows and macos - tray.on('click', () => { - win.show(); + initTemplate() { + //在linux下,鼠标左右键都会呼出contextMenu + //所以此处单独为linux添加一个 显示主面板 选项 + this.template = [ + { + label: '显示主面板', + click: () => { + this.win.show(); + }, + }, + { + type: 'separator', + }, + ].concat(createMenuTemplate(this.win)); + } + + handleEvents() { + this.emitter.on('updateTooltip', title => this.tray.setToolTip(title)); + this.emitter.on('updatePlayState', isPlaying => { + this.contextMenu.getMenuItemById('play').visible = !isPlaying; + this.contextMenu.getMenuItemById('pause').visible = isPlaying; + this.tray.setContextMenu(this.contextMenu); + }); + this.emitter.on('updateLikeState', isLiked => { + this.contextMenu.getMenuItemById('like').visible = !isLiked; + this.contextMenu.getMenuItemById('unlike').visible = isLiked; + this.tray.setContextMenu(this.contextMenu); + }); + } +} + +class YPMTrayWindowsImpl { + constructor(tray, win, emitter) { + this.tray = tray; + this.win = win; + this.emitter = emitter; + this.template = createMenuTemplate(win); + this.contextMenu = Menu.buildFromTemplate(this.template); + + this.isPlaying = false; + this.curDisplayPlaying = false; + + this.isLiked = false; + this.curDisplayLiked = false; + + this.handleEvents(); + } + + handleEvents() { + this.tray.on('click', () => { + this.win.show(); }); - tray.on('right-click', () => { - tray.popUpContextMenu(contextMenu); + this.tray.on('right-click', () => { + if (this.isPlaying !== this.curDisplayPlaying) { + this.curDisplayPlaying = this.isPlaying; + this.contextMenu.getMenuItemById('play').visible = !this.isPlaying; + this.contextMenu.getMenuItemById('pause').visible = this.isPlaying; + } + + if (this.isLiked !== this.curDisplayLiked) { + this.curDisplayLiked = this.isLiked; + this.contextMenu.getMenuItemById('like').visible = !this.isLiked; + this.contextMenu.getMenuItemById('unlike').visible = this.isLiked; + } + + this.tray.popUpContextMenu(this.contextMenu); }); + + this.emitter.on('updateTooltip', title => this.tray.setToolTip(title)); + this.emitter.on( + 'updatePlayState', + isPlaying => (this.isPlaying = isPlaying) + ); + this.emitter.on('updateLikeState', isLiked => (this.isLiked = isLiked)); } +} + +export function createTray(win, eventEmitter) { + let icon = nativeImage + .createFromPath(path.join(__static, 'img/icons/menu@88.png')) + .resize({ + height: 20, + width: 20, + }); + + let tray = new Tray(icon); + tray.setToolTip('YesPlayMusic'); - return tray; + return isLinux + ? new YPMTrayLinuxImpl(tray, win, eventEmitter) + : new YPMTrayWindowsImpl(tray, win, eventEmitter); } diff --git a/src/utils/Player.js b/src/utils/Player.js index 5bb7cd9..8054f69 100644 --- a/src/utils/Player.js +++ b/src/utils/Player.js @@ -9,6 +9,7 @@ import { personalFM, fmTrash } from '@/api/others'; import store from '@/store'; import { isAccountLoggedIn } from '@/utils/auth'; import { trackUpdateNowPlaying, trackScrobble } from '@/api/lastfm'; +import { isCreateTray } from '@/utils/platform'; const electron = process.env.IS_ELECTRON === true ? window.require('electron') : null; @@ -20,6 +21,21 @@ const excludeSaveKeys = [ '_personalFMNextLoading', ]; +function setTitle(track) { + document.title = track + ? `${track.name} · ${track.ar[0].name} - YesPlayMusic` + : 'YesPlayMusic'; + if (isCreateTray) { + ipcRenderer.send('updateTrayTooltip', document.title); + } +} + +function setTrayLikeState(isLiked) { + if (isCreateTray) { + ipcRenderer.send('updateTrayLikeState', isLiked); + } +} + export default class { constructor() { // 播放器状态 @@ -194,6 +210,12 @@ export default class { }); } } + _setPlaying(isPlaying) { + this._playing = isPlaying; + if (isCreateTray) { + ipcRenderer.send('updateTrayPlayState', this._playing); + } + } _setIntervals() { // 同步播放进度 // TODO: 如果 _progress 在别的地方被改变了,这个定时器会覆盖之前改变的值,是bug @@ -284,8 +306,9 @@ export default class { if (autoplay) { this.play(); if (this._currentTrack.name) { - document.title = `${this._currentTrack.name} · ${this._currentTrack.ar[0].name} - YesPlayMusic`; + setTitle(this._currentTrack); } + setTrayLikeState(store.state.liked.songs.includes(this.currentTrack.id)); } this.setOutputDevice(); this._howler.once('end', () => { @@ -539,7 +562,7 @@ export default class { const [trackID, index] = this._getNextTrack(); if (trackID === undefined) { this._howler?.stop(); - this._playing = false; + this._setPlaying(false); return false; } this.current = index; @@ -593,16 +616,16 @@ export default class { pause() { this._howler?.pause(); - this._playing = false; - document.title = 'YesPlayMusic'; + this._setPlaying(false); + setTitle(null); this._pauseDiscordPresence(this._currentTrack); } play() { if (this._howler?.playing()) return; this._howler?.play(); - this._playing = true; + this._setPlaying(true); if (this._currentTrack.name) { - document.title = `${this._currentTrack.name} · ${this._currentTrack.ar[0].name} - YesPlayMusic`; + setTitle(this._currentTrack); } this._playDiscordPresence(this._currentTrack, this.seek()); if (store.state.lastfm.key !== undefined) { @@ -737,10 +760,12 @@ export default class { sendSelfToIpcMain() { if (process.env.IS_ELECTRON !== true) return false; + let liked = store.state.liked.songs.includes(this.currentTrack.id); ipcRenderer.send('player', { playing: this.playing, - likedCurrentTrack: store.state.liked.songs.includes(this.currentTrack.id), + likedCurrentTrack: liked, }); + setTrayLikeState(liked); } switchRepeatMode() { diff --git a/src/utils/platform.js b/src/utils/platform.js new file mode 100644 index 0000000..1d803a4 --- /dev/null +++ b/src/utils/platform.js @@ -0,0 +1,6 @@ +export const isWindows = process.platform === 'win32'; +export const isMac = process.platform === 'darwin'; +export const isLinux = process.platform === 'linux'; +export const isDevelopment = process.env.NODE_ENV === 'development'; + +export const isCreateTray = isWindows || isLinux || isDevelopment;