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
|
|
@ -103,6 +103,10 @@ module.exports = {
|
||||||
from: 'src/main/migrations',
|
from: 'src/main/migrations',
|
||||||
to: 'dist/main/migrations',
|
to: 'dist/main/migrations',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
from: 'src/main/assets',
|
||||||
|
to: 'dist/main/assets',
|
||||||
|
},
|
||||||
'!**/node_modules/*/{*.MD,*.md,README,readme}',
|
'!**/node_modules/*/{*.MD,*.md,README,readme}',
|
||||||
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
|
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
|
||||||
'!**/node_modules/*.d.ts',
|
'!**/node_modules/*.d.ts',
|
||||||
|
|
|
||||||
BIN
src/main/assets/icons/exit.png
Normal file
|
After Width: | Height: | Size: 223 B |
BIN
src/main/assets/icons/left.png
Normal file
|
After Width: | Height: | Size: 191 B |
BIN
src/main/assets/icons/like.png
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
src/main/assets/icons/menu.png
Normal file
|
After Width: | Height: | Size: 311 B |
BIN
src/main/assets/icons/menu@88.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/main/assets/icons/pause.png
Normal file
|
After Width: | Height: | Size: 953 B |
BIN
src/main/assets/icons/play.png
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
src/main/assets/icons/repeat.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
src/main/assets/icons/right.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
src/main/assets/icons/unlike.png
Normal file
|
After Width: | Height: | Size: 932 B |
|
|
@ -9,9 +9,11 @@ import {
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { release } from 'os'
|
import { release } from 'os'
|
||||||
import path, { join } from 'path'
|
import { join } from 'path'
|
||||||
import log from './log'
|
import log from './log'
|
||||||
import { initIpcMain } from './ipcMain'
|
import { initIpcMain } from './ipcMain'
|
||||||
|
import { createTray, YPMTray } from './tray'
|
||||||
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
|
|
@ -29,6 +31,7 @@ interface TypedElectronStore {
|
||||||
|
|
||||||
class Main {
|
class Main {
|
||||||
win: BrowserWindow | null = null
|
win: BrowserWindow | null = null
|
||||||
|
tray: YPMTray | null = null
|
||||||
store = new Store<TypedElectronStore>({
|
store = new Store<TypedElectronStore>({
|
||||||
defaults: {
|
defaults: {
|
||||||
window: {
|
window: {
|
||||||
|
|
@ -58,7 +61,8 @@ class Main {
|
||||||
this.createWindow()
|
this.createWindow()
|
||||||
this.handleAppEvents()
|
this.handleAppEvents()
|
||||||
this.handleWindowEvents()
|
this.handleWindowEvents()
|
||||||
initIpcMain(this.win)
|
this.createTray()
|
||||||
|
initIpcMain(this.win, this.tray)
|
||||||
this.initDevTools()
|
this.initDevTools()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +87,12 @@ class Main {
|
||||||
this.win.webContents.openDevTools()
|
this.win.webContents.openDevTools()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTray() {
|
||||||
|
if (isWindows || isLinux || isDev) {
|
||||||
|
this.tray = createTray(this.win!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createWindow() {
|
createWindow() {
|
||||||
const options: BrowserWindowConstructorOptions = {
|
const options: BrowserWindowConstructorOptions = {
|
||||||
title: 'YesPlayMusic',
|
title: 'YesPlayMusic',
|
||||||
|
|
@ -119,11 +129,11 @@ class Main {
|
||||||
|
|
||||||
// Window maximize and minimize
|
// Window maximize and minimize
|
||||||
this.win.on('maximize', () => {
|
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.on('unmaximize', () => {
|
||||||
this.win && this.win.webContents.send('is-maximized', false)
|
this.win && this.win.webContents.send(IpcChannels.IsMaximized, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Save window position
|
// Save window position
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import cache from './cache'
|
||||||
import log from './log'
|
import log from './log'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { APIs } from '../shared/CacheAPIs'
|
import { APIs } from '../shared/CacheAPIs'
|
||||||
|
import { YPMTray } from './tray'
|
||||||
|
|
||||||
const on = <T extends keyof IpcChannelsParams>(
|
const on = <T extends keyof IpcChannelsParams>(
|
||||||
channel: T,
|
channel: T,
|
||||||
|
|
@ -13,11 +14,16 @@ const on = <T extends keyof IpcChannelsParams>(
|
||||||
ipcMain.on(channel, listener)
|
ipcMain.on(channel, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initIpcMain(win: BrowserWindow | null, tray: YPMTray | null) {
|
||||||
|
initWindowIpcMain(win)
|
||||||
|
initTrayIpcMain(tray)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理需要win对象的事件
|
* 处理需要win对象的事件
|
||||||
* @param {BrowserWindow} win
|
* @param {BrowserWindow} win
|
||||||
*/
|
*/
|
||||||
export function initIpcMain(win: BrowserWindow | null) {
|
function initWindowIpcMain(win: BrowserWindow | null) {
|
||||||
on(IpcChannels.Minimize, () => {
|
on(IpcChannels.Minimize, () => {
|
||||||
win?.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缓存
|
* 清除API缓存
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
173
src/main/tray.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import reactQueryClient from '@/renderer/utils/reactQueryClient'
|
||||||
import Main from '@/renderer/components/Main'
|
import Main from '@/renderer/components/Main'
|
||||||
import TitleBar from '@/renderer/components/TitleBar'
|
import TitleBar from '@/renderer/components/TitleBar'
|
||||||
import Lyric from '@/renderer/components/Lyric'
|
import Lyric from '@/renderer/components/Lyric'
|
||||||
|
import IpcRendererReact from '@/renderer/IpcRendererReact'
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -23,6 +24,8 @@ const App = () => {
|
||||||
|
|
||||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||||
|
|
||||||
|
<IpcRendererReact />
|
||||||
|
|
||||||
{/* Devtool */}
|
{/* Devtool */}
|
||||||
<ReactQueryDevtools
|
<ReactQueryDevtools
|
||||||
initialIsOpen={false}
|
initialIsOpen={false}
|
||||||
|
|
|
||||||
52
src/renderer/IpcRendererReact.tsx
Normal 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
|
||||||
|
|
@ -10,8 +10,8 @@ import { resizeImage } from '@/renderer/utils/common'
|
||||||
import {
|
import {
|
||||||
State as PlayerState,
|
State as PlayerState,
|
||||||
Mode as PlayerMode,
|
Mode as PlayerMode,
|
||||||
RepeatMode as PlayerRepeatMode,
|
|
||||||
} from '@/renderer/utils/player'
|
} from '@/renderer/utils/player'
|
||||||
|
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
|
||||||
|
|
||||||
const PlayingTrack = () => {
|
const PlayingTrack = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -26,13 +26,16 @@ const PlayingTrack = () => {
|
||||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||||
const mutationLikeATrack = useMutationLikeATrack()
|
const mutationLikeATrack = useMutationLikeATrack()
|
||||||
|
|
||||||
|
const hasTrackListSource =
|
||||||
|
snappedPlayer.mode !== PlayerMode.FM && trackListSource?.type
|
||||||
|
|
||||||
const toAlbum = () => {
|
const toAlbum = () => {
|
||||||
const id = track?.al?.id
|
const id = track?.al?.id
|
||||||
if (id) navigate(`/album/${id}`)
|
if (id) navigate(`/album/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toTrackListSource = () => {
|
const toTrackListSource = () => {
|
||||||
if (trackListSource?.type)
|
if (hasTrackListSource)
|
||||||
navigate(`/${trackListSource.type}/${trackListSource.id}`)
|
navigate(`/${trackListSource.type}/${trackListSource.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +62,10 @@ const PlayingTrack = () => {
|
||||||
<div className='flex flex-col justify-center leading-tight'>
|
<div className='flex flex-col justify-center leading-tight'>
|
||||||
<div
|
<div
|
||||||
onClick={toTrackListSource}
|
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}
|
{track?.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { player } from '@/renderer/store'
|
import { player } from '@/renderer/store'
|
||||||
import SvgIcon from './SvgIcon'
|
import SvgIcon from './SvgIcon'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
|
import useIpcRenderer from '@/renderer/hooks/useIpcRenderer'
|
||||||
|
|
||||||
const Controls = () => {
|
const Controls = () => {
|
||||||
const [isMaximized, setIsMaximized] = useState(false)
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
||||||
useEffectOnce(() => {
|
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
||||||
return window.ipcRenderer?.on(IpcChannels.IsMaximized, (e, value) => {
|
setIsMaximized(value)
|
||||||
setIsMaximized(value)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const minimize = () => {
|
const minimize = () => {
|
||||||
|
|
|
||||||
14
src/renderer/hooks/useIpcRenderer.ts
Normal 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
|
||||||
35
src/renderer/ipcRenderer.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import './styles/accentColor.scss'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import pkg from '../../package.json'
|
import pkg from '../../package.json'
|
||||||
import ReactGA from 'react-ga4'
|
import ReactGA from 'react-ga4'
|
||||||
|
import { ipcRenderer } from '@/renderer/ipcRenderer'
|
||||||
|
|
||||||
ReactGA.initialize('G-KMJJCFZDKF')
|
ReactGA.initialize('G-KMJJCFZDKF')
|
||||||
|
|
||||||
|
|
@ -26,6 +27,8 @@ Sentry.init({
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcRenderer()
|
||||||
|
|
||||||
const container = document.getElementById('root') as HTMLElement
|
const container = document.getElementById('root') as HTMLElement
|
||||||
const root = ReactDOMClient.createRoot(container)
|
const root = ReactDOMClient.createRoot(container)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import axios from 'axios'
|
||||||
import { resizeImage } from './common'
|
import { resizeImage } from './common'
|
||||||
import { fetchPlaylistWithReactQuery } from '@/renderer/hooks/usePlaylist'
|
import { fetchPlaylistWithReactQuery } from '@/renderer/hooks/usePlaylist'
|
||||||
import { fetchAlbumWithReactQuery } from '@/renderer/hooks/useAlbum'
|
import { fetchAlbumWithReactQuery } from '@/renderer/hooks/useAlbum'
|
||||||
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
|
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||||
|
|
||||||
type TrackID = number
|
type TrackID = number
|
||||||
export enum TrackListSourceType {
|
export enum TrackListSourceType {
|
||||||
|
|
@ -32,11 +34,6 @@ export enum State {
|
||||||
Paused = 'paused',
|
Paused = 'paused',
|
||||||
Loading = 'loading',
|
Loading = 'loading',
|
||||||
}
|
}
|
||||||
export enum RepeatMode {
|
|
||||||
Off = 'off',
|
|
||||||
On = 'on',
|
|
||||||
One = 'one',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAY_PAUSE_FADE_DURATION = 200
|
const PLAY_PAUSE_FADE_DURATION = 200
|
||||||
|
|
||||||
|
|
@ -47,6 +44,7 @@ export class Player {
|
||||||
private _progress: number = 0
|
private _progress: number = 0
|
||||||
private _progressInterval: ReturnType<typeof setInterval> | undefined
|
private _progressInterval: ReturnType<typeof setInterval> | undefined
|
||||||
private _volume: number = 1 // 0 to 1
|
private _volume: number = 1 // 0 to 1
|
||||||
|
private _repeatMode: RepeatMode = RepeatMode.Off
|
||||||
|
|
||||||
state: State = State.Initializing
|
state: State = State.Initializing
|
||||||
mode: Mode = Mode.TrackList
|
mode: Mode = Mode.TrackList
|
||||||
|
|
@ -54,25 +52,26 @@ export class Player {
|
||||||
trackListSource: TrackListSource | null = null
|
trackListSource: TrackListSource | null = null
|
||||||
fmTrackList: TrackID[] = []
|
fmTrackList: TrackID[] = []
|
||||||
shuffle: boolean = false
|
shuffle: boolean = false
|
||||||
repeatMode: RepeatMode = RepeatMode.Off
|
|
||||||
fmTrack: Track | null = null
|
fmTrack: Track | null = null
|
||||||
|
|
||||||
init(params: { [key: string]: any }) {
|
init(params: { [key: string]: any }) {
|
||||||
if (params._track) this._track = params._track
|
if (params._track) this._track = params._track
|
||||||
if (params._trackIndex) this._trackIndex = params._trackIndex
|
if (params._trackIndex) this._trackIndex = params._trackIndex
|
||||||
if (params._volume) this._volume = params._volume
|
if (params._volume) this._volume = params._volume
|
||||||
|
if (params._repeatMode) this._repeatMode = params._repeatMode
|
||||||
if (params.state) this.trackList = params.state
|
if (params.state) this.trackList = params.state
|
||||||
if (params.mode) this.mode = params.mode
|
if (params.mode) this.mode = params.mode
|
||||||
if (params.trackList) this.trackList = params.trackList
|
if (params.trackList) this.trackList = params.trackList
|
||||||
if (params.trackListSource) this.trackListSource = params.trackListSource
|
if (params.trackListSource) this.trackListSource = params.trackListSource
|
||||||
if (params.fmTrackList) this.fmTrackList = params.fmTrackList
|
if (params.fmTrackList) this.fmTrackList = params.fmTrackList
|
||||||
if (params.shuffle) this.shuffle = params.shuffle
|
if (params.shuffle) this.shuffle = params.shuffle
|
||||||
if (params.repeatMode) this.repeatMode = params.repeatMode
|
|
||||||
if (params.fmTrack) this.fmTrack = params.fmTrack
|
if (params.fmTrack) this.fmTrack = params.fmTrack
|
||||||
|
|
||||||
this.state = State.Ready
|
this.state = State.Ready
|
||||||
this._playAudio(false) // just load the audio, not play
|
this._playAudio(false) // just load the audio, not play
|
||||||
this._initFM()
|
this._initFM()
|
||||||
|
|
||||||
|
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
|
||||||
}
|
}
|
||||||
|
|
||||||
get howler() {
|
get howler() {
|
||||||
|
|
@ -151,6 +150,14 @@ export class Player {
|
||||||
Howler.volume(this._volume)
|
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() {
|
private async _initFM() {
|
||||||
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
|
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { APIs } from './CacheAPIs'
|
import { APIs } from './CacheAPIs'
|
||||||
|
import { RepeatMode } from './playerDataTypes'
|
||||||
|
|
||||||
export const enum IpcChannels {
|
export const enum IpcChannels {
|
||||||
ClearAPICache = 'clear-api-cache',
|
ClearAPICache = 'clear-api-cache',
|
||||||
|
|
@ -9,8 +10,19 @@ export const enum IpcChannels {
|
||||||
GetApiCacheSync = 'get-api-cache-sync',
|
GetApiCacheSync = 'get-api-cache-sync',
|
||||||
DevDbExportJson = 'dev-db-export-json',
|
DevDbExportJson = 'dev-db-export-json',
|
||||||
CacheCoverColor = 'cache-cover-color',
|
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 {
|
export interface IpcChannelsParams {
|
||||||
[IpcChannels.ClearAPICache]: void
|
[IpcChannels.ClearAPICache]: void
|
||||||
[IpcChannels.Minimize]: void
|
[IpcChannels.Minimize]: void
|
||||||
|
|
@ -26,8 +38,26 @@ export interface IpcChannelsParams {
|
||||||
id: number
|
id: number
|
||||||
color: string
|
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 {
|
export interface IpcChannelsReturns {
|
||||||
[IpcChannels.ClearAPICache]: void
|
[IpcChannels.ClearAPICache]: void
|
||||||
[IpcChannels.Minimize]: void
|
[IpcChannels.Minimize]: void
|
||||||
|
|
@ -37,4 +67,13 @@ export interface IpcChannelsReturns {
|
||||||
[IpcChannels.GetApiCacheSync]: any
|
[IpcChannels.GetApiCacheSync]: any
|
||||||
[IpcChannels.DevDbExportJson]: void
|
[IpcChannels.DevDbExportJson]: void
|
||||||
[IpcChannels.CacheCoverColor]: 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
src/shared/playerDataTypes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum RepeatMode {
|
||||||
|
Off = 'off',
|
||||||
|
On = 'on',
|
||||||
|
One = 'one',
|
||||||
|
}
|
||||||