feat: monorepo

This commit is contained in:
qier222 2022-05-12 02:45:43 +08:00
parent 4d54060a4f
commit 42089d4996
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
200 changed files with 1530 additions and 1521 deletions

View file

@ -0,0 +1,124 @@
/**
* @type {import('electron-builder').Configuration}
* @see https://www.electron.build/configuration/configuration
*/
module.exports = {
appId: 'com.qier222.yesplaymusic',
productName: 'YesPlayMusic',
copyright: 'Copyright © 2022 ${author}',
asar: true,
directories: {
output: 'release',
buildResources: 'build',
},
npmRebuild: false,
buildDependenciesFromSource: true,
files: ['./dist'],
publish: [
{
provider: 'github',
owner: 'qier222',
repo: 'YesPlayMusic',
vPrefixedTagName: true,
releaseType: 'draft',
},
],
win: {
target: [
{
target: 'nsis',
arch: ['x64'],
},
{
target: 'nsis',
arch: ['arm64'],
},
{
target: 'portable',
arch: ['x64'],
},
],
publisherName: 'qier222',
icon: 'build/icons/icon.ico',
},
nsis: {
oneClick: false,
perMachine: true,
allowToChangeInstallationDirectory: true,
deleteAppDataOnUninstall: true,
artifactName: '${productName}-${version}-${os}-${arch}-Setup.${ext}',
},
portable: {
artifactName: '${productName}-${version}-${os}-${arch}-Portable.${ext}',
},
mac: {
target: [
{
target: 'dmg',
arch: ['x64', 'arm64', 'universal'],
},
],
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
darkModeSupport: true,
category: 'public.app-category.music',
},
dmg: {
icon: 'build/icons/icon.icns',
},
linux: {
target: [
{
target: 'deb',
arch: ['x64', 'arm64', 'armv7l'],
},
{
target: 'AppImage',
arch: ['x64'],
},
{
target: 'snap',
arch: ['x64'],
},
{
target: 'pacman',
arch: ['x64'],
},
{
target: 'rpm',
arch: ['x64'],
},
{
target: 'tar.gz',
arch: ['x64'],
},
],
artifactName: '${productName}-${version}-${os}.${ext}',
category: 'Music',
icon: './build/icon.icns',
},
files: [
'dist/main/**/*',
'dist/renderer/**/*',
{
from: 'packages/electron/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',
'!**/node_modules/.bin',
'!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}',
'!.editorconfig',
'!**/._*',
'!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}',
'!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}',
'!**/{appveyor.yml,.travis.yml,circle.yml}',
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
'!**/*.{map,debug.min.js}',
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

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

288
packages/electron/cache.ts Normal file
View file

@ -0,0 +1,288 @@
import { db, Tables } from './db'
import type { FetchTracksResponse } from '@/shared/api/Track'
import { app } from 'electron'
import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams, APIsResponse } from '../shared/CacheAPIs'
import { TablesStructures } from './db'
class Cache {
constructor() {
//
}
async set(api: string, data: any, query: any = {}) {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.Likelist: {
if (!data) return
db.upsert(Tables.AccountData, {
id: api,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Track: {
if (!data.songs) return
const tracks = (data as FetchTracksResponse).songs.map(t => ({
id: t.id,
json: JSON.stringify(t),
updatedAt: Date.now(),
}))
db.upsertMany(Tables.Track, tracks)
break
}
case APIs.Album: {
if (!data.album) return
data.album.songs = data.songs
db.upsert(Tables.Album, {
id: data.album.id,
json: JSON.stringify(data.album),
updatedAt: Date.now(),
})
break
}
case APIs.Playlist: {
if (!data.playlist) return
db.upsert(Tables.Playlist, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artist: {
if (!data.artist) return
db.upsert(Tables.Artist, {
id: data.artist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.ArtistAlbum: {
if (!data.hotAlbums) return
db.createMany(
Tables.Album,
data.hotAlbums.map((a: Album) => ({
id: a.id,
json: JSON.stringify(a),
updatedAt: Date.now(),
}))
)
const modifiedData = {
...data,
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
}
db.upsert(Tables.ArtistAlbum, {
id: data.artist.id,
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case APIs.Lyric: {
if (!data.lrc) return
db.upsert(Tables.Lyric, {
id: query.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.CoverColor: {
if (!data.id || !data.color) return
if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
return
}
db.upsert(Tables.CoverColor, {
id: data.id,
color: data.color,
})
}
}
}
get<T extends keyof APIsParams>(api: T, params: any): any {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.Likelist: {
const data = db.find(Tables.AccountData, api)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Track: {
const ids: number[] = params?.ids
.split(',')
.map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = db.findMany(Tables.Track, ids)
if (tracksRaw.length !== ids.length) {
return
}
const tracks = ids.map(id => {
const track = tracksRaw.find(t => t.id === Number(id)) as any
return JSON.parse(track.json)
})
return {
code: 200,
songs: tracks,
privileges: {},
}
}
case APIs.Album: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Album, params.id)
if (data?.json)
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(data.json),
}
break
}
case APIs.Playlist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Playlist, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Artist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Artist, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.ArtistAlbum: {
if (isNaN(Number(params?.id))) return
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
case APIs.Lyric: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Lyric, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.CoverColor: {
if (isNaN(Number(params?.id))) return
return db.find(Tables.CoverColor, params.id)?.color
}
}
}
getForExpress(api: string, req: Request) {
// Get track detail cache
if (api === APIs.Track) {
const cache = this.get(api, req.query)
if (cache) {
log.debug(`[cache] Cache hit for ${req.path}`)
return cache
}
}
}
getAudio(fileName: string, res: Response) {
if (!fileName) {
return res.status(400).send({ error: 'No filename provided' })
}
const id = Number(fileName.split('-')[0])
try {
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
const audio = fs.readFileSync(path)
if (audio.byteLength === 0) {
db.delete(Tables.Audio, id)
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
}
res
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader(
'Content-Range',
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
)
.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
fs.statSync(path)
} catch (e) {
fs.mkdirSync(path)
}
const meta = await musicMetadata.parseBuffer(buffer)
const br =
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
if (url.includes('migu.cn')) source = 'migu'
if (url.includes('kuwo.cn')) source = 'kuwo'
if (url.includes('bilivideo.com')) source = 'bilibili'
// TODO: missing kugou qq joox
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert(Tables.Audio, {
id,
br,
type: type as TablesStructures[Tables.Audio]['type'],
source,
updatedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
})
}
}
export default new Cache()

188
packages/electron/db.ts Normal file
View file

@ -0,0 +1,188 @@
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import SQLite3 from 'better-sqlite3'
import log from './log'
import { createFileIfNotExist } from './utils'
const isDev = process.env.NODE_ENV === 'development'
export const enum Tables {
Track = 'Track',
Album = 'Album',
Artist = 'Artist',
Playlist = 'Playlist',
ArtistAlbum = 'ArtistAlbum',
Lyric = 'Lyric',
Audio = 'Audio',
AccountData = 'AccountData',
CoverColor = 'CoverColor',
}
interface CommonTableStructure {
id: number
json: string
updatedAt: number
}
export interface TablesStructures {
[Tables.Track]: CommonTableStructure
[Tables.Album]: CommonTableStructure
[Tables.Artist]: CommonTableStructure
[Tables.Playlist]: CommonTableStructure
[Tables.ArtistAlbum]: CommonTableStructure
[Tables.Lyric]: CommonTableStructure
[Tables.AccountData]: {
id: string
json: string
updatedAt: number
}
[Tables.Audio]: {
id: number
br: number
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
source:
| 'unknown'
| 'netease'
| 'migu'
| 'kuwo'
| 'kugou'
| 'youtube'
| 'qq'
| 'bilibili'
| 'joox'
updatedAt: number
}
[Tables.CoverColor]: {
id: number
color: string
}
}
type TableNames = keyof TablesStructures
class DB {
sqlite: SQLite3.Database
dbFilePath: string = path.resolve(
app.getPath('userData'),
'./api_cache/db.sqlite'
)
constructor() {
log.info('[db] Initializing database...')
createFileIfNotExist(this.dbFilePath)
this.sqlite = new SQLite3(this.dbFilePath, {
nativeBinding: path.join(
__dirname,
`./better_sqlite3_${process.arch}.node`
),
})
this.sqlite.pragma('auto_vacuum = FULL')
this.initTables()
log.info('[db] Database initialized')
}
initTables() {
const migration = fs.readFileSync(
isDev
? path.join(process.cwd(), './migrations/init.sql')
: path.join(__dirname, './migrations/init.sql'),
'utf8'
)
this.sqlite.exec(migration)
}
find<T extends TableNames>(
table: T,
key: TablesStructures[T]['id']
): TablesStructures[T] {
return this.sqlite
.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
.get(key)
}
findMany<T extends TableNames>(
table: T,
keys: TablesStructures[T]['id'][]
): TablesStructures[T][] {
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE ${idsQuery}`).all()
}
findAll<T extends TableNames>(table: T): TablesStructures[T][] {
return this.sqlite.prepare(`SELECT * FROM ${table}`).all()
}
create<T extends TableNames>(
table: T,
data: TablesStructures[T],
skipWhenExist: boolean = true
) {
if (skipWhenExist && db.find(table, data.id)) return
return this.sqlite.prepare(`INSERT INTO ${table} VALUES (?)`).run(data)
}
createMany<T extends TableNames>(
table: T,
data: TablesStructures[T][],
skipWhenExist: boolean = true
) {
const valuesQuery = Object.keys(data[0])
.map(key => `:${key}`)
.join(', ')
const insert = this.sqlite.prepare(
`INSERT ${
skipWhenExist ? 'OR IGNORE' : ''
} INTO ${table} VALUES (${valuesQuery})`
)
const insertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => insert.run(row))
})
insertMany(data)
}
upsert<T extends TableNames>(table: T, data: TablesStructures[T]) {
const valuesQuery = Object.keys(data)
.map(key => `:${key}`)
.join(', ')
return this.sqlite
.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`)
.run(data)
}
upsertMany<T extends TableNames>(table: T, data: TablesStructures[T][]) {
const valuesQuery = Object.keys(data[0])
.map(key => `:${key}`)
.join(', ')
const upsert = this.sqlite.prepare(
`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`
)
const upsertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => upsert.run(row))
})
upsertMany(data)
}
delete<T extends TableNames>(table: T, key: TablesStructures[T]['id']) {
return this.sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key)
}
deleteMany<T extends TableNames>(
table: T,
keys: TablesStructures[T]['id'][]
) {
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
return this.sqlite.prepare(`DELETE FROM ${table} WHERE ${idsQuery}`).run()
}
truncate<T extends TableNames>(table: T) {
return this.sqlite.prepare(`DELETE FROM ${table}`).run()
}
vacuum() {
return this.sqlite.prepare('VACUUM').run()
}
}
export const db = new DB()

229
packages/electron/index.ts Normal file
View file

@ -0,0 +1,229 @@
import './preload' // must be first
import './sentry'
import './server'
import {
BrowserWindow,
BrowserWindowConstructorOptions,
app,
shell,
} from 'electron'
import Store from 'electron-store'
import { release } from 'os'
import { join } from 'path'
import log from './log'
import { initIpcMain } from './ipcMain'
import { createTray, YPMTray } from './tray'
import { IpcChannels } from '@/shared/IpcChannels'
import { createTaskbar, Thumbar } from './windowsTaskbar'
import { Store as State, initialState } from '@/shared/store'
const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const isDev = process.env.NODE_ENV === 'development'
export interface TypedElectronStore {
window: {
width: number
height: number
x?: number
y?: number
}
settings: State['settings']
}
class Main {
win: BrowserWindow | null = null
tray: YPMTray | null = null
thumbar: Thumbar | null = null
store = new Store<TypedElectronStore>({
defaults: {
window: {
width: 1440,
height: 960,
},
settings: initialState.settings,
},
})
constructor() {
log.info('[index] Main process start')
// Disable GPU Acceleration for Windows 7
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
// Set application name for Windows 10+ notifications
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
// Make sure the app only run on one instance
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
app.whenReady().then(() => {
log.info('[index] App ready')
this.createWindow()
this.handleAppEvents()
this.handleWindowEvents()
this.createTray()
this.createThumbar()
initIpcMain(this.win, this.tray, this.thumbar, this.store)
this.initDevTools()
})
}
initDevTools() {
if (!isDev || !this.win) return
// Install devtool extension
const {
default: installExtension,
REACT_DEVELOPER_TOOLS,
REDUX_DEVTOOLS,
// eslint-disable-next-line @typescript-eslint/no-var-requires
} = require('electron-devtools-installer')
installExtension(REACT_DEVELOPER_TOOLS.id).catch((err: any) =>
log.info('An error occurred: ', err)
)
installExtension(REDUX_DEVTOOLS.id).catch((err: any) =>
log.info('An error occurred: ', err)
)
this.win.webContents.openDevTools()
}
createTray() {
if (isWindows || isLinux || isDev) {
this.tray = createTray(this.win!)
}
}
createThumbar() {
if (isWindows) this.thumbar = createTaskbar(this.win!)
}
createWindow() {
const options: BrowserWindowConstructorOptions = {
title: 'YesPlayMusic',
webPreferences: {
preload: join(__dirname, 'rendererPreload.js'),
},
width: this.store.get('window.width'),
height: this.store.get('window.height'),
minWidth: 1080,
minHeight: 720,
vibrancy: 'fullscreen-ui',
titleBarStyle: 'hiddenInset',
frame: !(isWindows || isLinux), // TODO: 适用于linux下独立的启用开关
}
if (this.store.get('window')) {
options.x = this.store.get('window.x')
options.y = this.store.get('window.y')
}
this.win = new BrowserWindow(options)
// Web server
const url = `http://localhost:${process.env.ELECTRON_WEB_SERVER_PORT}`
this.win.loadURL(url)
// Make all links open with the browser, not with the application
this.win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) shell.openExternal(url)
return { action: 'deny' }
})
this.disableCORS()
}
disableCORS() {
if (!this.win) return
function UpsertKeyValue(obj, keyToChange, value) {
const keyToChangeLower = keyToChange.toLowerCase()
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
obj[key] = value
// Done
return
}
}
// Insert at end instead
obj[keyToChange] = value
}
this.win.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
const { requestHeaders, url } = details
UpsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*'])
if (url.includes('googlevideo.com')) {
requestHeaders['Sec-Fetch-Mode'] = 'no-cors'
requestHeaders['Sec-Fetch-Dest'] = 'audio'
requestHeaders['Range'] = 'bytes=0-'
}
callback({ requestHeaders })
}
)
this.win.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
const { responseHeaders } = details
UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*'])
UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*'])
callback({
responseHeaders,
})
}
)
}
handleWindowEvents() {
if (!this.win) return
// Window maximize and minimize
this.win.on('maximize', () => {
this.win && this.win.webContents.send(IpcChannels.IsMaximized, true)
})
this.win.on('unmaximize', () => {
this.win && this.win.webContents.send(IpcChannels.IsMaximized, false)
})
// Save window position
const saveBounds = () => {
const bounds = this.win?.getBounds()
if (bounds) {
this.store.set('window', bounds)
}
}
this.win.on('resized', saveBounds)
this.win.on('moved', saveBounds)
}
handleAppEvents() {
app.on('window-all-closed', () => {
this.win = null
if (!isMac) app.quit()
})
app.on('second-instance', () => {
if (!this.win) return
// Focus on the main window if the user tried to open another
if (this.win.isMinimized()) this.win.restore()
this.win.focus()
})
app.on('activate', () => {
const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length) {
allWindows[0].focus()
} else {
this.createWindow()
}
})
}
}
new Main()

