mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2024-11-22 10:56:23 +08:00
feat: updates
This commit is contained in:
parent
ce757215a3
commit
c1cd31840e
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -57,4 +57,4 @@ vercel.json
|
||||||
packages/web/bundle-stats-renderer.html
|
packages/web/bundle-stats-renderer.html
|
||||||
packages/web/bundle-stats.html
|
packages/web/bundle-stats.html
|
||||||
packages/web/storybook-static
|
packages/web/storybook-static
|
||||||
packages/desktop/prisma/client
|
packages/desktop/esbuild-kit
|
||||||
|
|
|
@ -49,6 +49,10 @@ API 源代码来自 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryif
|
||||||
|
|
||||||
任何基于此项目开发的项目都必须遵守开源协议,在项目 README/应用内的关于页面和介绍网站中明确说明基于此项目开发,并附上此项目 GitHub 页面的链接。
|
任何基于此项目开发的项目都必须遵守开源协议,在项目 README/应用内的关于页面和介绍网站中明确说明基于此项目开发,并附上此项目 GitHub 页面的链接。
|
||||||
|
|
||||||
|
## Credit
|
||||||
|
|
||||||
|
Designed by [JACKCRING](https://jackcring.com)
|
||||||
|
|
||||||
<!-- ## 🖼️ 截图 -->
|
<!-- ## 🖼️ 截图 -->
|
||||||
|
|
||||||
<!-- ![lyrics][lyrics-screenshot]
|
<!-- ![lyrics][lyrics-screenshot]
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.31.0",
|
"eslint": "^8.31.0",
|
||||||
"prettier": "^2.8.1",
|
"prettier": "^2.8.1",
|
||||||
"turbo": "^1.6.3",
|
"turbo": "^1.8.3",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"tsx": "^3.12.1",
|
"tsx": "^3.12.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.2.1"
|
"prettier-plugin-tailwindcss": "^0.2.1"
|
||||||
|
|
|
@ -9,7 +9,8 @@ const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
|
||||||
module.exports = {
|
module.exports = {
|
||||||
appId: 'app.r3play',
|
appId: 'app.r3play',
|
||||||
productName: pkg.productName,
|
productName: pkg.productName,
|
||||||
copyright: 'Copyright © 2022 qier222',
|
executableName: pkg.productName,
|
||||||
|
copyright: 'Copyright © 2023 qier222',
|
||||||
asar: true,
|
asar: true,
|
||||||
directories: {
|
directories: {
|
||||||
output: 'release',
|
output: 'release',
|
||||||
|
@ -70,14 +71,14 @@ module.exports = {
|
||||||
},
|
},
|
||||||
linux: {
|
linux: {
|
||||||
target: [
|
target: [
|
||||||
{
|
// {
|
||||||
target: 'deb',
|
// target: 'deb',
|
||||||
arch: [
|
// arch: [
|
||||||
'x64',
|
// 'x64',
|
||||||
// 'arm64',
|
// // 'arm64',
|
||||||
// 'armv7l'
|
// // 'armv7l'
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
target: 'AppImage',
|
target: 'AppImage',
|
||||||
arch: ['x64'],
|
arch: ['x64'],
|
||||||
|
@ -105,19 +106,13 @@ module.exports = {
|
||||||
},
|
},
|
||||||
files: [
|
files: [
|
||||||
'!**/*.ts',
|
'!**/*.ts',
|
||||||
'!**/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',
|
|
||||||
'!**/node_modules/.bin',
|
|
||||||
'!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}',
|
'!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}',
|
||||||
'!.editorconfig',
|
'!.editorconfig',
|
||||||
'!**/._*',
|
'!**/._*',
|
||||||
'!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}',
|
'!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}',
|
||||||
'!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}',
|
'!**/{pnpm-lock.yaml}',
|
||||||
'!**/{appveyor.yml,.travis.yml,circle.yml}',
|
|
||||||
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
|
|
||||||
'!**/*.{map,debug.min.js}',
|
'!**/*.{map,debug.min.js}',
|
||||||
|
'!**/node_modules/*',
|
||||||
|
|
||||||
{
|
{
|
||||||
from: './dist',
|
from: './dist',
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
|
import path from 'path'
|
||||||
|
import { isProd } from '../env'
|
||||||
|
import log from '../log'
|
||||||
|
import appleMusic from './routes/r3play/appleMusic'
|
||||||
|
import netease from './routes/netease/netease'
|
||||||
|
import audio from './routes/r3play/audio'
|
||||||
import fastifyCookie from '@fastify/cookie'
|
import fastifyCookie from '@fastify/cookie'
|
||||||
import fastifyMultipart from '@fastify/multipart'
|
import fastifyMultipart from '@fastify/multipart'
|
||||||
import fastifyStatic from '@fastify/static'
|
import fastifyStatic from '@fastify/static'
|
||||||
import fastify from 'fastify'
|
import fastify from 'fastify'
|
||||||
import path from 'path'
|
|
||||||
import { isProd } from '../env'
|
log.info('[electron] appServer/appServer.ts')
|
||||||
import log from '../log'
|
|
||||||
import netease from './routes/netease/netease'
|
|
||||||
import appleMusic from './routes/r3play/appleMusic'
|
|
||||||
import audio from './routes/r3play/audio'
|
|
||||||
|
|
||||||
const initAppServer = async () => {
|
const initAppServer = async () => {
|
||||||
const server = fastify({
|
const server = fastify({
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import cache from '../../../cache'
|
||||||
import log from '@/desktop/main/log'
|
import log from '@/desktop/main/log'
|
||||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||||
import { pathCase, snakeCase } from 'change-case'
|
import { pathCase, snakeCase } from 'change-case'
|
||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||||
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
|
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
|
||||||
import cache from '../../../cache'
|
|
||||||
|
log.info('[electron] appServer/routes/netease.ts')
|
||||||
|
|
||||||
async function netease(fastify: FastifyInstance) {
|
async function netease(fastify: FastifyInstance) {
|
||||||
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
|
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import proxy from '@fastify/http-proxy'
|
import proxy from '@fastify/http-proxy'
|
||||||
import { isDev } from '@/desktop/main/env'
|
import { isDev } from '@/desktop/main/env'
|
||||||
|
import log from '@/desktop/main/log'
|
||||||
|
|
||||||
|
log.info('[electron] appServer/routes/r3play/appleMusic.ts')
|
||||||
|
|
||||||
async function appleMusic(fastify: FastifyInstance) {
|
async function appleMusic(fastify: FastifyInstance) {
|
||||||
fastify.register(proxy, {
|
fastify.register(proxy, {
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { FetchTracksResponse } from '@/shared/api/Track'
|
||||||
import store from '@/desktop/main/store'
|
import store from '@/desktop/main/store'
|
||||||
import { db, Tables } from '@/desktop/main/db'
|
import { db, Tables } from '@/desktop/main/db'
|
||||||
|
|
||||||
|
log.info('[electron] appServer/routes/r3play/audio.ts')
|
||||||
|
|
||||||
const getAudioFromCache = async (id: number) => {
|
const getAudioFromCache = async (id: number) => {
|
||||||
// get from cache
|
// get from cache
|
||||||
const cache = await db.find(Tables.Audio, id)
|
const cache = await db.find(Tables.Audio, id)
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { CacheAPIs, CacheAPIsParams } from '@/shared/CacheAPIs'
|
||||||
import { TablesStructures } from './db'
|
import { TablesStructures } from './db'
|
||||||
import { FastifyReply } from 'fastify'
|
import { FastifyReply } from 'fastify'
|
||||||
|
|
||||||
|
log.info('[electron] cache.ts')
|
||||||
|
|
||||||
class Cache {
|
class Cache {
|
||||||
constructor() {
|
constructor() {
|
||||||
//
|
//
|
||||||
|
|
|
@ -9,6 +9,8 @@ import pkg from '../../../package.json'
|
||||||
import { compare, validate } from 'compare-versions'
|
import { compare, validate } from 'compare-versions'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
|
|
||||||
|
log.info('[electron] db.ts')
|
||||||
|
|
||||||
export const enum Tables {
|
export const enum Tables {
|
||||||
Track = 'Track',
|
Track = 'Track',
|
||||||
Album = 'Album',
|
Album = 'Album',
|
||||||
|
@ -108,7 +110,7 @@ class DB {
|
||||||
const prodBinPaths = {
|
const prodBinPaths = {
|
||||||
darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`),
|
darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`),
|
||||||
win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
|
win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
|
||||||
linux: '',
|
linux: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
|
||||||
}
|
}
|
||||||
return isProd
|
return isProd
|
||||||
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']
|
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']
|
||||||
|
|
|
@ -13,6 +13,8 @@ import { isDev, isWindows, isLinux, isMac, appName } from './env'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
import initAppServer from './appServer/appServer'
|
import initAppServer from './appServer/appServer'
|
||||||
|
|
||||||
|
log.info('[electron] index.ts')
|
||||||
|
|
||||||
class Main {
|
class Main {
|
||||||
win: BrowserWindow | null = null
|
win: BrowserWindow | null = null
|
||||||
tray: YPMTray | null = null
|
tray: YPMTray | null = null
|
||||||
|
@ -103,7 +105,7 @@ class Main {
|
||||||
|
|
||||||
// Make all links open with the browser, not with the application
|
// Make all links open with the browser, not with the application
|
||||||
this.win.webContents.setWindowOpenHandler(({ url }) => {
|
this.win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (url.startsWith('https:')) shell.openExternal(url)
|
if (url.startsWith('https://')) shell.openExternal(url)
|
||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
29
packages/desktop/main/ipcMain.ts
Normal file → Executable file
29
packages/desktop/main/ipcMain.ts
Normal file → Executable file
|
@ -14,6 +14,8 @@ import path from 'path'
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
import { db, Tables } from './db'
|
import { db, Tables } from './db'
|
||||||
|
|
||||||
|
log.info('[electron] ipcMain.ts')
|
||||||
|
|
||||||
const on = <T extends keyof IpcChannelsParams>(
|
const on = <T extends keyof IpcChannelsParams>(
|
||||||
channel: T,
|
channel: T,
|
||||||
listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void
|
listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void
|
||||||
|
@ -50,9 +52,30 @@ function initWindowIpcMain(win: BrowserWindow | null) {
|
||||||
win?.minimize()
|
win?.minimize()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isMaximized = false
|
||||||
|
let unMaximizeSize: { width: number; height: number } | null = null
|
||||||
|
let windowPosition: { x: number; y: number } | null = null
|
||||||
on(IpcChannels.MaximizeOrUnmaximize, () => {
|
on(IpcChannels.MaximizeOrUnmaximize, () => {
|
||||||
if (!win) return
|
if (!win) return false
|
||||||
win.isMaximized() ? win.unmaximize() : win.maximize()
|
|
||||||
|
if (isMaximized) {
|
||||||
|
if (unMaximizeSize) {
|
||||||
|
win.setSize(unMaximizeSize.width, unMaximizeSize.width, true)
|
||||||
|
}
|
||||||
|
if (windowPosition) {
|
||||||
|
win.setPosition(windowPosition.x, windowPosition.y, true)
|
||||||
|
}
|
||||||
|
win.unmaximize()
|
||||||
|
} else {
|
||||||
|
const size = win.getSize()
|
||||||
|
unMaximizeSize = { width: size[1], height: size[0] }
|
||||||
|
const position = win.getPosition()
|
||||||
|
windowPosition = { x: position[0], y: position[1] }
|
||||||
|
win.maximize()
|
||||||
|
}
|
||||||
|
|
||||||
|
isMaximized = !isMaximized
|
||||||
|
win.webContents.send(IpcChannels.IsMaximized, isMaximized)
|
||||||
})
|
})
|
||||||
|
|
||||||
on(IpcChannels.Close, () => {
|
on(IpcChannels.Close, () => {
|
||||||
|
@ -66,7 +89,7 @@ function initWindowIpcMain(win: BrowserWindow | null) {
|
||||||
|
|
||||||
handle(IpcChannels.IsMaximized, () => {
|
handle(IpcChannels.IsMaximized, () => {
|
||||||
if (!win) return
|
if (!win) return
|
||||||
return win.isMaximized()
|
return isMaximized
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/** By default, it writes logs to the following locations:
|
/** By default, it writes logs to the following locations:
|
||||||
* on Linux: ~/.config/r3play/logs/main.log
|
* on Linux: ~/.config/R3PLAY/logs/main.log
|
||||||
* on macOS: ~/Library/Logs/r3play/main.log
|
* on macOS: ~/Library/Logs/r3play/main.log
|
||||||
* on Windows: %USERPROFILE%\AppData\Roaming\r3play\logs\main.log
|
* on Windows: %USERPROFILE%\AppData\Roaming\r3play\logs\main.log
|
||||||
* @see https://www.npmjs.com/package/electron-log
|
* @see https://www.npmjs.com/package/electron-log
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import {
|
import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron'
|
||||||
app,
|
|
||||||
BrowserWindow,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
MenuItemConstructorOptions,
|
|
||||||
shell,
|
|
||||||
} from 'electron'
|
|
||||||
import { isMac } from './env'
|
import { isMac } from './env'
|
||||||
import { logsPath } from './utils'
|
import { logsPath } from './utils'
|
||||||
import { exec } from 'child_process'
|
import { exec } from 'child_process'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
|
log.info('[electron] menu.ts')
|
||||||
|
|
||||||
export const createMenu = (win: BrowserWindow) => {
|
export const createMenu = (win: BrowserWindow) => {
|
||||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||||
|
@ -51,9 +47,7 @@ export const createMenu = (win: BrowserWindow) => {
|
||||||
{
|
{
|
||||||
label: '反馈问题',
|
label: '反馈问题',
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal('https://github.com/qier222/YesPlayMusic/issues/new')
|
||||||
'https://github.com/qier222/YesPlayMusic/issues/new'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
@ -66,17 +60,13 @@ export const createMenu = (win: BrowserWindow) => {
|
||||||
{
|
{
|
||||||
label: '访问论坛',
|
label: '访问论坛',
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal('https://github.com/qier222/YesPlayMusic/discussions')
|
||||||
'https://github.com/qier222/YesPlayMusic/discussions'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '加入交流群',
|
label: '加入交流群',
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal('https://github.com/qier222/YesPlayMusic/discussions')
|
||||||
'https://github.com/qier222/YesPlayMusic/discussions'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
|
log.info('[electron] store.ts')
|
||||||
|
|
||||||
export interface TypedElectronStore {
|
export interface TypedElectronStore {
|
||||||
window: {
|
window: {
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import {
|
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from 'electron'
|
||||||
app,
|
|
||||||
BrowserWindow,
|
|
||||||
Menu,
|
|
||||||
MenuItemConstructorOptions,
|
|
||||||
nativeImage,
|
|
||||||
Tray,
|
|
||||||
} from 'electron'
|
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { RepeatMode } from '@/shared/playerDataTypes'
|
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||||
import { appName } from './env'
|
import { appName } from './env'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
|
log.info('[electron] tray.ts')
|
||||||
|
|
||||||
const iconDirRoot =
|
const iconDirRoot =
|
||||||
process.env.NODE_ENV === 'development'
|
process.env.NODE_ENV === 'development'
|
||||||
|
|
|
@ -3,6 +3,9 @@ import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import pkg from '../../../package.json'
|
import pkg from '../../../package.json'
|
||||||
import { appName, isDev } from './env'
|
import { appName, isDev } from './env'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
|
log.info('[electron] utils.ts')
|
||||||
|
|
||||||
export const dirname = isDev ? process.cwd() : __dirname
|
export const dirname = isDev ? process.cwd() : __dirname
|
||||||
export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
|
export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
|
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
|
log.info('[electron] windowsTaskbar.ts')
|
||||||
|
|
||||||
enum ItemKeys {
|
enum ItemKeys {
|
||||||
Play = 'play',
|
Play = 'play',
|
||||||
|
@ -66,15 +69,11 @@ class ThumbarImpl implements Thumbar {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateThumbarButtons(clear: boolean) {
|
private _updateThumbarButtons(clear: boolean) {
|
||||||
this._win.setThumbarButtons(
|
this._win.setThumbarButtons(clear ? [] : [this._previous, this._playOrPause, this._next])
|
||||||
clear ? [] : [this._previous, this._playOrPause, this._next]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlayState(isPlaying: boolean) {
|
setPlayState(isPlaying: boolean) {
|
||||||
this._playOrPause = this._buttons.get(
|
this._playOrPause = this._buttons.get(isPlaying ? ItemKeys.Pause : ItemKeys.Play)!
|
||||||
isPlaying ? ItemKeys.Pause : ItemKeys.Play
|
|
||||||
)!
|
|
||||||
this._updateThumbarButtons(false)
|
this._updateThumbarButtons(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"main": "./main/index.js",
|
"main": "./main/index.js",
|
||||||
"author": "*",
|
"author": "qier222 <qier222@outlook.com>",
|
||||||
|
"homepage": "https://github.com/qier222/YesPlayMusic",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "tsx scripts/build.sqlite3.ts",
|
"postinstall": "tsx scripts/build.sqlite3.ts",
|
||||||
"dev": "tsx scripts/build.main.ts --watch",
|
"dev": "tsx scripts/build.main.ts --watch",
|
||||||
|
@ -25,7 +26,6 @@
|
||||||
"@fastify/multipart": "^7.4.0",
|
"@fastify/multipart": "^7.4.0",
|
||||||
"@fastify/static": "^6.6.1",
|
"@fastify/static": "^6.6.1",
|
||||||
"@sentry/electron": "^3.0.7",
|
"@sentry/electron": "^3.0.7",
|
||||||
"@yimura/scraper": "^1.2.4",
|
|
||||||
"NeteaseCloudMusicApi": "^4.8.9",
|
"NeteaseCloudMusicApi": "^4.8.9",
|
||||||
"better-sqlite3": "8.1.0",
|
"better-sqlite3": "8.1.0",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
|
@ -41,10 +41,10 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.3",
|
"@types/better-sqlite3": "^7.6.3",
|
||||||
"@vitest/ui": "^0.20.3",
|
"@vitest/ui": "^0.20.3",
|
||||||
"axios": "^1.2.1",
|
"axios": "^1.3.4",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"electron": "^23.1.1",
|
"electron": "^23.1.4",
|
||||||
"electron-builder": "23.6.0",
|
"electron-builder": "23.6.0",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-rebuild": "^3.2.9",
|
"electron-rebuild": "^3.2.9",
|
||||||
|
|
|
@ -39,10 +39,11 @@ exports.default = async function (context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'win32') {
|
// Windows and Linux
|
||||||
if (arch !== 'x64') return // Skip other archs
|
if (platform === 'win32' || platform === 'linux') {
|
||||||
|
if (platform === 'win32' && arch !== 'x64') return // Skip windows arm
|
||||||
|
|
||||||
const from = `${binDir}/better_sqlite3_win32_${arch}.node`
|
const from = `${binDir}/better_sqlite3_${platform}_${arch}.node`
|
||||||
const to = `${context.appOutDir}/resources/bin/better_sqlite3.node`
|
const to = `${context.appOutDir}/resources/bin/better_sqlite3.node`
|
||||||
console.info(`copy ${from} to ${to}`)
|
console.info(`copy ${from} to ${to}`)
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,8 @@ export enum UserApiNames {
|
||||||
FetchUserArtists = 'fetchUserArtists',
|
FetchUserArtists = 'fetchUserArtists',
|
||||||
FetchListenedRecords = 'fetchListenedRecords',
|
FetchListenedRecords = 'fetchListenedRecords',
|
||||||
FetchUserVideos = 'fetchUserVideos',
|
FetchUserVideos = 'fetchUserVideos',
|
||||||
|
RefreshCookie = 'refreshCookie',
|
||||||
|
DailyCheckIn = 'dailyCheckIn',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账号详情
|
// 获取账号详情
|
||||||
|
@ -130,3 +132,15 @@ export interface FetchListenedRecordsResponse {
|
||||||
song: Track
|
song: Track
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新Cookie
|
||||||
|
export interface RefreshCookieResponse {
|
||||||
|
code: number
|
||||||
|
cookie: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每日签到
|
||||||
|
export interface DailyCheckInResponse {
|
||||||
|
code: number
|
||||||
|
point: number
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
useMutationLikeATrack,
|
|
||||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||||
import { State as PlayerState } from '@/web/utils/player'
|
import { State as PlayerState } from '@/web/utils/player'
|
||||||
|
|
|
@ -7,9 +7,7 @@ import {
|
||||||
} from '@/shared/api/Album'
|
} from '@/shared/api/Album'
|
||||||
|
|
||||||
// 专辑详情
|
// 专辑详情
|
||||||
export function fetchAlbum(
|
export function fetchAlbum(params: FetchAlbumParams): Promise<FetchAlbumResponse> {
|
||||||
params: FetchAlbumParams
|
|
||||||
): Promise<FetchAlbumResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/album',
|
url: '/album',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
@ -20,9 +18,7 @@ export function fetchAlbum(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function likeAAlbum(
|
export function likeAAlbum(params: LikeAAlbumParams): Promise<LikeAAlbumResponse> {
|
||||||
params: LikeAAlbumParams
|
|
||||||
): Promise<LikeAAlbumResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/album/sub',
|
url: '/album/sub',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
|
|
@ -13,9 +13,7 @@ import {
|
||||||
} from '@/shared/api/Artist'
|
} from '@/shared/api/Artist'
|
||||||
|
|
||||||
// 歌手详情
|
// 歌手详情
|
||||||
export function fetchArtist(
|
export function fetchArtist(params: FetchArtistParams): Promise<FetchArtistResponse> {
|
||||||
params: FetchArtistParams
|
|
||||||
): Promise<FetchArtistResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/artists',
|
url: '/artists',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
@ -46,9 +44,7 @@ export function fetchSimilarArtists(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取歌手MV
|
// 获取歌手MV
|
||||||
export function fetchArtistMV(
|
export function fetchArtistMV(params: FetchArtistMVParams): Promise<FetchArtistMVResponse> {
|
||||||
params: FetchArtistMVParams
|
|
||||||
): Promise<FetchArtistMVResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/artist/mv',
|
url: '/artist/mv',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
@ -57,9 +53,7 @@ export function fetchArtistMV(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收藏歌手
|
// 收藏歌手
|
||||||
export function likeAArtist(
|
export function likeAArtist(params: LikeAArtistParams): Promise<LikeAArtistResponse> {
|
||||||
params: LikeAArtistParams
|
|
||||||
): Promise<LikeAArtistResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: 'artist/sub',
|
url: 'artist/sub',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import request from '@/web/utils/request'
|
import request from '@/web/utils/request'
|
||||||
import { FetchUserAccountResponse } from '@/shared/api/User'
|
import { FetchUserAccountResponse, RefreshCookieResponse } from '@/shared/api/User'
|
||||||
|
|
||||||
// 手机号登录
|
// 手机号登录
|
||||||
interface LoginWithPhoneParams {
|
interface LoginWithPhoneParams {
|
||||||
|
@ -14,9 +14,7 @@ export interface LoginWithPhoneResponse {
|
||||||
code: number
|
code: number
|
||||||
cookie: string
|
cookie: string
|
||||||
}
|
}
|
||||||
export function loginWithPhone(
|
export function loginWithPhone(params: LoginWithPhoneParams): Promise<LoginWithPhoneResponse> {
|
||||||
params: LoginWithPhoneParams
|
|
||||||
): Promise<LoginWithPhoneResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/login/cellphone',
|
url: '/login/cellphone',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
@ -47,9 +45,7 @@ export interface LoginWithEmailResponse extends FetchUserAccountResponse {
|
||||||
userId: number
|
userId: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
export function loginWithEmail(
|
export function loginWithEmail(params: LoginWithEmailParams): Promise<LoginWithEmailResponse> {
|
||||||
params: LoginWithEmailParams
|
|
||||||
): Promise<LoginWithEmailResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/login',
|
url: '/login',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
@ -99,7 +95,7 @@ export function checkLoginQrCodeStatus(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新登录
|
// 刷新登录
|
||||||
export function refreshCookie() {
|
export function refreshCookie(): Promise<RefreshCookieResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/login/refresh',
|
url: '/login/refresh',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { fetchAudioSource, fetchTracks } from '@/web/api/track'
|
import { fetchAudioSource, fetchTracks } from '@/web/api/track'
|
||||||
import type {} from '@/web/api/track'
|
import type { } from '@/web/api/track'
|
||||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { fetchUserAccount } from '@/web/api/user'
|
import { dailyCheckIn, fetchUserAccount } from '@/web/api/user'
|
||||||
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
|
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
|
||||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
import { logout as logoutAPI } from '../auth'
|
import { logout as logoutAPI, refreshCookie } from '../auth'
|
||||||
import { removeAllCookies } from '@/web/utils/cookie'
|
import { removeAllCookies, setCookies } from '@/web/utils/cookie'
|
||||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
export default function useUser() {
|
export default function useUser() {
|
||||||
|
@ -31,6 +31,43 @@ export default function useUser() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRefreshCookie() {
|
||||||
|
const user = useUser()
|
||||||
|
return useQuery(
|
||||||
|
[UserApiNames.RefreshCookie],
|
||||||
|
async () => {
|
||||||
|
const result = await refreshCookie()
|
||||||
|
if (result?.code === 200) {
|
||||||
|
setCookies(result.cookie)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchInterval: 1000 * 60 * 30,
|
||||||
|
enabled: !!user.data?.profile?.userId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDailyCheckIn() {
|
||||||
|
const user = useUser()
|
||||||
|
return useQuery(
|
||||||
|
[UserApiNames.DailyCheckIn],
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
Promise.allSettled([dailyCheckIn(0), dailyCheckIn(1)])
|
||||||
|
return 'ok'
|
||||||
|
} catch (e: any) {
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchInterval: 1000 * 60 * 30,
|
||||||
|
enabled: !!user.data?.profile?.userId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const useIsLoggedIn = () => {
|
export const useIsLoggedIn = () => {
|
||||||
const { data, isLoading } = useUser()
|
const { data, isLoading } = useUser()
|
||||||
if (isLoading) return true
|
if (isLoading) return true
|
||||||
|
|
|
@ -10,9 +10,7 @@ import {
|
||||||
} from '@/shared/api/Playlists'
|
} from '@/shared/api/Playlists'
|
||||||
|
|
||||||
// 歌单详情
|
// 歌单详情
|
||||||
export function fetchPlaylist(
|
export function fetchPlaylist(params: FetchPlaylistParams): Promise<FetchPlaylistResponse> {
|
||||||
params: FetchPlaylistParams
|
|
||||||
): Promise<FetchPlaylistResponse> {
|
|
||||||
if (!params.s) params.s = 0 // 网易云默认返回8个收藏者,这里设置为0,减少返回的JSON体积
|
if (!params.s) params.s = 0 // 网易云默认返回8个收藏者,这里设置为0,减少返回的JSON体积
|
||||||
return request({
|
return request({
|
||||||
url: '/playlist/detail',
|
url: '/playlist/detail',
|
||||||
|
@ -46,9 +44,7 @@ export function fetchDailyRecommendPlaylists(): Promise<FetchDailyRecommendPlayl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function likeAPlaylist(
|
export function likeAPlaylist(params: LikeAPlaylistParams): Promise<LikeAPlaylistResponse> {
|
||||||
params: LikeAPlaylistParams
|
|
||||||
): Promise<LikeAPlaylistResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/playlist/subscribe',
|
url: '/playlist/subscribe',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
|
|
@ -12,14 +12,10 @@ import {
|
||||||
FetchListenedRecordsResponse,
|
FetchListenedRecordsResponse,
|
||||||
FetchUserVideosResponse,
|
FetchUserVideosResponse,
|
||||||
FetchUserVideosParams,
|
FetchUserVideosParams,
|
||||||
|
DailyCheckInResponse,
|
||||||
} from '@/shared/api/User'
|
} from '@/shared/api/User'
|
||||||
|
|
||||||
/**
|
// 获取用户详情
|
||||||
* 获取用户详情
|
|
||||||
* 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户详情
|
|
||||||
* - uid : 用户 id
|
|
||||||
* @param {number} uid
|
|
||||||
*/
|
|
||||||
export function userDetail(uid: number) {
|
export function userDetail(uid: number) {
|
||||||
return request({
|
return request({
|
||||||
url: '/user/detail',
|
url: '/user/detail',
|
||||||
|
@ -53,6 +49,7 @@ export function fetchUserPlaylists(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户收藏的歌曲ID列表
|
||||||
export function fetchUserLikedTracksIDs(
|
export function fetchUserLikedTracksIDs(
|
||||||
params: FetchUserLikedTracksIDsParams
|
params: FetchUserLikedTracksIDsParams
|
||||||
): Promise<FetchUserLikedTracksIDsResponse> {
|
): Promise<FetchUserLikedTracksIDsResponse> {
|
||||||
|
@ -102,9 +99,9 @@ export function fetchListenedRecords(
|
||||||
* - type: 签到类型 , 默认 0, 其中 0 为安卓端签到 ,1 为 web/PC 签到
|
* - type: 签到类型 , 默认 0, 其中 0 为安卓端签到 ,1 为 web/PC 签到
|
||||||
* @param {number} type
|
* @param {number} type
|
||||||
*/
|
*/
|
||||||
export function dailySignin(type = 0) {
|
export function dailyCheckIn(type = 0): Promise<DailyCheckInResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/daily_signin',
|
url: '/daily/signin',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
params: {
|
params: {
|
||||||
type,
|
type,
|
||||||
|
@ -113,9 +110,7 @@ export function dailySignin(type = 0) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchUserAlbums(
|
export function fetchUserAlbums(params: FetchUserAlbumsParams): Promise<FetchUserAlbumsResponse> {
|
||||||
params: FetchUserAlbumsParams
|
|
||||||
): Promise<FetchUserAlbumsResponse> {
|
|
||||||
return request({
|
return request({
|
||||||
url: '/album/sublist',
|
url: '/album/sublist',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
|
@ -96,6 +96,10 @@ const MenuItem = ({
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
{/* 增加三角形,避免斜着移动到submenu时意外关闭菜单 */}
|
||||||
|
<div className='absolute -right-8 -bottom-6 h-12 w-12 rotate-45'></div>
|
||||||
|
<div className='absolute -right-8 -top-6 h-12 w-12 rotate-45'></div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -73,13 +73,21 @@ const Playlist = ({ playlist }: { playlist: Playlist }) => {
|
||||||
}, [playlist.id])
|
}, [playlist.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<div className='group relative'>
|
||||||
onClick={goTo}
|
<Image
|
||||||
key={playlist.id}
|
onClick={goTo}
|
||||||
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
|
key={playlist.id}
|
||||||
className='aspect-square rounded-24'
|
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
|
||||||
onMouseOver={prefetch}
|
className='aspect-square rounded-24'
|
||||||
/>
|
onMouseOver={prefetch}
|
||||||
|
/>
|
||||||
|
{/* Hover mask layer */}
|
||||||
|
<div className='pointer-events-none absolute inset-0 w-full bg-gradient-to-b from-transparent to-black opacity-0 transition-all duration-400 group-hover:opacity-100 '></div>
|
||||||
|
{/* Name */}
|
||||||
|
<div className='pointer-events-none absolute bottom-0 p-3 text-sm font-medium text-neutral-300 opacity-0 transition-all duration-400 group-hover:opacity-100'>
|
||||||
|
{playlist.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,11 +46,7 @@ const CoverRow = ({
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
{title && (
|
{title && <h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>{title}</h4>}
|
||||||
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
className='no-scrollbar'
|
className='no-scrollbar'
|
||||||
|
@ -66,20 +62,14 @@ const CoverRow = ({
|
||||||
Footer: () => <div className='h-16'></div>,
|
Footer: () => <div className='h-16'></div>,
|
||||||
}}
|
}}
|
||||||
itemContent={(index, row) => (
|
itemContent={(index, row) => (
|
||||||
<div
|
<div key={index} className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'>
|
||||||
key={index}
|
|
||||||
className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'
|
|
||||||
>
|
|
||||||
{row.map((item: Item) => (
|
{row.map((item: Item) => (
|
||||||
<img
|
<img
|
||||||
onClick={() => goTo(item.id)}
|
onClick={() => goTo(item.id)}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
src={resizeImage(
|
src={resizeImage(
|
||||||
item?.picUrl ||
|
item?.picUrl || (item as Playlist)?.coverImgUrl || item?.picUrl || '',
|
||||||
(item as Playlist)?.coverImgUrl ||
|
|
||||||
item?.picUrl ||
|
|
||||||
'',
|
|
||||||
'md'
|
'md'
|
||||||
)}
|
)}
|
||||||
className='aspect-square w-full rounded-24'
|
className='aspect-square w-full rounded-24'
|
||||||
|
|
|
@ -22,11 +22,7 @@ const sizes = {
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const CoverWall = ({
|
const CoverWall = ({ albums }: { albums: { id: number; coverUrl: string; large: boolean }[] }) => {
|
||||||
albums,
|
|
||||||
}: {
|
|
||||||
albums: { id: number; coverUrl: string; large: boolean }[]
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const breakpoint = useBreakpoint()
|
const breakpoint = useBreakpoint()
|
||||||
|
|
||||||
|
@ -41,10 +37,7 @@ const CoverWall = ({
|
||||||
>
|
>
|
||||||
{albums.map(album => (
|
{albums.map(album => (
|
||||||
<Image
|
<Image
|
||||||
src={resizeImage(
|
src={resizeImage(album.coverUrl, sizes[album.large ? 'large' : 'small'][breakpoint])}
|
||||||
album.coverUrl,
|
|
||||||
sizes[album.large ? 'large' : 'small'][breakpoint]
|
|
||||||
)}
|
|
||||||
key={album.id}
|
key={album.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
'aspect-square h-full w-full rounded-20 lg:rounded-24',
|
'aspect-square h-full w-full rounded-20 lg:rounded-24',
|
||||||
|
|
|
@ -1,27 +1,16 @@
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
|
||||||
|
|
||||||
const Devtool = () => {
|
const Devtool = () => {
|
||||||
const isMobile = useIsMobile()
|
|
||||||
return (
|
return (
|
||||||
<ReactQueryDevtools
|
<ReactQueryDevtools
|
||||||
initialIsOpen={false}
|
initialIsOpen={false}
|
||||||
toggleButtonProps={{
|
toggleButtonProps={{
|
||||||
style: {
|
style: {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
...(isMobile
|
top: 36,
|
||||||
? {
|
right: 148,
|
||||||
top: 0,
|
bottom: 'atuo',
|
||||||
right: 0,
|
left: 'auto',
|
||||||
bottom: 'auto',
|
|
||||||
left: 'atuo',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
top: 36,
|
|
||||||
right: 148,
|
|
||||||
bottom: 'atuo',
|
|
||||||
left: 'auto',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
45
packages/web/components/Dropdown.tsx
Normal file
45
packages/web/components/Dropdown.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
export interface DropdownItem {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dropdown({ items, onClose }: { items: DropdownItem[]; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cx(
|
||||||
|
'origin-top rounded-12 border border-white/[.06] bg-gray-900/95 p-px py-2.5 shadow-xl outline outline-1 outline-black backdrop-blur-3xl',
|
||||||
|
css`
|
||||||
|
min-width: 200px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
className='active:bg-gray/50 relative flex w-full items-center justify-between whitespace-nowrap rounded-[5px] p-3 text-16 font-medium text-neutral-200 transition-colors duration-400 hover:bg-white/[.06]'
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dropdown
|
|
@ -11,9 +11,7 @@ const ErrorBoundary = ({ children }: { children: ReactNode }) => {
|
||||||
>
|
>
|
||||||
<div className='app-region-no-drag'>
|
<div className='app-region-no-drag'>
|
||||||
<p>Something went wrong:</p>
|
<p>Something went wrong:</p>
|
||||||
<pre className='mb-2 text-18 dark:text-white'>
|
<pre className='mb-2 text-18 dark:text-white'>{error.toString()}</pre>
|
||||||
{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'>
|
<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()}
|
{componentStack?.trim()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -101,8 +101,7 @@ const ImageDesktop = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageMobile = (props: Props) => {
|
const ImageMobile = (props: Props) => {
|
||||||
const { src, className, srcSet, sizes, lazyLoad, onClick, onMouseOver } =
|
const { src, className, srcSet, sizes, lazyLoad, onClick, onMouseOver } = props
|
||||||
props
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
|
@ -21,11 +21,8 @@ const Layout = () => {
|
||||||
<div
|
<div
|
||||||
id='layout'
|
id='layout'
|
||||||
className={cx(
|
className={cx(
|
||||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
|
'relative grid h-screen select-none overflow-hidden bg-black',
|
||||||
window.env?.isElectron && !fullscreen && 'rounded-24',
|
window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||||
css`
|
|
||||||
min-width: 720px;
|
|
||||||
`
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BlurBackground />
|
<BlurBackground />
|
||||||
|
@ -47,7 +44,15 @@ const Layout = () => {
|
||||||
|
|
||||||
<ContextMenus />
|
<ContextMenus />
|
||||||
|
|
||||||
{/* {window.env?.isElectron && <Airplay />} */}
|
{/* Border */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'pointer-events-none fixed inset-0 z-50 rounded-24',
|
||||||
|
css`
|
||||||
|
box-shadow: inset 0px 0px 0px 1px rgba(255, 255, 255, 0.06);
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,10 +94,7 @@ const LoginWithQRCode = () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const text = useMemo(
|
const text = useMemo(
|
||||||
() =>
|
() => (key?.data?.unikey ? `https://music.163.com/login?codekey=${key.data.unikey}` : ''),
|
||||||
key?.data?.unikey
|
|
||||||
? `https://music.163.com/login?codekey=${key.data.unikey}`
|
|
||||||
: '',
|
|
||||||
[key?.data?.unikey]
|
[key?.data?.unikey]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -6,9 +6,7 @@ import { MotionConfig, motion } from 'framer-motion'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import Icon from '../Icon'
|
import Icon from '../Icon'
|
||||||
import { State as PlayerState } from '@/web/utils/player'
|
import { State as PlayerState } from '@/web/utils/player'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
useMutationLikeATrack,
|
|
||||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
|
||||||
|
|
||||||
const LikeButton = () => {
|
const LikeButton = () => {
|
||||||
const { track } = useSnapshot(player)
|
const { track } = useSnapshot(player)
|
||||||
|
@ -38,9 +36,7 @@ const Controls = () => {
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cx(
|
className={cx(
|
||||||
'fixed bottom-0 right-0 flex',
|
'fixed bottom-0 right-0 flex',
|
||||||
mini
|
mini ? 'flex-col items-center justify-between' : 'items-center justify-between',
|
||||||
? 'flex-col items-center justify-between'
|
|
||||||
: 'items-center justify-between',
|
|
||||||
mini
|
mini
|
||||||
? css`
|
? css`
|
||||||
right: 24px;
|
right: 24px;
|
||||||
|
@ -85,11 +81,7 @@ const Controls = () => {
|
||||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name={
|
name={[PlayerState.Playing, PlayerState.Loading].includes(state) ? 'pause' : 'play'}
|
||||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
|
||||||
? 'pause'
|
|
||||||
: 'play'
|
|
||||||
}
|
|
||||||
className='h-6 w-6 '
|
className='h-6 w-6 '
|
||||||
/>
|
/>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import persistedUiStates from '@/web/states/persistedUiStates'
|
||||||
import Controls from './Controls'
|
import Controls from './Controls'
|
||||||
import Cover from './Cover'
|
import Cover from './Cover'
|
||||||
import Progress from './Progress'
|
import Progress from './Progress'
|
||||||
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
const NowPlaying = () => {
|
const NowPlaying = () => {
|
||||||
const { track } = useSnapshot(player)
|
const { track } = useSnapshot(player)
|
||||||
|
@ -21,6 +22,7 @@ const NowPlaying = () => {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ ease, duration: 0.4 }}
|
||||||
className={cx(
|
className={cx(
|
||||||
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
|
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
|
||||||
css`
|
css`
|
||||||
|
|
|
@ -25,6 +25,7 @@ const Player = () => {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ ease, duration: 0.4 }}
|
||||||
>
|
>
|
||||||
<PlayingNext />
|
<PlayingNext />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -8,9 +8,7 @@ import { resizeImage } from '@/web/utils/common'
|
||||||
import { motion, PanInfo } from 'framer-motion'
|
import { motion, PanInfo } from 'framer-motion'
|
||||||
import { useLockBodyScroll } from 'react-use'
|
import { useLockBodyScroll } from 'react-use'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
useMutationLikeATrack,
|
|
||||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
|
||||||
import uiStates from '@/web/states/uiStates'
|
import uiStates from '@/web/states/uiStates'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
|
@ -27,10 +25,7 @@ const LikeButton = () => {
|
||||||
className='flex h-full items-center'
|
className='flex h-full items-center'
|
||||||
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
|
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7 text-white/10' />
|
||||||
name={isLiked ? 'heart' : 'heart-outline'}
|
|
||||||
className='h-7 w-7 text-white/10'
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -42,10 +37,7 @@ const PlayerMobile = () => {
|
||||||
useLockBodyScroll(locked)
|
useLockBodyScroll(locked)
|
||||||
const { mobileShowPlayingNext } = useSnapshot(uiStates)
|
const { mobileShowPlayingNext } = useSnapshot(uiStates)
|
||||||
|
|
||||||
const onDragEnd = (
|
const onDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||||
event: MouseEvent | TouchEvent | PointerEvent,
|
|
||||||
info: PanInfo
|
|
||||||
) => {
|
|
||||||
console.log(JSON.stringify(info))
|
console.log(JSON.stringify(info))
|
||||||
const { x, y } = info.offset
|
const { x, y } = info.offset
|
||||||
const offset = 100
|
const offset = 100
|
||||||
|
@ -107,9 +99,7 @@ const PlayerMobile = () => {
|
||||||
className='flex h-full flex-grow items-center '
|
className='flex h-full flex-grow items-center '
|
||||||
>
|
>
|
||||||
<div className='flex-shrink-0'>
|
<div className='flex-shrink-0'>
|
||||||
<div className='line-clamp-1 text-14 font-bold text-white'>
|
<div className='line-clamp-1 text-14 font-bold text-white'>{track?.name}</div>
|
||||||
{track?.name}
|
|
||||||
</div>
|
|
||||||
<div className='line-clamp-1 mt-1 text-12 font-bold text-white/60'>
|
<div className='line-clamp-1 mt-1 text-12 font-bold text-white/60'>
|
||||||
{track?.ar?.map(a => a.name).join(', ')}
|
{track?.ar?.map(a => a.name).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
|
@ -143,10 +133,7 @@ const PlayerMobile = () => {
|
||||||
onClick={() => player.playOrPause()}
|
onClick={() => player.playOrPause()}
|
||||||
className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5'
|
className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon name={state === 'playing' ? 'pause' : 'play'} className='h-6 w-6 text-white/80' />
|
||||||
name={state === 'playing' ? 'pause' : 'play'}
|
|
||||||
className='h-6 w-6 text-white/80'
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -67,10 +67,7 @@ const PlayingNextMobile = () => {
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon name='player-handler' className='mb-5 h-2.5 rotate-180 text-brand-700' />
|
||||||
name='player-handler'
|
|
||||||
className='mb-5 h-2.5 rotate-180 text-brand-700'
|
|
||||||
/>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
|
|
|
@ -1,21 +1,17 @@
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
import { AnimatePresence } from 'framer-motion'
|
||||||
import React, { ReactNode, Suspense } from 'react'
|
import React, { lazy, Suspense } from 'react'
|
||||||
import VideoPlayer from './VideoPlayer'
|
import VideoPlayer from './VideoPlayer'
|
||||||
|
|
||||||
const My = React.lazy(() => import('@/web/pages/My'))
|
const My = lazy(() => import('@/web/pages/My'))
|
||||||
const Discover = React.lazy(() => import('@/web/pages/Discover'))
|
const Discover = lazy(() => import('@/web/pages/Discover'))
|
||||||
const Browse = React.lazy(() => import('@/web/pages/Browse'))
|
const Browse = lazy(() => import('@/web/pages/Browse'))
|
||||||
const Album = React.lazy(() => import('@/web/pages/Album'))
|
const Album = lazy(() => import('@/web/pages/Album'))
|
||||||
const Playlist = React.lazy(() => import('@/web/pages/Playlist'))
|
const Playlist = lazy(() => import('@/web/pages/Playlist'))
|
||||||
const Artist = React.lazy(() => import('@/web/pages/Artist'))
|
const Artist = lazy(() => import('@/web/pages/Artist'))
|
||||||
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics'))
|
const Lyrics = lazy(() => import('@/web/pages/Lyrics'))
|
||||||
const Search = React.lazy(() => import('@/web/pages/Search'))
|
const Search = lazy(() => import('@/web/pages/Search'))
|
||||||
const Settings = React.lazy(() => import('@/web/pages/Settings'))
|
const Settings = lazy(() => import('@/web/pages/Settings'))
|
||||||
|
|
||||||
const lazy = (component: ReactNode) => {
|
|
||||||
return <Suspense>{component}</Suspense>
|
|
||||||
}
|
|
||||||
|
|
||||||
const Router = () => {
|
const Router = () => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
@ -24,16 +20,16 @@ const Router = () => {
|
||||||
<AnimatePresence mode='wait'>
|
<AnimatePresence mode='wait'>
|
||||||
<VideoPlayer />
|
<VideoPlayer />
|
||||||
<Routes location={location} key={location.pathname}>
|
<Routes location={location} key={location.pathname}>
|
||||||
<Route path='/' element={lazy(<My />)} />
|
<Route path='/' element={<My />} />
|
||||||
<Route path='/discover' element={lazy(<Discover />)} />
|
<Route path='/discover' element={<Discover />} />
|
||||||
<Route path='/browse' element={lazy(<Browse />)} />
|
<Route path='/browse' element={<Browse />} />
|
||||||
<Route path='/album/:id' element={lazy(<Album />)} />
|
<Route path='/album/:id' element={<Album />} />
|
||||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
<Route path='/playlist/:id' element={<Playlist />} />
|
||||||
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
<Route path='/artist/:id' element={<Artist />} />
|
||||||
<Route path='/settings' element={lazy(<Settings />)} />
|
<Route path='/settings' element={<Settings />} />
|
||||||
<Route path='/lyrics' element={lazy(<Lyrics />)} />
|
<Route path='/lyrics' element={<Lyrics />} />
|
||||||
<Route path='/search/:keywords' element={lazy(<Search />)}>
|
<Route path='/search/:keywords' element={<Search />}>
|
||||||
<Route path=':type' element={lazy(<Search />)} />
|
<Route path=':type' element={<Search />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
@ -23,8 +23,7 @@ const Slider = ({
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [draggingValue, setDraggingValue] = useState(value)
|
const [draggingValue, setDraggingValue] = useState(value)
|
||||||
const memoedValue = useMemo(
|
const memoedValue = useMemo(
|
||||||
() =>
|
() => (isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value),
|
||||||
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
|
|
||||||
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
|
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -50,8 +49,7 @@ const Slider = ({
|
||||||
* Handle slider click event
|
* Handle slider click event
|
||||||
*/
|
*/
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) =>
|
(e: React.MouseEvent<HTMLDivElement>) => onChange(getNewValue({ x: e.clientX, y: e.clientY })),
|
||||||
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
|
|
||||||
[getNewValue, onChange]
|
[getNewValue, onChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -69,22 +67,14 @@ const Slider = ({
|
||||||
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
||||||
if (!isDragging) return
|
if (!isDragging) return
|
||||||
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
|
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
|
||||||
onlyCallOnChangeAfterDragEnded
|
onlyCallOnChangeAfterDragEnded ? setDraggingValue(newValue) : onChange(newValue)
|
||||||
? setDraggingValue(newValue)
|
|
||||||
: onChange(newValue)
|
|
||||||
}
|
}
|
||||||
document.addEventListener('pointermove', handlePointerMove)
|
document.addEventListener('pointermove', handlePointerMove)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('pointermove', handlePointerMove)
|
document.removeEventListener('pointermove', handlePointerMove)
|
||||||
}
|
}
|
||||||
}, [
|
}, [isDragging, onChange, setDraggingValue, onlyCallOnChangeAfterDragEnded, getNewValue])
|
||||||
isDragging,
|
|
||||||
onChange,
|
|
||||||
setDraggingValue,
|
|
||||||
onlyCallOnChangeAfterDragEnded,
|
|
||||||
getNewValue,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle pointer up events
|
* Handle pointer up events
|
||||||
|
@ -102,28 +92,18 @@ const Slider = ({
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('pointerup', handlePointerUp)
|
document.removeEventListener('pointerup', handlePointerUp)
|
||||||
}
|
}
|
||||||
}, [
|
}, [isDragging, setIsDragging, onlyCallOnChangeAfterDragEnded, draggingValue, onChange])
|
||||||
isDragging,
|
|
||||||
setIsDragging,
|
|
||||||
onlyCallOnChangeAfterDragEnded,
|
|
||||||
draggingValue,
|
|
||||||
onChange,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track and thumb styles
|
* Track and thumb styles
|
||||||
*/
|
*/
|
||||||
const usedTrackStyle = useMemo(() => {
|
const usedTrackStyle = useMemo(() => {
|
||||||
const percentage = `${(memoedValue / max) * 100}%`
|
const percentage = `${(memoedValue / max) * 100}%`
|
||||||
return orientation === 'horizontal'
|
return orientation === 'horizontal' ? { width: percentage } : { height: percentage }
|
||||||
? { width: percentage }
|
|
||||||
: { height: percentage }
|
|
||||||
}, [max, memoedValue, orientation])
|
}, [max, memoedValue, orientation])
|
||||||
const thumbStyle = useMemo(() => {
|
const thumbStyle = useMemo(() => {
|
||||||
const percentage = `${(memoedValue / max) * 100}%`
|
const percentage = `${(memoedValue / max) * 100}%`
|
||||||
return orientation === 'horizontal'
|
return orientation === 'horizontal' ? { left: percentage } : { bottom: percentage }
|
||||||
? { left: percentage }
|
|
||||||
: { bottom: percentage }
|
|
||||||
}, [max, memoedValue, orientation])
|
}, [max, memoedValue, orientation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -159,9 +139,7 @@ const Slider = ({
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
|
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
|
||||||
isDragging || alwaysShowThumb
|
isDragging || alwaysShowThumb ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0 group-hover:opacity-100',
|
|
||||||
orientation === 'horizontal' && '-translate-x-1',
|
orientation === 'horizontal' && '-translate-x-1',
|
||||||
orientation === 'vertical' && 'translate-y-1'
|
orientation === 'vertical' && 'translate-y-1'
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -3,12 +3,14 @@ import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
|
import uiStates from '../states/uiStates'
|
||||||
|
|
||||||
const Controls = () => {
|
const Controls = () => {
|
||||||
const [isMaximized, setIsMaximized] = useState(false)
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
||||||
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
||||||
setIsMaximized(value)
|
setIsMaximized(value)
|
||||||
|
uiStates.fullscreen = value
|
||||||
})
|
})
|
||||||
|
|
||||||
const minimize = () => {
|
const minimize = () => {
|
||||||
|
@ -38,10 +40,7 @@ const Controls = () => {
|
||||||
<Icon className='h-3 w-3' name='windows-minimize' />
|
<Icon className='h-3 w-3' name='windows-minimize' />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={maxRestore} className={classNames}>
|
<button onClick={maxRestore} className={classNames}>
|
||||||
<Icon
|
<Icon className='h-3 w-3' name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'} />
|
||||||
className='h-3 w-3'
|
|
||||||
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import Icon from '../Icon'
|
import Icon from '../Icon'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import useUser, { useMutationLogout } from '@/web/api/hooks/useUser'
|
import useUser, {
|
||||||
|
useDailyCheckIn,
|
||||||
|
useMutationLogout,
|
||||||
|
useRefreshCookie,
|
||||||
|
} from '@/web/api/hooks/useUser'
|
||||||
import uiStates from '@/web/states/uiStates'
|
import uiStates from '@/web/states/uiStates'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import BasicContextMenu from '../ContextMenus/BasicContextMenu'
|
import BasicContextMenu from '../ContextMenus/BasicContextMenu'
|
||||||
|
@ -15,6 +19,9 @@ const Avatar = ({ className }: { className?: string }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useRefreshCookie()
|
||||||
|
useDailyCheckIn()
|
||||||
|
|
||||||
const avatarUrl = user?.profile?.avatarUrl
|
const avatarUrl = user?.profile?.avatarUrl
|
||||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||||
: ''
|
: ''
|
||||||
|
|
|
@ -8,8 +8,7 @@ const TrafficLight = () => {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
const className =
|
const className = 'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
|
||||||
'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
|
|
||||||
return (
|
return (
|
||||||
<div className='flex'>
|
<div className='flex'>
|
||||||
<div className={className}></div>
|
<div className={className}></div>
|
||||||
|
|
|
@ -13,7 +13,7 @@ const delay = ['-100ms', '-500ms', '-1200ms', '-1000ms', '-700ms']
|
||||||
|
|
||||||
const Wave = ({ playing }: { playing: boolean }) => {
|
const Wave = ({ playing }: { playing: boolean }) => {
|
||||||
return (
|
return (
|
||||||
<div className='grid h-3 grid-cols-5 items-end gap-0.5'>
|
<div className='grid h-3 flex-shrink-0 grid-cols-5 items-end gap-0.5'>
|
||||||
{[...new Array(5).keys()].map(i => (
|
{[...new Array(5).keys()].map(i => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
|
10
packages/web/global.d.ts
vendored
10
packages/web/global.d.ts
vendored
|
@ -14,16 +14,10 @@ declare global {
|
||||||
channel: T,
|
channel: T,
|
||||||
params?: IpcChannelsParams[T]
|
params?: IpcChannelsParams[T]
|
||||||
) => Promise<IpcChannelsReturns[T]>
|
) => Promise<IpcChannelsReturns[T]>
|
||||||
send: <T extends keyof IpcChannelsParams>(
|
send: <T extends keyof IpcChannelsParams>(channel: T, params?: IpcChannelsParams[T]) => void
|
||||||
channel: T,
|
|
||||||
params?: IpcChannelsParams[T]
|
|
||||||
) => void
|
|
||||||
on: <T extends keyof IpcChannelsParams>(
|
on: <T extends keyof IpcChannelsParams>(
|
||||||
channel: T,
|
channel: T,
|
||||||
listener: (
|
listener: (event: Electron.IpcRendererEvent, value: IpcChannelsReturns[T]) => void
|
||||||
event: Electron.IpcRendererEvent,
|
|
||||||
value: IpcChannelsReturns[T]
|
|
||||||
) => void
|
|
||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
env?: {
|
env?: {
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import { useState, useEffect, RefObject } from 'react'
|
import { useState, useEffect, RefObject } from 'react'
|
||||||
|
|
||||||
const useIntersectionObserver = (
|
const useIntersectionObserver = (element: RefObject<Element>): { onScreen: boolean } => {
|
||||||
element: RefObject<Element>
|
|
||||||
): { onScreen: boolean } => {
|
|
||||||
const [onScreen, setOnScreen] = useState(false)
|
const [onScreen, setOnScreen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (element.current) {
|
if (element.current) {
|
||||||
const observer = new IntersectionObserver(([entry]) =>
|
const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting))
|
||||||
setOnScreen(entry.isIntersecting)
|
|
||||||
)
|
|
||||||
observer.observe(element.current)
|
observer.observe(element.current)
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
|
|
|
@ -43,13 +43,10 @@ const useScroll = (
|
||||||
|
|
||||||
const arrivedState: ArrivedState = {
|
const arrivedState: ArrivedState = {
|
||||||
left: target.scrollLeft <= 0 + (offset?.left || 0),
|
left: target.scrollLeft <= 0 + (offset?.left || 0),
|
||||||
right:
|
right: target.scrollLeft + target.clientWidth >= target.scrollWidth - (offset?.right || 0),
|
||||||
target.scrollLeft + target.clientWidth >=
|
|
||||||
target.scrollWidth - (offset?.right || 0),
|
|
||||||
top: target.scrollTop <= 0 + (offset?.top || 0),
|
top: target.scrollTop <= 0 + (offset?.top || 0),
|
||||||
bottom:
|
bottom:
|
||||||
target.scrollTop + target.clientHeight >=
|
target.scrollTop + target.clientHeight >= target.scrollHeight - (offset?.bottom || 0),
|
||||||
target.scrollHeight - (offset?.bottom || 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setScroll({
|
setScroll({
|
||||||
|
@ -59,9 +56,7 @@ const useScroll = (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const readHandleScroll = throttle
|
const readHandleScroll = throttle ? lodashThrottle(handleScroll, throttle) : handleScroll
|
||||||
? lodashThrottle(handleScroll, throttle)
|
|
||||||
: handleScroll
|
|
||||||
|
|
||||||
const element = 'current' in ref ? ref?.current : ref
|
const element = 'current' in ref ? ref?.current : ref
|
||||||
element?.addEventListener('scroll', readHandleScroll)
|
element?.addEventListener('scroll', readHandleScroll)
|
||||||
|
|
|
@ -68,14 +68,15 @@
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"player": "Player",
|
"player": "Player",
|
||||||
"lyrics": "Lyrics",
|
|
||||||
"lab": "Lab",
|
"lab": "Lab",
|
||||||
"general-choose-language": "Choose Language",
|
"general-choose-language": "Choose Language",
|
||||||
"player-youtube-unlock": "YouTube Unlock"
|
"player-youtube-unlock": "YouTube Unlock",
|
||||||
|
"player-find-alternative-track-on-youtube-if-not-available-on-netease": "Find alternative track on YouTube if not available on NetEase.",
|
||||||
|
"about": "About"
|
||||||
},
|
},
|
||||||
"context-menu": {
|
"context-menu": {
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"copy-netease-link": "Copy Netease Link",
|
"copy-netease-link": "Copy NetEase Link",
|
||||||
"add-to-playlist": "Add to playlist",
|
"add-to-playlist": "Add to playlist",
|
||||||
"add-to-liked-tracks": "Add to Liked Tracks",
|
"add-to-liked-tracks": "Add to Liked Tracks",
|
||||||
"go-to-album": "Go to album",
|
"go-to-album": "Go to album",
|
||||||
|
@ -94,11 +95,5 @@
|
||||||
"listen": "Listen",
|
"listen": "Listen",
|
||||||
"latest-releases": "Latest Releases",
|
"latest-releases": "Latest Releases",
|
||||||
"popular": "Popular"
|
"popular": "Popular"
|
||||||
},
|
|
||||||
"menu-bar": {
|
|
||||||
"discover": "DISCOVER",
|
|
||||||
"explore": "EXPLORE",
|
|
||||||
"lyrics": "LYRICS",
|
|
||||||
"my-music": "MY MUSIC"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,9 +68,11 @@
|
||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"lab": "实验室",
|
"lab": "实验室",
|
||||||
"lyrics": "歌词",
|
|
||||||
"player": "播放",
|
"player": "播放",
|
||||||
"general-choose-language": "选择语言"
|
"general-choose-language": "选择语言",
|
||||||
|
"player-find-alternative-track-on-youtube-if-not-available-on-netease": "当播放的歌曲无版权或无法播放时,自动从 YouTube 寻找替代音频。",
|
||||||
|
"player-youtube-unlock": "YouTube 解锁",
|
||||||
|
"about": "关于"
|
||||||
},
|
},
|
||||||
"context-menu": {
|
"context-menu": {
|
||||||
"share": "分享",
|
"share": "分享",
|
||||||
|
@ -93,11 +95,5 @@
|
||||||
"listen": "播放",
|
"listen": "播放",
|
||||||
"latest-releases": "最新发行",
|
"latest-releases": "最新发行",
|
||||||
"popular": "热门歌曲"
|
"popular": "热门歌曲"
|
||||||
},
|
|
||||||
"menu-bar": {
|
|
||||||
"discover": "发现",
|
|
||||||
"explore": "浏览",
|
|
||||||
"lyrics": "歌词",
|
|
||||||
"my-music": "我的"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,28 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<title>R3PLAY</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
<head>
|
<body>
|
||||||
<meta charset="UTF-8" />
|
<div id="root">
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" />
|
<div
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
|
id="placeholder"
|
||||||
<meta http-equiv="Content-Security-Policy"
|
style="background-color: #000; position: fixed; inset: 0; border-radius: 24px"
|
||||||
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;" />
|
></div>
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
</div>
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<script type="module" src="/main.tsx"></script>
|
||||||
<title>R3PLAY</title>
|
</body>
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root">
|
|
||||||
<div id="placeholder" style="background-color: #000; position: fixed; inset: 0; border-radius: 24px;"></div>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import {
|
import { IpcChannels, IpcChannelsReturns, IpcChannelsParams } from '@/shared/IpcChannels'
|
||||||
IpcChannels,
|
|
||||||
IpcChannelsReturns,
|
|
||||||
IpcChannelsParams,
|
|
||||||
} from '@/shared/IpcChannels'
|
|
||||||
import uiStates from './states/uiStates'
|
import uiStates from './states/uiStates'
|
||||||
|
|
||||||
const on = <T extends keyof IpcChannelsParams>(
|
const on = <T extends keyof IpcChannelsParams>(
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"analyze:css": "npx windicss-analysis",
|
"analyze:css": "npx windicss-analysis",
|
||||||
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
|
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
|
||||||
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
|
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
|
||||||
"api:netease": "npx NeteaseCloudMusicApi@latest"
|
"api:netease": "PORT=30001 npx NeteaseCloudMusicApi@latest"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
|
@ -23,15 +23,15 @@
|
||||||
"@sentry/react": "^7.29.0",
|
"@sentry/react": "^7.29.0",
|
||||||
"@sentry/tracing": "^7.29.0",
|
"@sentry/tracing": "^7.29.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.0",
|
"@tailwindcss/container-queries": "^0.1.0",
|
||||||
"@tanstack/react-query": "^4.20.9",
|
"@tanstack/react-query": "^4.26.1",
|
||||||
"@tanstack/react-query-devtools": "^4.20.9",
|
"@tanstack/react-query-devtools": "^4.26.1",
|
||||||
"ahooks": "^3.7.4",
|
"ahooks": "^3.7.4",
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.2.2",
|
||||||
"color.js": "^1.2.0",
|
"color.js": "^1.2.0",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"framer-motion": "^8.1.7",
|
"framer-motion": "^8.1.7",
|
||||||
"hls.js": "^1.2.9",
|
"hls.js": "^1.3.5",
|
||||||
"howler": "^2.2.3",
|
"howler": "^2.2.3",
|
||||||
"i18next": "^22.4.9",
|
"i18next": "^22.4.9",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
|
@ -58,7 +58,7 @@
|
||||||
"@types/qrcode": "^1.4.2",
|
"@types/qrcode": "^1.4.2",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@vitejs/plugin-react-swc": "^3.0.1",
|
"@vitejs/plugin-react-swc": "^3.2.0",
|
||||||
"@vitest/ui": "^0.26.3",
|
"@vitest/ui": "^0.26.3",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"c8": "^7.12.0",
|
"c8": "^7.12.0",
|
||||||
|
@ -71,8 +71,8 @@
|
||||||
"rollup-plugin-visualizer": "^5.9.0",
|
"rollup-plugin-visualizer": "^5.9.0",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4",
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"vite": "^4.0.4",
|
"vite": "^4.2.0",
|
||||||
"vite-plugin-pwa": "^0.14.1",
|
"vite-plugin-pwa": "^0.14.4",
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"vite-plugin-svg-icons": "^2.0.1",
|
||||||
"vitest": "^0.26.3"
|
"vitest": "^0.26.3"
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import TrackListHeader from '@/web/components/TrackListHeader'
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import { formatDuration } from '@/web/utils/common'
|
import { formatDuration } from '@/web/utils/common'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
@ -68,18 +68,21 @@ const Header = () => {
|
||||||
return !!userLikedAlbums?.data?.find(item => item.id === id)
|
return !!userLikedAlbums?.data?.find(item => item.id === id)
|
||||||
}, [params.id, userLikedAlbums?.data])
|
}, [params.id, userLikedAlbums?.data])
|
||||||
|
|
||||||
const onPlay = async (trackID: number | null = null) => {
|
const onPlay = useCallback(
|
||||||
if (!album?.id) {
|
async (trackID: number | null = null) => {
|
||||||
toast('无法播放专辑,该专辑不存在')
|
if (!album?.id) {
|
||||||
return
|
toast('无法播放专辑,该专辑不存在')
|
||||||
}
|
return
|
||||||
player.playAlbum(album.id, trackID)
|
}
|
||||||
}
|
player.playAlbum(album.id, trackID)
|
||||||
|
},
|
||||||
|
[album?.id]
|
||||||
|
)
|
||||||
|
|
||||||
const likeAAlbum = useMutationLikeAAlbum()
|
const likeAAlbum = useMutationLikeAAlbum()
|
||||||
const onLike = async () => {
|
const onLike = useCallback(async () => {
|
||||||
likeAAlbum.mutateAsync(album?.id || Number(params.id))
|
likeAAlbum.mutateAsync(album?.id || Number(params.id))
|
||||||
}
|
}, [likeAAlbum.mutateAsync, album?.id, params.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TrackListHeader
|
<TrackListHeader
|
||||||
|
|
|
@ -14,12 +14,9 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
|
||||||
if (!albums) return []
|
if (!albums) return []
|
||||||
const allReleases = albums?.hotAlbums || []
|
const allReleases = albums?.hotAlbums || []
|
||||||
const filteredAlbums = allReleases.filter(
|
const filteredAlbums = allReleases.filter(
|
||||||
album =>
|
album => ['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
|
||||||
['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
|
|
||||||
)
|
|
||||||
const singles = allReleases.filter(
|
|
||||||
album => album.type === 'Single' || album.size === 1
|
|
||||||
)
|
)
|
||||||
|
const singles = allReleases.filter(album => album.type === 'Single' || album.size === 1)
|
||||||
|
|
||||||
const qualifiedAlbums = [...filteredAlbums, ...singles]
|
const qualifiedAlbums = [...filteredAlbums, ...singles]
|
||||||
|
|
||||||
|
@ -41,10 +38,7 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 去除 remix 专辑
|
// 去除 remix 专辑
|
||||||
if (
|
if (a.name.toLowerCase().includes('remix)') || a.name.toLowerCase().includes('remixes)')) {
|
||||||
a.name.toLowerCase().includes('remix)') ||
|
|
||||||
a.name.toLowerCase().includes('remixes)')
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import useUserArtists, {
|
import useUserArtists, { useMutationLikeAArtist } from '@/web/api/hooks/useUserArtists'
|
||||||
useMutationLikeAArtist,
|
|
||||||
} from '@/web/api/hooks/useUserArtists'
|
|
||||||
import Icon from '@/web/components/Icon'
|
import Icon from '@/web/components/Icon'
|
||||||
import { openContextMenu } from '@/web/states/contextMenus'
|
import { openContextMenu } from '@/web/states/contextMenus'
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import { cx } from '@emotion/css'
|
import { cx } from '@emotion/css'
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
@ -50,10 +47,7 @@ const Actions = ({ isLoading }: { isLoading: boolean }) => {
|
||||||
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
|
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
|
||||||
name={isLiked ? 'heart' : 'heart-outline'}
|
|
||||||
className='h-7 w-7'
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -59,7 +59,7 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
|
||||||
className={cx(
|
className={cx(
|
||||||
'line-clamp-5 mt-6 text-14 font-bold text-transparent',
|
'line-clamp-5 mt-6 text-14 font-bold text-transparent',
|
||||||
css`
|
css`
|
||||||
height: 86px;
|
min-height: 85px;
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -68,15 +68,14 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'line-clamp-5 mt-6 overflow-hidden whitespace-pre-wrap text-14 font-bold text-white/40 transition-colors duration-500 hover:text-white/60'
|
'line-clamp-5 mt-6 overflow-hidden whitespace-pre-wrap text-14 font-bold text-white/40 transition-colors duration-500 hover:text-white/60',
|
||||||
// css`
|
css`
|
||||||
// height: 86px;
|
height: 85px;
|
||||||
// `
|
`
|
||||||
)}
|
)}
|
||||||
onClick={() => setIsOpenDescription(true)}
|
onClick={() => setIsOpenDescription(true)}
|
||||||
>
|
dangerouslySetInnerHTML={{ __html: description }}
|
||||||
{description}
|
></div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<DescriptionViewer
|
<DescriptionViewer
|
||||||
|
|
|
@ -104,11 +104,7 @@ const LatestRelease = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isLoadingVideos && !isLoadingAlbums && (
|
{!isLoadingVideos && !isLoadingAlbums && (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className='mx-2.5 lg:mx-0'>
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className='mx-2.5 lg:mx-0'
|
|
||||||
>
|
|
||||||
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
|
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
|
||||||
{t`artist.latest-releases`}
|
{t`artist.latest-releases`}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -37,9 +37,7 @@ const Track = ({
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'line-clamp-1 text-16 font-medium ',
|
'line-clamp-1 text-16 font-medium ',
|
||||||
isPlaying
|
isPlaying ? 'text-brand-700' : 'text-neutral-700 dark:text-neutral-200'
|
||||||
? 'text-brand-700'
|
|
||||||
: 'text-neutral-700 dark:text-neutral-200'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{track?.name}
|
{track?.name}
|
||||||
|
@ -71,9 +69,7 @@ const Popular = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-4 text-12 font-medium uppercase text-neutral-300'>
|
<div className='mb-4 text-12 font-medium uppercase text-neutral-300'>{t`artist.popular`}</div>
|
||||||
{t`artist.popular`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'>
|
<div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'>
|
||||||
{tracks?.slice(0, 9)?.map(t => (
|
{tracks?.slice(0, 9)?.map(t => (
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import CoverWall from '@/web/components/CoverWall'
|
import CoverWall from '@/web/components/CoverWall'
|
||||||
import PageTransition from '@/web/components/PageTransition'
|
import PageTransition from '@/web/components/PageTransition'
|
||||||
import {
|
import { fetchPlaylistWithReactQuery, fetchFromCache } from '@/web/api/hooks/usePlaylist'
|
||||||
fetchPlaylistWithReactQuery,
|
|
||||||
fetchFromCache,
|
|
||||||
} from '@/web/api/hooks/usePlaylist'
|
|
||||||
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
||||||
import { sampleSize } from 'lodash-es'
|
import { sampleSize } from 'lodash-es'
|
||||||
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
||||||
|
@ -39,9 +36,7 @@ const getAlbumsFromAPI = async () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
let ids: number[] = []
|
let ids: number[] = []
|
||||||
playlists.forEach(playlist =>
|
playlists.forEach(playlist => playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id)))
|
||||||
playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id))
|
|
||||||
)
|
|
||||||
if (!ids.length) return []
|
if (!ids.length) return []
|
||||||
ids = sampleSize(ids, 100)
|
ids = sampleSize(ids, 100)
|
||||||
|
|
||||||
|
@ -77,8 +72,7 @@ const Discover = () => {
|
||||||
const { data: albums } = useQuery(
|
const { data: albums } = useQuery(
|
||||||
['DiscoveryAlbums'],
|
['DiscoveryAlbums'],
|
||||||
async () => {
|
async () => {
|
||||||
const albumsInLocalStorageTime =
|
const albumsInLocalStorageTime = localStorage.getItem('discoverAlbumsTime')
|
||||||
localStorage.getItem('discoverAlbumsTime')
|
|
||||||
if (
|
if (
|
||||||
!albumsInLocalStorageTime ||
|
!albumsInLocalStorageTime ||
|
||||||
Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次
|
Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次
|
||||||
|
|
|
@ -19,6 +19,7 @@ import VideoRow from '@/web/components/VideoRow'
|
||||||
import useUserVideos from '@/web/api/hooks/useUserVideos'
|
import useUserVideos from '@/web/api/hooks/useUserVideos'
|
||||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||||
import settings from '@/web/states/settings'
|
import settings from '@/web/states/settings'
|
||||||
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
|
|
||||||
const collections = ['playlists', 'albums', 'artists', 'videos'] as const
|
const collections = ['playlists', 'albums', 'artists', 'videos'] as const
|
||||||
type Collection = typeof collections[number]
|
type Collection = typeof collections[number]
|
||||||
|
@ -31,8 +32,37 @@ const Albums = () => {
|
||||||
|
|
||||||
const Playlists = () => {
|
const Playlists = () => {
|
||||||
const { data: playlists } = useUserPlaylists()
|
const { data: playlists } = useUserPlaylists()
|
||||||
const p = useMemo(() => playlists?.playlist?.slice(1), [playlists])
|
const user = useUser()
|
||||||
return <CoverRow playlists={p} />
|
const myPlaylists = useMemo(
|
||||||
|
() => playlists?.playlist?.slice(1).filter(p => p.userId === user?.data?.account?.id),
|
||||||
|
[playlists, user]
|
||||||
|
)
|
||||||
|
const otherPlaylists = useMemo(
|
||||||
|
() => playlists?.playlist?.slice(1).filter(p => p.userId !== user?.data?.account?.id),
|
||||||
|
[playlists, user]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* My playlists */}
|
||||||
|
{myPlaylists && (
|
||||||
|
<>
|
||||||
|
<div className='mb-4 mt-2 text-14 font-medium uppercase text-neutral-400'>
|
||||||
|
Created BY ME
|
||||||
|
</div>
|
||||||
|
<CoverRow playlists={myPlaylists} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Other playlists */}
|
||||||
|
{otherPlaylists && (
|
||||||
|
<>
|
||||||
|
<div className='mb-4 mt-8 text-14 font-medium uppercase text-neutral-400'>
|
||||||
|
Created BY OTHERS
|
||||||
|
</div>
|
||||||
|
<CoverRow playlists={otherPlaylists} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Artists = () => {
|
const Artists = () => {
|
||||||
|
@ -50,14 +80,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||||
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
||||||
|
|
||||||
const tabs: { id: Collection; name: string }[] = [
|
const tabs: { id: Collection; name: string }[] = [
|
||||||
{
|
|
||||||
id: 'playlists',
|
|
||||||
name: t`common.playlist_other`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'albums',
|
id: 'albums',
|
||||||
name: t`common.album_other`,
|
name: t`common.album_other`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'playlists',
|
||||||
|
name: t`common.playlist_other`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'artists',
|
id: 'artists',
|
||||||
name: t`common.artist_other`,
|
name: t`common.artist_other`,
|
||||||
|
@ -75,7 +105,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='relative'>
|
||||||
{/* Topbar background */}
|
{/* Topbar background */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showBg && (
|
{showBg && (
|
||||||
|
@ -84,14 +114,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className={cx(
|
className={cx(
|
||||||
'pointer-events-none fixed top-0 right-0 left-10 z-10',
|
'pointer-events-none absolute right-0 left-0 z-10',
|
||||||
css`
|
css`
|
||||||
height: 230px;
|
height: 230px;
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
right: `${minimizePlayer ? 0 : playerWidth + 32}px`,
|
top: '-132px',
|
||||||
backgroundImage: `url(${topbarBackground})`,
|
backgroundImage: `url(${topbarBackground})`,
|
||||||
}}
|
}}
|
||||||
></motion.div>
|
></motion.div>
|
||||||
|
@ -115,7 +145,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||||
top: `${topbarHeight}px`,
|
top: `${topbarHeight}px`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,7 +161,7 @@ const Collections = () => {
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<motion.div layout>
|
||||||
<CollectionTabs showBg={isScrollReachBottom} />
|
<CollectionTabs showBg={isScrollReachBottom} />
|
||||||
<div
|
<div
|
||||||
className={cx('no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0')}
|
className={cx('no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0')}
|
||||||
|
@ -146,7 +176,7 @@ const Collections = () => {
|
||||||
{selectedTab === 'videos' && <Videos />}
|
{selectedTab === 'videos' && <Videos />}
|
||||||
</div>
|
</div>
|
||||||
<div ref={observePoint}></div>
|
<div ref={observePoint}></div>
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import PageTransition from '@/web/components/PageTransition'
|
||||||
import RecentlyListened from './RecentlyListened'
|
import RecentlyListened from './RecentlyListened'
|
||||||
import Collections from './Collections'
|
import Collections from './Collections'
|
||||||
import { useIsLoggedIn } from '@/web/api/hooks/useUser'
|
import { useIsLoggedIn } from '@/web/api/hooks/useUser'
|
||||||
|
import { LayoutGroup, motion } from 'framer-motion'
|
||||||
|
|
||||||
function PleaseLogin() {
|
function PleaseLogin() {
|
||||||
return <></>
|
return <></>
|
||||||
|
@ -13,11 +14,13 @@ const My = () => {
|
||||||
return (
|
return (
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<div className='grid grid-cols-1 gap-10'>
|
<LayoutGroup>
|
||||||
<PlayLikedSongsCard />
|
<div className='grid grid-cols-1 gap-10'>
|
||||||
<RecentlyListened />
|
<PlayLikedSongsCard />
|
||||||
<Collections />
|
<RecentlyListened />
|
||||||
</div>
|
<Collections />
|
||||||
|
</div>
|
||||||
|
</LayoutGroup>
|
||||||
) : (
|
) : (
|
||||||
<PleaseLogin />
|
<PleaseLogin />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { resizeImage } from '@/web/utils/common'
|
||||||
import { breakpoint as bp } from '@/web/utils/const'
|
import { breakpoint as bp } from '@/web/utils/const'
|
||||||
import useUser from '@/web/api/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
@ -104,7 +105,8 @@ const PlayLikedSongsCard = () => {
|
||||||
}, [likedSongsPlaylist?.playlist?.tracks, sampledTracks])
|
}, [likedSongsPlaylist?.playlist?.tracks, sampledTracks])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
|
layout
|
||||||
className={cx(
|
className={cx(
|
||||||
'mx-2.5 flex flex-col justify-between rounded-24 p-8 dark:bg-white/10 lg:mx-0',
|
'mx-2.5 flex flex-col justify-between rounded-24 p-8 dark:bg-white/10 lg:mx-0',
|
||||||
css`
|
css`
|
||||||
|
@ -141,7 +143,7 @@ const PlayLikedSongsCard = () => {
|
||||||
<Icon name='forward' className='h-7 w-7 ' />
|
<Icon name='forward' className='h-7 w-7 ' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords'
|
import useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords'
|
||||||
import useArtists from '@/web/api/hooks/useArtists'
|
import useArtists from '@/web/api/hooks/useArtists'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import ArtistRow from '@/web/components/ArtistRow'
|
import ArtistRow from '@/web/components/ArtistRow'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
|
||||||
const RecentlyListened = () => {
|
const RecentlyListened = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { data: listenedRecords } = useUserListenedRecords({ type: 'week' })
|
const { data: listenedRecords, isLoading } = useUserListenedRecords({ type: 'week' })
|
||||||
const recentListenedArtistsIDs = useMemo(() => {
|
const recentListenedArtistsIDs = useMemo(() => {
|
||||||
const artists: {
|
const artists: {
|
||||||
id: number
|
id: number
|
||||||
|
@ -31,10 +32,26 @@ const RecentlyListened = () => {
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(artist => artist.id)
|
.map(artist => artist.id)
|
||||||
}, [listenedRecords])
|
}, [listenedRecords])
|
||||||
const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs)
|
const { data: recentListenedArtists, isLoading: isLoadingArtistsDetail } =
|
||||||
const artist = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists])
|
useArtists(recentListenedArtistsIDs)
|
||||||
|
const artists = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists])
|
||||||
|
|
||||||
return <ArtistRow artists={artist} placeholderRow={1} title={t`my.recently-listened`} />
|
const show = useMemo(() => {
|
||||||
|
if (listenedRecords?.weekData?.length === 0) return false
|
||||||
|
if (isLoading || isLoadingArtistsDetail) return true
|
||||||
|
if (artists?.length) return true
|
||||||
|
return false
|
||||||
|
}, [isLoading, artists, listenedRecords, isLoadingArtistsDetail])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div layout exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<ArtistRow artists={artists} placeholderRow={1} title={t`my.recently-listened`} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RecentlyListened
|
export default RecentlyListened
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||||
import useUser from '@/web/api/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import useUserPlaylists, {
|
import useUserPlaylists, { useMutationLikeAPlaylist } from '@/web/api/hooks/useUserPlaylists'
|
||||||
useMutationLikeAPlaylist,
|
|
||||||
} from '@/web/api/hooks/useUserPlaylists'
|
|
||||||
import TrackListHeader from '@/web/components/TrackListHeader'
|
import TrackListHeader from '@/web/components/TrackListHeader'
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import { formatDate } from '@/web/utils/common'
|
import { formatDate } from '@/web/utils/common'
|
||||||
|
@ -32,8 +30,7 @@ const Header = () => {
|
||||||
const extraInfo = useMemo(() => {
|
const extraInfo = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
Updated at {formatDate(playlist?.updateTime || 0, 'en')} ·{' '}
|
Updated at {formatDate(playlist?.updateTime || 0, 'en')} · {playlist?.trackCount} tracks
|
||||||
{playlist?.trackCount} tracks
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}, [playlist])
|
}, [playlist])
|
||||||
|
@ -64,8 +61,7 @@ const Header = () => {
|
||||||
extraInfo,
|
extraInfo,
|
||||||
cover,
|
cover,
|
||||||
isLiked,
|
isLiked,
|
||||||
onLike:
|
onLike: user?.account?.id === playlist?.creator?.userId ? undefined : onLike,
|
||||||
user?.account?.id === playlist?.creator?.userId ? undefined : onLike,
|
|
||||||
onPlay,
|
onPlay,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import TrackList from './TrackList'
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
|
import useTracks from '@/web/api/hooks/useTracks'
|
||||||
|
|
||||||
const Playlist = () => {
|
const Playlist = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
@ -11,6 +12,13 @@ const Playlist = () => {
|
||||||
id: Number(params.id),
|
id: Number(params.id),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: 分页加载
|
||||||
|
const { data: playlistTracks } = useTracks({
|
||||||
|
ids: playlist?.playlist?.trackIds?.map(t => t.id) ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(playlistTracks)
|
||||||
|
|
||||||
const onPlay = async (trackID: number | null = null) => {
|
const onPlay = async (trackID: number | null = null) => {
|
||||||
await player.playPlaylist(playlist?.playlist?.id, trackID)
|
await player.playPlaylist(playlist?.playlist?.id, trackID)
|
||||||
}
|
}
|
||||||
|
@ -20,7 +28,7 @@ const Playlist = () => {
|
||||||
<Header />
|
<Header />
|
||||||
<div className='pb-10'>
|
<div className='pb-10'>
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={playlist?.playlist?.tracks ?? []}
|
tracks={playlistTracks?.songs ?? playlist?.playlist?.tracks ?? []}
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
className='z-10 mt-10'
|
className='z-10 mt-10'
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import player from '@/web/states/player'
|
||||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||||
import { State as PlayerState } from '@/web/utils/player'
|
import { State as PlayerState } from '@/web/utils/player'
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { Fragment } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
@ -58,7 +59,17 @@ const Track = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='line-clamp-1 mt-1 text-14 font-bold text-white/30'>
|
<div className='line-clamp-1 mt-1 text-14 font-bold text-white/30'>
|
||||||
{track?.ar.map(a => a.name).join(', ')}
|
{track?.ar.map((a, index) => (
|
||||||
|
<Fragment key={a.id}>
|
||||||
|
{index > 0 && ', '}
|
||||||
|
<NavLink
|
||||||
|
className='transition-all duration-300 hover:text-white/70'
|
||||||
|
to={`/artist/${a.id}`}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</NavLink>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -102,7 +113,7 @@ function TrackList({
|
||||||
placeholderRows?: number
|
placeholderRows?: number
|
||||||
}) {
|
}) {
|
||||||
const { trackID, state } = useSnapshot(player)
|
const { trackID, state } = useSnapshot(player)
|
||||||
const playingTrackIndex = tracks?.findIndex(track => track.id === trackID) ?? -1
|
const playingTrack = tracks?.find(track => track.id === trackID)
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
|
@ -129,7 +140,7 @@ function TrackList({
|
||||||
key={track.id}
|
key={track.id}
|
||||||
track={track}
|
track={track}
|
||||||
index={index}
|
index={index}
|
||||||
playingTrackIndex={playingTrackIndex}
|
playingTrackID={playingTrack?.id || 0}
|
||||||
state={state}
|
state={state}
|
||||||
handleClick={handleClick}
|
handleClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -13,7 +13,7 @@ function Player() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FindTrackOnYouTube() {
|
function FindTrackOnYouTube() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
const { enableFindTrackOnYouTube, httpProxyForYouTube } = useSnapshot(settings)
|
const { enableFindTrackOnYouTube, httpProxyForYouTube } = useSnapshot(settings)
|
||||||
|
|
||||||
|
@ -21,12 +21,18 @@ function FindTrackOnYouTube() {
|
||||||
<div>
|
<div>
|
||||||
<BlockTitle>{t`settings.player-youtube-unlock`}</BlockTitle>
|
<BlockTitle>{t`settings.player-youtube-unlock`}</BlockTitle>
|
||||||
<BlockDescription>
|
<BlockDescription>
|
||||||
Find alternative track on YouTube if not available on NetEase.
|
{t`settings.player-find-alternative-track-on-youtube-if-not-available-on-netease`}
|
||||||
|
{i18n.language === 'zh-CN' && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
此功能需要开启 Clash for Windows 的 TUN Mode 或 ClashX Pro 的增强模式。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</BlockDescription>
|
</BlockDescription>
|
||||||
|
|
||||||
{/* Switch */}
|
{/* Switch */}
|
||||||
<Option>
|
<Option>
|
||||||
<OptionText>Enable YouTube Unlock </OptionText>
|
<OptionText>Enable YouTube Unlock</OptionText>
|
||||||
<Switch
|
<Switch
|
||||||
enabled={enableFindTrackOnYouTube}
|
enabled={enableFindTrackOnYouTube}
|
||||||
onChange={value => (settings.enableFindTrackOnYouTube = value)}
|
onChange={value => (settings.enableFindTrackOnYouTube = value)}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import useUser from '@/web/api/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import Appearance from './Appearance'
|
import Appearance from './Appearance'
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import UserCard from './UserCard'
|
import UserCard from './UserCard'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { motion, useAnimationControls } from 'framer-motion'
|
import { motion, useAnimationControls } from 'framer-motion'
|
||||||
|
@ -10,7 +10,7 @@ import Player from './Player'
|
||||||
import PageTransition from '@/web/components/PageTransition'
|
import PageTransition from '@/web/components/PageTransition'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
export const categoryIds = ['general', 'appearance', 'player', 'lyrics', 'lab'] as const
|
export const categoryIds = ['general', 'appearance', 'player', 'lab', 'about'] as const
|
||||||
export type Category = typeof categoryIds[number]
|
export type Category = typeof categoryIds[number]
|
||||||
|
|
||||||
const Sidebar = ({
|
const Sidebar = ({
|
||||||
|
@ -25,22 +25,22 @@ const Sidebar = ({
|
||||||
{ name: t`settings.general`, id: 'general' },
|
{ name: t`settings.general`, id: 'general' },
|
||||||
{ name: t`settings.appearance`, id: 'appearance' },
|
{ name: t`settings.appearance`, id: 'appearance' },
|
||||||
{ name: t`settings.player`, id: 'player' },
|
{ name: t`settings.player`, id: 'player' },
|
||||||
{ name: t`settings.lyrics`, id: 'lyrics' },
|
|
||||||
{ name: t`settings.lab`, id: 'lab' },
|
{ name: t`settings.lab`, id: 'lab' },
|
||||||
|
{ name: t`settings.about`, id: 'about' },
|
||||||
]
|
]
|
||||||
const animation = useAnimationControls()
|
|
||||||
|
|
||||||
const onClick = (categoryId: Category) => {
|
// Indicator animation
|
||||||
setActiveCategory(categoryId)
|
const indicatorAnimation = useAnimationControls()
|
||||||
const index = categories.findIndex(category => category.id === categoryId)
|
useEffect(() => {
|
||||||
animation.start({ y: index * 40 + 11.5 })
|
const index = categories.findIndex(category => category.id === activeCategory)
|
||||||
}
|
indicatorAnimation.start({ y: index * 40 + 11.5 })
|
||||||
|
}, [activeCategory])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: 11.5 }}
|
initial={{ y: 11.5 }}
|
||||||
animate={animation}
|
animate={indicatorAnimation}
|
||||||
transition={{ type: 'spring', duration: 0.6, bounce: 0.36 }}
|
transition={{ type: 'spring', duration: 0.6, bounce: 0.36 }}
|
||||||
className='absolute top-0 left-3 mr-2 h-4 w-1 rounded-full bg-brand-700'
|
className='absolute top-0 left-3 mr-2 h-4 w-1 rounded-full bg-brand-700'
|
||||||
></motion.div>
|
></motion.div>
|
||||||
|
@ -48,7 +48,7 @@ const Sidebar = ({
|
||||||
{categories.map(category => (
|
{categories.map(category => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
onClick={() => onClick(category.id)}
|
onClick={() => setActiveCategory(category.id)}
|
||||||
initial={{ x: activeCategory === category.id ? 12 : 0 }}
|
initial={{ x: activeCategory === category.id ? 12 : 0 }}
|
||||||
animate={{ x: activeCategory === category.id ? 12 : 0 }}
|
animate={{ x: activeCategory === category.id ? 12 : 0 }}
|
||||||
className={cx(
|
className={cx(
|
||||||
|
@ -71,8 +71,8 @@ const Settings = () => {
|
||||||
{ id: 'general', component: <General /> },
|
{ id: 'general', component: <General /> },
|
||||||
{ id: 'appearance', component: <Appearance /> },
|
{ id: 'appearance', component: <Appearance /> },
|
||||||
{ id: 'player', component: <Player /> },
|
{ id: 'player', component: <Player /> },
|
||||||
{ id: 'lyrics', component: <span className='text-white'>开发中</span> },
|
|
||||||
{ id: 'lab', component: <span className='text-white'>开发中</span> },
|
{ id: 'lab', component: <span className='text-white'>开发中</span> },
|
||||||
|
{ id: 'about', component: <span className='text-white'>开发中</span> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -20,10 +20,7 @@ const replaceBrandColorWithCSSVar = () => {
|
||||||
if (decl?.parent?.selector?.includes('-blue-')) {
|
if (decl?.parent?.selector?.includes('-blue-')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
value = value.replace(
|
value = value.replace(`rgb(${blue.rgb}`, `hsl(var(--brand-color-${blue.key})`)
|
||||||
`rgb(${blue.rgb}`,
|
|
||||||
`hsl(var(--brand-color-${blue.key})`
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
// if (decl.value !== value) {
|
// if (decl.value !== value) {
|
||||||
// console.log({
|
// console.log({
|
||||||
|
|
|
@ -11,9 +11,7 @@ class ScrollPositions {
|
||||||
const nestedPath = `/${pathname.split('/')[1]}`
|
const nestedPath = `/${pathname.split('/')[1]}`
|
||||||
const restPath = pathname.split('/').slice(2).join('/')
|
const restPath = pathname.split('/').slice(2).join('/')
|
||||||
if (this._nestedPaths.includes(nestedPath)) {
|
if (this._nestedPaths.includes(nestedPath)) {
|
||||||
return this._positions?.[nestedPath]?.find(
|
return this._positions?.[nestedPath]?.find(({ path }) => path === restPath)?.top
|
||||||
({ path }) => path === restPath
|
|
||||||
)?.top
|
|
||||||
} else {
|
} else {
|
||||||
return this._generalPositions?.[pathname]
|
return this._generalPositions?.[pathname]
|
||||||
}
|
}
|
||||||
|
@ -30,14 +28,10 @@ class ScrollPositions {
|
||||||
}
|
}
|
||||||
|
|
||||||
// set nested position
|
// set nested position
|
||||||
const existsPath = this._positions[nestedPath].find(
|
const existsPath = this._positions[nestedPath].find(p => p.path === restPath)
|
||||||
p => p.path === restPath
|
|
||||||
)
|
|
||||||
if (existsPath) {
|
if (existsPath) {
|
||||||
existsPath.top = top
|
existsPath.top = top
|
||||||
this._positions[nestedPath] = this._positions[nestedPath].filter(
|
this._positions[nestedPath] = this._positions[nestedPath].filter(p => p.path !== restPath)
|
||||||
p => p.path !== restPath
|
|
||||||
)
|
|
||||||
this._positions[nestedPath].push(existsPath)
|
this._positions[nestedPath].push(existsPath)
|
||||||
} else {
|
} else {
|
||||||
this._positions[nestedPath].push({ path: restPath, top })
|
this._positions[nestedPath].push({ path: restPath, top })
|
||||||
|
|
|
@ -118,10 +118,8 @@ module.exports = {
|
||||||
xl: '12px',
|
xl: '12px',
|
||||||
'2xl': '20px',
|
'2xl': '20px',
|
||||||
'3xl': '45px',
|
'3xl': '45px',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [require('@tailwindcss/container-queries')],
|
||||||
require('@tailwindcss/container-queries'),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,142 +58,142 @@ test('formatDuration', () => {
|
||||||
expect(formatDuration(0, 'zh-CN', 'hh[hr] mm[min]')).toBe('0 分钟')
|
expect(formatDuration(0, 'zh-CN', 'hh[hr] mm[min]')).toBe('0 分钟')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cacheCoverColor', () => {
|
// describe('cacheCoverColor', () => {
|
||||||
test('cache with valid url', () => {
|
// test('cache with valid url', () => {
|
||||||
vi.stubGlobal('ipcRenderer', {
|
// vi.stubGlobal('ipcRenderer', {
|
||||||
send: (channel: IpcChannels, ...args: any[]) => {
|
// send: (channel: IpcChannels, ...args: any[]) => {
|
||||||
expect(channel).toBe(IpcChannels.CacheCoverColor)
|
// expect(channel).toBe(IpcChannels.CacheCoverColor)
|
||||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
// expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||||
expect(args[0].query).toEqual({
|
// expect(args[0].query).toEqual({
|
||||||
id: '109951165911363',
|
// id: '109951165911363',
|
||||||
color: '#fff',
|
// color: '#fff',
|
||||||
})
|
// })
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
|
|
||||||
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
// const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
||||||
cacheCoverColor(
|
// cacheCoverColor(
|
||||||
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256',
|
// 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256',
|
||||||
'#fff'
|
// '#fff'
|
||||||
)
|
// )
|
||||||
|
|
||||||
expect(sendSpy).toHaveBeenCalledTimes(1)
|
// expect(sendSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
vi.stubGlobal('ipcRenderer', undefined)
|
// vi.stubGlobal('ipcRenderer', undefined)
|
||||||
})
|
// })
|
||||||
|
|
||||||
test('cache with invalid url', () => {
|
// test('cache with invalid url', () => {
|
||||||
vi.stubGlobal('ipcRenderer', {
|
// vi.stubGlobal('ipcRenderer', {
|
||||||
send: (channel: IpcChannels, ...args: any[]) => {
|
// send: (channel: IpcChannels, ...args: any[]) => {
|
||||||
expect(channel).toBe(IpcChannels.CacheCoverColor)
|
// expect(channel).toBe(IpcChannels.CacheCoverColor)
|
||||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
// expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||||
expect(args[0].query).toEqual({
|
// expect(args[0].query).toEqual({
|
||||||
id: '',
|
// id: '',
|
||||||
color: '#fff',
|
// color: '#fff',
|
||||||
})
|
// })
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
|
|
||||||
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
// const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
||||||
cacheCoverColor('not a valid url', '#fff')
|
// cacheCoverColor('not a valid url', '#fff')
|
||||||
expect(sendSpy).toHaveBeenCalledTimes(0)
|
// expect(sendSpy).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
vi.stubGlobal('ipcRenderer', undefined)
|
// vi.stubGlobal('ipcRenderer', undefined)
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
|
|
||||||
test('calcCoverColor', async () => {
|
// test('calcCoverColor', async () => {
|
||||||
vi.mock('color.js', () => {
|
// vi.mock('color.js', () => {
|
||||||
return {
|
// return {
|
||||||
average: vi.fn(
|
// average: vi.fn(
|
||||||
() =>
|
// () =>
|
||||||
new Promise(resolve => {
|
// new Promise(resolve => {
|
||||||
resolve('#fff')
|
// resolve('#fff')
|
||||||
})
|
// })
|
||||||
),
|
// ),
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
vi.stubGlobal('ipcRenderer', {
|
// vi.stubGlobal('ipcRenderer', {
|
||||||
send: (channel: IpcChannels, ...args: any[]) => {
|
// send: (channel: IpcChannels, ...args: any[]) => {
|
||||||
expect(channel).toBe(IpcChannels.CacheCoverColor)
|
// expect(channel).toBe(IpcChannels.CacheCoverColor)
|
||||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
// expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||||
expect(args[0].query).toEqual({
|
// expect(args[0].query).toEqual({
|
||||||
id: '109951165911363',
|
// id: '109951165911363',
|
||||||
color: '#808080',
|
// color: '#808080',
|
||||||
})
|
// })
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
|
|
||||||
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
// const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
||||||
|
|
||||||
expect(
|
// expect(
|
||||||
await calcCoverColor(
|
// await calcCoverColor(
|
||||||
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
|
// 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
|
||||||
)
|
// )
|
||||||
).toBe('#808080')
|
// ).toBe('#808080')
|
||||||
|
|
||||||
vi.stubGlobal('ipcRenderer', undefined)
|
// vi.stubGlobal('ipcRenderer', undefined)
|
||||||
})
|
// })
|
||||||
|
|
||||||
describe('getCoverColor', () => {
|
// describe('getCoverColor', () => {
|
||||||
test('hit cache', async () => {
|
// test('hit cache', async () => {
|
||||||
vi.stubGlobal('ipcRenderer', {
|
// vi.stubGlobal('ipcRenderer', {
|
||||||
sendSync: (channel: IpcChannels, ...args: any[]) => {
|
// sendSync: (channel: IpcChannels, ...args: any[]) => {
|
||||||
expect(channel).toBe(IpcChannels.GetApiCache)
|
// expect(channel).toBe(IpcChannels.GetApiCache)
|
||||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
// expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||||
expect(args[0].query).toEqual({
|
// expect(args[0].query).toEqual({
|
||||||
id: '109951165911363',
|
// id: '109951165911363',
|
||||||
})
|
// })
|
||||||
return '#fff'
|
// return '#fff'
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
|
|
||||||
const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync')
|
// const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync')
|
||||||
|
|
||||||
expect(
|
// expect(
|
||||||
await getCoverColor(
|
// await getCoverColor(
|
||||||
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
|
// 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
|
||||||
)
|
// )
|
||||||
).toBe('#fff')
|
// ).toBe('#fff')
|
||||||
|
|
||||||
expect(sendSyncSpy).toHaveBeenCalledTimes(1)
|
// expect(sendSyncSpy).toHaveBeenCalledTimes(1)
|
||||||
vi.stubGlobal('ipcRenderer', undefined)
|
// vi.stubGlobal('ipcRenderer', undefined)
|
||||||
})
|
// })
|
||||||
|
|
||||||
test('did not hit cache', async () => {
|
// test('did not hit cache', async () => {
|
||||||
vi.stubGlobal('ipcRenderer', {
|
// vi.stubGlobal('ipcRenderer', {
|
||||||
sendSync: (channel: IpcChannels, ...args: any[]) => {
|
// sendSync: (channel: IpcChannels, ...args: any[]) => {
|
||||||
expect(channel).toBe(IpcChannels.GetApiCache)
|
// expect(channel).toBe(IpcChannels.GetApiCache)
|
||||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
// expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||||
expect(args[0].query).toEqual({
|
// expect(args[0].query).toEqual({
|
||||||
id: '109951165911363',
|
// id: '109951165911363',
|
||||||
})
|
// })
|
||||||
return undefined
|
// return undefined
|
||||||
},
|
// },
|
||||||
send: () => {
|
// send: () => {
|
||||||
//
|
// //
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
|
|
||||||
const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync')
|
// const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync')
|
||||||
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
// const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
|
||||||
|
|
||||||
expect(
|
// expect(
|
||||||
await getCoverColor(
|
// await getCoverColor(
|
||||||
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
|
// 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
|
||||||
)
|
// )
|
||||||
).toBe('#808080')
|
// ).toBe('#808080')
|
||||||
|
|
||||||
expect(sendSyncSpy).toHaveBeenCalledTimes(1)
|
// expect(sendSyncSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(sendSpy).toHaveBeenCalledTimes(1)
|
// expect(sendSpy).toHaveBeenCalledTimes(1)
|
||||||
vi.stubGlobal('ipcRenderer', undefined)
|
// vi.stubGlobal('ipcRenderer', undefined)
|
||||||
})
|
// })
|
||||||
|
|
||||||
test('invalid url', async () => {
|
// test('invalid url', async () => {
|
||||||
expect(await getCoverColor('not a valid url')).toBe(undefined)
|
// expect(await getCoverColor('not a valid url')).toBe(undefined)
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
|
|
||||||
test('storage', () => {
|
test('storage', () => {
|
||||||
const mockLocalStorage: any = {
|
const mockLocalStorage: any = {
|
||||||
|
|
|
@ -24,11 +24,7 @@ describe('parseCookies', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('parse cookies with empty value, expires and double semicolon', () => {
|
test('parse cookies with empty value, expires and double semicolon', () => {
|
||||||
expect(
|
expect(parseCookies('test=; test2=test2; Expires=Wed, 21 Oct 2015 07:28:00 GMT;;')).toEqual([
|
||||||
parseCookies(
|
|
||||||
'test=; test2=test2; Expires=Wed, 21 Oct 2015 07:28:00 GMT;;'
|
|
||||||
)
|
|
||||||
).toEqual([
|
|
||||||
{
|
{
|
||||||
key: 'test',
|
key: 'test',
|
||||||
value: '',
|
value: '',
|
||||||
|
@ -153,9 +149,7 @@ describe('setCookies', () => {
|
||||||
)
|
)
|
||||||
expect(Cookies.get('__remember_me')).toBe('true')
|
expect(Cookies.get('__remember_me')).toBe('true')
|
||||||
expect(Cookies.get('__csrf')).toBe('78328f711c179391b096a67ad9d0f08b')
|
expect(Cookies.get('__csrf')).toBe('78328f711c179391b096a67ad9d0f08b')
|
||||||
expect(Cookies.get('NMTID')).toBe(
|
expect(Cookies.get('NMTID')).toBe('00OEhPKoaEluGvd9kgutai-iADpQkEAAAGAJz_PTQ')
|
||||||
'00OEhPKoaEluGvd9kgutai-iADpQkEAAAGAJz_PTQ'
|
|
||||||
)
|
|
||||||
expect(Cookies.get('MUSIC_R_T')).toBe(undefined) // because of path is not /
|
expect(Cookies.get('MUSIC_R_T')).toBe(undefined) // because of path is not /
|
||||||
expect(Cookies.get('MUSIC_A_T')).toBe(undefined) // because of path is not /
|
expect(Cookies.get('MUSIC_A_T')).toBe(undefined) // because of path is not /
|
||||||
})
|
})
|
||||||
|
|
|
@ -168,7 +168,7 @@ export async function calcCoverColor(coverUrl: string) {
|
||||||
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||||
export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||||
export const isPWA =
|
export const isPWA =
|
||||||
(navigator as any).standalone || window.matchMedia('(display-mode: standalone)').matches
|
() => (navigator as any).standalone || window.matchMedia('(display-mode: standalone)').matches
|
||||||
export const isIosPwa = isIOS && isPWA && isSafari
|
export const isIosPwa = isIOS && isPWA() && isSafari
|
||||||
|
|
||||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
|
@ -12,8 +12,7 @@ export function lyricParser(lrc: FetchLyricResponse) {
|
||||||
/**
|
/**
|
||||||
* @see {@link https://regexr.com/6e52n}
|
* @see {@link https://regexr.com/6e52n}
|
||||||
*/
|
*/
|
||||||
const extractLrcRegex =
|
const extractLrcRegex = /^(?<lyricTimestamps>(?:\[.+?\])+)(?!\[)(?<content>.+)$/gm
|
||||||
/^(?<lyricTimestamps>(?:\[.+?\])+)(?!\[)(?<content>.+)$/gm
|
|
||||||
const extractTimestampRegex = /\[(?<min>\d+):(?<sec>\d+)(?:\.|:)*(?<ms>\d+)*\]/g
|
const extractTimestampRegex = /\[(?<min>\d+):(?<sec>\d+)(?:\.|:)*(?<ms>\d+)*\]/g
|
||||||
|
|
||||||
interface ParsedLyric {
|
interface ParsedLyric {
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
export const changeTheme = (theme: 'light' | 'dark') => {
|
export const changeTheme = (theme: 'light' | 'dark') => {
|
||||||
document.body.setAttribute('class', theme)
|
document.body.setAttribute('class', theme)
|
||||||
if (!window.env?.isElectron) {
|
if (!window.env?.isElectron) {
|
||||||
document.documentElement.style.background =
|
document.documentElement.style.background = theme === 'dark' ? '#000' : '#fff'
|
||||||
theme === 'dark' ? '#000' : '#fff'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { appName } from './utils/const'
|
||||||
|
|
||||||
dotenv.config({ path: join(__dirname, '../../.env') })
|
dotenv.config({ path: join(__dirname, '../../.env') })
|
||||||
const IS_ELECTRON = process.env.IS_ELECTRON
|
const IS_ELECTRON = process.env.IS_ELECTRON
|
||||||
|
const ELECTRON_DEV_NETEASE_API_PORT = Number(process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001)
|
||||||
|
const ELECTRON_WEB_SERVER_PORT = Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://vitejs.dev/config/
|
* @see https://vitejs.dev/config/
|
||||||
|
@ -23,6 +25,7 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': join(__dirname, '..'),
|
'@': join(__dirname, '..'),
|
||||||
|
'hls.js': 'hls.js/dist/hls.min.js',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
@ -37,33 +40,35 @@ export default defineConfig({
|
||||||
/**
|
/**
|
||||||
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html
|
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html
|
||||||
*/
|
*/
|
||||||
VitePWA({
|
IS_ELECTRON
|
||||||
registerType: 'autoUpdate',
|
? VitePWA({
|
||||||
manifest: {
|
registerType: 'autoUpdate',
|
||||||
name: appName,
|
manifest: {
|
||||||
short_name: appName,
|
name: appName,
|
||||||
description: 'Description of your app',
|
short_name: appName,
|
||||||
theme_color: '#000',
|
description: 'Description of your app',
|
||||||
icons: [
|
theme_color: '#000',
|
||||||
{
|
icons: [
|
||||||
src: 'pwa-192x192.png',
|
{
|
||||||
sizes: '192x192',
|
src: 'pwa-192x192.png',
|
||||||
type: 'image/png',
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
})
|
||||||
src: 'pwa-512x512.png',
|
: undefined,
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'pwa-512x512.png',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/png',
|
|
||||||
purpose: 'any maskable',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://github.com/vbenjs/vite-plugin-svg-icons
|
* @see https://github.com/vbenjs/vite-plugin-svg-icons
|
||||||
|
@ -90,22 +95,22 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710),
|
port: ELECTRON_WEB_SERVER_PORT,
|
||||||
strictPort: IS_ELECTRON ? true : false,
|
strictPort: IS_ELECTRON ? true : false,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/netease/': {
|
'/netease/': {
|
||||||
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
|
target: `http://127.0.0.1:${ELECTRON_DEV_NETEASE_API_PORT}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
|
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
|
||||||
},
|
},
|
||||||
'/r3play/': {
|
'/r3play/': {
|
||||||
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
|
target: `http://127.0.0.1:${ELECTRON_DEV_NETEASE_API_PORT}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
port: Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710),
|
port: ELECTRON_WEB_SERVER_PORT,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|
|
@ -10,21 +10,13 @@ export type Conversion = {
|
||||||
const filenamesToType = (conversions: Conversion[]): Plugin => {
|
const filenamesToType = (conversions: Conversion[]): Plugin => {
|
||||||
const generateTypes = async (conversion: Conversion) => {
|
const generateTypes = async (conversion: Conversion) => {
|
||||||
const filenames = await fs.readdir(conversion.dictionary).catch(reason => {
|
const filenames = await fs.readdir(conversion.dictionary).catch(reason => {
|
||||||
console.error(
|
console.error('vite-plugin-filenames-to-type: unable to read directory. ', reason)
|
||||||
'vite-plugin-filenames-to-type: unable to read directory. ',
|
|
||||||
reason
|
|
||||||
)
|
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
if (!filenames.length) return
|
if (!filenames.length) return
|
||||||
|
|
||||||
const iconNames = filenames.map(
|
const iconNames = filenames.map(fileName => `'${fileName.replace(/\.[^.]+$/, '')}'`)
|
||||||
fileName => `'${fileName.replace(/\.[^.]+$/, '')}'`
|
await fs.writeFile(conversion.typeFile, `export type IconNames = ${iconNames.join(' | ')}`)
|
||||||
)
|
|
||||||
await fs.writeFile(
|
|
||||||
conversion.typeFile,
|
|
||||||
`export type IconNames = ${iconNames.join(' | ')}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const findConversion = (filePath: string) => {
|
const findConversion = (filePath: string) => {
|
||||||
|
|
510
pnpm-lock.yaml
510
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -10,11 +10,11 @@
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{
|
{
|
||||||
"source": "/netease/:match*",
|
"source": "/netease/:match*",
|
||||||
"destination": "http://168.138.174.244:30001/:match*"
|
"destination": "http://129.150.45.86:30001/:match*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"source": "/r3play/:match*",
|
"source": "/r3play/:match*",
|
||||||
"destination": "http://168.138.174.244:35530/:match*"
|
"destination": "http://129.150.45.86:35530/:match*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"source": "/(.*)",
|
"source": "/(.*)",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user