diff --git a/.electron-builder.config.js b/.electron-builder.config.js index 72bb482..099a41a 100644 --- a/.electron-builder.config.js +++ b/.electron-builder.config.js @@ -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', diff --git a/src/main/assets/icons/exit.png b/src/main/assets/icons/exit.png new file mode 100644 index 0000000..01e21b2 Binary files /dev/null and b/src/main/assets/icons/exit.png differ diff --git a/src/main/assets/icons/left.png b/src/main/assets/icons/left.png new file mode 100644 index 0000000..9e49d7e Binary files /dev/null and b/src/main/assets/icons/left.png differ diff --git a/src/main/assets/icons/like.png b/src/main/assets/icons/like.png new file mode 100644 index 0000000..4bea102 Binary files /dev/null and b/src/main/assets/icons/like.png differ diff --git a/src/main/assets/icons/menu.png b/src/main/assets/icons/menu.png new file mode 100644 index 0000000..bd91a03 Binary files /dev/null and b/src/main/assets/icons/menu.png differ diff --git a/src/main/assets/icons/menu@88.png b/src/main/assets/icons/menu@88.png new file mode 100644 index 0000000..cc14a82 Binary files /dev/null and b/src/main/assets/icons/menu@88.png differ diff --git a/src/main/assets/icons/pause.png b/src/main/assets/icons/pause.png new file mode 100644 index 0000000..509d738 Binary files /dev/null and b/src/main/assets/icons/pause.png differ diff --git a/src/main/assets/icons/play.png b/src/main/assets/icons/play.png new file mode 100644 index 0000000..90537c8 Binary files /dev/null and b/src/main/assets/icons/play.png differ diff --git a/src/main/assets/icons/repeat.png b/src/main/assets/icons/repeat.png new file mode 100644 index 0000000..d4c3fc7 Binary files /dev/null and b/src/main/assets/icons/repeat.png differ diff --git a/src/main/assets/icons/right.png b/src/main/assets/icons/right.png new file mode 100644 index 0000000..50c2e75 Binary files /dev/null and b/src/main/assets/icons/right.png differ diff --git a/src/main/assets/icons/unlike.png b/src/main/assets/icons/unlike.png new file mode 100644 index 0000000..a0afa24 Binary files /dev/null and b/src/main/assets/icons/unlike.png differ diff --git a/src/main/index.ts b/src/main/index.ts index 657518b..8bd000b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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({ 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 diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index 832ca7c..a6cb2c3 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -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 = ( channel: T, @@ -13,11 +14,16 @@ const on = ( 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缓存 */ diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..cc46bd2 --- /dev/null +++ b/src/main/tray.ts @@ -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) +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9391d01..5ce7a6a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 = () => { + + {/* Devtool */} { + 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 diff --git a/src/renderer/components/Player.tsx b/src/renderer/components/Player.tsx index 35ffada..e831c3c 100644 --- a/src/renderer/components/Player.tsx +++ b/src/renderer/components/Player.tsx @@ -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 = () => {
{track?.name}
diff --git a/src/renderer/components/TitleBar.tsx b/src/renderer/components/TitleBar.tsx index b98f4d1..2de912a 100644 --- a/src/renderer/components/TitleBar.tsx +++ b/src/renderer/components/TitleBar.tsx @@ -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 = () => { diff --git a/src/renderer/hooks/useIpcRenderer.ts b/src/renderer/hooks/useIpcRenderer.ts new file mode 100644 index 0000000..eaf5f30 --- /dev/null +++ b/src/renderer/hooks/useIpcRenderer.ts @@ -0,0 +1,14 @@ +import { IpcChannelsParams, IpcChannelsReturns } from '@/shared/IpcChannels' +import { useEffect } from 'react' + + +const useIpcRenderer = ( + channcel: T, + listener: (event: any, value: IpcChannelsReturns[T]) => void +) => { + useEffect(() => { + return window.ipcRenderer?.on(channcel, listener) + }, []) +} + +export default useIpcRenderer diff --git a/src/renderer/ipcRenderer.ts b/src/renderer/ipcRenderer.ts new file mode 100644 index 0000000..ca5efe4 --- /dev/null +++ b/src/renderer/ipcRenderer.ts @@ -0,0 +1,35 @@ +import { player } from '@/renderer/store' +import { IpcChannels, IpcChannelsReturns, IpcChannelsParams } from '@/shared/IpcChannels' + +const on = ( + 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 + }) +} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index aa596aa..c04a110 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -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) diff --git a/src/renderer/utils/player.ts b/src/renderer/utils/player.ts index 51628e5..bfe7cf3 100644 --- a/src/renderer/utils/player.ts +++ b/src/renderer/utils/player.ts @@ -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 | 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() diff --git a/src/shared/IpcChannels.ts b/src/shared/IpcChannels.ts index 3708bca..98400e7 100644 --- a/src/shared/IpcChannels.ts +++ b/src/shared/IpcChannels.ts @@ -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 } diff --git a/src/shared/playerDataTypes.ts b/src/shared/playerDataTypes.ts new file mode 100644 index 0000000..55533b9 --- /dev/null +++ b/src/shared/playerDataTypes.ts @@ -0,0 +1,5 @@ +export enum RepeatMode { + Off = 'off', + On = 'on', + One = 'one', +}