View file

@ -0,0 +1,155 @@
import { BrowserWindow, ipcMain, app } from 'electron'
import { db, Tables } from './db'
import { IpcChannels, IpcChannelsParams } from '../shared/IpcChannels'
import cache from './cache'
import log from './log'
import fs from 'fs'
import Store from 'electron-store'
import { TypedElectronStore } from './index'
import { APIs } from '../shared/CacheAPIs'
import { YPMTray } from './tray'
import { Thumbar } from './windowsTaskbar'
const on = <T extends keyof IpcChannelsParams>(
channel: T,
listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void
) => {
ipcMain.on(channel, listener)
}
export function initIpcMain(
win: BrowserWindow | null,
tray: YPMTray | null,
thumbar: Thumbar | null,
store: Store<TypedElectronStore>
) {
initWindowIpcMain(win)
initTrayIpcMain(tray)
initTaskbarIpcMain(thumbar)
initStoreIpcMain(store)
initOtherIpcMain()
}
/**
* win对象的事件
* @param {BrowserWindow} win
*/
function initWindowIpcMain(win: BrowserWindow | null) {
on(IpcChannels.Minimize, () => {
win?.minimize()
})
on(IpcChannels.MaximizeOrUnmaximize, () => {
if (!win) return
win.isMaximized() ? win.unmaximize() : win.maximize()
})
on(IpcChannels.Close, () => {
app.exit()
})
}
/**
* tray对象的事件
* @param {YPMTray} tray
*/
function initTrayIpcMain(tray: YPMTray | null) {
on(IpcChannels.SetTrayTooltip, (e, { text }) => tray?.setTooltip(text))
on(IpcChannels.Like, (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))
}
/**
* thumbar对象的事件
* @param {Thumbar} thumbar
*/
function initTaskbarIpcMain(thumbar: Thumbar | null) {
on(IpcChannels.Play, () => thumbar?.setPlayState(true))
on(IpcChannels.Pause, () => thumbar?.setPlayState(false))
}
/**
* electron-store的事件
* @param {Store<TypedElectronStore>} store
*/
function initStoreIpcMain(store: Store<TypedElectronStore>) {
/**
* Main
*/
on(IpcChannels.SyncSettings, (event, settings) => {
store.set('settings', settings)
})
}
/**
*
*/
function initOtherIpcMain() {
/**
* API缓存
*/
on(IpcChannels.ClearAPICache, () => {
db.truncate(Tables.Track)
db.truncate(Tables.Album)
db.truncate(Tables.Artist)
db.truncate(Tables.Playlist)
db.truncate(Tables.ArtistAlbum)
db.truncate(Tables.AccountData)
db.truncate(Tables.Audio)
db.vacuum()
})
/**
* Get API cache
*/
on(IpcChannels.GetApiCacheSync, (event, args) => {
const { api, query } = args
const data = cache.get(api, query)
event.returnValue = data
})
/**
*
*/
on(IpcChannels.CacheCoverColor, (event, args) => {
const { id, color } = args
cache.set(APIs.CoverColor, { id, color })
})
/**
* tables到json文件便table大小dev环境
*/
if (process.env.NODE_ENV === 'development') {
on(IpcChannels.DevDbExportJson, () => {
const tables = [
Tables.ArtistAlbum,
Tables.Playlist,
Tables.Album,
Tables.Track,
Tables.Artist,
Tables.Audio,
Tables.AccountData,
Tables.Lyric,
]
tables.forEach(table => {
const data = db.findAll(table)
fs.writeFile(
`./tmp/${table}.json`,
JSON.stringify(data),
function (err) {
if (err) {
return console.log(err)
}
console.log('The file was saved!')
}
)
})
})
}
}

29
packages/electron/log.ts Normal file
View file

@ -0,0 +1,29 @@
/** By default, it writes logs to the following locations:
* on Linux: ~/.config/{app name}/logs/{process type}.log
* on macOS: ~/Library/Logs/{app name}/{process type}.log
* on Windows: %USERPROFILE%\AppData\Roaming\{app name}\logs\{process type}.log
* @see https://www.npmjs.com/package/electron-log
*/
import log from 'electron-log'
import pc from 'picocolors'
Object.assign(console, log.functions)
log.variables.process = 'main'
log.transports.console.format = `[{process}] ${pc.dim(
'{h}:{i}:{s}{scope}'
)} {level} {text}`
log.transports.file.level = 'info'
log.info(
`\n\n██╗ ██╗███████╗███████╗██████╗ ██╗ █████╗ ██╗ ██╗███╗ ███╗██╗ ██╗███████╗██╗ ██████╗
\n`
)
export default log
log.info(`[logger] logger initialized`)

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS "AccountData" ("id" text NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "ArtistAlbum" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Artist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"srouce" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Lyric" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" integer NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Playlist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Track" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "CoverColor" ("id" integer NOT NULL,"color" text NOT NULL, PRIMARY KEY (id));

View file

@ -0,0 +1,63 @@
{
"name": "electron",
"private": true,
"version": "2.0.0",
"main": "./index.js",
"scripts": {
"post-install": "node scripts/build.sqlite3.js",
"dev": "node scripts/build.main.mjs --watch",
"build": "node scripts/build.main.mjs",
"test:types": "tsc --noEmit --project src/main/tsconfig.json",
"lint": "eslint --ext .ts,.js ./",
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"dependencies": {
"@sentry/node": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@unblockneteasemusic/rust-napi": "^0.3.0-pre.1",
"NeteaseCloudMusicApi": "^4.5.12",
"better-sqlite3": "7.5.1",
"change-case": "^4.1.2",
"cookie-parser": "^1.4.6",
"electron-log": "^4.4.6",
"electron-store": "^8.0.1",
"express": "^4.18.1",
"fast-folder-size": "^1.6.1"
},
"devDependencies": {
"@electron/universal": "1.2.1",
"@types/better-sqlite3": "^7.5.0",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13",
"@types/express-fileupload": "^1.2.2",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.21.0",
"@vitejs/plugin-react": "^1.3.1",
"axios": "^0.27.2",
"cross-env": "^7.0.3",
"dotenv": "^16.0.0",
"electron": "^18.2.2",
"electron-builder": "^23.0.3",
"electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.7",
"electron-releases": "^3.1009.0",
"esbuild": "^0.14.38",
"eslint": "*",
"express-fileupload": "^1.3.1",
"minimist": "^1.2.6",
"music-metadata": "^7.12.3",
"open-cli": "^7.0.1",
"ora": "^6.1.0",
"picocolors": "^1.0.0",
"prettier": "*",
"prettier-plugin-tailwindcss": "^0.1.10",
"typescript": "*",
"wait-on": "^6.0.1"
},
"resolutions": {
"@electron/universal": "1.2.1"
}
}

View file

@ -0,0 +1,13 @@
import log from './log'
import path from 'path'
import { app } from 'electron'
import { createDirIfNotExist } from './utils'
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
createDirIfNotExist(devUserDataPath)
app.setPath('appData', devUserDataPath)
}
log.info(`[index] userData path: ${app.getPath('userData')}`)

View file

@ -0,0 +1,36 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { IpcChannels } from '@/shared/IpcChannels'
const { contextBridge, ipcRenderer } = require('electron')
const log = require('electron-log')
const isDev = process.env.NODE_ENV === 'development'
log.transports.file.level = 'info'
log.variables.process = 'renderer'
log.transports.console.format = isDev
? `[{process}] {text}`
: `[{process}] {h}:{i}:{s}{scope} {level} {text}`
contextBridge.exposeInMainWorld('log', log)
contextBridge.exposeInMainWorld('ipcRenderer', {
sendSync: ipcRenderer.sendSync,
send: ipcRenderer.send,
on: (
channel: IpcChannels,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) => {
ipcRenderer.on(channel, listener)
return () => {
ipcRenderer.removeListener(channel, listener)
}
},
})
contextBridge.exposeInMainWorld('env', {
isElectron: true,
isEnableTitlebar:
process.platform === 'win32' || process.platform === 'linux',
isLinux: process.platform === 'linux',
isMac: process.platform === 'darwin',
isWin: process.platform === 'win32',
})

View file

@ -0,0 +1,113 @@
import { build } from 'esbuild'
import ora from 'ora'
import { builtinModules } from 'module'
import electron from 'electron'
import { spawn } from 'child_process'
import path from 'path'
import waitOn from 'wait-on'
import dotenv from 'dotenv'
import pc from 'picocolors'
import minimist from 'minimist'
const env = dotenv.config({
path: path.resolve(process.cwd(), '../../.env'),
})
const envForEsbuild = {}
Object.entries(env.parsed || {}).forEach(([key, value]) => {
envForEsbuild[`process.env.${key}`] = `"${value}"`
})
console.log(envForEsbuild)
const argv = minimist(process.argv.slice(2))
const TAG = '[script/build.main.ts]'
const spinner = ora(`${TAG} Main Process Building...`)
const options = {
entryPoints: ['./index.ts', './rendererPreload.ts'],
outdir: './dist',
platform: 'node',
format: 'cjs',
bundle: true,
sourcemap: true,
define: envForEsbuild,
external: [
...builtinModules.filter(
x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)
),
'electron',
'NeteaseCloudMusicApi',
'better-sqlite3',
'@unblockneteasemusic/rust-napi',
],
}
const runApp = () => {
return spawn(electron, [path.resolve(process.cwd(), './dist/index.js')], {
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'development',
},
})
}
if (argv.watch) {
waitOn(
{
resources: [
`http://127.0.0.1:${process.env.ELECTRON_WEB_SERVER_PORT}/index.html`,
],
timeout: 5000,
},
err => {
if (err) {
console.log(err)
process.exit(1)
} else {
let child
build({
...options,
watch: {
onRebuild(error) {
if (error) {
console.error(pc.red('Rebuild Failed:'), error)
} else {
console.log(pc.green('Rebuild Succeeded'))
if (child) child.kill()
child = runApp()
}
},
},
sourcemap: true,
}).then(() => {
console.log(pc.yellow(`⚡ Run App`))
if (child) child.kill()
child = runApp()
})
}
}
)
} else {
spinner.start()
build({
...options,
define: {
...options.define,
'process.env.NODE_ENV': '"production"',
},
minify: true,
})
.then(() => {
console.log(TAG, pc.green('Main Process Build Succeeded.'))
})
.catch(error => {
console.log(
`\n${TAG} ${pc.red('Main Process Build Failed')}\n`,
error,
'\n'
)
})
.finally(() => {
spinner.stop()
})
}

