feat: 实现托盘菜单 (#1538)

* 从 v1 添加托盘相关图标

* feat: ipcRenderer事件

* feat: 托盘菜单实现

* 修复合并后的错误

* fix: 托盘图标的like

* 将 tray 相关的 ipc 放入ipcMain.ts

* update

* update

* feat: 设置托盘Tooltip

* fix

* fix: tray play/pause fade

* fix: 暂时将tray like与tooltip的设置移入Player组件中

useUserLikedTracksIDs 会在重新聚焦而不是切换track时触发,导致托盘无法实时更新数据

基于以上一点,在Player组件中有了一个用于设置tray数据的useEffect,故将tray tooltip的设置也放入其中,使tray的数据尽可能简单的和player数据保持一致

* 将部分ipcRenderer调用挪到单独的IpcRendererReact组件

* 移除SetTrayPlayState,复用已有channel

* update
This commit is contained in:
memorydream 2022-04-20 20:25:20 +08:00 committed by GitHub
parent b1fd51233a
commit ffdf66b57e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 392 additions and 19 deletions

View File

@ -103,6 +103,10 @@ module.exports = {
from: 'src/main/migrations',
to: 'dist/main/migrations',
},
{
from: 'src/main/assets',
to: 'dist/main/assets',
},
'!**/node_modules/*/{*.MD,*.md,README,readme}',
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
'!**/node_modules/*.d.ts',

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

View File

@ -9,9 +9,11 @@ import {
} from 'electron'
import Store from 'electron-store'
import { release } from 'os'
import path, { join } from 'path'
import { join } from 'path'
import log from './log'
import { initIpcMain } from './ipcMain'
import { createTray, YPMTray } from './tray'
import { IpcChannels } from '@/shared/IpcChannels'
const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
@ -29,6 +31,7 @@ interface TypedElectronStore {
class Main {
win: BrowserWindow | null = null
tray: YPMTray | null = null
store = new Store<TypedElectronStore>({
defaults: {
window: {
@ -58,7 +61,8 @@ class Main {
this.createWindow()
this.handleAppEvents()
this.handleWindowEvents()
initIpcMain(this.win)
this.createTray()
initIpcMain(this.win, this.tray)
this.initDevTools()
})
}
@ -83,6 +87,12 @@ class Main {
this.win.webContents.openDevTools()
}
createTray() {
if (isWindows || isLinux || isDev) {
this.tray = createTray(this.win!)
}
}
createWindow() {
const options: BrowserWindowConstructorOptions = {
title: 'YesPlayMusic',
@ -119,11 +129,11 @@ class Main {
// Window maximize and minimize
this.win.on('maximize', () => {
this.win && this.win.webContents.send('is-maximized', true)
this.win && this.win.webContents.send(IpcChannels.IsMaximized, true)
})
this.win.on('unmaximize', () => {
this.win && this.win.webContents.send('is-maximized', false)
this.win && this.win.webContents.send(IpcChannels.IsMaximized, false)
})
// Save window position

View File

@ -5,6 +5,7 @@ import cache from './cache'
import log from './log'
import fs from 'fs'
import { APIs } from '../shared/CacheAPIs'
import { YPMTray } from './tray'
const on = <T extends keyof IpcChannelsParams>(
channel: T,
@ -13,11 +14,16 @@ const on = <T extends keyof IpcChannelsParams>(
ipcMain.on(channel, listener)
}
export function initIpcMain(win: BrowserWindow | null, tray: YPMTray | null) {
initWindowIpcMain(win)
initTrayIpcMain(tray)
}
/**
* win对象的事件
* @param {BrowserWindow} win
*/
export function initIpcMain(win: BrowserWindow | null) {
function initWindowIpcMain(win: BrowserWindow | null) {
on(IpcChannels.Minimize, () => {
win?.minimize()
})
@ -32,6 +38,23 @@ export function initIpcMain(win: BrowserWindow | null) {
})
}
/**
* tray对象的事件
* @param {YPMTray} tray
*/
function initTrayIpcMain(tray: YPMTray | null) {
on(IpcChannels.SetTrayTooltip, (e, { text }) => tray?.setTooltip(text))
on(IpcChannels.SetTrayLikeState, (e, { isLiked }) =>
tray?.setLikeState(isLiked)
)
on(IpcChannels.Play, () => tray?.setPlayState(true))
on(IpcChannels.Pause, () => tray?.setPlayState(false))
on(IpcChannels.Repeat, (e, { mode }) => tray?.setRepeatMode(mode))
}
/**
* API缓存
*/

173
src/main/tray.ts Normal file
View File

@ -0,0 +1,173 @@
import path from 'path'
import {
app,
BrowserWindow,
Menu,
MenuItemConstructorOptions,
nativeImage,
Tray,
} from 'electron'
import { IpcChannels } from '@/shared/IpcChannels'
import { RepeatMode } from '@/shared/playerDataTypes'
const iconDirRoot =
process.env.NODE_ENV === 'development'
? path.join(process.cwd(), './src/main/assets/icons')
: path.join(__dirname, './assets/icons')
enum MenuItemIDs {
Play = 'play',
Pause = 'pause',
Like = 'like',
Unlike = 'unlike',
}
export interface YPMTray {
setTooltip(text: string): void
setLikeState(isLiked: boolean): void
setPlayState(isPlaying: boolean): void
setRepeatMode(mode: RepeatMode): void
}
function createNativeImage(filename: string) {
return nativeImage.createFromPath(path.join(iconDirRoot, filename))
}
function createMenuTemplate(win: BrowserWindow): MenuItemConstructorOptions[] {
let template: MenuItemConstructorOptions[] =
process.platform === 'linux'
? [
{
label: '显示主面板',
click: () => win.show(),
},
{
type: 'separator',
},
]
: []
return template.concat([
{
label: '播放',
click: () => win.webContents.send(IpcChannels.Play),
icon: createNativeImage('play.png'),
id: MenuItemIDs.Play,
},
{
label: '暂停',
click: () => win.webContents.send(IpcChannels.Pause),
icon: createNativeImage('pause.png'),
id: MenuItemIDs.Pause,
visible: false,
},
{
label: '上一首',
click: () => win.webContents.send(IpcChannels.Previous),
icon: createNativeImage('left.png'),
},
{
label: '下一首',
click: () => win.webContents.send(IpcChannels.Next),
icon: createNativeImage('right.png'),
},
{
label: '循环模式',
icon: createNativeImage('repeat.png'),
submenu: [
{
label: '关闭循环',
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.Off),
id: RepeatMode.Off,
checked: true,
type: 'radio',
},
{
label: '列表循环',
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.On),
id: RepeatMode.On,
type: 'radio',
},
{
label: '单曲循环',
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.One),
id: RepeatMode.One,
type: 'radio',
},
],
},
{
label: '加入喜欢',
click: () => win.webContents.send(IpcChannels.Like),
icon: createNativeImage('like.png'),
id: MenuItemIDs.Like,
},
{
label: '取消喜欢',
click: () => win.webContents.send(IpcChannels.Like),
icon: createNativeImage('unlike.png'),
id: MenuItemIDs.Unlike,
visible: false,
},
{
label: '退出',
click: () => app.exit(),
icon: createNativeImage('exit.png'),
},
])
}
class YPMTrayImpl implements YPMTray {
private _win: BrowserWindow
private _tray: Tray
private _template: MenuItemConstructorOptions[]
private _contextMenu: Menu
constructor(win: BrowserWindow) {
this._win = win
const icon = createNativeImage('menu@88.png').resize({
height: 20,
width: 20,
})
this._tray = new Tray(icon)
this._template = createMenuTemplate(this._win)
this._contextMenu = Menu.buildFromTemplate(this._template)
this._updateContextMenu()
this.setTooltip('YesPlayMusic')
this._tray.on('click', () => win.show())
}
private _updateContextMenu() {
this._tray.setContextMenu(this._contextMenu)
}
setTooltip(text: string) {
this._tray.setToolTip(text)
}
setLikeState(isLiked: boolean) {
this._contextMenu.getMenuItemById(MenuItemIDs.Like)!.visible = !isLiked
this._contextMenu.getMenuItemById(MenuItemIDs.Unlike)!.visible = isLiked
this._updateContextMenu()
}
setPlayState(isPlaying: boolean) {
this._contextMenu.getMenuItemById(MenuItemIDs.Play)!.visible = !isPlaying
this._contextMenu.getMenuItemById(MenuItemIDs.Pause)!.visible = isPlaying
this._updateContextMenu()
}
setRepeatMode(mode: RepeatMode) {
const item = this._contextMenu.getMenuItemById(mode)
if (item) {
item.checked = true
this._updateContextMenu()
}
}
}
export function createTray(win: BrowserWindow): YPMTray {
return new YPMTrayImpl(win)
}

View File

@ -7,6 +7,7 @@ import reactQueryClient from '@/renderer/utils/reactQueryClient'
import Main from '@/renderer/components/Main'
import TitleBar from '@/renderer/components/TitleBar'
import Lyric from '@/renderer/components/Lyric'
import IpcRendererReact from '@/renderer/IpcRendererReact'
const App = () => {
return (
@ -23,6 +24,8 @@ const App = () => {
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
<IpcRendererReact />
{/* Devtool */}
<ReactQueryDevtools
initialIsOpen={false}

View File

@ -0,0 +1,52 @@
import { IpcChannels } from '@/shared/IpcChannels'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/renderer/hooks/useUserLikedTracksIDs'
import { player } from '@/renderer/store'
import useIpcRenderer from '@/renderer/hooks/useIpcRenderer'
import { State as PlayerState } from '@/renderer/utils/player'
const IpcRendererReact = () => {
const [isPlaying, setIsPlaying] = useState(false)
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const trackIDRef = useRef(0)
// Liked songs ids
const { data: userLikedSongs } = useUserLikedTracksIDs()
const mutationLikeATrack = useMutationLikeATrack()
useIpcRenderer(IpcChannels.Like, () => {
const id = trackIDRef.current
id && mutationLikeATrack.mutate(id)
})
useEffect(() => {
trackIDRef.current = track?.id ?? 0
const text = track?.name ? `${track.name} - YesPlayMusic` : 'YesPlayMusic'
window.ipcRenderer?.send(IpcChannels.SetTrayTooltip, {
text,
})
document.title = text
}, [track])
useEffect(() => {
window.ipcRenderer?.send(IpcChannels.SetTrayLikeState, {
isLiked: userLikedSongs?.ids?.includes(track?.id ?? 0) ?? false,
})
}, [userLikedSongs, track])
useEffect(() => {
const playing = [PlayerState.Playing, PlayerState.Loading].includes(state)
if (isPlaying === playing) return
window.ipcRenderer?.send(playing ? IpcChannels.Play : IpcChannels.Pause)
setIsPlaying(playing)
}, [state])
return <></>
}
export default IpcRendererReact

View File

@ -10,8 +10,8 @@ import { resizeImage } from '@/renderer/utils/common'
import {
State as PlayerState,
Mode as PlayerMode,
RepeatMode as PlayerRepeatMode,
} from '@/renderer/utils/player'
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
const PlayingTrack = () => {
const navigate = useNavigate()
@ -26,13 +26,16 @@ const PlayingTrack = () => {
const { data: userLikedSongs } = useUserLikedTracksIDs()
const mutationLikeATrack = useMutationLikeATrack()
const hasTrackListSource =
snappedPlayer.mode !== PlayerMode.FM && trackListSource?.type
const toAlbum = () => {
const id = track?.al?.id
if (id) navigate(`/album/${id}`)
}
const toTrackListSource = () => {
if (trackListSource?.type)
if (hasTrackListSource)
navigate(`/${trackListSource.type}/${trackListSource.id}`)
}
@ -59,7 +62,10 @@ const PlayingTrack = () => {
<div className='flex flex-col justify-center leading-tight'>
<div
onClick={toTrackListSource}
className='line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-300'
className={classNames(
'line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 dark:text-white dark:decoration-gray-300',
hasTrackListSource && 'hover:underline'
)}
>
{track?.name}
</div>

View File

@ -1,14 +1,13 @@
import { player } from '@/renderer/store'
import SvgIcon from './SvgIcon'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/renderer/hooks/useIpcRenderer'
const Controls = () => {
const [isMaximized, setIsMaximized] = useState(false)
useEffectOnce(() => {
return window.ipcRenderer?.on(IpcChannels.IsMaximized, (e, value) => {
setIsMaximized(value)
})
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
setIsMaximized(value)
})
const minimize = () => {

View File

@ -0,0 +1,14 @@
import { IpcChannelsParams, IpcChannelsReturns } from '@/shared/IpcChannels'
import { useEffect } from 'react'
const useIpcRenderer = <T extends keyof IpcChannelsParams> (
channcel: T,
listener: (event: any, value: IpcChannelsReturns[T]) => void
) => {
useEffect(() => {
return window.ipcRenderer?.on(channcel, listener)
}, [])
}
export default useIpcRenderer

View File

@ -0,0 +1,35 @@
import { player } from '@/renderer/store'
import { IpcChannels, IpcChannelsReturns, IpcChannelsParams } from '@/shared/IpcChannels'
const on = <T extends keyof IpcChannelsParams>(
channel: T,
listener: (event: any, params: IpcChannelsReturns[T]) => void
) => {
window.ipcRenderer?.on(channel, listener)
}
export function ipcRenderer() {
on(IpcChannels.Play, () => {
player.play(true)
})
on(IpcChannels.Pause, () => {
player.pause(true)
})
on(IpcChannels.PlayOrPause, () => {
player.playOrPause()
})
on(IpcChannels.Next, () => {
player.nextTrack()
})
on(IpcChannels.Previous, () => {
player.prevTrack()
})
on(IpcChannels.Repeat, (e, mode) => {
player.repeatMode = mode
})
}

View File

@ -11,6 +11,7 @@ import './styles/accentColor.scss'
import App from './App'
import pkg from '../../package.json'
import ReactGA from 'react-ga4'
import { ipcRenderer } from '@/renderer/ipcRenderer'
ReactGA.initialize('G-KMJJCFZDKF')
@ -26,6 +27,8 @@ Sentry.init({
tracesSampleRate: 1.0,
})
ipcRenderer()
const container = document.getElementById('root') as HTMLElement
const root = ReactDOMClient.createRoot(container)

View File

@ -11,6 +11,8 @@ import axios from 'axios'
import { resizeImage } from './common'
import { fetchPlaylistWithReactQuery } from '@/renderer/hooks/usePlaylist'
import { fetchAlbumWithReactQuery } from '@/renderer/hooks/useAlbum'
import { IpcChannels } from '@/shared/IpcChannels'
import { RepeatMode } from '@/shared/playerDataTypes'
type TrackID = number
export enum TrackListSourceType {
@ -32,11 +34,6 @@ export enum State {
Paused = 'paused',
Loading = 'loading',
}
export enum RepeatMode {
Off = 'off',
On = 'on',
One = 'one',
}
const PLAY_PAUSE_FADE_DURATION = 200
@ -47,6 +44,7 @@ export class Player {
private _progress: number = 0
private _progressInterval: ReturnType<typeof setInterval> | undefined
private _volume: number = 1 // 0 to 1
private _repeatMode: RepeatMode = RepeatMode.Off
state: State = State.Initializing
mode: Mode = Mode.TrackList
@ -54,25 +52,26 @@ export class Player {
trackListSource: TrackListSource | null = null
fmTrackList: TrackID[] = []
shuffle: boolean = false
repeatMode: RepeatMode = RepeatMode.Off
fmTrack: Track | null = null
init(params: { [key: string]: any }) {
if (params._track) this._track = params._track
if (params._trackIndex) this._trackIndex = params._trackIndex
if (params._volume) this._volume = params._volume
if (params._repeatMode) this._repeatMode = params._repeatMode
if (params.state) this.trackList = params.state
if (params.mode) this.mode = params.mode
if (params.trackList) this.trackList = params.trackList
if (params.trackListSource) this.trackListSource = params.trackListSource
if (params.fmTrackList) this.fmTrackList = params.fmTrackList
if (params.shuffle) this.shuffle = params.shuffle
if (params.repeatMode) this.repeatMode = params.repeatMode
if (params.fmTrack) this.fmTrack = params.fmTrack
this.state = State.Ready
this._playAudio(false) // just load the audio, not play
this._initFM()
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
}
get howler() {
@ -151,6 +150,14 @@ export class Player {
Howler.volume(this._volume)
}
get repeatMode(): RepeatMode {
return this._repeatMode
}
set repeatMode(value) {
this._repeatMode = value
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
}
private async _initFM() {
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()

View File

@ -1,4 +1,5 @@
import { APIs } from './CacheAPIs'
import { RepeatMode } from './playerDataTypes'
export const enum IpcChannels {
ClearAPICache = 'clear-api-cache',
@ -9,8 +10,19 @@ export const enum IpcChannels {
GetApiCacheSync = 'get-api-cache-sync',
DevDbExportJson = 'dev-db-export-json',
CacheCoverColor = 'cache-cover-color',
SetTrayTooltip = 'set-tray-tooltip',
SetTrayLikeState = 'set-tray-like-state',
// 准备三个播放相关channel, 为 mpris 预留接口
Play = 'play',
Pause = 'pause',
PlayOrPause = 'play-or-pause',
Next = 'next',
Previous = 'previous',
Like = 'like',
Repeat = 'repeat',
}
// ipcMain.on params
export interface IpcChannelsParams {
[IpcChannels.ClearAPICache]: void
[IpcChannels.Minimize]: void
@ -26,8 +38,26 @@ export interface IpcChannelsParams {
id: number
color: string
}
[IpcChannels.SetTrayTooltip]: {
text: string
}
[IpcChannels.SetTrayLikeState]: {
isLiked: boolean
}
[IpcChannels.Play]: void
[IpcChannels.Pause]: void
[IpcChannels.PlayOrPause]: void
[IpcChannels.Next]: void
[IpcChannels.Previous]: void
[IpcChannels.Like]: {
isLiked: boolean
}
[IpcChannels.Repeat]: {
mode: RepeatMode
}
}
// ipcRenderer.on params
export interface IpcChannelsReturns {
[IpcChannels.ClearAPICache]: void
[IpcChannels.Minimize]: void
@ -37,4 +67,13 @@ export interface IpcChannelsReturns {
[IpcChannels.GetApiCacheSync]: any
[IpcChannels.DevDbExportJson]: void
[IpcChannels.CacheCoverColor]: void
[IpcChannels.SetTrayTooltip]: void
[IpcChannels.SetTrayLikeState]: void
[IpcChannels.Play]: void
[IpcChannels.Pause]: void
[IpcChannels.PlayOrPause]: void
[IpcChannels.Next]: void
[IpcChannels.Previous]: void
[IpcChannels.Like]: void
[IpcChannels.Repeat]: RepeatMode
}

View File

@ -0,0 +1,5 @@
export enum RepeatMode {
Off = 'off',
On = 'on',
One = 'one',
}