feat: updates

This commit is contained in:
qier222 2022-08-03 23:48:39 +08:00
parent 47e41dea9b
commit ebebf2a733
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
160 changed files with 4148 additions and 2001 deletions

View file

@ -1,6 +1,6 @@
{
"name": "yesplamusic",
"productName": "YesPlayMusic",
"productName": "YesPlayMusic-alpha",
"private": true,
"version": "2.0.0",
"description": "A nifty third-party NetEase Music player",

View file

@ -3,9 +3,11 @@
* @see https://www.electron.build/configuration/configuration
*/
const pkg = require('../../package.json')
module.exports = {
appId: 'com.qier222.yesplaymusic',
productName: 'YesPlayMusic',
appId: 'com.qier222.yesplaymusic.alpha',
productName: pkg.productName,
copyright: 'Copyright © 2022 qier222',
asar: false,
directories: {
@ -99,7 +101,7 @@ module.exports = {
},
files: [
'!**/*.ts',
'!**/node_modules/better-sqlite3/{bin,build,deps}/**',
// '!**/node_modules/better-sqlite3/{bin,build,deps}/**',
'!**/node_modules/*/{*.MD,*.md,README,readme}',
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
'!**/node_modules/*.d.ts',

View file

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 936 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 612 B

After

Width:  |  Height:  |  Size: 612 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 844 B

After

Width:  |  Height:  |  Size: 844 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 890 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 223 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 191 B

After

Width:  |  Height:  |  Size: 191 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 308 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 953 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 396 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 344 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 932 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 523 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 474 B

After

Width:  |  Height:  |  Size: 474 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 750 B

After

Width:  |  Height:  |  Size: 750 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 965 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -0,0 +1,283 @@
import { $ } from 'zx'
import store from './store'
import { ipcMain, BrowserWindow } from 'electron'
import { getNetworkInfo, sleep } from './utils'
import { spawn, ChildProcessWithoutNullStreams } from 'child_process'
import log from './log'
type Protocol = 'dmap' | 'mrp' | 'airplay' | 'companion' | 'raop'
interface Device {
name: string
address: string
identifier: string
services: { protocol: Protocol; port: number }[]
}
class Airplay {
devices: Device[] = []
pairProcess: ChildProcessWithoutNullStreams | null = null
window: BrowserWindow
constructor(window: BrowserWindow) {
log.debug('[airplay] ini')
this.window = window
this.initIpc()
}
async checkIsInstalled() {
const help = (await $`atvscript -h`).toString()
return String(help).includes('usage: atvscript')
}
async scanDevices(excludeThisDevice: boolean = true) {
if (!this.checkIsInstalled()) {
return {
result: 'failure',
error: 'pyatv is not installed',
}
}
let scanResult = null
try {
scanResult = await $`atvscript scan`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
let json = null
try {
json = JSON.parse(scanResult.toString())
} catch (e) {
return {
result: 'failure',
error: String(e),
}
}
if (excludeThisDevice) {
const macAddress = getNetworkInfo()?.mac
if (macAddress) {
json.devices = json.devices.filter(
(device: Device) =>
device.identifier.toLowerCase() !== macAddress.toLowerCase()
)
}
}
if (json.result === 'success') {
this.devices = json.devices
}
return json
}
async pairDevice(deviceID: string, protocol: Protocol) {
this.pairProcess = spawn('atvremote', [
'--id',
deviceID,
'--protocol',
protocol,
'pair',
])
let paired = false
let done = false
this.pairProcess.stdout.on('data', (data: any) => {
console.log('stdout', String(data))
if (data.includes('You may now use these credentials:')) {
store.set(
`airplay.credentials.${deviceID}`,
String(data).split('credentials:')[1].trim()
)
paired = true
done = true
}
if (data.includes('Pairing failed')) {
paired = false
done = true
}
})
while (!done) {
console.log('not done yet')
await sleep(1000)
}
return paired
}
async enterPairPin(pin: string) {
if (!this.pairProcess) {
return false
}
this.pairProcess.stdin.write(`${pin}\n`)
return true
}
async playUrl(deviceID: string, url: string) {
log.debug(`[airplay] playUrl ${url}`)
const credentials: string = store.get(`airplay.credentials.${deviceID}`)
if (url.includes('127.0.0.1')) {
const ip = getNetworkInfo()?.address
if (ip) url = url.replace('127.0.0.1', ip)
}
try {
spawn('atvremote', [
'--id',
deviceID,
'--airplay-credentials',
credentials,
`play_url=${url}`,
])
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
}
async getPlaying(deviceID: string) {
if (!this.checkIsInstalled()) {
return {
result: 'failure',
error: 'pyatv is not installed',
}
}
const credentials = store.get(`airplay.credentials.${deviceID}`)
let playing = null
try {
playing =
await $`atvscript --id ${deviceID} --airplay-credentials=${credentials} playing`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
let json = null
try {
json = JSON.parse(playing.toString())
} catch (e) {
return {
result: 'failure',
error: String(e),
}
}
return json
}
async play(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} play`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async pause(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} pause`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async playOrPause(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} play_pause`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async setProgress(deviceID: string, progress: number) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvremote --id ${deviceID} --airplay-credentials ${credentials} set_position=${progress}`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async pushUpdates(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
let updates = null
try {
updates = $`atvscript --id ${deviceID} --airplay-credentials ${credentials} push_updates`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
for await (const chunk of updates.stdout) {
this.window.webContents.send('airplay-updates', chunk)
}
}
async initIpc() {
ipcMain.handle('airplay-scan-devices', () => {
return this.scanDevices()
})
ipcMain.handle('airplay-pair', async () => {
console.log('airplay-pair')
return this.pairDevice('58:D3:49:F0:C9:71', 'airplay')
})
ipcMain.handle('airplay-pair-enter-pin', async (e, pin) => {
return this.enterPairPin(pin)
})
ipcMain.handle('airplay-play-url', async (e, { deviceID, url }) => {
return this.playUrl(deviceID, url)
})
ipcMain.handle('airplay-get-playing', async (e, { deviceID }) => {
return this.getPlaying(deviceID)
})
}
}
export default Airplay

View file

@ -41,13 +41,14 @@ export const getAlbum = async ({
log.debug('[appleMusic] Search album error', e)
})
const albums = searchResult?.data?.results?.albums?.data as AppleMusicAlbum[]
const albums: AppleMusicAlbum[] | undefined =
searchResult?.data?.results?.albums?.data
const album =
albums.find(
albums?.find(
a =>
a.attributes.name.toLowerCase() === name.toLowerCase() &&
a.attributes.artistName.toLowerCase() === artist.toLowerCase()
) || albums[0]
) || albums?.[0]
if (!album) {
log.debug('[appleMusic] No album found on apple music')
return

View file

@ -7,7 +7,6 @@ import {
app,
shell,
} from 'electron'
import Store from 'electron-store'
import { release } from 'os'
import { join } from 'path'
import log from './log'
@ -17,30 +16,13 @@ import { IpcChannels } from '@/shared/IpcChannels'
import { createTaskbar, Thumbar } from './windowsTaskbar'
import { createMenu } from './menu'
import { isDev, isWindows, isLinux, isMac } from './utils'
export interface TypedElectronStore {
window: {
width: number
height: number
x?: number
y?: number
}
// settings: State['settings']
}
import store from './store'
import Airplay from './airplay'
class Main {
win: BrowserWindow | null = null
tray: YPMTray | null = null
thumbar: Thumbar | null = null
store = new Store<TypedElectronStore>({
defaults: {
window: {
width: 1440,
height: 1024,
},
// settings: initialState.settings,
},
})
constructor() {
log.info('[index] Main process start')
@ -63,10 +45,11 @@ class Main {
this.handleAppEvents()
this.handleWindowEvents()
this.createTray()
createMenu(this.win!)
createMenu(this.win)
this.createThumbar()
initIpcMain(this.win, this.tray, this.thumbar, this.store)
initIpcMain(this.win, this.tray, this.thumbar, store)
this.initDevTools()
new Airplay(this.win)
})
}
@ -102,8 +85,8 @@ class Main {
webPreferences: {
preload: join(__dirname, 'rendererPreload.js'),
},
width: this.store.get('window.width'),
height: this.store.get('window.height'),
width: store.get('window.width'),
height: store.get('window.height'),
minWidth: 1240,
minHeight: 848,
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
@ -111,9 +94,9 @@ class Main {
frame: false,
transparent: true,
}
if (this.store.get('window')) {
options.x = this.store.get('window.x')
options.y = this.store.get('window.y')
if (store.get('window')) {
options.x = store.get('window.x')
options.y = store.get('window.y')
}
this.win = new BrowserWindow(options)
@ -186,11 +169,21 @@ class Main {
this.win && this.win.webContents.send(IpcChannels.IsMaximized, false)
})
this.win.on('enter-full-screen', () => {
this.win &&
this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
})
this.win.on('leave-full-screen', () => {
this.win &&
this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
})
// Save window position
const saveBounds = () => {
const bounds = this.win?.getBounds()
if (bounds) {
this.store.set('window', bounds)
store.set('window', bounds)
}
}
this.win.on('resized', saveBounds)

View file

@ -5,7 +5,7 @@ import cache from './cache'
import log from './log'
import fs from 'fs'
import Store from 'electron-store'
import { TypedElectronStore } from './index'
import { TypedElectronStore } from './store'
import { APIs } from '@/shared/CacheAPIs'
import { YPMTray } from './tray'
import { Thumbar } from './windowsTaskbar'
@ -66,6 +66,11 @@ function initWindowIpcMain(win: BrowserWindow | null) {
if (!win) return
win?.setSize(1440, 1024, true)
})
on(IpcChannels.IsMaximized, e => {
if (!win) return
e.returnValue = win.isMaximized()
})
}
/**

View file

@ -1,4 +1,5 @@
import {
app,
BrowserWindow,
Menu,
MenuItem,
@ -28,6 +29,18 @@ export const createMenu = (win: BrowserWindow) => {
}
},
},
{
label: '打开应用数据目录',
click: async () => {
const path = app.getPath('userData')
if (isMac) {
exec(`open ${path}`)
} else {
// TODO: 测试Windows和Linux是否能正确打开日志目录
shell.openPath(path)
}
},
},
{
label: '打开开发者工具',
click: async () => {

View file

@ -1,5 +1,4 @@
import * as Sentry from '@sentry/node'
import * as Tracing from '@sentry/tracing'
import * as Sentry from '@sentry/electron'
import pkg from '../../../package.json'
import log from './log'

View file

@ -13,6 +13,7 @@ import UNM from '@unblockneteasemusic/rust-napi'
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
import { isProd } from './utils'
import { APIs } from '@/shared/CacheAPIs'
import history from 'connect-history-api-fallback'
class Server {
port = Number(
@ -77,7 +78,8 @@ class Server {
serveStaticForProduction() {
if (isProd) {
this.app.use('/', express.static(path.join(__dirname, '../web/')))
this.app.use(history())
this.app.use(express.static(path.join(__dirname, '../web')))
}
}
@ -274,7 +276,7 @@ class Server {
return
}
} catch (error) {
log.error(`[server] getFromNetease failed: ${String(error)}`)
log.error(`[server] getFromUNM failed: ${String(error)}`)
}
if (fromNetease?.data?.[0].freeTrialInfo) {
@ -331,7 +333,7 @@ class Server {
}
listen() {
this.app.listen(this.port, () => {
this.app.listen(this.port, '0.0.0.0', () => {
log.info(`[server] API server listening on port ${this.port}`)
})
}

View file

@ -0,0 +1,31 @@
import Store from 'electron-store'
export interface TypedElectronStore {
window: {
width: number
height: number
x?: number
y?: number
}
// settings: State['settings']
airplay: {
credentials: {
[key: string]: string
}
}
}
const store = new Store<TypedElectronStore>({
defaults: {
window: {
width: 1440,
height: 1024,
},
// settings: initialState.settings,
airplay: {
credentials: {},
},
},
})
export default store

View file

@ -1,5 +1,6 @@
import fs from 'fs'
import path from 'path'
import os from 'os'
import pkg from '../../../package.json'
export const isDev = process.env.NODE_ENV === 'development'
@ -31,3 +32,9 @@ export const createFileIfNotExist = (file: string) => {
fs.writeFileSync(file, '')
}
}
export const getNetworkInfo = () => {
return os.networkInterfaces().en0?.find(n => n.family === 'IPv4')
}
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

View file

@ -8,6 +8,6 @@ CREATE TABLE IF NOT EXISTS "Track" ("id" integer NOT NULL,"json" text NOT NULL,"
CREATE TABLE IF NOT EXISTS "AppData" ("id" text NOT NULL,"value" text, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "CoverColor" ("id" integer NOT NULL,"color" text NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"source" text NOT NULL,"updatedAt" int NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"source" text NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "AppleMusicArtist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "AppleMusicAlbum" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));

View file

@ -1,5 +1,6 @@
{
"name": "electron",
"name": "desktop",
"productName": "YesPlayMusic-alpha",
"private": true,
"version": "2.0.0",
"main": "./main/index.js",
@ -11,26 +12,31 @@
"pack": "electron-builder build -c .electron-builder.config.js",
"test:types": "tsc --noEmit --project ./tsconfig.json",
"lint": "eslint --ext .ts,.js ./",
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'"
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"dependencies": {
"@sentry/node": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@sentry/electron": "^3.0.7",
"@unblockneteasemusic/rust-napi": "^0.3.0",
"NeteaseCloudMusicApi": "^4.6.2",
"NeteaseCloudMusicApi": "^4.6.7",
"better-sqlite3": "7.5.1",
"change-case": "^4.1.2",
"chromecast-api": "^0.4.0",
"compare-versions": "^4.1.3",
"connect-history-api-fallback": "^2.0.0",
"cookie-parser": "^1.4.6",
"electron-log": "^4.4.6",
"electron-store": "^8.0.1",
"electron-log": "^4.4.8",
"electron-store": "^8.0.2",
"express": "^4.18.1",
"fast-folder-size": "^1.7.0",
"m3u8-parser": "^4.7.1",
"pretty-bytes": "^6.0.0"
"pretty-bytes": "^6.0.0",
"zx": "^7.0.7"
},
"devDependencies": {
"@electron/universal": "1.2.1",
@ -38,27 +44,29 @@
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13",
"@types/express-fileupload": "^1.2.2",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7",
"@vitejs/plugin-react": "^1.3.1",
"@vitest/ui": "^0.12.10",
"axios": "^0.27.2",
"cross-env": "^7.0.3",
"dotenv": "^16.0.0",
"electron": "^19.0.3",
"electron-builder": "^23.0.3",
"electron": "^19.0.8",
"electron-builder": "23.3.1",
"electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.7",
"electron-releases": "^3.1026.0",
"esbuild": "^0.14.42",
"electron-rebuild": "^3.2.8",
"electron-releases": "^3.1072.0",
"esbuild": "^0.14.49",
"eslint": "*",
"express-fileupload": "^1.4.0",
"minimist": "^1.2.6",
"music-metadata": "^7.12.3",
"music-metadata": "^7.12.4",
"open-cli": "^7.0.1",
"ora": "^6.1.0",
"ora": "^6.1.2",
"picocolors": "^1.0.0",
"prettier": "*",
"typescript": "*",
"vitest": "^0.12.10",
"wait-on": "^6.0.1"
},
"resolutions": {

View file

@ -25,8 +25,8 @@ const argv = minimist(process.argv.slice(2))
const projectDir = path.resolve(process.cwd(), '../../')
if (!fs.existsSync(`${projectDir}/packages/electron/dist/binary`)) {
fs.mkdirSync(`${projectDir}/packages/electron/dist/binary`, {
if (!fs.existsSync(`${projectDir}/packages/desktop/dist/binary`)) {
fs.mkdirSync(`${projectDir}/packages/desktop/dist/binary`, {
recursive: true,
})
}
@ -71,7 +71,7 @@ const download = async arch => {
try {
fs.copyFileSync(
`${dir}/build/Release/better_sqlite3.node`,
`${projectDir}/packages/electron/dist/binary/better_sqlite3_${arch}.node`
`${projectDir}/packages/desktop/dist/binary/better_sqlite3_${arch}.node`
)
} catch (e) {
console.log(pc.red('Copy failed! Skip copy.', e))
@ -105,7 +105,7 @@ const build = async arch => {
console.info('Build succeeded')
fs.copyFileSync(
`${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`,
`${projectDir}/packages/electron/dist/binary/better_sqlite3_${arch}.node`
`${projectDir}/packages/desktop/dist/binary/better_sqlite3_${arch}.node`
)
})
.catch(e => {

View file

@ -0,0 +1,10 @@
import { expect, test, describe, vi } from 'vitest'
import { pairDevice, scanDevices } from '../main/airplay'
// test('scanDevices', async () => {
// console.log(await scanDevices())
// }, 10000000)
// test('pairDevice', async () => {
// console.log(await pairDevice('58:D3:49:F0:C9:71', 'airplay'))
// }, 100000)

View file

@ -10,9 +10,9 @@
"resolveJsonModule": true,
"strict": true,
"jsx": "react-jsx",
"baseUrl": "./",
"baseUrl": "../",
"paths": {
"@/*": ["../*"]
"@/*": ["./*"]
}
},
"include": ["./**/*.ts", "../shared/**/*.ts"]

View file

@ -8,6 +8,7 @@ export const enum IpcChannels {
MaximizeOrUnmaximize = 'MaximizeOrUnmaximize',
Close = 'Close',
IsMaximized = 'IsMaximized',
FullscreenStateChange = 'FullscreenStateChange',
GetApiCacheSync = 'GetApiCacheSync',
DevDbExportJson = 'DevDbExportJson',
CacheCoverColor = 'CacheCoverColor',
@ -34,6 +35,7 @@ export interface IpcChannelsParams {
[IpcChannels.MaximizeOrUnmaximize]: void
[IpcChannels.Close]: void
[IpcChannels.IsMaximized]: void
[IpcChannels.FullscreenStateChange]: void
[IpcChannels.GetApiCacheSync]: {
api: APIs
query?: any
@ -75,6 +77,7 @@ export interface IpcChannelsReturns {
[IpcChannels.MaximizeOrUnmaximize]: void
[IpcChannels.Close]: void
[IpcChannels.IsMaximized]: boolean
[IpcChannels.FullscreenStateChange]: boolean
[IpcChannels.GetApiCacheSync]: any
[IpcChannels.DevDbExportJson]: void
[IpcChannels.CacheCoverColor]: void

View file

@ -2,6 +2,7 @@ export enum ArtistApiNames {
FetchArtist = 'fetchArtist',
FetchArtistAlbums = 'fetchArtistAlbums',
FetchSimilarArtists = 'fetchSimilarArtists',
FetchArtistMV = 'fetchArtistMV',
}
// 歌手详情
@ -36,3 +37,39 @@ export interface FetchSimilarArtistsResponse {
code: number
artists: Artist[]
}
// 获取歌手MV
export interface FetchArtistMVParams {
id: number
offset?: number
limit?: number
}
export interface FetchArtistMVResponse {
code: number
hasMore: boolean
time: number
mvs: {
artist: Artist
artistName: string
duration: number
id: number
imgurl: string
imgurl16v9: string
name: string
playCount: number
publishTime: string
status: number
subed: boolean
}[]
}
// 收藏歌手
export interface LikeAArtistParams {
id: number
like: boolean
}
export interface LikeAArtistResponse {
code: number
data: null
message: string
}

80
packages/shared/api/MV.ts Normal file
View file

@ -0,0 +1,80 @@
export enum MVApiNames {
FetchMV = 'fetchMV',
FetchMVUrl = 'fetchMVUrl',
}
// MV详情
export interface FetchMVParams {
mvid: number
}
export interface FetchMVResponse {
code: number
loadingPic: string
bufferPic: string
loadingPicFS: string
bufferPicFS: string
data: {
artistId: number
artistName: string
artists: Artist[]
briefDesc: string
brs: {
br: number
point: number
size: number
}[]
commentCount: number
commentThreadId: string
cover: string
coverId: number
coverId_str: string
desc: string
duration: number
id: number
nType: number
name: string
playCount: number
price: null | unknown
publishTime: string
shareCount: number
subCount: number
videoGroup: unknown[]
}
mp: {
cp: number
dl: number
fee: number
id: number
msg: null | string
mvFee: number
normal: boolean
payed: number
pl: number
sid: number
st: number
unauthorized: boolean
}
}
// MV地址
export interface FetchMVUrlParams {
id: number
r?: number
}
export interface FetchMVUrlResponse {
code: number
data: {
code: number
expi: number
fee: number
id: number
md5: string
msg: string
mvFee: number
promotionVo: null | unknown
r: number
size: number
st: number
url: string
}
}

View file

@ -3,7 +3,7 @@ export enum UserApiNames {
FetchUserLikedTracksIds = 'fetchUserLikedTracksIDs',
FetchUserPlaylists = 'fetchUserPlaylists',
FetchUserAlbums = 'fetchUserAlbums',
FetchUserArtist = 'fetchUserArtists',
FetchUserArtists = 'fetchUserArtists',
FetchListenedRecords = 'fetchListenedRecords',
}

View file

@ -1,5 +1,5 @@
import { Toaster } from 'react-hot-toast'
import { QueryClientProvider } from 'react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from 'react-query/devtools'
import Player from '@/web/components/Player'
import Sidebar from '@/web/components/Sidebar'

View file

@ -8,15 +8,15 @@ import {
// 专辑详情
export function fetchAlbum(
params: FetchAlbumParams,
noCache: boolean
params: FetchAlbumParams
): Promise<FetchAlbumResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
return request({
url: '/album',
method: 'get',
params: { ...params, ...otherParams },
params: {
...params,
timestamp: new Date().getTime(),
},
})
}

View file

@ -6,19 +6,20 @@ import {
FetchArtistAlbumsResponse,
FetchSimilarArtistsParams,
FetchSimilarArtistsResponse,
FetchArtistMVParams,
FetchArtistMVResponse,
LikeAArtistParams,
LikeAArtistResponse,
} from '@/shared/api/Artist'
// 歌手详情
export function fetchArtist(
params: FetchArtistParams,
noCache: boolean
params: FetchArtistParams
): Promise<FetchArtistResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
return request({
url: '/artists',
method: 'get',
params: { ...params, ...otherParams },
params: { ...params, timestamp: new Date().getTime() },
})
}
@ -33,6 +34,7 @@ export function fetchArtistAlbums(
})
}
// 获取相似歌手
export function fetchSimilarArtists(
params: FetchSimilarArtistsParams
): Promise<FetchSimilarArtistsResponse> {
@ -42,3 +44,28 @@ export function fetchSimilarArtists(
params,
})
}
// 获取歌手MV
export function fetchArtistMV(
params: FetchArtistMVParams
): Promise<FetchArtistMVResponse> {
return request({
url: '/artist/mv',
method: 'get',
params,
})
}
// 收藏歌手
export function likeAArtist(
params: LikeAArtistParams
): Promise<LikeAArtistResponse> {
return request({
url: 'artist/sub',
method: 'get',
params: {
id: params.id,
t: Number(params.like),
},
})
}

View file

@ -30,7 +30,7 @@ export interface LoginWithEmailParams {
password?: string
md5_password?: string
}
export interface loginWithEmailResponse extends FetchUserAccountResponse {
export interface LoginWithEmailResponse extends FetchUserAccountResponse {
code: number
cookie: string
loginType: number
@ -49,7 +49,7 @@ export interface loginWithEmailResponse extends FetchUserAccountResponse {
}
export function loginWithEmail(
params: LoginWithEmailParams
): Promise<loginWithEmailResponse> {
): Promise<LoginWithEmailResponse> {
return request({
url: '/login',
method: 'post',
@ -58,14 +58,14 @@ export function loginWithEmail(
}
// 生成二维码key
export interface fetchLoginQrCodeKeyResponse {
export interface FetchLoginQrCodeKeyResponse {
code: number
data: {
code: number
unikey: string
}
}
export function fetchLoginQrCodeKey(): Promise<fetchLoginQrCodeKeyResponse> {
export function fetchLoginQrCodeKey(): Promise<FetchLoginQrCodeKeyResponse> {
return request({
url: '/login/qr/key',
method: 'get',

View file

@ -7,37 +7,33 @@ import {
AlbumApiNames,
FetchAlbumResponse,
} from '@/shared/api/Album'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
const fetch = async (params: FetchAlbumParams, noCache?: boolean) => {
const album = await fetchAlbum(params, !!noCache)
const fetch = async (params: FetchAlbumParams) => {
const album = await fetchAlbum(params)
if (album?.album?.songs) {
album.album.songs = album.songs
}
return album
}
const fetchFromCache = (id: number): FetchAlbumResponse =>
const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Album,
query: { id },
query: params,
})
export default function useAlbum(params: FetchAlbumParams, noCache?: boolean) {
return useQuery(
[AlbumApiNames.FetchAlbum, params.id],
() => fetch(params, noCache),
{
enabled: !!params.id,
staleTime: 24 * 60 * 60 * 1000, // 24 hours
placeholderData: () => fetchFromCache(params.id),
}
)
export default function useAlbum(params: FetchAlbumParams) {
return useQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
enabled: !!params.id,
staleTime: 24 * 60 * 60 * 1000, // 24 hours
placeholderData: () => fetchFromCache(params),
})
}
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
return reactQueryClient.fetchQuery(
[AlbumApiNames.FetchAlbum, params.id],
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),
{
staleTime: Infinity,
@ -46,9 +42,9 @@ export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
}
export async function prefetchAlbum(params: FetchAlbumParams) {
if (fetchFromCache(params.id)) return
if (fetchFromCache(params)) return
await reactQueryClient.prefetchQuery(
[AlbumApiNames.FetchAlbum, params.id],
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),
{
staleTime: Infinity,

View file

@ -6,25 +6,46 @@ import {
ArtistApiNames,
FetchArtistResponse,
} from '@/shared/api/Artist'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useArtist(
params: FetchArtistParams,
noCache?: boolean
) {
const fetchFromCache = (id: number): FetchArtistResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Artist,
query: {
id,
},
})
export default function useArtist(params: FetchArtistParams) {
return useQuery(
[ArtistApiNames.FetchArtist, params],
() => fetchArtist(params, !!noCache),
() => fetchArtist(params),
{
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 5 * 60 * 1000, // 5 mins
placeholderData: (): FetchArtistResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Artist,
query: {
id: params.id,
},
}),
placeholderData: () => fetchFromCache(params.id),
}
)
}
export function fetchArtistWithReactQuery(params: FetchArtistParams) {
return reactQueryClient.fetchQuery(
[ArtistApiNames.FetchArtist, params],
() => fetchArtist(params),
{
staleTime: Infinity,
}
)
}
export async function prefetchArtist(params: FetchArtistParams) {
if (fetchFromCache(params.id)) return
await reactQueryClient.prefetchQuery(
[ArtistApiNames.FetchArtist, params],
() => fetchArtist(params),
{
staleTime: Infinity,
}
)
}

View file

@ -6,9 +6,9 @@ import {
ArtistApiNames,
FetchArtistAlbumsResponse,
} from '@/shared/api/Artist'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
export default function useUserAlbums(params: FetchArtistAlbumsParams) {
export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
return useQuery(
[ArtistApiNames.FetchArtistAlbums, params],
async () => {

View file

@ -0,0 +1,30 @@
import { fetchArtistMV } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchArtistMVParams,
ArtistApiNames,
FetchArtistMVResponse,
} from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
export default function useArtistMV(params: FetchArtistMVParams) {
return useQuery(
[ArtistApiNames.FetchArtistMV, params],
async () => {
const data = await fetchArtistMV(params)
return data
},
{
enabled: !!params.id && params.id !== 0,
staleTime: 3600000,
// placeholderData: (): FetchArtistMVResponse =>
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
// api: APIs.ArtistAlbum,
// query: {
// id: params.id,
// },
// }),
}
)
}

View file

@ -6,7 +6,7 @@ import {
ArtistApiNames,
FetchArtistResponse,
} from '@/shared/api/Artist'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
export default function useArtists(ids: number[]) {
return useQuery(

View file

@ -7,7 +7,7 @@ import {
} from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
export default function useLyric(params: FetchLyricParams) {
return useQuery(

View file

@ -0,0 +1,31 @@
import { fetchMV, fetchMVUrl } from '@/web/api/mv'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
MVApiNames,
FetchMVParams,
FetchMVResponse,
FetchMVUrlParams,
} from '@/shared/api/MV'
import { useQuery } from '@tanstack/react-query'
export default function useMV(params: FetchMVParams) {
return useQuery([MVApiNames.FetchMV, params], () => fetchMV(params), {
enabled: !!params.mvid && params.mvid > 0 && !isNaN(Number(params.mvid)),
staleTime: 5 * 60 * 1000, // 5 mins
// placeholderData: (): FetchMVResponse =>
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
// api: APIs.SimilarArtist,
// query: {
// id: params.id,
// },
// }),
})
}
export function useMVUrl(params: FetchMVUrlParams) {
return useQuery([MVApiNames.FetchMVUrl, params], () => fetchMVUrl(params), {
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 60 * 60 * 1000, // 60 mins
})
}

View file

@ -3,7 +3,7 @@ import reactQueryClient from '@/web/utils/reactQueryClient'
export function fetchPersonalFMWithReactQuery() {
return reactQueryClient.fetchQuery(
PersonalFMApiNames.FetchPersonalFm,
[PersonalFMApiNames.FetchPersonalFm],
async () => {
const data = await fetchPersonalFM()
if (!data.data?.length) {

View file

@ -7,10 +7,10 @@ import {
PlaylistApiNames,
FetchPlaylistResponse,
} from '@/shared/api/Playlists'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
const fetch = (params: FetchPlaylistParams, noCache?: boolean) => {
return fetchPlaylist(params, !!noCache)
const fetch = (params: FetchPlaylistParams) => {
return fetchPlaylist(params)
}
export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
@ -19,13 +19,10 @@ export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
query: { id },
})
export default function usePlaylist(
params: FetchPlaylistParams,
noCache?: boolean
) {
export default function usePlaylist(params: FetchPlaylistParams) {
return useQuery(
[PlaylistApiNames.FetchPlaylist, params],
() => fetch(params, noCache),
() => fetch(params),
{
enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))),
refetchOnWindowFocus: true,

View file

@ -6,7 +6,7 @@ import {
ArtistApiNames,
FetchSimilarArtistsResponse,
} from '@/shared/api/Artist'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
return useQuery(
@ -15,6 +15,7 @@ export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
{
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 5 * 60 * 1000, // 5 mins
retry: 0,
placeholderData: (): FetchSimilarArtistsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.SimilarArtist,

View file

@ -9,7 +9,7 @@ import {
TrackApiNames,
} from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
export default function useTracks(params: FetchTracksParams) {
return useQuery(

View file

@ -1,5 +1,5 @@
import { FetchTracksParams, TrackApiNames } from '@/shared/api/Track'
import { useInfiniteQuery } from 'react-query'
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchTracks } from '../track'
// 100 tracks each page

View file

@ -2,10 +2,10 @@ import { fetchUserAccount } from '@/web/api/user'
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
export default function useUser() {
return useQuery(UserApiNames.FetchUserAccount, fetchUserAccount, {
return useQuery([UserApiNames.FetchUserAccount], fetchUserAccount, {
refetchOnWindowFocus: true,
placeholderData: (): FetchUserAccountResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {

View file

@ -1,5 +1,5 @@
import { likeAAlbum } from '@/web/api/album'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import useUser from './useUser'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
@ -10,11 +10,15 @@ import {
} from '@/shared/api/User'
import { fetchUserAlbums } from '../user'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { cloneDeep } from 'lodash-es'
import { AlbumApiNames, FetchAlbumResponse } from '@/shared/api/Album'
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
const { data: user } = useUser()
const uid = user?.profile?.userId ?? 0
return useQuery(
[UserApiNames.FetchUserAlbums, user?.profile?.userId ?? 0],
[UserApiNames.FetchUserAlbums, uid],
() => fetchUserAlbums(params),
{
refetchOnWindowFocus: true,
@ -28,53 +32,91 @@ export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
}
export const useMutationLikeAAlbum = () => {
const queryClient = useQueryClient()
const { data: user } = useUser()
const { data: userAlbums } = useUserAlbums({ limit: 2000 })
const uid = user?.account?.id ?? 0
const uid = user?.profile?.userId ?? 0
const key = [UserApiNames.FetchUserAlbums, uid]
return useMutation(
async (album: Album) => {
if (!album.id || userAlbums?.data === undefined) {
async (albumID: number) => {
if (!albumID || userAlbums?.data === undefined) {
throw new Error('album id is required or userAlbums is undefined')
}
const response = await likeAAlbum({
id: album.id,
t: userAlbums?.data.findIndex(a => a.id === album.id) > -1 ? 2 : 1,
id: albumID,
t: userAlbums?.data.findIndex(a => a.id === albumID) > -1 ? 2 : 1,
})
if (response.code !== 200) throw new Error((response as any).msg)
return response
},
{
onMutate: async album => {
onMutate: async albumID => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(key)
await reactQueryClient.cancelQueries(key)
console.log(reactQueryClient.getQueryData(key))
// 如果还未获取用户收藏的专辑列表,则获取一次
if (!reactQueryClient.getQueryData(key)) {
await reactQueryClient.fetchQuery(key)
}
// Snapshot the previous value
const previousData = queryClient.getQueryData(key)
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserAlbumsResponse
// Optimistically update to the new value
queryClient.setQueryData(key, old => {
const userAlbums = old as FetchUserAlbumsResponse
const albums = userAlbums.data
const newAlbums =
albums.findIndex(a => a.id === album.id) > -1
? albums.filter(a => a.id !== album.id)
: [...albums, album]
return {
...userAlbums,
data: newAlbums,
const isLiked = !!previousData?.data.find(a => a.id === albumID)
const newAlbums = cloneDeep(previousData!)
console.log({ isLiked })
if (isLiked) {
newAlbums.data = previousData.data.filter(a => a.id !== albumID)
} else {
// 从react-query缓存获取专辑
console.log({ albumID })
const albumFromCache: FetchAlbumResponse | undefined =
reactQueryClient.getQueryData([
AlbumApiNames.FetchAlbum,
{ id: albumID },
])
console.log({ albumFromCache })
// 从api获取专辑
const album: FetchAlbumResponse | undefined = albumFromCache
? albumFromCache
: await reactQueryClient.fetchQuery([
AlbumApiNames.FetchAlbum,
{ id: albumID },
])
if (!album?.album) {
toast.error('Failed to like album: unable to fetch album info')
throw new Error('unable to fetch album info')
}
})
newAlbums.data.unshift(album.album)
// Optimistically update to the new value
reactQueryClient.setQueriesData(key, newAlbums)
}
reactQueryClient.setQueriesData(key, newAlbums)
console.log({ newAlbums })
// Return a context object with the snapshotted value
return { previousData }
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (err, trackID, context) => {
queryClient.setQueryData(key, (context as any).previousData)
toast((err as any).toString())
onSettled: (data, error, albumID, context) => {
if (data?.code !== 200) {
reactQueryClient.setQueryData(key, (context as any).previousData)
toast((error as any).toString())
}
},
}
)

View file

@ -2,10 +2,19 @@ import { fetchUserArtists } from '@/web/api/user'
import { UserApiNames, FetchUserArtistsResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from 'react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { likeAArtist } from '../artist'
import { ArtistApiNames, FetchArtistResponse } from '@/shared/api/Artist'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { cloneDeep } from 'lodash-es'
const KEYS = {
useUserArtists: [UserApiNames.FetchUserArtists],
}
export default function useUserArtists() {
return useQuery([UserApiNames.FetchUserArtist], fetchUserArtists, {
return useQuery(KEYS.useUserArtists, fetchUserArtists, {
refetchOnWindowFocus: true,
placeholderData: (): FetchUserArtistsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
@ -13,3 +22,85 @@ export default function useUserArtists() {
}),
})
}
export const useMutationLikeAArtist = () => {
const { data: userLikedArtists } = useUserArtists()
return useMutation(
async (artistID: number) => {
if (!artistID || !userLikedArtists?.data) {
throw new Error('artistID is required or userLikedArtists is undefined')
}
const response = await likeAArtist({
id: artistID,
like: !userLikedArtists.data.find(a => a.id === artistID),
})
if (response.code !== 200) throw new Error((response as any).msg)
return response
},
{
onMutate: async artistID => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await reactQueryClient.cancelQueries(KEYS.useUserArtists)
// 如果还未获取用户收藏的歌手列表,则获取一次
if (!reactQueryClient.getQueryData(KEYS.useUserArtists)) {
await reactQueryClient.fetchQuery(KEYS.useUserArtists)
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
KEYS.useUserArtists
) as FetchUserArtistsResponse
const isLiked = !!previousData?.data.find(a => a.id === artistID)
const newLikedArtists = cloneDeep(previousData!)
if (isLiked) {
newLikedArtists.data = previousData.data.filter(
a => a.id !== artistID
)
} else {
// 从react-query缓存获取歌手信息
const artistFromCache: FetchArtistResponse | undefined =
reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
// 从api获取歌手信息
const artist: FetchArtistResponse | undefined = artistFromCache
? artistFromCache
: await reactQueryClient.fetchQuery([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
if (!artist?.artist) {
toast.error('Failed to like artist: unable to fetch artist info')
throw new Error('unable to fetch artist info')
}
newLikedArtists.data.unshift(artist.artist)
// Optimistically update to the new value
reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists)
}
reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists)
// Return a context object with the snapshotted value
return { previousData }
},
// If the mutation fails, use the context returned from onMutate to roll back
onSettled: (data, error, artistID, context) => {
if (data?.code !== 200) {
reactQueryClient.setQueryData(
KEYS.useUserArtists,
(context as any).previousData
)
toast((error as any).toString())
}
},
}
)
}

View file

@ -1,6 +1,6 @@
import { likeATrack } from '@/web/api/track'
import useUser from './useUser'
import { useMutation, useQueryClient } from 'react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { fetchUserLikedTracksIDs } from '../user'
@ -8,8 +8,9 @@ import {
FetchUserLikedTracksIDsResponse,
UserApiNames,
} from '@/shared/api/User'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useUserLikedTracksIDs() {
const { data: user } = useUser()
@ -33,7 +34,6 @@ export default function useUserLikedTracksIDs() {
}
export const useMutationLikeATrack = () => {
const queryClient = useQueryClient()
const { data: user } = useUser()
const { data: userLikedSongs } = useUserLikedTracksIDs()
const uid = user?.account?.id ?? 0
@ -54,13 +54,13 @@ export const useMutationLikeATrack = () => {
{
onMutate: async trackID => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(key)
await reactQueryClient.cancelQueries(key)
// Snapshot the previous value
const previousData = queryClient.getQueryData(key)
const previousData = reactQueryClient.getQueryData(key)
// Optimistically update to the new value
queryClient.setQueryData(key, old => {
reactQueryClient.setQueryData(key, old => {
const likedSongs = old as FetchUserLikedTracksIDsResponse
const ids = likedSongs.ids
const newIds = ids.includes(trackID)
@ -77,7 +77,7 @@ export const useMutationLikeATrack = () => {
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (err, trackID, context) => {
queryClient.setQueryData(key, (context as any).previousData)
reactQueryClient.setQueryData(key, (context as any).previousData)
toast((err as any).toString())
},
}

View file

@ -2,7 +2,7 @@ import { fetchListenedRecords } from '@/web/api/user'
import { UserApiNames, FetchListenedRecordsResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
import useUser from './useUser'
export default function useUserListenedRecords(params: {

View file

@ -1,11 +1,14 @@
import { likeAPlaylist } from '@/web/api/playlist'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import useUser from './useUser'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { fetchUserPlaylists } from '@/web/api/user'
import { FetchUserPlaylistsResponse, UserApiNames } from '@/shared/api/User'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { cloneDeep } from 'lodash-es'
import { FetchPlaylistResponse, PlaylistApiNames } from '@/shared/api/Playlists'
export default function useUserPlaylists() {
const { data: user } = useUser()
@ -45,21 +48,20 @@ export default function useUserPlaylists() {
}
export const useMutationLikeAPlaylist = () => {
const queryClient = useQueryClient()
const { data: user } = useUser()
const { data: userPlaylists } = useUserPlaylists()
const uid = user?.account?.id ?? 0
const key = [UserApiNames.FetchUserPlaylists, uid]
return useMutation(
async (playlist: Playlist) => {
if (!playlist.id || userPlaylists?.playlist === undefined) {
async (playlistID: number) => {
if (!playlistID || userPlaylists?.playlist === undefined) {
throw new Error('playlist id is required or userPlaylists is undefined')
}
const response = await likeAPlaylist({
id: playlist.id,
id: playlistID,
t:
userPlaylists.playlist.findIndex(p => p.id === playlist.id) > -1
userPlaylists.playlist.findIndex(p => p.id === playlistID) > -1
? 2
: 1,
})
@ -67,34 +69,73 @@ export const useMutationLikeAPlaylist = () => {
return response
},
{
onMutate: async playlist => {
onMutate: async playlistID => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(key)
await reactQueryClient.cancelQueries(key)
console.log(reactQueryClient.getQueryData(key))
// 如果还未获取用户收藏的专辑列表,则获取一次
if (!reactQueryClient.getQueryData(key)) {
await reactQueryClient.fetchQuery(key)
}
// Snapshot the previous value
const previousData = queryClient.getQueryData(key)
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserPlaylistsResponse
// Optimistically update to the new value
queryClient.setQueryData(key, old => {
const userPlaylists = old as FetchUserPlaylistsResponse
const playlists = userPlaylists.playlist
const newPlaylists =
playlists.findIndex(p => p.id === playlist.id) > -1
? playlists.filter(p => p.id !== playlist.id)
: [...playlists, playlist]
return {
...userPlaylists,
playlist: newPlaylists,
const isLiked = !!previousData?.playlist.find(p => p.id === playlistID)
const newPlaylists = cloneDeep(previousData!)
console.log({ isLiked })
if (isLiked) {
newPlaylists.playlist = previousData.playlist.filter(
p => p.id !== playlistID
)
} else {
// 从react-query缓存获取歌单信息
const playlistFromCache: FetchPlaylistResponse | undefined =
reactQueryClient.getQueryData([
PlaylistApiNames.FetchPlaylist,
{ id: playlistID },
])
// 从api获取歌单信息
const playlist: FetchPlaylistResponse | undefined = playlistFromCache
? playlistFromCache
: await reactQueryClient.fetchQuery([
PlaylistApiNames.FetchPlaylist,
{ id: playlistID },
])
if (!playlist?.playlist) {
toast.error(
'Failed to like playlist: unable to fetch playlist info'
)
throw new Error('unable to fetch playlist info')
}
})
newPlaylists.playlist.splice(1, 0, playlist.playlist)
// Optimistically update to the new value
reactQueryClient.setQueriesData(key, newPlaylists)
}
reactQueryClient.setQueriesData(key, newPlaylists)
console.log({ newPlaylists })
// Return a context object with the snapshotted value
return { previousData }
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (err, trackID, context) => {
queryClient.setQueryData(key, (context as any).previousData)
toast((err as any).toString())
onSettled: (data, error, playlistID, context) => {
if (data?.code !== 200) {
reactQueryClient.setQueryData(key, (context as any).previousData)
toast((error as any).toString())
}
},
}
)

62
packages/web/api/mv.ts Normal file
View file

@ -0,0 +1,62 @@
import {
FetchMVResponse,
FetchMVParams,
FetchMVUrlParams,
FetchMVUrlResponse,
} from '@/shared/api/MV'
import request from '@/web/utils/request'
// 获取 mv 数据
export function fetchMV(params: FetchMVParams): Promise<FetchMVResponse> {
return request({
url: '/mv/detail',
method: 'get',
params: {
mvid: params.mvid,
timestamp: new Date().getTime(),
},
})
}
// mv 地址
export function fetchMVUrl(
params: FetchMVUrlParams
): Promise<FetchMVUrlResponse> {
return request({
url: '/mv/url',
method: 'get',
params,
})
}
/**
* mv
* 说明 : 调用此接口 , mvid mv
* @param {number} mvid
*/
export function simiMv(mvid) {
return request({
url: '/simi/mv',
method: 'get',
params: { mvid },
})
}
/**
* / MV
* 说明 : 调用此接口,/ MV
* - mvid: mv id
* - t: 1 ,
* @param {Object} params
* @param {number} params.mvid
* @param {number=} params.t
*/
export function likeAMV(params) {
params.timestamp = new Date().getTime()
return request({
url: '/mv/sub',
method: 'post',
params,
})
}

View file

@ -11,18 +11,15 @@ import {
// 歌单详情
export function fetchPlaylist(
params: FetchPlaylistParams,
noCache: boolean
params: FetchPlaylistParams
): 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,
timestamp: new Date().getTime(),
},
})
}

View file

@ -0,0 +1,67 @@
import player from '@/web/states/player'
import { css, cx } from '@emotion/css'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useSnapshot } from 'valtio'
const useAirplayDevices = () => {
return useQuery(['useAirplayDevices'], () =>
window.ipcRenderer?.invoke('airplay-scan-devices')
)
}
const Airplay = () => {
const [showPanel, setShowPanel] = useState(false)
const { data: devices, isLoading } = useAirplayDevices()
const { remoteDevice } = useSnapshot(player)
const selectedAirplayDeviceID =
remoteDevice?.protocol === 'airplay' ? remoteDevice?.id : ''
return (
<div
className={cx(
'fixed z-20',
css`
top: 46px;
right: 256px;
`
)}
>
<div
onClick={() => setShowPanel(!showPanel)}
className='flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-24 text-white'
>
A
</div>
{showPanel && (
<div
className={cx(
'absolute rounded-24 border border-white/10 bg-black/60 p-2 backdrop-blur-xl',
css`
width: 256px;
height: 256px;
`
)}
>
{devices?.devices?.map(device => (
<div
key={device.identifier}
className={cx(
'rounded-12 p-2 hover:bg-white/10',
device.identifier === selectedAirplayDeviceID
? 'text-brand-500'
: 'text-white'
)}
onClick={() => player.switchToAirplayDevice(device.identifier)}
>
{device.name}
</div>
))}
</div>
)}
</div>
)
}
export default Airplay

View file

@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css'
import { memo } from 'react'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
import { prefetchArtist } from '@/web/api/hooks/useArtist'
const Artist = ({ artist }: { artist: Artist }) => {
const navigate = useNavigate()
@ -11,7 +12,10 @@ const Artist = ({ artist }: { artist: Artist }) => {
}
return (
<div className='text-center'>
<div
className='text-center'
onMouseOver={() => prefetchArtist({ id: artist.id })}
>
<Image
onClick={to}
src={resizeImage(artist.img1v1Url, 'md')}
@ -72,19 +76,14 @@ const ArtistRow = ({
<div className={className}>
{/* Title */}
{title && (
<h4
className={cx(
'text-12 font-medium uppercase dark:text-neutral-300 lg:text-14',
'mx-2.5 mb-6 lg:mx-0 lg:font-bold'
)}
>
<h4 className='mx-2.5 mb-6 text-12 font-medium uppercase dark:text-neutral-300 lg:mx-0 lg:text-14 lg:font-bold'>
{title}
</h4>
)}
{/* Artists */}
{artists && (
<div className='no-scrollbar flex snap-x overflow-x-scroll lg:grid lg:w-auto lg:grid-cols-5 lg:gap-10'>
<div className='no-scrollbar flex snap-x overflow-x-scroll lg:grid lg:w-auto lg:grid-cols-5 lg:gap-x-10 lg:gap-y-8'>
{artists.map(artist => (
<div className='snap-start px-2.5 lg:px-0' key={artist.id}>
<Artist artist={artist} key={artist.id} />

View file

@ -3,21 +3,34 @@ import { cx, css } from '@emotion/css'
import useIsMobile from '@/web/hooks/useIsMobile'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import { AnimatePresence, motion } from 'framer-motion'
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { useLocation } from 'react-router-dom'
import { useEffect } from 'react'
const BlurBackground = ({ cover }: { cover?: string }) => {
const BlurBackground = () => {
const isMobile = useIsMobile()
const { hideTopbarBackground } = useSnapshot(uiStates)
const { hideTopbarBackground, blurBackgroundImage } = useSnapshot(uiStates)
const location = useLocation()
const animate = useAnimation()
useEffect(() => {
uiStates.blurBackgroundImage = null
}, [location.pathname])
const onLoad = async () => {
animate.start({ opacity: 1 })
}
return (
<AnimatePresence>
{!isMobile && cover && hideTopbarBackground && (
{!isMobile && blurBackgroundImage && hideTopbarBackground && (
<motion.img
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
animate={animate}
exit={{ opacity: 0 }}
transition={{ ease }}
onLoad={onLoad}
className={cx(
'absolute z-0 object-cover opacity-70',
css`
@ -28,7 +41,7 @@ const BlurBackground = ({ cover }: { cover?: string }) => {
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
src={resizeImage(blurBackgroundImage, 'sm')}
/>
)}
</AnimatePresence>

View file

@ -5,6 +5,7 @@ import Image from './Image'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { memo, useCallback } from 'react'
import dayjs from 'dayjs'
const Album = ({ album }: { album: Album }) => {
const navigate = useNavigate()
@ -16,13 +17,21 @@ const Album = ({ album }: { album: Album }) => {
}
return (
<Image
onClick={goTo}
key={album.id}
src={resizeImage(album?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
<div>
<Image
onClick={goTo}
key={album.id}
src={resizeImage(album?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{album.name}
</div>
<div className='mt-1 text-14 font-medium text-neutral-700'>
{dayjs(album.publishTime || 0).year()}
</div>
</div>
)
}

View file

@ -1,19 +1,16 @@
import { resizeImage } from '@/web/utils/common'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { useVirtualizer } from '@tanstack/react-virtual'
import { CSSProperties, useRef } from 'react'
import { Virtuoso } from 'react-virtuoso'
import { CSSProperties } from 'react'
const CoverRow = ({
albums,
playlists,
title,
className,
containerClassName,
containerStyle,
}: {
title?: string
className?: string
@ -34,9 +31,6 @@ const CoverRow = ({
if (playlists) prefetchPlaylist({ id })
}
// The scrollable element for your list
const parentRef = useRef<HTMLDivElement>(null)
type Item = Album | Playlist
const items: Item[] = albums || playlists || []
const rows = items.reduce((rows: Item[][], item: Item, index: number) => {
@ -49,29 +43,6 @@ const CoverRow = ({
return rows
}, [])
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
overscan: 5,
estimateSize: () => {
const containerWidth = parentRef.current?.clientWidth
console.log(parentRef.current?.clientWidth)
if (!containerWidth) {
return 192
}
const gridGapY = 24
const gridGapX = 40
const gridColumns = 4
console.log(
(containerWidth - (gridColumns - 1) * gridGapX) / gridColumns + gridGapY
)
return (
(containerWidth - (gridColumns - 1) * gridGapX) / gridColumns + gridGapY
)
},
})
return (
<div className={className}>
{/* Title */}
@ -81,46 +52,43 @@ const CoverRow = ({
</h4>
)}
<div
ref={parentRef}
className={cx('w-full overflow-auto', containerClassName)}
style={containerStyle}
>
<div
className='relative w-full'
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((row: any) => (
<div
key={row.index}
className='absolute top-0 left-0 grid w-full grid-cols-4 gap-4 lg:gap-10'
style={{
height: `${row.size}px`,
transform: `translateY(${row.start}px)`,
}}
>
{rows[row.index].map((item: Item) => (
<img
onClick={() => goTo(item.id)}
key={item.id}
alt={item.name}
src={resizeImage(
<Virtuoso
className='no-scrollbar'
style={{
height: 'calc(100vh - 132px)',
}}
data={rows}
overscan={5}
itemSize={el => el.getBoundingClientRect().height + 24}
totalCount={rows.length}
components={{
Header: () => <div className='h-16'></div>,
Footer: () => <div className='h-16'></div>,
}}
itemContent={(index, row) => (
<div
key={index}
className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'
>
{row.map((item: Item) => (
<img
onClick={() => goTo(item.id)}
key={item.id}
alt={item.name}
src={resizeImage(
item?.picUrl ||
(item as Playlist)?.coverImgUrl ||
item?.picUrl ||
(item as Playlist)?.coverImgUrl ||
item?.picUrl ||
'',
'md'
)}
className='aspect-square rounded-24'
onMouseOver={() => prefetch(item.id)}
/>
))}
</div>
))}
</div>
</div>
'',
'md'
)}
className='rounded-24'
onMouseOver={() => prefetch(item.id)}
/>
))}
</div>
)}
/>
</div>
)
}

View file

@ -1,5 +1,4 @@
import useBreakpoint from '@/web/hooks/useBreakpoint'
import { ReactQueryDevtools } from 'react-query/devtools'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import useIsMobile from '@/web/hooks/useIsMobile'
const Devtool = () => {
@ -10,10 +9,19 @@ const Devtool = () => {
toggleButtonProps={{
style: {
position: 'fixed',
bottom: isMobile ? 'auto' : 0,
right: 0,
top: isMobile ? 0 : 'auto',
left: 'auto',
...(isMobile
? {
top: 0,
right: 0,
bottom: 'auto',
left: 'atuo',
}
: {
top: 36,
right: 148,
bottom: 'atuo',
left: 'auto',
}),
},
}}
/>

View file

@ -1,42 +1,34 @@
import { ReactNode } from 'react'
import { ErrorBoundary as ErrorBoundaryRaw } from 'react-error-boundary'
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error
resetErrorBoundary: () => void
}) {
return (
<div
role='alert'
className='app-region-drag flex h-screen w-screen items-center justify-center bg-white dark:bg-black'
>
<div className='app-region-no-drag'>
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button
onClick={resetErrorBoundary}
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
>
Try Again
</button>
</div>
</div>
)
}
import * as Sentry from '@sentry/react'
const ErrorBoundary = ({ children }: { children: ReactNode }) => {
return (
<ErrorBoundaryRaw
FallbackComponent={ErrorFallback}
onReset={() => {
// reset the state of your app so the error doesn't happen again
}}
<Sentry.ErrorBoundary
fallback={({ error, componentStack }) => (
<div
role='alert'
className='app-region-drag flex h-screen w-screen items-center justify-center rounded-24 bg-white dark:bg-black'
>
<div className='app-region-no-drag'>
<p>Something went wrong:</p>
<pre className='mb-2 text-18 dark:text-white'>
{error.toString()}
</pre>
<div className='max-h-72 max-w-2xl overflow-scroll whitespace-pre-line rounded-12 border border-white/10 px-3 py-2 dark:text-white/50'>
{componentStack?.trim()}
</div>
<button
onClick={() => location.reload()}
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
>
Reload
</button>
</div>
</div>
)}
>
{children}
</ErrorBoundaryRaw>
</Sentry.ErrorBoundary>
)
}

View file

@ -92,7 +92,7 @@ const ImageDesktop = ({
{placeholder && (
<motion.div
{...placeholderMotionProps}
className='absolute inset-0 h-full w-full bg-white dark:bg-neutral-800'
className='absolute inset-0 h-full w-full bg-white dark:bg-white/10'
></motion.div>
)}
</AnimatePresence>

View file

@ -7,9 +7,14 @@ import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Login from './Login'
import TrafficLight from './TrafficLight'
import BlurBackground from './BlurBackground'
import Airplay from './Airplay'
import TitleBar from './TitleBar'
import uiStates from '@/web/states/uiStates'
const Layout = () => {
const playerSnapshot = useSnapshot(player)
const { fullscreen } = useSnapshot(uiStates)
const showPlayer = !!playerSnapshot.track
return (
@ -17,24 +22,10 @@ const Layout = () => {
id='layout'
className={cx(
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
window.env?.isElectron && 'rounded-24',
css`
grid-template-columns: 6.5rem auto 358px;
grid-template-rows: 132px auto;
`,
showPlayer
? css`
grid-template-areas:
'menubar main -'
'menubar main player';
`
: css`
grid-template-areas:
'menubar main main'
'menubar main main';
`
window.env?.isElectron && !fullscreen && 'rounded-24'
)}
>
<BlurBackground />
<MenuBar />
<Topbar />
<Main />
@ -46,6 +37,10 @@ const Layout = () => {
<TrafficLight />
</div>
)}
{window.env?.isWindows && <TitleBar />}
{/* {window.env?.isElectron && <Airplay />} */}
</div>
)
}

View file

@ -8,6 +8,7 @@ import Icon from '@/web/components/Icon'
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import LoginWithQRCode from './LoginWithQRCode'
import persistedUiStates from '@/web/states/persistedUiStates'
import useUser from '@/web/api/hooks/useUser'
const OR = ({
children,
@ -26,7 +27,7 @@ const OR = ({
<div className='mt-4 flex justify-center'>
<button
className='text-16 font-medium text-night-50 transition-colors duration-300 hover:text-white'
className='text-16 font-medium text-night-50 transition-colors duration-400 hover:text-white'
onClick={onClick}
>
{children}
@ -37,12 +38,20 @@ const OR = ({
}
const Login = () => {
const { data: user, isLoading: isLoadingUser } = useUser()
const { loginType } = useSnapshot(persistedUiStates)
const { showLoginPanel } = useSnapshot(uiStates)
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
loginType === 'qrCode' ? 'qrCode' : 'phone/email'
)
// Show login panel when user first loads the page and not logged in
useEffect(() => {
if (!user?.account && !isLoadingUser) {
uiStates.showLoginPanel = true
}
}, [user?.account, isLoadingUser])
const animateCard = useAnimation()
const handleSwitchCard = async () => {
const transition = { duration: 0.36, ease }
@ -80,7 +89,7 @@ const Login = () => {
{/* Content */}
<AnimatePresence>
{showLoginPanel && (
<div className='fixed inset-0 z-30 flex justify-center rounded-24 pt-56'>
<div className='fixed inset-0 z-30 flex items-center justify-center rounded-24 pt-24'>
<motion.div
className='flex flex-col items-center'
variants={{
@ -135,9 +144,9 @@ const Login = () => {
layout='position'
transition={{ ease }}
onClick={() => (uiStates.showLoginPanel = false)}
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10'
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
>
<Icon name='x' className='h-7 w-7 text-white/50' />
<Icon name='x' className='h-7 w-7 ' />
</motion.div>
</AnimatePresence>
</motion.div>

View file

@ -1,7 +1,12 @@
import { cx, css } from '@emotion/css'
import { useState } from 'react'
import { useMutation } from 'react-query'
import { loginWithEmail, loginWithPhone } from '@/web/api/auth'
import { useMutation } from '@tanstack/react-query'
import {
loginWithEmail,
LoginWithEmailResponse,
loginWithPhone,
LoginWithPhoneResponse,
} from '@/web/api/auth'
import md5 from 'md5'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
@ -10,6 +15,8 @@ import { ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { UserApiNames } from '@/shared/api/User'
const LoginWithPhoneOrEmail = () => {
const { loginPhoneCountryCode, loginType: persistedLoginType } =
@ -24,26 +31,36 @@ const LoginWithPhoneOrEmail = () => {
persistedLoginType === 'email' ? 'email' : 'phone'
)
const handleAfterLogin = (
result: LoginWithEmailResponse | LoginWithPhoneResponse
) => {
if (result?.code !== 200) return
setCookies(result.cookie)
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
uiStates.showLoginPanel = false
}
const handleError = (data: any, error: any) => {
if (data?.code === 200) return
toast(
`Login failed: ${
data?.message ||
data?.msg ||
data?.error ||
error?.response?.data?.message ||
error?.response?.data?.msg ||
error
}`
)
}
const doEmailLogin = useMutation(
() =>
loginWithEmail({
email: email.trim(),
md5_password: md5(password.trim()),
}),
{
onSuccess: result => {
if (result?.code !== 200) {
toast(`Login failed: ${result.code}`)
return
}
setCookies(result.cookie)
uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
},
}
{ onSuccess: handleAfterLogin, onSettled: handleError }
)
const handleEmailLogin = () => {
@ -75,19 +92,7 @@ const LoginWithPhoneOrEmail = () => {
md5_password: md5(password.trim()),
})
},
{
onSuccess: result => {
if (result?.code !== 200) {
toast(`Login failed: ${result.code}`)
return
}
setCookies(result.cookie)
uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
},
}
{ onSuccess: handleAfterLogin, onSettled: handleError }
)
const handlePhoneLogin = () => {
@ -124,12 +129,12 @@ const LoginWithPhoneOrEmail = () => {
return (
<>
<div className='text-center text-18 font-medium text-night-600'>
<div className='text-center text-18 font-medium text-white/20'>
Log in with{' '}
<span
className={cx(
'transition-colors duration-300',
loginType === 'phone' ? 'text-brand-600' : 'hover:text-night-50'
loginType === 'phone' ? 'text-brand-600' : 'hover:text-white/70'
)}
onClick={() => {
const type = loginType === 'phone' ? 'email' : 'phone'
@ -143,7 +148,7 @@ const LoginWithPhoneOrEmail = () => {
<span
className={cx(
'transition-colors duration-300',
loginType === 'email' ? 'text-brand-600' : 'hover:text-night-50'
loginType === 'email' ? 'text-brand-600' : 'hover:text-white/70'
)}
onClick={() => {
if (loginType !== 'email') setLoginType('email')

View file

@ -1,11 +1,13 @@
import { cx, css } from '@emotion/css'
import { useEffect, useState, useMemo } from 'react'
import qrCode from 'qrcode'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
import { checkLoginQrCodeStatus, fetchLoginQrCodeKey } from '@/web/api/auth'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
import uiStates from '@/web/states/uiStates'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { UserApiNames } from '@/shared/api/User'
const QRCode = ({ className, text }: { className?: string; text: string }) => {
const [image, setImage] = useState<string>('')
@ -40,7 +42,7 @@ const LoginWithQRCode = () => {
status: keyStatus,
refetch: refetchKey,
} = useQuery(
'qrCodeKey',
['qrCodeKey'],
async () => {
const result = await fetchLoginQrCodeKey()
if (result.data.code !== 200) {
@ -58,7 +60,7 @@ const LoginWithQRCode = () => {
)
const { data: status } = useQuery(
'qrCodeStatus',
['qrCodeStatus'],
async () => checkLoginQrCodeStatus({ key: key?.data?.unikey || '' }),
{
refetchInterval: 1000,
@ -80,6 +82,7 @@ const LoginWithQRCode = () => {
break
}
setCookies(status.cookie)
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
uiStates.showLoginPanel = false
break
}
@ -97,7 +100,7 @@ const LoginWithQRCode = () => {
return (
<>
<div className='text-center text-18 font-medium text-night-600'>
<div className='text-center text-18 font-medium text-white/20'>
Log in with NetEase QR
</div>

View file

@ -2,9 +2,17 @@ import { css, cx } from '@emotion/css'
import Router from './Router'
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
import uiStates from '@/web/states/uiStates'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { breakpoint as bp, ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import persistedUiStates from '@/web/states/persistedUiStates'
import { motion, useAnimation } from 'framer-motion'
import { sleep } from '@/web/utils/common'
import player from '@/web/states/player'
const Main = () => {
const playerSnapshot = useSnapshot(player)
// Show/hide topbar background
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen } = useIntersectionObserver(observePoint)
@ -15,12 +23,33 @@ const Main = () => {
}
}, [onScreen])
// Change width when player is minimized
const { minimizePlayer } = useSnapshot(persistedUiStates)
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
const controlsMain = useAnimation()
useEffect(() => {
const animate = async () => {
await controlsMain.start({ opacity: 0 })
await sleep(100)
setIsMaxWidth(minimizePlayer)
await controlsMain.start({ opacity: 1 })
}
if (minimizePlayer !== isMaxWidth) animate()
}, [controlsMain, isMaxWidth, minimizePlayer])
return (
<main
<motion.main
id='main'
animate={controlsMain}
transition={{ ease, duration: 0.4 }}
className={cx(
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
'no-scrollbar z-10 h-screen overflow-y-auto',
css`
grid-area: main;
${bp.lg} {
margin-left: 144px;
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
}
`
)}
>
@ -32,7 +61,7 @@ const Main = () => {
>
<Router />
</div>
</main>
</motion.main>
)
}

View file

@ -4,8 +4,11 @@ import Icon from '../Icon'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAnimation, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import TrafficLight from './TrafficLight'
import useIsMobile from '@/web/hooks/useIsMobile'
import { breakpoint as bp } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
const tabs = [
{
@ -132,15 +135,17 @@ const Tabs = () => {
)
}
const MenuBar = ({ className }: { className?: string }) => {
const MenuBar = () => {
const isMobile = useIsMobile()
return (
<div
className={cx(
'app-region-drag relative flex h-full w-full flex-col justify-center',
className,
'lg:fixed lg:left-0 lg:top-0 lg:bottom-0',
css`
grid-area: menubar;
${bp.lg} {
width: 104px;
}
`
)}
>

View file

@ -1,183 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { css, cx } from '@emotion/css'
import Icon from '../Icon'
import { formatDuration, resizeImage } from '@/web/utils/common'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import Slider from './Slider'
import { animate, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import ArtistInline from './ArtistsInLine'
const Progress = () => {
const { track, progress } = useSnapshot(player)
return (
<div className='mt-10 flex w-full flex-col'>
<Slider
min={0}
max={(track?.dt ?? 100000) / 1000}
value={progress}
onChange={value => {
player.progress = value
}}
onlyCallOnChangeAfterDragEnded={true}
/>
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
</div>
</div>
)
}
const Cover = () => {
const playerSnapshot = useSnapshot(player)
const [cover, setCover] = useState('')
const animationStartTime = useRef(0)
const controls = useAnimation()
const duration = 150 // ms
useEffect(() => {
const resizedCover = resizeImage(
playerSnapshot.track?.al.picUrl || '',
'lg'
)
const animate = async () => {
animationStartTime.current = Date.now()
await controls.start({ opacity: 0 })
setCover(resizedCover)
}
animate()
}, [controls, playerSnapshot.track?.al.picUrl])
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
useEffect(() => {
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
if (cover !== realCover) setCover(realCover)
}, [cover, playerSnapshot.track?.al.picUrl])
const onLoad = () => {
const passedTime = Date.now() - animationStartTime.current
controls.start({
opacity: 1,
transition: {
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
},
})
}
return (
<motion.img
animate={controls}
transition={{ duration: duration / 1000, ease }}
className={cx('absolute inset-0 w-full')}
src={cover}
onLoad={onLoad}
/>
)
}
const LikeButton = () => {
const { track } = useSnapshot(player)
const { data: likedIDs } = useUserLikedTracksIDs()
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
const likeATrack = useMutationLikeATrack()
return (
<button onClick={() => track?.id && likeATrack.mutateAsync(track.id)}>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7 text-black/90 dark:text-white/40'
/>
</button>
)
}
const NowPlaying = () => {
const { state, track } = useSnapshot(player)
return (
<div
className={cx(
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
css`
border-color: hsl(0, 100%, 100%, 0.08);
`
)}
>
{/* Cover */}
<Cover />
{/* Info & Controls */}
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
{/* Track Info */}
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<ArtistInline
artists={track?.ar || []}
className='text-black/30 dark:text-white/30'
hoverClassName='hover:text-black/50 dark:hover:text-white/50 transition-colors duration-500'
/>
{/* Dividing line */}
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
{/* Progress */}
<Progress />
{/* Controls */}
<div className='mt-4 flex w-full items-center justify-between'>
<button>
<Icon
name='hide-list'
className='h-7 w-7 text-black/90 dark:text-white/40'
/>
</button>
<div className='text-black/95 dark:text-white/80'>
<button
onClick={() => track && player.prevTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon name='previous' className='h-6 w-6 ' />
</button>
<button
onClick={() => track && player.playOrPause()}
className='mx-2 rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
className='h-6 w-6 '
/>
</button>
<button
onClick={() => track && player.nextTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon name='next' className='h-6 w-6 ' />
</button>
</div>
<LikeButton />
</div>
</div>
</div>
)
}
export default NowPlaying

View file

@ -0,0 +1,114 @@
import persistedUiStates from '@/web/states/persistedUiStates'
import player from '@/web/states/player'
import { ease } from '@/web/utils/const'
import { cx, css } from '@emotion/css'
import { MotionConfig, motion } from 'framer-motion'
import { useSnapshot } from 'valtio'
import Icon from '../../Icon'
import { State as PlayerState } from '@/web/utils/player'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
const LikeButton = () => {
const { track } = useSnapshot(player)
const { data: likedIDs } = useUserLikedTracksIDs()
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
const likeATrack = useMutationLikeATrack()
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
return (
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
>
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
</motion.button>
)
}
const Controls = () => {
const { state, track } = useSnapshot(player)
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
return (
<MotionConfig transition={{ ease, duration: 0.6 }}>
<motion.div
className={cx(
'fixed bottom-0 right-0 flex',
mini
? 'flex-col items-center justify-between'
: 'items-center justify-between',
mini
? css`
right: 24px;
bottom: 18px;
width: 44px;
height: 254px;
`
: css`
bottom: 56px;
right: 56px;
width: 254px;
`
)}
>
{/* Minimize */}
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
onClick={() => {
persistedUiStates.minimizePlayer = !mini
}}
>
<Icon name='hide-list' className='h-7 w-7 ' />
</motion.button>
{/* Media controls */}
<div className='flex flex-wrap gap-2 text-black/95 dark:text-white/80'>
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track && player.prevTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
>
<Icon name='previous' className='h-6 w-6' />
</motion.button>
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track && player.playOrPause()}
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
>
<Icon
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
className='h-6 w-6 '
/>
</motion.button>
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track && player.nextTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
>
<Icon name='next' className='h-6 w-6 ' />
</motion.button>
</div>
{/* Like */}
<LikeButton />
</motion.div>
</MotionConfig>
)
}
export default Controls

View file

@ -0,0 +1,62 @@
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import { ease } from '@/web/utils/const'
import { cx } from '@emotion/css'
import { useAnimation, motion } from 'framer-motion'
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
const Cover = () => {
const playerSnapshot = useSnapshot(player)
const [cover, setCover] = useState('')
const animationStartTime = useRef(0)
const controls = useAnimation()
const duration = 150 // ms
const navigate = useNavigate()
useEffect(() => {
const resizedCover = resizeImage(
playerSnapshot.track?.al.picUrl || '',
'lg'
)
const animate = async () => {
animationStartTime.current = Date.now()
await controls.start({ opacity: 0 })
setCover(resizedCover)
}
animate()
}, [controls, playerSnapshot.track?.al.picUrl])
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
useEffect(() => {
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
if (cover !== realCover) setCover(realCover)
}, [cover, playerSnapshot.track?.al.picUrl])
const onLoad = () => {
const passedTime = Date.now() - animationStartTime.current
controls.start({
opacity: 1,
transition: {
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
},
})
}
return (
<motion.img
animate={controls}
transition={{ duration: duration / 1000, ease }}
className={cx('absolute inset-0 w-full')}
src={cover}
onLoad={onLoad}
onClick={() => {
const id = playerSnapshot.track?.al.id
if (id) navigate(`/album/${id}`)
}}
/>
)
}
export default Cover

View file

@ -1,7 +1,7 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import NowPlaying from './NowPlaying'
import tracks from '../../.storybook/mock/tracks'
import tracks from '@/web/.storybook/mock/tracks'
import { sample } from 'lodash-es'
export default {

View file

@ -0,0 +1,65 @@
import { css, cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import { AnimatePresence, motion } from 'framer-motion'
import ArtistInline from '@/web/components/New/ArtistsInLine'
import persistedUiStates from '@/web/states/persistedUiStates'
import Controls from './Controls'
import Cover from './Cover'
import Progress from './Progress'
const NowPlaying = () => {
const { track } = useSnapshot(player)
const { minimizePlayer } = useSnapshot(persistedUiStates)
return (
<>
{/* Now Playing */}
<AnimatePresence>
{!minimizePlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cx(
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
css`
border-color: hsl(0, 100%, 100%, 0.08);
`
)}
>
{/* Cover */}
<Cover />
{/* Info & Controls */}
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
{/* Track Info */}
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<ArtistInline
artists={track?.ar || []}
className='text-black/30 dark:text-white/30'
hoverClassName='hover:text-black/50 dark:hover:text-white/70 transition-colors duration-400'
/>
{/* Dividing line */}
<div className='mt-2 h-px w-2/5 bg-black/10 dark:bg-white/10'></div>
{/* Progress */}
<Progress />
{/* Controls placeholder */}
<div className='h-11'></div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Controls (for Animation) */}
<Controls />
</>
)
}
export default NowPlaying

View file

@ -0,0 +1,29 @@
import player from '@/web/states/player'
import { formatDuration } from '@/web/utils/common'
import { useSnapshot } from 'valtio'
import Slider from '../Slider'
const Progress = () => {
const { track, progress } = useSnapshot(player)
return (
<div className='mt-9 mb-4 flex w-full flex-col'>
<Slider
min={0}
max={(track?.dt ?? 100000) / 1000}
value={progress}
onChange={value => {
player.progress = value
}}
onlyCallOnChangeAfterDragEnded={true}
/>
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
</div>
</div>
)
}
export default Progress

View file

@ -0,0 +1,3 @@
import NowPlaying from './NowPlaying'
export default NowPlaying

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