View file

@ -0,0 +1,135 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { rebuild } = require('electron-rebuild')
const fs = require('fs')
const minimist = require('minimist')
const pc = require('picocolors')
const releases = require('electron-releases')
const pkg = require(`${process.cwd()}/package.json`)
const axios = require('axios')
const { execSync } = require('child_process')
const path = require('path')
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
const betterSqlite3Version = pkg.dependencies['better-sqlite3'].replaceAll(
'^',
''
)
const electronModuleVersion = releases.find(r =>
r.version.includes(electronVersion)
)?.deps?.modules
const argv = minimist(process.argv.slice(2))
const isWin = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const projectDir = path.resolve(process.cwd(), '../../')
if (!fs.existsSync(`${projectDir}/src/main/dist`)) {
fs.mkdirSync(`${projectDir}/src/main/dist`, {
recursive: true,
})
}
const download = async arch => {
console.log(pc.cyan(`Downloading for ${arch}...`))
if (!electronModuleVersion) {
console.log(pc.red('No electron module version found! Skip download.'))
return false
}
const dir = `${projectDir}/tmp/better-sqlite3`
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
const zipFileName = `${fileName}.tar.gz`
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {
recursive: true,
})
}
try {
await axios({
method: 'get',
url,
responseType: 'stream',
}).then(response => {
response.data.pipe(fs.createWriteStream(`${dir}/${zipFileName}`))
return true
})
} catch (e) {
console.log(pc.red('Download failed! Skip download.'))
return false
}
try {
execSync(`tar -xvzf ${dir}/${zipFileName} -C ${dir}`)
} catch (e) {
console.log(pc.red('Extract failed! Skip extract.'))
return false
}
try {
fs.copyFileSync(
`${dir}/build/Release/better_sqlite3.node`,
`${projectDir}/src/main/dist/better_sqlite3_${arch}.node`
)
} catch (e) {
console.log(pc.red('Copy failed! Skip copy.', e))
return false
}
try {
fs.rmSync(`${dir}/build`, { recursive: true, force: true })
} catch (e) {
console.log(pc.red('Delete failed! Skip delete.'))
return false
}
return true
}
const build = async arch => {
const downloaded = await download(arch)
if (downloaded) return
console.log(pc.cyan(`Building for ${arch}...`))
await rebuild({
// projectRootPath: projectDir,
// buildPath: process.cwd(),
projectRootPath: projectDir,
buildPath: process.cwd(),
electronVersion,
arch,
onlyModules: ['better-sqlite3'],
force: true,
})
.then(() => {
console.info('Build succeeded')
fs.copyFileSync(
`${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`,
`${projectDir}/src/main/dist/better_sqlite3_${arch}.node`
)
})
.catch(e => {
console.error(pc.red('Build failed!'))
console.error(pc.red(e))
})
}
const main = async () => {
if (argv.x64 || argv.arm64 || argv.arm) {
if (argv.x64) await build('x64')
if (argv.arm64) await build('arm64')
if (argv.arm) await build('arm')
} else {
if (isWin || isMac) {
await build('x64')
await build('arm64')
} else if (isLinux) {
await build('x64')
await build('arm64')
await build('arm')
}
}
}
main()

View file

@ -0,0 +1,19 @@
import * as Sentry from '@sentry/node'
import * as Tracing from '@sentry/tracing'
import pkg from '../../package.json'
import log from './log'
log.info(`[sentry] sentry initializing`)
Sentry.init({
dsn: 'https://2aaaa67f1c3d4d6baefafa5d58fcf340@o436528.ingest.sentry.io/6274637',
release: `yesplaymusic@${pkg.version}`,
environment: process.env.NODE_ENV,
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0,
})
log.info(`[sentry] sentry initialized`)

332
packages/electron/server.ts Normal file
View file

@ -0,0 +1,332 @@
import { pathCase } from 'change-case'
import cookieParser from 'cookie-parser'
import express, { Request, Response } from 'express'
import log from './log'
import cache from './cache'
import fileUpload from 'express-fileupload'
import path from 'path'
import fs from 'fs'
import { db, Tables } from 'db'
import { app } from 'electron'
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
import UNM from '@unblockneteasemusic/rust-napi'
import { APIs as CacheAPIs } from '../shared/CacheAPIs'
const isDev = process.env.NODE_ENV === 'development'
const isProd = process.env.NODE_ENV === 'production'
class Server {
port = Number(
isProd
? process.env.ELECTRON_WEB_SERVER_PORT ?? 42710
: process.env.ELECTRON_DEV_NETEASE_API_PORT ?? 3000
)
app = express()
constructor() {
log.info('[server] starting http server')
this.app.use(cookieParser())
this.app.use(fileUpload())
this.getAudioUrlHandler()
this.neteaseHandler()
this.cacheAudioHandler()
this.serveStaticForProd()
this.listen()
}
neteaseHandler() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const neteaseApi = require('NeteaseCloudMusicApi') as (params: any) => any[]
Object.entries(neteaseApi).forEach(([name, handler]) => {
if (['serveNcmApi', 'getModulesDefinitions', 'song_url'].includes(name)) {
return
}
name = pathCase(name)
const wrappedHandler = async (req: Request, res: Response) => {
log.debug(`[server] Handling request: ${req.path}`)
// Get from cache
const cacheData = await cache.getForExpress(name, req)
if (cacheData) return res.json(cacheData)
// Request netease api
try {
const result = await handler({
...req.query,
cookie: req.cookies,
})
cache.set(name, result.body, req.query)
return res.send(result.body)
} catch (error: any) {
if ([400, 301].includes(error.status)) {
return res.status(error.status).send(error.body)
}
return res.status(500)
}
}
this.app.get(`/netease/${name}`, wrappedHandler)
this.app.post(`/netease/${name}`, wrappedHandler)
})
}
serveStaticForProd() {
if (isProd) {
this.app.use('/', express.static(path.join(__dirname, '../web/')))
}
}
getAudioUrlHandler() {
const getFromCache = (id: number) => {
// get from cache
const cache = db.find(Tables.Audio, id)
if (!cache) return
const audioFileName = `${cache.id}-${cache.br}.${cache.type}`
const isAudioFileExists = fs.existsSync(
`${app.getPath('userData')}/audio_cache/${audioFileName}`
)
if (!isAudioFileExists) return
log.debug(`[server] Audio cache hit for song/url`)
return {
data: [
{
source: cache.source,
id: cache.id,
url: `http://127.0.0.1:42710/yesplaymusic/audio/${audioFileName}`,
br: cache.br,
size: 0,
md5: '',
code: 200,
expi: 0,
type: cache.type,
gain: 0,
fee: 8,
uf: null,
payed: 0,
flag: 4,
canExtend: false,
freeTrialInfo: null,
level: 'standard',
encodeType: cache.type,
freeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
listenType: null,
},
freeTimeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
type: 0,
remainTime: 0,
},
urlSource: 0,
},
],
code: 200,
}
}
const getFromNetease = async (
req: Request
): Promise<FetchAudioSourceResponse | undefined> => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const getSongUrl = (require('NeteaseCloudMusicApi') as any).song_url
const result = await getSongUrl({ ...req.query, cookie: req.cookies })
return result.body
} catch (error: any) {
return
}
}
const unmExecutor = new UNM.Executor()
const getFromUNM = async (id: number, req: Request) => {
log.debug('[server] Fetching audio url from UNM')
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })
?.songs?.[0]
if (!track) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const getSongDetail = (require('NeteaseCloudMusicApi') as any)
.song_detail
track = await getSongDetail({ ...req.query, cookie: req.cookies })
}
if (!track) return
const trackForUNM = {
id: String(track.id),
name: track.name,
duration: track.dt,
album: {
id: String(track.al.id),
name: track.al.name,
},
artists: [
...track.ar.map((a: Artist) => ({
id: String(a.id),
name: a.name,
})),
],
}
const sourceList = ['ytdl']
const context = {}
const matchedAudio = await unmExecutor.search(
sourceList,
trackForUNM,
context
)
const retrievedSong = await unmExecutor.retrieve(matchedAudio, context)
const source =
retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
if (retrievedSong.url) {
return {
data: [
{
source,
id,
url: retrievedSong.url,
br: 128000,
size: 0,
md5: '',
code: 200,
expi: 0,
type: 'unknown',
gain: 0,
fee: 8,
uf: null,
payed: 0,
flag: 4,
canExtend: false,
freeTrialInfo: null,
level: 'standard',
encodeType: 'unknown',
freeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
listenType: null,
},
freeTimeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
type: 0,
remainTime: 0,
},
urlSource: 0,
unm: {
source,
song: matchedAudio.song,
},
},
],
code: 200,
}
}
}
const handler = async (req: Request, res: Response) => {
const id = Number(req.query.id) || 0
if (id === 0) {
return res.status(400).send({
code: 400,
msg: 'id is required or id is invalid',
})
}
// try {
// const fromCache = await getFromCache(id)
// if (fromCache) {
// res.status(200).send(fromCache)
// return
// }
// } catch (error) {
// log.error(`[server] getFromCache failed: ${String(error)}`)
// }
// const fromNetease = await getFromNetease(req)
// if (fromNetease?.code === 200 && !fromNetease?.data?.[0].freeTrialInfo) {
// res.status(200).send(fromNetease)
// return
// }
try {
const fromUNM = await getFromUNM(id, req)
if (fromUNM) {
res.status(200).send(fromUNM)
return
}
} catch (error) {
log.error(`[server] getFromNetease failed: ${String(error)}`)
}
// if (fromNetease?.data?.[0].freeTrialInfo) {
// fromNetease.data[0].url = ''
// }
// res.status(fromNetease?.code ?? 500).send(fromNetease)
}
this.app.get('/netease/song/url', handler)
}
cacheAudioHandler() {
this.app.get(
'/yesplaymusic/audio/:filename',
async (req: Request, res: Response) => {
cache.getAudio(req.params.filename, res)
}
)
this.app.post(
'/yesplaymusic/audio/:id',
async (req: Request, res: Response) => {
const id = Number(req.params.id)
const { url } = req.query
if (isNaN(id)) {
return res.status(400).send({ error: 'Invalid param id' })
}
if (!url) {
return res.status(400).send({ error: 'Invalid query url' })
}
if (
!req.files ||
Object.keys(req.files).length === 0 ||
!req.files.file
) {
return res.status(400).send('No audio were uploaded.')
}
if ('length' in req.files.file) {
return res.status(400).send('Only can upload one audio at a time.')
}
try {
await cache.setAudio(req.files.file.data, {
id,
url: String(req.query.url) || '',
})
res.status(200).send('Audio cached!')
} catch (error) {
res.status(500).send({ error })
}
}
)
}
listen() {
this.app.listen(this.port, () => {
log.info(`[server] API server listening on port ${this.port}`)
})
}
}
export default new Server()

