feat: monorepo
124
packages/electron/.electron-builder.config.js
Normal 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}',
|
||||
],
|
||||
}
|
||||
BIN
packages/electron/assets/icons/taskbar/next.png
Normal file
|
After Width: | Height: | Size: 936 B |
BIN
packages/electron/assets/icons/taskbar/pause.png
Normal file
|
After Width: | Height: | Size: 612 B |
BIN
packages/electron/assets/icons/taskbar/play.png
Normal file
|
After Width: | Height: | Size: 844 B |
BIN
packages/electron/assets/icons/taskbar/previous.png
Normal file
|
After Width: | Height: | Size: 890 B |
BIN
packages/electron/assets/icons/tray/exit.png
Normal file
|
After Width: | Height: | Size: 223 B |
BIN
packages/electron/assets/icons/tray/left.png
Normal file
|
After Width: | Height: | Size: 191 B |
BIN
packages/electron/assets/icons/tray/like.png
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
packages/electron/assets/icons/tray/menu.png
Normal file
|
After Width: | Height: | Size: 311 B |
BIN
packages/electron/assets/icons/tray/menu@88.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/electron/assets/icons/tray/pause.png
Normal file
|
After Width: | Height: | Size: 953 B |
BIN
packages/electron/assets/icons/tray/play.png
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
packages/electron/assets/icons/tray/repeat.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
packages/electron/assets/icons/tray/right.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
packages/electron/assets/icons/tray/unlike.png
Normal file
|
After Width: | Height: | Size: 932 B |
288
packages/electron/cache.ts
Normal 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
|
|
@ -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
|
|
@ -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()
|
||||
155
packages/electron/ipcMain.ts
Normal 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
|
|
@ -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`)
|
||||
9
packages/electron/migrations/init.sql
Normal 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));
|
||||
63
packages/electron/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
packages/electron/preload.ts
Normal 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')}`)
|
||||
36
packages/electron/rendererPreload.ts
Normal 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',
|
||||
})
|
||||
113
packages/electron/scripts/build.main.mjs
Normal 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()
|
||||
})
|
||||
}
|
||||
135
packages/electron/scripts/build.sqlite3.js
Normal 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()
|
||||
19
packages/electron/sentry.ts
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
19
packages/electron/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
packages/electron/utils.ts
Normal 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, '')
|
||||
}
|
||||
}
|
||||
84
packages/electron/windowsTaskbar.ts
Normal 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)
|
||||
}
|
||||
72
packages/shared/CacheAPIs.ts
Normal 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
|
||||
}
|
||||
77
packages/shared/IpcChannels.ts
Normal 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
|
||||
}
|
||||
23
packages/shared/api/Album.ts
Normal 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
|
||||
}
|
||||
28
packages/shared/api/Artist.ts
Normal 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
|
||||
}
|
||||
48
packages/shared/api/Playlists.ts
Normal 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
|
||||
}
|
||||
82
packages/shared/api/Search.ts
Normal 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'>
|
||||
}
|
||||
}
|
||||
104
packages/shared/api/Track.ts
Normal 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 999000,320000 = 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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
5
packages/shared/playerDataTypes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum RepeatMode {
|
||||
Off = 'off',
|
||||
On = 'on',
|
||||
One = 'one',
|
||||
}
|
||||
46
packages/shared/store.ts
Normal 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: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
19
packages/shared/tsconfig.json
Normal 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
|
|
@ -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
|
||||
62
packages/web/IpcRendererReact.tsx
Normal 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
|
|
@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
32
packages/web/api/artist.ts
Normal 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
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
119
packages/web/api/personalFM.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
64
packages/web/api/playlist.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
31
packages/web/api/search.ts
Normal 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
|
|
@ -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
|
|
@ -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,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
29
packages/web/api/yesplaymusic.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
BIN
packages/web/assets/fonts/Barlow-Black.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Black.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-Bold.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Bold.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-ExtraBold.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-ExtraBold.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-Medium.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Medium.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-Regular.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Regular.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-SemiBold.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-SemiBold.woff2
Normal file
3
packages/web/assets/icons/back.svg
Normal 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 |
3
packages/web/assets/icons/dislike.svg
Normal 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 |
4
packages/web/assets/icons/dj.svg
Normal 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 |
4
packages/web/assets/icons/email.svg
Normal 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 |
3
packages/web/assets/icons/explicit.svg
Normal 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 |
3
packages/web/assets/icons/eye-off.svg
Normal 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 |
4
packages/web/assets/icons/eye.svg
Normal 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 |
7
packages/web/assets/icons/fm.svg
Normal 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 |
3
packages/web/assets/icons/forward.svg
Normal 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 |
3
packages/web/assets/icons/heart-outline.svg
Normal 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 |
3
packages/web/assets/icons/heart.svg
Normal 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 |
3
packages/web/assets/icons/home.svg
Normal 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 |
1
packages/web/assets/icons/lock.svg
Normal 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 |
6
packages/web/assets/icons/lyrics.svg
Normal 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 |
5
packages/web/assets/icons/more.svg
Normal 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 |
11
packages/web/assets/icons/music-library.svg
Normal 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 |
4
packages/web/assets/icons/music-note.svg
Normal 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 |
3
packages/web/assets/icons/next.svg
Normal 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 |
4
packages/web/assets/icons/pause.svg
Normal 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 |
3
packages/web/assets/icons/phone.svg
Normal 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 |
3
packages/web/assets/icons/play-fill.svg
Normal 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 |
3
packages/web/assets/icons/play.svg
Normal 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 |
6
packages/web/assets/icons/playlist.svg
Normal 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 |
6
packages/web/assets/icons/podcast.svg
Normal 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 |
3
packages/web/assets/icons/previous.svg
Normal 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 |
4
packages/web/assets/icons/qrcode.svg
Normal 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 |
8
packages/web/assets/icons/repeat-1.svg
Normal 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 |
6
packages/web/assets/icons/repeat.svg
Normal 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 |
4
packages/web/assets/icons/search.svg
Normal 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 |
4
packages/web/assets/icons/settings.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
6
packages/web/assets/icons/shuffle.svg
Normal 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 |
4
packages/web/assets/icons/user.svg
Normal 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 |