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