173
packages/electron/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/tray')
: path.join(__dirname, './assets/icons/tray')
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

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"strict": true,
"jsx": "react-jsx",
"baseUrl": "./",
"paths": {
"@/*": ["../*"]
}
},
"include": ["./**/*.ts", "../shared/**/*.ts"]
}

View file

@ -0,0 +1,15 @@
import fs from 'fs'
import path from 'path'
export const createDirIfNotExist = (dir: string) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
export const createFileIfNotExist = (file: string) => {
createDirIfNotExist(path.dirname(file))
if (!fs.existsSync(file)) {
fs.writeFileSync(file, '')
}
}

View file

@ -0,0 +1,84 @@
import { IpcChannels } from '@/shared/IpcChannels'
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
import path from 'path'
enum ItemKeys {
Play = 'play',
Pause = 'pause',
Previous = 'previous',
Next = 'next',
}
type ThumbarButtonMap = Map<ItemKeys, ThumbarButton>
const iconDirRoot =
process.env.NODE_ENV === 'development'
? path.join(process.cwd(), './src/main/assets/icons/taskbar')
: path.join(__dirname, './assets/icons/taskbar')
function createNativeImage(filename: string) {
return nativeImage.createFromPath(path.join(iconDirRoot, filename))
}
function createThumbarButtons(win: BrowserWindow): ThumbarButtonMap {
return new Map<ItemKeys, ThumbarButton>()
.set(ItemKeys.Play, {
click: () => win.webContents.send(IpcChannels.Play),
icon: createNativeImage('play.png'),
tooltip: '播放',
})
.set(ItemKeys.Pause, {
click: () => win.webContents.send(IpcChannels.Pause),
icon: createNativeImage('pause.png'),
tooltip: '暂停',
})
.set(ItemKeys.Previous, {
click: () => win.webContents.send(IpcChannels.Previous),
icon: createNativeImage('previous.png'),
tooltip: '上一首',
})
.set(ItemKeys.Next, {
click: () => win.webContents.send(IpcChannels.Next),
icon: createNativeImage('next.png'),
tooltip: '下一首',
})
}
export interface Thumbar {
setPlayState(isPlaying: boolean): void
}
class ThumbarImpl implements Thumbar {
private _win: BrowserWindow
private _buttons: ThumbarButtonMap
private _playOrPause: ThumbarButton
private _previous: ThumbarButton
private _next: ThumbarButton
constructor(win: BrowserWindow) {
this._win = win
this._buttons = createThumbarButtons(win)
this._playOrPause = this._buttons.get(ItemKeys.Play)!
this._previous = this._buttons.get(ItemKeys.Previous)!
this._next = this._buttons.get(ItemKeys.Next)!
}
private _updateThumbarButtons(clear: boolean) {
this._win.setThumbarButtons(
clear ? [] : [this._previous, this._playOrPause, this._next]
)
}
setPlayState(isPlaying: boolean) {
this._playOrPause = this._buttons.get(
isPlaying ? ItemKeys.Pause : ItemKeys.Play
)!
this._updateThumbarButtons(false)
}
}
export function createTaskbar(win: BrowserWindow): Thumbar {
return new ThumbarImpl(win)
}

View file

@ -0,0 +1,72 @@
import { FetchArtistAlbumsResponse, FetchArtistResponse } from './api/Artist'
import { FetchAlbumResponse } from './api/Album'
import {
FetchUserAccountResponse,
FetchUserAlbumsResponse,
FetchUserArtistsResponse,
FetchUserLikedTracksIDsResponse,
FetchUserPlaylistsResponse,
} from './api/User'
import {
FetchAudioSourceResponse,
FetchLyricResponse,
FetchTracksResponse,
} from './api/Track'
import {
FetchPlaylistResponse,
FetchRecommendedPlaylistsResponse,
} from './api/Playlists'
export const enum APIs {
Album = 'album',
Artist = 'artists',
ArtistAlbum = 'artist/album',
CoverColor = 'cover_color',
Likelist = 'likelist',
Lyric = 'lyric',
Personalized = 'personalized',
Playlist = 'playlist/detail',
RecommendResource = 'recommend/resource',
SongUrl = 'song/url',
Track = 'song/detail',
UserAccount = 'user/account',
UserAlbums = 'album/sublist',
UserArtists = 'artist/sublist',
UserPlaylist = 'user/playlist',
}
export interface APIsParams {
[APIs.Album]: { id: number }
[APIs.Artist]: { id: number }
[APIs.ArtistAlbum]: { id: number }
[APIs.CoverColor]: { id: number }
[APIs.Likelist]: void
[APIs.Lyric]: { id: number }
[APIs.Personalized]: void
[APIs.Playlist]: { id: number }
[APIs.RecommendResource]: void
[APIs.SongUrl]: { id: string }
[APIs.Track]: { ids: string }
[APIs.UserAccount]: void
[APIs.UserAlbums]: void
[APIs.UserArtists]: void
[APIs.UserPlaylist]: void
}
export interface APIsResponse {
[APIs.Album]: FetchAlbumResponse
[APIs.Artist]: FetchArtistResponse
[APIs.ArtistAlbum]: FetchArtistAlbumsResponse
[APIs.CoverColor]: string | undefined
[APIs.Likelist]: FetchUserLikedTracksIDsResponse
[APIs.Lyric]: FetchLyricResponse
[APIs.Personalized]: FetchRecommendedPlaylistsResponse
[APIs.Playlist]: FetchPlaylistResponse
[APIs.RecommendResource]: FetchRecommendedPlaylistsResponse
[APIs.SongUrl]: FetchAudioSourceResponse
[APIs.Track]: FetchTracksResponse
[APIs.UserAccount]: FetchUserAccountResponse
[APIs.UserAlbums]: FetchUserAlbumsResponse
[APIs.UserArtists]: FetchUserArtistsResponse
[APIs.UserPlaylist]: FetchUserPlaylistsResponse
}

View file

@ -0,0 +1,77 @@
import { APIs } from './CacheAPIs'
import { RepeatMode } from './playerDataTypes'
import { Store } from '@/shared/store'
export const enum IpcChannels {
ClearAPICache = 'clear-api-cache',
Minimize = 'minimize',
MaximizeOrUnmaximize = 'maximize-or-unmaximize',
Close = 'close',
IsMaximized = 'is-maximized',
GetApiCacheSync = 'get-api-cache-sync',
DevDbExportJson = 'dev-db-export-json',
CacheCoverColor = 'cache-cover-color',
SetTrayTooltip = 'set-tray-tooltip',
// 准备三个播放相关channel, 为 mpris 预留接口
Play = 'play',
Pause = 'pause',
PlayOrPause = 'play-or-pause',
Next = 'next',
Previous = 'previous',
Like = 'like',
Repeat = 'repeat',
SyncSettings = 'sync-settings',
}
// ipcMain.on params
export interface IpcChannelsParams {
[IpcChannels.ClearAPICache]: void
[IpcChannels.Minimize]: void
[IpcChannels.MaximizeOrUnmaximize]: void
[IpcChannels.Close]: void
[IpcChannels.IsMaximized]: void
[IpcChannels.GetApiCacheSync]: {
api: APIs
query?: any
}
[IpcChannels.DevDbExportJson]: void
[IpcChannels.CacheCoverColor]: {
id: number
color: string
}
[IpcChannels.SetTrayTooltip]: {
text: string
}
[IpcChannels.Play]: void
[IpcChannels.Pause]: void
[IpcChannels.PlayOrPause]: void
[IpcChannels.Next]: void
[IpcChannels.Previous]: void
[IpcChannels.Like]: {
isLiked: boolean
}
[IpcChannels.Repeat]: {
mode: RepeatMode
}
[IpcChannels.SyncSettings]: Store['settings']
}
// ipcRenderer.on params
export interface IpcChannelsReturns {
[IpcChannels.ClearAPICache]: void
[IpcChannels.Minimize]: void
[IpcChannels.MaximizeOrUnmaximize]: void
[IpcChannels.Close]: void
[IpcChannels.IsMaximized]: boolean
[IpcChannels.GetApiCacheSync]: any
[IpcChannels.DevDbExportJson]: void
[IpcChannels.CacheCoverColor]: void
[IpcChannels.SetTrayTooltip]: 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,23 @@
export enum AlbumApiNames {
FetchAlbum = 'fetchAlbum',
}
// 专辑详情
export interface FetchAlbumParams {
id: number
}
export interface FetchAlbumResponse {
code: number
resourceState: boolean
album: Album
songs: Track[]
description: string
}
export interface LikeAAlbumParams {
t: 1 | 2
id: number
}
export interface LikeAAlbumResponse {
code: number
}

View file

@ -0,0 +1,28 @@
export enum ArtistApiNames {
FetchArtist = 'fetchArtist',
FetchArtistAlbums = 'fetchArtistAlbums',
}
// 歌手详情
export interface FetchArtistParams {
id: number
}
export interface FetchArtistResponse {
code: number
more: boolean
artist: Artist
hotSongs: Track[]
}
// 获取歌手的专辑列表
export interface FetchArtistAlbumsParams {
id: number
limit?: number // default: 50
offset?: number // default: 0
}
export interface FetchArtistAlbumsResponse {
code: number
hotAlbums: Album[]
more: boolean
artist: Artist
}

View file

@ -0,0 +1,48 @@
export enum PlaylistApiNames {
FetchPlaylist = 'fetchPlaylist',
FetchRecommendedPlaylists = 'fetchRecommendedPlaylists',
FetchDailyRecommendPlaylists = 'fetchDailyRecommendPlaylists',
LikeAPlaylist = 'likeAPlaylist',
}
// 歌单详情
export interface FetchPlaylistParams {
id: number
s?: number // 歌单最近的 s 个收藏者
}
export interface FetchPlaylistResponse {
code: number
playlist: Playlist
privileges: unknown // TODO: unknown type
relatedVideos: null
resEntrance: null
sharedPrivilege: null
urls: null
}
// 推荐歌单
export interface FetchRecommendedPlaylistsParams {
limit?: number
}
export interface FetchRecommendedPlaylistsResponse {
code: number
category: number
hasTaste: boolean
result: Playlist[]
}
// 每日推荐歌单(需要登录)
export interface FetchDailyRecommendPlaylistsResponse {
code: number
featureFirst: boolean
haveRcmdSongs: boolean
recommend: Playlist[]
}
export interface LikeAPlaylistParams {
t: 1 | 2
id: number
}
export interface LikeAPlaylistResponse {
code: number
}

View file

@ -0,0 +1,82 @@
export enum SearchApiNames {
Search = 'search',
MultiMatchSearch = 'multiMatchSearch',
}
// 搜索
export enum SearchTypes {
Single = '1',
Album = '10',
Artist = '100',
Playlist = '1000',
User = '1002',
Mv = '1004',
Lyrics = '1006',
Radio = '1009',
Video = '1014',
All = '1018',
}
export interface SearchParams {
keywords: string
limit?: number // 返回数量 , 默认为 30
offset?: number // 偏移数量,用于分页 , 如 : 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
type: keyof typeof SearchTypes // type: 搜索类型
}
export interface SearchResponse {
code: number
result: {
album: {
albums: Album[]
more: boolean
moreText: string
resourceIds: number[]
}
artist: {
artists: Artist[]
more: boolean
moreText: string
resourceIds: number[]
}
playList: {
playLists: Playlist[]
more: boolean
moreText: string
resourceIds: number[]
}
song: {
songs: Track[]
more: boolean
moreText: string
resourceIds: number[]
}
user: {
users: User[]
more: boolean
moreText: string
resourceIds: number[]
}
circle: unknown
new_mlog: unknown
order: string[]
rec_type: null
rec_query: null[]
sim_query: unknown
voice: unknown
voiceList: unknown
}
}
// 搜索多重匹配
export interface MultiMatchSearchParams {
keywords: string
}
export interface MultiMatchSearchResponse {
code: number
result: {
album: Album[]
artist: Artist[]
playlist: Playlist[]
orpheus: unknown
orders: Array<'artist' | 'album'>
}
}

View file

@ -0,0 +1,104 @@
export enum TrackApiNames {
FetchTracks = 'fetchTracks',
FetchAudioSource = 'fetchAudioSource',
FetchLyric = 'fetchLyric',
}
// 获取歌曲详情
export interface FetchTracksParams {
ids: number[]
}
export interface FetchTracksResponse {
code: number
songs: Track[]
privileges: {
[key: string]: unknown
}
}
// 获取音源URL
export interface FetchAudioSourceParams {
id: number
br?: number // bitrate, default 999000320000 = 320kbps
}
export interface FetchAudioSourceResponse {
code: number
data: {
br: number
canExtend: boolean
code: number
encodeType: 'mp3' | null
expi: number
fee: number
flag: number
freeTimeTrialPrivilege: {
[key: string]: unknown
}
freeTrialPrivilege: {
[key: string]: unknown
}
freeTrialInfo: null
gain: number
id: number
level: 'standard' | 'null'
md5: string | null
payed: number
size: number
type: 'mp3' | null
uf: null
url: string | null
urlSource: number
}[]
}
// 获取歌词
export interface FetchLyricParams {
id: number
}
export interface FetchLyricResponse {
code: number
sgc: boolean
sfy: boolean
qfy: boolean
lyricUser?: {
id: number
status: number
demand: number
userid: number
nickname: string
uptime: number
}
transUser?: {
id: number
status: number
demand: number
userid: number
nickname: string
uptime: number
}
lrc: {
version: number
lyric: string
}
klyric?: {
version: number
lyric: string
}
tlyric?: {
version: number
lyric: string
}
}
// 收藏歌曲
export interface LikeATrackParams {
id: number
like: boolean
}
export interface LikeATrackResponse {
code: number
playlistId: number
songs: Track[]
}

108
packages/shared/api/User.ts Normal file
View file

@ -0,0 +1,108 @@
export enum UserApiNames {
FetchUserAccount = 'fetchUserAccount',
FetchUserLikedTracksIds = 'fetchUserLikedTracksIDs',
FetchUserPlaylists = 'fetchUserPlaylists',
FetchUserAlbums = 'fetchUserAlbums',
FetchUserArtist = 'fetchUserArtists',
}
// 获取账号详情
export interface FetchUserAccountResponse {
code: number
account: {
anonimousUser: boolean
ban: number
baoyueVersion: number
createTime: number
donateVersion: number
id: number
paidFee: boolean
status: number
tokenVersion: number
type: number
userName: string
vipType: number
whitelistAuthority: number
} | null
profile: {
userId: number
userType: number
nickname: string
avatarImgId: number
avatarUrl: string
backgroundImgId: number
backgroundUrl: string
signature: string
createTime: number
userName: string
accountType: number
shortUserName: string
birthday: number
authority: number
gender: number
accountStatus: number
province: number
city: number
authStatus: number
description: string | null
detailDescription: string | null
defaultAvatar: boolean
expertTags: [] | null
experts: [] | null
djStatus: number
locationStatus: number
vipType: number
followed: boolean
mutual: boolean
authenticated: boolean
lastLoginTime: number
lastLoginIP: string
remarkName: string | null
viptypeVersion: number
authenticationTypes: number
avatarDetail: string | null
anchor: boolean
} | null
}
// 获取用户歌单
export interface FetchUserPlaylistsParams {
uid: number
offset: number
limit?: number // default 30
}
export interface FetchUserPlaylistsResponse {
code: number
more: boolean
version: string
playlist: Playlist[]
}
export interface FetchUserLikedTracksIDsParams {
uid: number
}
export interface FetchUserLikedTracksIDsResponse {
code: number
checkPoint: number
ids: number[]
}
export interface FetchUserAlbumsParams {
offset?: number // default 0
limit?: number // default 25
}
export interface FetchUserAlbumsResponse {
code: number
hasMore: boolean
paidCount: number
count: number
data: Album[]
}
// 获取收藏的歌手
export interface FetchUserArtistsResponse {
code: number
hasMore: boolean
count: number
data: Artist[]
}

207
packages/shared/interface.d.ts vendored Normal file
View file

@ -0,0 +1,207 @@
declare interface Playlist {
id: number
name: string
highQuality: boolean
playCount: number
trackCount: number
trackNumberUpdateTime: number
// 非必有
adType?: number
alg?: string
anonimous?: boolean
artists?: Artist[]
backgroundCoverId?: number
backgroundCoverUrl?: string | null
creator: User
canDislike?: boolean
cloudTrackCount?: number
commentThreadId?: string
copywriter?: string
coverImgId_str?: string
coverImgId?: number
coverImgUrl?: string
createTime?: number
description?: string | null
englishTitle?: string | null
historySharedUsers: null
newImported?: boolean
officialPlaylistType: null
opRecommend?: boolean
ordered?: boolean
picUrl?: string
privacy?: number
recommendInfo?: null
remixVideo?: null
sharedUsers?: null
shareStatus?: null
shareCount?: number
specialType?: number
status?: number
subscribed?: boolean
subscribedCount?: number
subscribers?: []
tags?: []
titleImage?: number
titleImageUrl?: string | null
totalDuration?: number
trackIds?: {
alg: null
at: number
id: number
rcmdReason: string
t: number
uid: number
v: number
}[]
trackUpdateTime?: number
tracks?: Track[]
type?: number
updateFrequency?: null
updateTime?: number
userId?: number
videoIds: null // TODO: unknown type
videos?: null
}
declare interface Track {
id: number
a: null
al: Album
alia: string[]
ar: Artist[]
cd: string
cf?: string
copyright: number
cp: number
crbt: null
djId: number
dt: number
fee: number
ftype: number
[key in ('h' | 'm' | 'l')]: {
br: number
fid: number
size: number
vd: number
}
mark: number
mst: number
mv: number
name: string
no: number
noCopyrightRcmd: null
originCoverType: number
originSongSimpleData: null
pop: number
pst: number
publishTime: number
resourceState: boolean
rt: string
rtUrl: string | null
rtUrls: (string | null)[]
rtType: number
rurl: null
s_id: number
single: number
songJumpInfo: null
st: number
t: number
tagPicList: null
v: number
version: number
tns: (string | null)[]
}
declare interface Artist {
alias: unknown[]
id: number
name: string
tns: string[]
picUrl: string
albumSize: number
picId: string
img1v1Url: string
accountId: number
img1v1: number
identityIconUrl: string
mvSize: number
followed: boolean
alg: string
trans: unknown
cover?: string
musicSize?: number
img1v1Id?: number
topicPerson?: number
briefDesc?: string
publishTime?: number
picId_str?: string
img1v1Id_str?: string
occupation?: string
}
declare interface Album {
alias: unknown[]
artist: Artist
artists: Artist[]
blurPicUrl: string
briefDesc: string
commentThreadId: string
company: string
companyId: string
copyrightId: number
description: string
id: number
info: {
commentThread: unknown
}
mark: number
name: string
onSale: boolean
paid: boolean
pic_str: string
pic: number
picId_str: string
picId: number
picUrl: string
publishTime: number
size: number
songs: Track[]
status: number
subType: string
tags: string
tns: unknown[]
type: '专辑' | 'Single' | 'EP/Single' | 'EP' | '精选集'
}
declare interface User {
defaultAvatar: boolean
province: number
authStatus: number
followed: boolean
avatarUrl: string
accountStatus: number
gender: number
city: number
birthday: number
userId: number
userType: number
nickname: string
signature: string
description: string
detailDescription: string
avatarImgId: number
backgroundImgId: number
backgroundUrl: string
authority: number
mutual: boolean
expertTags: null
experts: null
djStatus: number
vipType: number
remarkName: null
authenticationTypes: number
avatarDetail: null
avatarImgIdStr: string
backgroundImgIdStr: string
anchor: boolean
avatarImgId_str: string
}

View file

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

46
packages/shared/store.ts Normal file
View file

@ -0,0 +1,46 @@
export interface Store {
uiStates: {
loginPhoneCountryCode: string
showLyricPanel: boolean
}
settings: {
showSidebar: boolean
accentColor: string
unm: {
enabled: boolean
sources: Array<
'migu' | 'kuwo' | 'kugou' | 'ytdl' | 'qq' | 'bilibili' | 'joox'
>
searchMode: 'order-first' | 'fast-first'
proxy: null | {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
username?: string
password?: string
}
cookies: {
qq?: string
joox?: string
}
}
}
}
export const initialState: Store = {
uiStates: {
loginPhoneCountryCode: '+86',
showLyricPanel: false,
},
settings: {
showSidebar: true,
accentColor: 'blue',
unm: {
enabled: true,
sources: ['migu'],
searchMode: 'order-first',
proxy: null,
cookies: {},
},
},
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"strict": true,
"jsx": "react-jsx",
"baseUrl": "./",
"paths": {
"@/*": ["../*"]
}
},
"include": ["./**/*.ts"]
}

45
packages/web/App.tsx Normal file
View file

@ -0,0 +1,45 @@
import { Toaster } from 'react-hot-toast'
import { QueryClientProvider } from 'react-query'
import { ReactQueryDevtools } from 'react-query/devtools'
import Player from '@/web/components/Player'
import Sidebar from '@/web/components/Sidebar'
import reactQueryClient from '@/web/utils/reactQueryClient'
import Main from '@/web/components/Main'
import TitleBar from '@/web/components/TitleBar'
import Lyric from '@/web/components/Lyric'
import IpcRendererReact from '@/web/IpcRendererReact'
const App = () => {
return (
<QueryClientProvider client={reactQueryClient}>
{window.env?.isEnableTitlebar && <TitleBar />}
<div id='layout' className='grid select-none grid-cols-[16rem_auto]'>
<Sidebar />
<Main />
<Player />
</div>
<Lyric />
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
<IpcRendererReact />
{/* Devtool */}
<ReactQueryDevtools
initialIsOpen={false}
toggleButtonProps={{
style: {
position: 'fixed',
right: '0',
left: 'auto',
bottom: '4rem',
},
}}
/>
</QueryClientProvider>
)
}
export default App

View file

@ -0,0 +1,62 @@
import { IpcChannels } from '@/shared/IpcChannels'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
import { player } from '@/web/store'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { State as PlayerState } from '@/web/utils/player'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffectOnce } from 'react-use'
import { useSnapshot } from 'valtio'
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.Like, {
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])
useEffectOnce(() => {
// 用于显示 windows taskbar buttons
if (playerSnapshot.track?.id) {
window.ipcRenderer?.send(IpcChannels.Pause)
}
})
return <></>
}
export default IpcRendererReact

34
packages/web/api/album.ts Normal file
View file

@ -0,0 +1,34 @@
import request from '@/web/utils/request'
import {
FetchAlbumParams,
FetchAlbumResponse,
LikeAAlbumParams,
LikeAAlbumResponse,
} from '@/shared/api/Album'
// 专辑详情
export function fetchAlbum(
params: FetchAlbumParams,
noCache: boolean
): Promise<FetchAlbumResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
return request({
url: '/album',
method: 'get',
params: { ...params, ...otherParams },
})
}
export function likeAAlbum(
params: LikeAAlbumParams
): Promise<LikeAAlbumResponse> {
return request({
url: '/album/sub',
method: 'post',
params: {
...params,
timestamp: Date.now(),
},
})
}

View file

@ -0,0 +1,32 @@
import request from '@/web/utils/request'
import {
FetchArtistParams,
FetchArtistResponse,
FetchArtistAlbumsParams,
FetchArtistAlbumsResponse,
} from '@/shared/api/Artist'
// 歌手详情
export function fetchArtist(
params: FetchArtistParams,
noCache: boolean
): Promise<FetchArtistResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
return request({
url: '/artists',
method: 'get',
params: { ...params, ...otherParams },
})
}
// 获取歌手的专辑列表
export function fetchArtistAlbums(
params: FetchArtistAlbumsParams
): Promise<FetchArtistAlbumsResponse> {
return request({
url: 'artist/album',
method: 'get',
params,
})
}

115
packages/web/api/auth.ts Normal file
View file

@ -0,0 +1,115 @@
import request from '@/web/utils/request'
import { FetchUserAccountResponse } from '@/shared/api/User'
// 手机号登录
interface LoginWithPhoneParams {
countrycode: number | string
phone: string
password?: string
md5_password?: string
captcha?: string | number
}
export interface LoginWithPhoneResponse {
loginType: number
code: number
cookie: string
}
export function loginWithPhone(
params: LoginWithPhoneParams
): Promise<LoginWithPhoneResponse> {
return request({
url: '/login/cellphone',
method: 'post',
params,
})
}
// 邮箱登录
export interface LoginWithEmailParams {
email: string
password?: string
md5_password?: string
}
export interface loginWithEmailResponse extends FetchUserAccountResponse {
code: number
cookie: string
loginType: number
token: string
binding: {
bindingTime: number
expired: boolean
expiresIn: number
id: number
refreshTime: number
tokenJsonStr: string
type: number
url: string
userId: number
}[]
}
export function loginWithEmail(
params: LoginWithEmailParams
): Promise<loginWithEmailResponse> {
return request({
url: '/login',
method: 'post',
params,
})
}
// 生成二维码key
export interface fetchLoginQrCodeKeyResponse {
code: number
data: {
code: number
unikey: string
}
}
export function fetchLoginQrCodeKey(): Promise<fetchLoginQrCodeKeyResponse> {
return request({
url: '/login/qr/key',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
})
}
// 二维码检测扫码状态接口
// 说明: 轮询此接口可获取二维码扫码状态,800为二维码过期,801为等待扫码,802为待确认,803为授权登录成功(803状态码下会返回cookies)
export interface CheckLoginQrCodeStatusParams {
key: string
}
export interface CheckLoginQrCodeStatusResponse {
code: number
message?: string
cookie?: string
}
export function checkLoginQrCodeStatus(
params: CheckLoginQrCodeStatusParams
): Promise<CheckLoginQrCodeStatusResponse> {
return request({
url: '/login/qr/check',
method: 'get',
params: {
key: params.key,
timestamp: new Date().getTime(),
},
})
}
// 刷新登录
export function refreshCookie() {
return request({
url: '/login/refresh',
method: 'post',
})
}
// 退出登录
export function logout() {
return request({
url: '/logout',
method: 'post',
})
}

View file

@ -0,0 +1,119 @@
import request from '@/web/utils/request'
export enum PersonalFMApiNames {
FetchPersonalFm = 'fetchPersonalFM',
}
export interface PersonalMusic {
name: null | string
id: number
size: number
extension: 'mp3' | 'flac' | null
sr: number
dfsId: number
bitrate: number
playTime: number
volumeDelta: number
}
export interface FetchPersonalFMResponse {
code: number
popAdjust: boolean
data: {
name: string
id: number
position: number
alias: string[]
status: number
fee: number
copyrightId: number
disc?: string
no: number
artists: Artist[]
album: Album
starred: boolean
popularity: number
score: number
starredNum: number
duration: number
playedNum: number
dayPlays: number
hearTime: number
ringtone: null
crbt: null
audition: null
copyFrom: string
commentThreadId: string
rtUrl: string | null
ftype: number
rtUrls: (string | null)[]
copyright: number
transName: null | string
sign: null
mark: number
originCoverType: number
originSongSimpleData: null
single: number
noCopyrightRcmd: null
mvid: number
bMusic?: PersonalMusic
lMusic?: PersonalMusic
mMusic?: PersonalMusic
hMusic?: PersonalMusic
reason: string
privilege: {
id: number
fee: number
payed: number
st: number
pl: number
dl: number
sp: number
cp: number
subp: number
cs: boolean
maxbr: number
fl: number
toast: boolean
flag: number
preShell: boolean
playMaxbr: number
downloadMaxbr: number
rscl: null
freeTrialPrivilege: {
[key: string]: unknown
}
chargeInfoList: {
[key: string]: unknown
}[]
}
alg: string
s_ctrp: string
}[]
}
export function fetchPersonalFM(): Promise<FetchPersonalFMResponse> {
return request({
url: '/personal/fm',
method: 'get',
params: {
timestamp: Date.now(),
},
})
}
export interface FMTrashResponse {
songs: null[]
code: number
count: number
}
export function fmTrash(id: number): Promise<FMTrashResponse> {
return request({
url: '/fm/trash',
method: 'post',
params: {
id,
timestamp: Date.now(),
},
})
}

View file

@ -0,0 +1,64 @@
import request from '@/web/utils/request'
import {
FetchPlaylistParams,
FetchPlaylistResponse,
FetchRecommendedPlaylistsParams,
FetchRecommendedPlaylistsResponse,
FetchDailyRecommendPlaylistsResponse,
LikeAPlaylistParams,
LikeAPlaylistResponse,
} from '@/shared/api/Playlists'
// 歌单详情
export function fetchPlaylist(
params: FetchPlaylistParams,
noCache: boolean
): Promise<FetchPlaylistResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
if (!params.s) params.s = 0 // 网易云默认返回8个收藏者这里设置为0减少返回的JSON体积
return request({
url: '/playlist/detail',
method: 'get',
params: {
...params,
...otherParams,
},
})
}
// 推荐歌单
export function fetchRecommendedPlaylists(
params: FetchRecommendedPlaylistsParams
): Promise<FetchRecommendedPlaylistsResponse> {
return request({
url: '/personalized',
method: 'get',
params,
})
}
// 每日推荐歌单(需要登录)
export function fetchDailyRecommendPlaylists(): Promise<FetchDailyRecommendPlaylistsResponse> {
return request({
url: '/recommend/resource',
method: 'get',
params: {
timestamp: Date.now(),
},
})
}
export function likeAPlaylist(
params: LikeAPlaylistParams
): Promise<LikeAPlaylistResponse> {
return request({
url: '/playlist/subscribe',
method: 'post',
params: {
...params,
timestamp: Date.now(),
},
})
}

View file

@ -0,0 +1,31 @@
import request from '@/web/utils/request'
import {
SearchParams,
SearchResponse,
SearchTypes,
MultiMatchSearchParams,
MultiMatchSearchResponse,
} from '@/shared/api/Search'
// 搜索
export function search(params: SearchParams): Promise<SearchResponse> {
return request({
url: '/search',
method: 'get',
params: {
...params,
type: SearchTypes[params.type ?? SearchTypes.All],
},
})
}
// 搜索多重匹配
export function multiMatchSearch(
params: MultiMatchSearchParams
): Promise<MultiMatchSearchResponse> {
return request({
url: '/search/multimatch',
method: 'get',
params: params,
})
}

60
packages/web/api/track.ts Normal file
View file

@ -0,0 +1,60 @@
import request from '@/web/utils/request'
import {
FetchAudioSourceParams,
FetchAudioSourceResponse,
FetchLyricParams,
FetchLyricResponse,
FetchTracksParams,
FetchTracksResponse,
LikeATrackParams,
LikeATrackResponse,
} from '@/shared/api/Track'
// 获取歌曲详情
export function fetchTracks(
params: FetchTracksParams
): Promise<FetchTracksResponse> {
return request({
url: '/song/detail',
method: 'get',
params: {
ids: params.ids.join(','),
},
})
}
// 获取音源URL
export function fetchAudioSource(
params: FetchAudioSourceParams
): Promise<FetchAudioSourceResponse> {
return request({
url: '/song/url',
method: 'get',
params,
})
}
// 获取歌词
export function fetchLyric(
params: FetchLyricParams
): Promise<FetchLyricResponse> {
return request({
url: '/lyric',
method: 'get',
params,
})
}
// 收藏歌曲
export function likeATrack(
params: LikeATrackParams
): Promise<LikeATrackResponse> {
return request({
url: '/like',
method: 'post',
params: {
...params,
timestamp: Date.now(),
},
})
}

188
packages/web/api/user.ts Normal file
View file

@ -0,0 +1,188 @@
import request from '@/web/utils/request'
import {
FetchUserAccountResponse,
FetchUserPlaylistsParams,
FetchUserPlaylistsResponse,
FetchUserLikedTracksIDsParams,
FetchUserLikedTracksIDsResponse,
FetchUserAlbumsParams,
FetchUserAlbumsResponse,
FetchUserArtistsResponse,
} from '@/shared/api/User'
/**
*
* 说明 : 登录后调用此接口 , id,
* - uid : 用户 id
* @param {number} uid
*/
export function userDetail(uid: number) {
return request({
url: '/user/detail',
method: 'get',
params: {
uid,
timestamp: new Date().getTime(),
},
})
}
// 获取账号详情
export function fetchUserAccount(): Promise<FetchUserAccountResponse> {
return request({
url: '/user/account',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
})
}
// 获取用户歌单
export function fetchUserPlaylists(
params: FetchUserPlaylistsParams
): Promise<FetchUserPlaylistsResponse> {
return request({
url: '/user/playlist',
method: 'get',
params,
})
}
export function fetchUserLikedTracksIDs(
params: FetchUserLikedTracksIDsParams
): Promise<FetchUserLikedTracksIDsResponse> {
return request({
url: '/likelist',
method: 'get',
params: {
uid: params.uid,
timestamp: new Date().getTime(),
},
})
}
/**
*
* 说明 : 调用此接口可签到获取积分
* - type: , 0, 0 ,1 web/PC
* @param {number} type
*/
export function dailySignin(type = 0) {
return request({
url: '/daily_signin',
method: 'post',
params: {
type,
timestamp: new Date().getTime(),
},
})
}
export function fetchUserAlbums(
params: FetchUserAlbumsParams
): Promise<FetchUserAlbumsResponse> {
return request({
url: '/album/sublist',
method: 'get',
params: {
...params,
timestamp: new Date().getTime(),
},
})
}
// 获取收藏的歌手
export function fetchUserArtists(): Promise<FetchUserArtistsResponse> {
return request({
url: '/artist/sublist',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
})
}
/**
* MV
* 说明 : 调用此接口可获取到用户收藏的MV
*/
// export function likedMVs(params) {
// return request({
// url: '/mv/sublist',
// method: 'get',
// params: {
// limit: params.limit,
// timestamp: new Date().getTime(),
// },
// })
// }
/**
*
*/
// export function uploadSong(file) {
// let formData = new FormData()
// formData.append('songFile', file)
// return request({
// url: '/cloud',
// method: 'post',
// params: {
// timestamp: new Date().getTime(),
// },
// data: formData,
// headers: {
// 'Content-Type': 'multipart/form-data',
// },
// timeout: 200000,
// }).catch(error => {
// alert(`上传失败Error: ${error}`)
// })
// }
/**
*
* 说明 : 登录后调用此接口 , , url, /song/url url
* - limit : 返回数量 , 200
* - offset : 偏移数量 , :( -1)*200, 200 limit , 0
* @param {Object} params
* @param {number} params.limit
* @param {number=} params.offset
*/
// export function cloudDisk(params = {}) {
// params.timestamp = new Date().getTime()
// return request({
// url: '/user/cloud',
// method: 'get',
// params,
// })
// }
/**
*
*/
// export function cloudDiskTrackDetail(id) {
// return request({
// url: '/user/cloud/detail',
// method: 'get',
// params: {
// timestamp: new Date().getTime(),
// id,
// },
// })
// }
/**
*
* @param {Array} id
*/
// export function cloudDiskTrackDelete(id) {
// return request({
// url: '/user/cloud/del',
// method: 'get',
// params: {
// timestamp: new Date().getTime(),
// id,
// },
// })
// }

View file

@ -0,0 +1,29 @@
import axios, { AxiosInstance } from 'axios'
const baseURL = String(
import.meta.env.DEV ? '/yesplaymusic' : `http://127.0.0.1:42710/yesplaymusic`
)
const request: AxiosInstance = axios.create({
baseURL,
withCredentials: true,
timeout: 15000,
})
export async function cacheAudio(id: number, audio: string) {
const file = await axios.get(audio, { responseType: 'arraybuffer' })
if (file.status !== 200 && file.status !== 206) return
const formData = new FormData()
const blob = new Blob([file.data], { type: 'multipart/form-data' })
formData.append('file', blob)
request.post(`/audio/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
params: {
url: audio,
},
})
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5 5L9.26369 10.6391C8.55114 11.4065 8.55114 12.5935 9.26369 13.3609L14.5 19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 14.5V5.5M9 5H8.5C7.39543 5 6.5 5.89543 6.5 7V14.0668C6.5 14.3523 6.56113 14.6345 6.67927 14.8944L8.46709 18.8276C8.79163 19.5416 9.50354 20 10.2878 20H10.8815C12.2002 20 13.158 18.746 12.811 17.4738L12.3445 15.7631C12.171 15.127 12.6499 14.5 13.3093 14.5H17V14.5C19.1704 14.5 20.489 12.1076 19.33 10.2726L16.5517 5.87354C16.2083 5.32974 15.61 5 14.9668 5H9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="24" height="24"
viewBox="0 0 24 24"
style=" fill:#000000;"><path d="M19.971,14.583C19.985,14.39,20,14.197,20,14c0-0.513-0.053-1.014-0.145-1.5C19.947,12.014,20,11.513,20,11 c0-4.418-3.582-8-8-8s-8,3.582-8,8c0,0.513,0.053,1.014,0.145,1.5C4.053,12.986,4,13.487,4,14c0,0.197,0.015,0.39,0.029,0.583 C3.433,14.781,3,15.337,3,16c0,0.828,0.672,1.5,1.5,1.5c0.103,0,0.203-0.01,0.3-0.03C6.093,20.148,8.827,22,12,22 s5.907-1.852,7.2-4.53c0.097,0.02,0.197,0.03,0.3,0.03c0.828,0,1.5-0.672,1.5-1.5C21,15.337,20.567,14.781,19.971,14.583z" opacity=".35"></path><path d="M21,18h-2v-6h2c1.105,0,2,0.895,2,2v2C23,17.105,22.105,18,21,18z"></path><path d="M3,12h2v6H3c-1.105,0-2-0.895-2-2v-2C1,12.895,1.895,12,3,12z"></path><path d="M5,13c0-0.843,0-1.638,0-2c0-3.866,3.134-7,7-7s7,3.134,7,7c0,0.362,0,1.157,0,2h2c0-0.859,0-1.617,0-2c0-4.971-4.029-9-9-9 s-9,4.029-9,9c0,0.383,0,1.141,0,2H5z"></path></svg>

After

Width:  |  Height:  |  Size: 946 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 2C3.34315 2 2 3.34315 2 5V19C2 20.6569 3.34315 22 5 22H19C20.6569 22 22 20.6569 22 19V5C22 3.34315 20.6569 2 19 2H5ZM9 6C8.44772 6 8 6.44772 8 7V12V17C8 17.5523 8.44772 18 9 18H15C15.5523 18 16 17.5523 16 17C16 16.4477 15.5523 16 15 16H10V13H15C15.5523 13 16 12.5523 16 12C16 11.4477 15.5523 11 15 11H10V8H15C15.5523 8 16 7.55228 16 7C16 6.44772 15.5523 6 15 6H9Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>

After

Width:  |  Height:  |  Size: 494 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>

After

Width:  |  Height:  |  Size: 429 B

View file

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 18V10C4 8.89543 4.89543 8 6 8H18C19.1046 8 20 8.89543 20 10V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="16" cy="12" r="1" fill="currentColor"/>
<circle cx="16" cy="16" r="1" fill="currentColor"/>
<path d="M18 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="10" cy="14" r="3" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 5L14.7363 10.6391C15.4489 11.4065 15.4489 12.5935 14.7363 13.3609L9.5 19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6963 7.8793C11.8739 8.02633 12.1261 8.02633 12.3037 7.8793L13.6433 6.76983C15.2993 5.39827 17.7553 5.95672 18.7038 7.9205C19.345 9.24819 19.0937 10.8517 18.0798 11.9014L12.3437 17.8397C12.1539 18.0362 11.8461 18.0362 11.6563 17.8397L5.92022 11.9014C4.90633 10.8517 4.65498 9.24819 5.29622 7.9205C6.24467 5.95672 8.70067 5.39827 10.3567 6.76983L11.6963 7.8793ZM12 5.55297L12.4286 5.19799C15.0513 3.02586 18.9408 3.91027 20.4429 7.02028C21.4584 9.12294 21.0603 11.6624 19.4547 13.3247L13.7186 19.263C12.7694 20.2457 11.2305 20.2457 10.2814 19.263L4.54533 13.3247C2.93965 11.6624 2.54158 9.12294 3.55711 7.02028C5.05915 3.91027 8.9487 3.02586 11.5714 5.19799L12 5.55297Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 847 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 5.55297L12.4286 5.19799C15.0513 3.02586 18.9408 3.91027 20.4429 7.02028C21.4584 9.12294 21.0603 11.6624 19.4547 13.3247L13.7186 19.263C12.7694 20.2457 11.2305 20.2457 10.2814 19.263L4.54533 13.3247C2.93965 11.6624 2.54158 9.12294 3.55711 7.02028C5.05915 3.91027 8.9487 3.02586 11.5714 5.19799L12 5.55297Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 484 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.457 6.143L19.9427 5.26889L19.9426 5.26883L19.457 6.143ZM13.457 2.81L13.9426 1.93583L13.9424 1.93571L13.457 2.81ZM10.543 2.81L10.0576 1.93571L10.0574 1.93583L10.543 2.81ZM4.543 6.143L4.05739 5.26883L4.05728 5.26889L4.543 6.143ZM15 21V22C15.5523 22 16 21.5523 16 21H15ZM9 21H8C8 21.5523 8.44772 22 9 22V21ZM6 20C4.89528 20 4 19.1047 4 18H2C2 20.2093 3.79072 22 6 22V20ZM18 20H6V22H18V20ZM20 18C20 19.1047 19.1047 20 18 20V22C20.2093 22 22 20.2093 22 18H20ZM20 8.76501V18H22V8.76501H20ZM18.9713 7.01712C19.606 7.36984 20 8.03935 20 8.76501H22C22 7.31266 21.212 5.97417 19.9427 5.26889L18.9713 7.01712ZM12.9714 3.68418L18.9714 7.01718L19.9426 5.26883L13.9426 1.93583L12.9714 3.68418ZM11.0284 3.6843C11.6325 3.34891 12.3675 3.34891 12.9716 3.6843L13.9424 1.93571C12.7345 1.2651 11.2655 1.2651 10.0576 1.93571L11.0284 3.6843ZM5.02861 7.01718L11.0286 3.68418L10.0574 1.93583L4.05739 5.26883L5.02861 7.01718ZM4 8.76501C4 8.03854 4.39379 7.36993 5.02872 7.01712L4.05728 5.26889C2.78821 5.97408 2 7.31147 2 8.76501H4ZM4 18V8.76501H2V18H4ZM15 20H9V22H15V20ZM14 14.25V21H16V14.25H14ZM13 13C13.4452 13 14 13.4452 14 14.25H16C16 12.5686 14.7648 11 13 11V13ZM11 13H13V11H11V13ZM10 14.25C10 13.4452 10.5548 13 11 13V11C9.23524 11 8 12.5686 8 14.25H10ZM10 21V14.25H8V21H10Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="lock-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-lock-alt fa-w-14 fa-7x"><path fill="currentColor" d="M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zM264 392c0 22.1-17.9 40-40 40s-40-17.9-40-40v-48c0-22.1 17.9-40 40-40s40 17.9 40 40v48zm32-168H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 550 B

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 8C4.66667 8 6 8.4 6 10C6 11.6 6 15.3333 6 17C6 17.6667 6.4 19 8 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="5.5" cy="4.5" r="1.5" fill="currentColor"/>
<path d="M9 5H17C17.6667 5 19 5.4 19 7C19 8.6 19 15 19 18C19 18.6667 18.6 20 17 20M9 8H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="12.5" cy="14.5" r="3.5" stroke="currentColor" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 535 B

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="2" fill="currentColor"/>
<circle cx="19" cy="12" r="2" fill="currentColor"/>
<circle cx="5" cy="12" r="2" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

View file

@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 18C12.1046 18 13 17.1046 13 16C13 14.8954 12.1046 14 11 14C9.89543 14 9 14.8954 9 16C9 17.1046 9.89543 18 11 18Z" fill="currentColor"/>
<path d="M7.15385 3C7.15385 3.55228 6.67169 4 6.07692 4C5.48215 4 5 3.55228 5 3C5 2.44772 5.48215 2 6.07692 2C6.67169 2 7.15385 2.44772 7.15385 3Z" fill="currentColor"/>
<path d="M19 3C19 3.55228 18.5178 4 17.9231 4C17.3283 4 16.8462 3.55228 16.8462 3C16.8462 2.44772 17.3283 2 17.9231 2C18.5178 2 19 2.44772 19 3Z" fill="currentColor"/>
<path d="M6.07692 2H17.9231V4H6.07692V2Z" fill="currentColor"/>
<rect x="11" y="11" width="2" height="5" fill="currentColor"/>
<circle cx="12" cy="11" r="1" fill="currentColor"/>
<circle cx="14" cy="11" r="1" fill="currentColor"/>
<rect x="12" y="10" width="2" height="2" fill="currentColor"/>
<path d="M4 10V18C4 19.6569 5.34315 21 7 21H17C18.6569 21 20 19.6569 20 18V10C20 8.34315 18.6569 7 17 7H7C5.34315 7 4 8.34315 4 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 16.5C13 17.8807 11.8807 19 10.5 19C9.11929 19 8 17.8807 8 16.5C8 15.1193 9.11929 14 10.5 14C11.8807 14 13 15.1193 13 16.5Z" stroke="currentColor" stroke-width="2"/>
<path d="M13.2572 5.49711L15.9021 4.43917C16.4828 4.20687 17.1351 4.54037 17.2868 5.14719C17.4097 5.63869 17.1577 6.14671 16.692 6.34628L14.6061 7.24025C14.2384 7.39783 14 7.75937 14 8.1594V16.5L12 15V7.35407C12 6.53626 12.4979 5.80084 13.2572 5.49711Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 5V19M16.0239 12.7809L8.6247 18.7002C7.96993 19.2241 7 18.7579 7 17.9194V6.08062C7 5.24212 7.96993 4.77595 8.6247 5.29976L16.0239 11.2191C16.5243 11.6195 16.5243 12.3805 16.0239 12.7809Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="5" width="4" height="14" rx="1.1" stroke="currentColor" stroke-width="2"/>
<rect x="14" y="5" width="4" height="14" rx="1.1" stroke="currentColor" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 2a2 2 0 00-2 2v12a2 2 0 002 2h6a2 2 0 002-2V4a2 2 0 00-2-2H7zm3 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 17.783V6.21701C6 5.03661 7.30033 4.31873 8.29922 4.94766L17.484 10.7307C18.4183 11.319 18.4183 12.681 17.484 13.2693L8.29922 19.0523C7.30033 19.6813 6 18.9634 6 17.783Z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 17.783V6.21701C6 5.03661 7.30033 4.31873 8.29922 4.94766L17.484 10.7307C18.4183 11.319 18.4183 12.681 17.484 13.2693L8.29922 19.0523C7.30033 19.6813 6 18.9634 6 17.783Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 16.5C18 17.8807 16.8807 19 15.5 19C14.1193 19 13 17.8807 13 16.5C13 15.1193 14.1193 14 15.5 14C16.8807 14 18 15.1193 18 16.5Z" stroke="currentColor" stroke-width="2"/>
<path d="M18.2572 5.49711L20.9021 4.43917C21.4828 4.20687 22.1351 4.54037 22.2868 5.14719C22.4097 5.63869 22.1577 6.14671 21.692 6.34628L19.6061 7.24025C19.2384 7.39783 19 7.75937 19 8.1594V16.5L17 15V7.35407C17 6.53626 17.4979 5.80084 18.2572 5.49711Z" fill="currentColor"/>
<path d="M5 6H14M5 10H14M5 14H10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M5 18H10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14C13.1046 14 14 13.1046 14 12C14 10.8954 13.1046 10 12 10C10.8954 10 10 10.8954 10 12C10 13.1046 10.8954 14 12 14Z" fill="currentColor"/>
<path d="M14 17C14 15.895 13.105 15 12 15C10.895 15 10 15.895 10 17C10 17.749 10.677 20.83 10.677 20.83L10.683 20.829C10.762 21.49 11.318 22.004 12 22.004C12.682 22.004 13.238 21.49 13.317 20.829L13.323 20.83C13.323 20.83 14 17.741 14 17Z" fill="currentColor"/>
<path d="M11.271 6.043C8.675 6.351 6.517 8.397 6.086 10.975C5.83 12.507 6.161 13.953 6.888 15.131C7.322 15.834 8.361 15.775 8.684 15.015C8.808 14.724 8.798 14.386 8.628 14.12C8.037 13.197 7.803 12.029 8.188 10.781C8.597 9.455 9.718 8.412 11.071 8.105C13.679 7.515 16 9.491 16 12C16 12.783 15.764 13.506 15.371 14.121C15.201 14.386 15.193 14.725 15.316 15.015C15.639 15.775 16.678 15.835 17.112 15.132C17.674 14.22 18 13.148 18 12C18 8.451 14.904 5.613 11.271 6.043Z" fill="currentColor"/>
<path d="M10.095 2.177C6.08201 2.922 2.87 6.169 2.16 10.189C1.384 14.584 3.502 18.578 6.921 20.601C7.66 21.038 8.572 20.381 8.406 19.539C8.35 19.256 8.17 19.021 7.922 18.873C5.231 17.271 3.551 14.14 4.106 10.682C4.665 7.203 7.58001 4.443 11.081 4.05C15.902 3.511 20 7.286 20 12C20 14.923 18.422 17.48 16.075 18.875C15.828 19.022 15.647 19.257 15.592 19.539C15.426 20.38 16.335 21.041 17.073 20.604C20.019 18.864 22 15.662 22 12C22 5.863 16.443 0.999003 10.095 2.177Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 5V19M7.97609 12.7809L15.3753 18.7002C16.0301 19.2241 17 18.7579 17 17.9194V6.08062C17 5.24212 16.0301 4.77595 15.3753 5.29976L7.97609 11.2191C7.47568 11.6195 7.47568 12.3805 7.97609 12.7809Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2V5h1v1H5zM3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm2 2v-1h1v1H5zM13 3a1 1 0 00-1 1v3a1 1 0 001 1h3a1 1 0 001-1V4a1 1 0 00-1-1h-3zm1 2v1h1V5h-1z" clip-rule="evenodd" />
<path d="M11 4a1 1 0 10-2 0v1a1 1 0 002 0V4zM10 7a1 1 0 011 1v1h2a1 1 0 110 2h-3a1 1 0 01-1-1V8a1 1 0 011-1zM16 9a1 1 0 100 2 1 1 0 000-2zM9 13a1 1 0 011-1h1a1 1 0 110 2v2a1 1 0 11-2 0v-3zM7 11a1 1 0 100-2H4a1 1 0 100 2h3zM17 13a1 1 0 01-1 1h-2a1 1 0 110-2h2a1 1 0 011 1zM16 17a1 1 0 100-2h-3a1 1 0 100 2h3z" />
</svg>

After

Width:  |  Height:  |  Size: 708 B

View file

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11.5V11C5 8.79086 6.79086 7 9 7H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M19 12.5V13C19 15.2091 17.2091 17 15 17H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M10.5 9.34761V4.65239C10.5 4.57149 10.5911 4.52408 10.6573 4.57047L14.0111 6.91808C14.0679 6.95789 14.0679 7.04211 14.0111 7.08192L10.6573 9.42953C10.5911 9.47592 10.5 9.42851 10.5 9.34761Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
<path d="M8 14.5597V19.2516C8 19.3355 7.90301 19.3821 7.83753 19.3297L4.48617 16.6486C4.43175 16.6051 4.43743 16.5206 4.49719 16.4848L7.84855 14.474C7.9152 14.434 8 14.482 8 14.5597Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
<path d="M19 5V9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M17 6L19 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1,018 B

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11.5V11C5 8.79086 6.79086 7 9 7H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M19 12.5V13C19 15.2091 17.2091 17 15 17H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16 9.34761V4.65239C16 4.57149 16.0911 4.52408 16.1573 4.57047L19.5111 6.91808C19.5679 6.95789 19.5679 7.04211 19.5111 7.08192L16.1573 9.42953C16.0911 9.47592 16 9.42851 16 9.34761Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
<path d="M8 14.5597V19.2516C8 19.3355 7.90301 19.3821 7.83753 19.3297L4.48617 16.6486C4.43175 16.6051 4.43743 16.5206 4.49719 16.4848L7.84855 14.474C7.9152 14.434 8 14.482 8 14.5597Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View file

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="7.5" cy="7.5" r="6.5" stroke="currentColor" stroke-width="2"/>
<path d="M13.2071 11.7929L12.5 11.0858L11.0858 12.5L11.7929 13.2071L13.2071 11.7929ZM16.2929 17.7071C16.6834 18.0976 17.3166 18.0976 17.7071 17.7071C18.0976 17.3166 18.0976 16.6834 17.7071 16.2929L16.2929 17.7071ZM11.7929 13.2071L16.2929 17.7071L17.7071 16.2929L13.2071 11.7929L11.7929 13.2071Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 7H5.73524C7.14029 7 8.44232 7.7372 9.16521 8.94202L11 12L13.3057 15.2938C14.0542 16.3632 15.2774 17 16.5826 17H17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4.5 17H5.73524C7.14029 17 8.44232 16.2628 9.16521 15.058L11 12L13.3057 8.70615C14.0542 7.63685 15.2774 7 16.5826 7H17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16.5 9.34761V4.65239C16.5 4.57149 16.5911 4.52408 16.6573 4.57047L20.0111 6.91808C20.0679 6.95789 20.0679 7.04211 20.0111 7.08192L16.6573 9.42953C16.5911 9.47592 16.5 9.42851 16.5 9.34761Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
<path d="M16.5 19.3476V14.6524C16.5 14.5715 16.5911 14.5241 16.6573 14.5705L20.0111 16.9181C20.0679 16.9579 20.0679 17.0421 20.0111 17.0819L16.6573 19.4295C16.5911 19.4759 16.5 19.4285 16.5 19.3476Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1,023 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="8" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M6 19V19C6 16.7909 7.79086 15 10 15H14C16.2091 15 18 16.7909 18 19V19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

Some files were not shown because too many files have changed in this diff Show more