feat: updates

This commit is contained in:
qier222 2023-03-26 02:16:01 +08:00
parent ce757215a3
commit c1cd31840e
No known key found for this signature in database
86 changed files with 1048 additions and 778 deletions

2
.gitignore vendored
View File

@ -57,4 +57,4 @@ vercel.json
packages/web/bundle-stats-renderer.html packages/web/bundle-stats-renderer.html
packages/web/bundle-stats.html packages/web/bundle-stats.html
packages/web/storybook-static packages/web/storybook-static
packages/desktop/prisma/client packages/desktop/esbuild-kit

View File

@ -49,6 +49,10 @@ API 源代码来自 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryif
任何基于此项目开发的项目都必须遵守开源协议,在项目 README/应用内的关于页面和介绍网站中明确说明基于此项目开发,并附上此项目 GitHub 页面的链接。 任何基于此项目开发的项目都必须遵守开源协议,在项目 README/应用内的关于页面和介绍网站中明确说明基于此项目开发,并附上此项目 GitHub 页面的链接。
## Credit
Designed by [JACKCRING](https://jackcring.com)
<!-- ## 🖼️ 截图 --> <!-- ## 🖼️ 截图 -->
<!-- ![lyrics][lyrics-screenshot] <!-- ![lyrics][lyrics-screenshot]

View File

@ -26,7 +26,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.31.0", "eslint": "^8.31.0",
"prettier": "^2.8.1", "prettier": "^2.8.1",
"turbo": "^1.6.3", "turbo": "^1.8.3",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"tsx": "^3.12.1", "tsx": "^3.12.1",
"prettier-plugin-tailwindcss": "^0.2.1" "prettier-plugin-tailwindcss": "^0.2.1"

View File

@ -9,7 +9,8 @@ const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
module.exports = { module.exports = {
appId: 'app.r3play', appId: 'app.r3play',
productName: pkg.productName, productName: pkg.productName,
copyright: 'Copyright © 2022 qier222', executableName: pkg.productName,
copyright: 'Copyright © 2023 qier222',
asar: true, asar: true,
directories: { directories: {
output: 'release', output: 'release',
@ -70,14 +71,14 @@ module.exports = {
}, },
linux: { linux: {
target: [ target: [
{ // {
target: 'deb', // target: 'deb',
arch: [ // arch: [
'x64', // 'x64',
// 'arm64', // // 'arm64',
// 'armv7l' // // 'armv7l'
], // ],
}, // },
{ {
target: 'AppImage', target: 'AppImage',
arch: ['x64'], arch: ['x64'],
@ -105,19 +106,13 @@ module.exports = {
}, },
files: [ files: [
'!**/*.ts', '!**/*.ts',
'!**/node_modules/better-sqlite3/{bin,build,deps}/**',
'!**/node_modules/*/{*.MD,*.md,README,readme}',
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
'!**/node_modules/*.d.ts',
'!**/node_modules/.bin',
'!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}', '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}',
'!.editorconfig', '!.editorconfig',
'!**/._*', '!**/._*',
'!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}', '!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}',
'!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}', '!**/{pnpm-lock.yaml}',
'!**/{appveyor.yml,.travis.yml,circle.yml}',
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
'!**/*.{map,debug.min.js}', '!**/*.{map,debug.min.js}',
'!**/node_modules/*',
{ {
from: './dist', from: './dist',

View File

@ -1,13 +1,15 @@
import path from 'path'
import { isProd } from '../env'
import log from '../log'
import appleMusic from './routes/r3play/appleMusic'
import netease from './routes/netease/netease'
import audio from './routes/r3play/audio'
import fastifyCookie from '@fastify/cookie' import fastifyCookie from '@fastify/cookie'
import fastifyMultipart from '@fastify/multipart' import fastifyMultipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static' import fastifyStatic from '@fastify/static'
import fastify from 'fastify' import fastify from 'fastify'
import path from 'path'
import { isProd } from '../env' log.info('[electron] appServer/appServer.ts')
import log from '../log'
import netease from './routes/netease/netease'
import appleMusic from './routes/r3play/appleMusic'
import audio from './routes/r3play/audio'
const initAppServer = async () => { const initAppServer = async () => {
const server = fastify({ const server = fastify({

View File

@ -1,9 +1,11 @@
import cache from '../../../cache'
import log from '@/desktop/main/log' import log from '@/desktop/main/log'
import { CacheAPIs } from '@/shared/CacheAPIs' import { CacheAPIs } from '@/shared/CacheAPIs'
import { pathCase, snakeCase } from 'change-case' import { pathCase, snakeCase } from 'change-case'
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi' import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
import cache from '../../../cache'
log.info('[electron] appServer/routes/netease.ts')
async function netease(fastify: FastifyInstance) { async function netease(fastify: FastifyInstance) {
const getHandler = (name: string, neteaseApi: (params: any) => any) => { const getHandler = (name: string, neteaseApi: (params: any) => any) => {

View File

@ -1,6 +1,9 @@
import { FastifyInstance } from 'fastify' import { FastifyInstance } from 'fastify'
import proxy from '@fastify/http-proxy' import proxy from '@fastify/http-proxy'
import { isDev } from '@/desktop/main/env' import { isDev } from '@/desktop/main/env'
import log from '@/desktop/main/log'
log.info('[electron] appServer/routes/r3play/appleMusic.ts')
async function appleMusic(fastify: FastifyInstance) { async function appleMusic(fastify: FastifyInstance) {
fastify.register(proxy, { fastify.register(proxy, {

View File

@ -11,6 +11,8 @@ import { FetchTracksResponse } from '@/shared/api/Track'
import store from '@/desktop/main/store' import store from '@/desktop/main/store'
import { db, Tables } from '@/desktop/main/db' import { db, Tables } from '@/desktop/main/db'
log.info('[electron] appServer/routes/r3play/audio.ts')
const getAudioFromCache = async (id: number) => { const getAudioFromCache = async (id: number) => {
// get from cache // get from cache
const cache = await db.find(Tables.Audio, id) const cache = await db.find(Tables.Audio, id)

View File

@ -8,6 +8,8 @@ import { CacheAPIs, CacheAPIsParams } from '@/shared/CacheAPIs'
import { TablesStructures } from './db' import { TablesStructures } from './db'
import { FastifyReply } from 'fastify' import { FastifyReply } from 'fastify'
log.info('[electron] cache.ts')
class Cache { class Cache {
constructor() { constructor() {
// //

View File

@ -9,6 +9,8 @@ import pkg from '../../../package.json'
import { compare, validate } from 'compare-versions' import { compare, validate } from 'compare-versions'
import os from 'os' import os from 'os'
log.info('[electron] db.ts')
export const enum Tables { export const enum Tables {
Track = 'Track', Track = 'Track',
Album = 'Album', Album = 'Album',
@ -108,7 +110,7 @@ class DB {
const prodBinPaths = { const prodBinPaths = {
darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`), darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`),
win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`), win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
linux: '', linux: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
} }
return isProd return isProd
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux'] ? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']

View File

@ -13,6 +13,8 @@ import { isDev, isWindows, isLinux, isMac, appName } from './env'
import store from './store' import store from './store'
import initAppServer from './appServer/appServer' import initAppServer from './appServer/appServer'
log.info('[electron] index.ts')
class Main { class Main {
win: BrowserWindow | null = null win: BrowserWindow | null = null
tray: YPMTray | null = null tray: YPMTray | null = null
@ -103,7 +105,7 @@ class Main {
// Make all links open with the browser, not with the application // Make all links open with the browser, not with the application
this.win.webContents.setWindowOpenHandler(({ url }) => { this.win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) shell.openExternal(url) if (url.startsWith('https://')) shell.openExternal(url)
return { action: 'deny' } return { action: 'deny' }
}) })

29
packages/desktop/main/ipcMain.ts Normal file → Executable file
View File

@ -14,6 +14,8 @@ import path from 'path'
import prettyBytes from 'pretty-bytes' import prettyBytes from 'pretty-bytes'
import { db, Tables } from './db' import { db, Tables } from './db'
log.info('[electron] ipcMain.ts')
const on = <T extends keyof IpcChannelsParams>( const on = <T extends keyof IpcChannelsParams>(
channel: T, channel: T,
listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void
@ -50,9 +52,30 @@ function initWindowIpcMain(win: BrowserWindow | null) {
win?.minimize() win?.minimize()
}) })
let isMaximized = false
let unMaximizeSize: { width: number; height: number } | null = null
let windowPosition: { x: number; y: number } | null = null
on(IpcChannels.MaximizeOrUnmaximize, () => { on(IpcChannels.MaximizeOrUnmaximize, () => {
if (!win) return if (!win) return false
win.isMaximized() ? win.unmaximize() : win.maximize()
if (isMaximized) {
if (unMaximizeSize) {
win.setSize(unMaximizeSize.width, unMaximizeSize.width, true)
}
if (windowPosition) {
win.setPosition(windowPosition.x, windowPosition.y, true)
}
win.unmaximize()
} else {
const size = win.getSize()
unMaximizeSize = { width: size[1], height: size[0] }
const position = win.getPosition()
windowPosition = { x: position[0], y: position[1] }
win.maximize()
}
isMaximized = !isMaximized
win.webContents.send(IpcChannels.IsMaximized, isMaximized)
}) })
on(IpcChannels.Close, () => { on(IpcChannels.Close, () => {
@ -66,7 +89,7 @@ function initWindowIpcMain(win: BrowserWindow | null) {
handle(IpcChannels.IsMaximized, () => { handle(IpcChannels.IsMaximized, () => {
if (!win) return if (!win) return
return win.isMaximized() return isMaximized
}) })
} }

View File

@ -1,5 +1,5 @@
/** By default, it writes logs to the following locations: /** By default, it writes logs to the following locations:
* on Linux: ~/.config/r3play/logs/main.log * on Linux: ~/.config/R3PLAY/logs/main.log
* on macOS: ~/Library/Logs/r3play/main.log * on macOS: ~/Library/Logs/r3play/main.log
* on Windows: %USERPROFILE%\AppData\Roaming\r3play\logs\main.log * on Windows: %USERPROFILE%\AppData\Roaming\r3play\logs\main.log
* @see https://www.npmjs.com/package/electron-log * @see https://www.npmjs.com/package/electron-log

View File

@ -1,14 +1,10 @@
import { import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron'
app,
BrowserWindow,
Menu,
MenuItem,
MenuItemConstructorOptions,
shell,
} from 'electron'
import { isMac } from './env' import { isMac } from './env'
import { logsPath } from './utils' import { logsPath } from './utils'
import { exec } from 'child_process' import { exec } from 'child_process'
import log from './log'
log.info('[electron] menu.ts')
export const createMenu = (win: BrowserWindow) => { export const createMenu = (win: BrowserWindow) => {
const template: Array<MenuItemConstructorOptions | MenuItem> = [ const template: Array<MenuItemConstructorOptions | MenuItem> = [
@ -51,9 +47,7 @@ export const createMenu = (win: BrowserWindow) => {
{ {
label: '反馈问题', label: '反馈问题',
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal('https://github.com/qier222/YesPlayMusic/issues/new')
'https://github.com/qier222/YesPlayMusic/issues/new'
)
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
@ -66,17 +60,13 @@ export const createMenu = (win: BrowserWindow) => {
{ {
label: '访问论坛', label: '访问论坛',
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal('https://github.com/qier222/YesPlayMusic/discussions')
'https://github.com/qier222/YesPlayMusic/discussions'
)
}, },
}, },
{ {
label: '加入交流群', label: '加入交流群',
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal('https://github.com/qier222/YesPlayMusic/discussions')
'https://github.com/qier222/YesPlayMusic/discussions'
)
}, },
}, },
], ],

View File

@ -1,4 +1,7 @@
import Store from 'electron-store' import Store from 'electron-store'
import log from './log'
log.info('[electron] store.ts')
export interface TypedElectronStore { export interface TypedElectronStore {
window: { window: {

View File

@ -1,15 +1,11 @@
import path from 'path' import path from 'path'
import { import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from 'electron'
app,
BrowserWindow,
Menu,
MenuItemConstructorOptions,
nativeImage,
Tray,
} from 'electron'
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { RepeatMode } from '@/shared/playerDataTypes' import { RepeatMode } from '@/shared/playerDataTypes'
import { appName } from './env' import { appName } from './env'
import log from './log'
log.info('[electron] tray.ts')
const iconDirRoot = const iconDirRoot =
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'

View File

@ -3,6 +3,9 @@ import path from 'path'
import os from 'os' import os from 'os'
import pkg from '../../../package.json' import pkg from '../../../package.json'
import { appName, isDev } from './env' import { appName, isDev } from './env'
import log from './log'
log.info('[electron] utils.ts')
export const dirname = isDev ? process.cwd() : __dirname export const dirname = isDev ? process.cwd() : __dirname
export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData') export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')

View File

@ -1,6 +1,9 @@
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron' import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
import path from 'path' import path from 'path'
import log from './log'
log.info('[electron] windowsTaskbar.ts')
enum ItemKeys { enum ItemKeys {
Play = 'play', Play = 'play',
@ -66,15 +69,11 @@ class ThumbarImpl implements Thumbar {
} }
private _updateThumbarButtons(clear: boolean) { private _updateThumbarButtons(clear: boolean) {
this._win.setThumbarButtons( this._win.setThumbarButtons(clear ? [] : [this._previous, this._playOrPause, this._next])
clear ? [] : [this._previous, this._playOrPause, this._next]
)
} }
setPlayState(isPlaying: boolean) { setPlayState(isPlaying: boolean) {
this._playOrPause = this._buttons.get( this._playOrPause = this._buttons.get(isPlaying ? ItemKeys.Pause : ItemKeys.Play)!
isPlaying ? ItemKeys.Pause : ItemKeys.Play
)!
this._updateThumbarButtons(false) this._updateThumbarButtons(false)
} }
} }

View File

@ -4,7 +4,8 @@
"private": true, "private": true,
"version": "2.0.0", "version": "2.0.0",
"main": "./main/index.js", "main": "./main/index.js",
"author": "*", "author": "qier222 <qier222@outlook.com>",
"homepage": "https://github.com/qier222/YesPlayMusic",
"scripts": { "scripts": {
"postinstall": "tsx scripts/build.sqlite3.ts", "postinstall": "tsx scripts/build.sqlite3.ts",
"dev": "tsx scripts/build.main.ts --watch", "dev": "tsx scripts/build.main.ts --watch",
@ -25,7 +26,6 @@
"@fastify/multipart": "^7.4.0", "@fastify/multipart": "^7.4.0",
"@fastify/static": "^6.6.1", "@fastify/static": "^6.6.1",
"@sentry/electron": "^3.0.7", "@sentry/electron": "^3.0.7",
"@yimura/scraper": "^1.2.4",
"NeteaseCloudMusicApi": "^4.8.9", "NeteaseCloudMusicApi": "^4.8.9",
"better-sqlite3": "8.1.0", "better-sqlite3": "8.1.0",
"change-case": "^4.1.2", "change-case": "^4.1.2",
@ -41,10 +41,10 @@
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.3", "@types/better-sqlite3": "^7.6.3",
"@vitest/ui": "^0.20.3", "@vitest/ui": "^0.20.3",
"axios": "^1.2.1", "axios": "^1.3.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"electron": "^23.1.1", "electron": "^23.1.4",
"electron-builder": "23.6.0", "electron-builder": "23.6.0",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",

View File

@ -39,10 +39,11 @@ exports.default = async function (context) {
} }
} }
if (platform === 'win32') { // Windows and Linux
if (arch !== 'x64') return // Skip other archs if (platform === 'win32' || platform === 'linux') {
if (platform === 'win32' && arch !== 'x64') return // Skip windows arm
const from = `${binDir}/better_sqlite3_win32_${arch}.node` const from = `${binDir}/better_sqlite3_${platform}_${arch}.node`
const to = `${context.appOutDir}/resources/bin/better_sqlite3.node` const to = `${context.appOutDir}/resources/bin/better_sqlite3.node`
console.info(`copy ${from} to ${to}`) console.info(`copy ${from} to ${to}`)

View File

@ -6,6 +6,8 @@ export enum UserApiNames {
FetchUserArtists = 'fetchUserArtists', FetchUserArtists = 'fetchUserArtists',
FetchListenedRecords = 'fetchListenedRecords', FetchListenedRecords = 'fetchListenedRecords',
FetchUserVideos = 'fetchUserVideos', FetchUserVideos = 'fetchUserVideos',
RefreshCookie = 'refreshCookie',
DailyCheckIn = 'dailyCheckIn',
} }
// 获取账号详情 // 获取账号详情
@ -130,3 +132,15 @@ export interface FetchListenedRecordsResponse {
song: Track song: Track
}[] }[]
} }
// 刷新Cookie
export interface RefreshCookieResponse {
code: number
cookie: string
}
// 每日签到
export interface DailyCheckInResponse {
code: number
point: number
}

View File

@ -1,7 +1,5 @@
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import useUserLikedTracksIDs, { import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import player from '@/web/states/player' import player from '@/web/states/player'
import useIpcRenderer from '@/web/hooks/useIpcRenderer' import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { State as PlayerState } from '@/web/utils/player' import { State as PlayerState } from '@/web/utils/player'

View File

@ -7,9 +7,7 @@ import {
} from '@/shared/api/Album' } from '@/shared/api/Album'
// 专辑详情 // 专辑详情
export function fetchAlbum( export function fetchAlbum(params: FetchAlbumParams): Promise<FetchAlbumResponse> {
params: FetchAlbumParams
): Promise<FetchAlbumResponse> {
return request({ return request({
url: '/album', url: '/album',
method: 'get', method: 'get',
@ -20,9 +18,7 @@ export function fetchAlbum(
}) })
} }
export function likeAAlbum( export function likeAAlbum(params: LikeAAlbumParams): Promise<LikeAAlbumResponse> {
params: LikeAAlbumParams
): Promise<LikeAAlbumResponse> {
return request({ return request({
url: '/album/sub', url: '/album/sub',
method: 'post', method: 'post',

View File

@ -13,9 +13,7 @@ import {
} from '@/shared/api/Artist' } from '@/shared/api/Artist'
// 歌手详情 // 歌手详情
export function fetchArtist( export function fetchArtist(params: FetchArtistParams): Promise<FetchArtistResponse> {
params: FetchArtistParams
): Promise<FetchArtistResponse> {
return request({ return request({
url: '/artists', url: '/artists',
method: 'get', method: 'get',
@ -46,9 +44,7 @@ export function fetchSimilarArtists(
} }
// 获取歌手MV // 获取歌手MV
export function fetchArtistMV( export function fetchArtistMV(params: FetchArtistMVParams): Promise<FetchArtistMVResponse> {
params: FetchArtistMVParams
): Promise<FetchArtistMVResponse> {
return request({ return request({
url: '/artist/mv', url: '/artist/mv',
method: 'get', method: 'get',
@ -57,9 +53,7 @@ export function fetchArtistMV(
} }
// 收藏歌手 // 收藏歌手
export function likeAArtist( export function likeAArtist(params: LikeAArtistParams): Promise<LikeAArtistResponse> {
params: LikeAArtistParams
): Promise<LikeAArtistResponse> {
return request({ return request({
url: 'artist/sub', url: 'artist/sub',
method: 'get', method: 'get',

View File

@ -1,5 +1,5 @@
import request from '@/web/utils/request' import request from '@/web/utils/request'
import { FetchUserAccountResponse } from '@/shared/api/User' import { FetchUserAccountResponse, RefreshCookieResponse } from '@/shared/api/User'
// 手机号登录 // 手机号登录
interface LoginWithPhoneParams { interface LoginWithPhoneParams {
@ -14,9 +14,7 @@ export interface LoginWithPhoneResponse {
code: number code: number
cookie: string cookie: string
} }
export function loginWithPhone( export function loginWithPhone(params: LoginWithPhoneParams): Promise<LoginWithPhoneResponse> {
params: LoginWithPhoneParams
): Promise<LoginWithPhoneResponse> {
return request({ return request({
url: '/login/cellphone', url: '/login/cellphone',
method: 'post', method: 'post',
@ -47,9 +45,7 @@ export interface LoginWithEmailResponse extends FetchUserAccountResponse {
userId: number userId: number
}[] }[]
} }
export function loginWithEmail( export function loginWithEmail(params: LoginWithEmailParams): Promise<LoginWithEmailResponse> {
params: LoginWithEmailParams
): Promise<LoginWithEmailResponse> {
return request({ return request({
url: '/login', url: '/login',
method: 'post', method: 'post',
@ -99,7 +95,7 @@ export function checkLoginQrCodeStatus(
} }
// 刷新登录 // 刷新登录
export function refreshCookie() { export function refreshCookie(): Promise<RefreshCookieResponse> {
return request({ return request({
url: '/login/refresh', url: '/login/refresh',
method: 'post', method: 'post',

View File

@ -1,5 +1,5 @@
import { fetchAudioSource, fetchTracks } from '@/web/api/track' import { fetchAudioSource, fetchTracks } from '@/web/api/track'
import type {} from '@/web/api/track' import type { } from '@/web/api/track'
import reactQueryClient from '@/web/utils/reactQueryClient' import reactQueryClient from '@/web/utils/reactQueryClient'
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { import {

View File

@ -1,10 +1,10 @@
import { fetchUserAccount } from '@/web/api/user' import { dailyCheckIn, fetchUserAccount } from '@/web/api/user'
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User' import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
import { CacheAPIs } from '@/shared/CacheAPIs' import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { logout as logoutAPI } from '../auth' import { logout as logoutAPI, refreshCookie } from '../auth'
import { removeAllCookies } from '@/web/utils/cookie' import { removeAllCookies, setCookies } from '@/web/utils/cookie'
import reactQueryClient from '@/web/utils/reactQueryClient' import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useUser() { export default function useUser() {
@ -31,6 +31,43 @@ export default function useUser() {
) )
} }
export function useRefreshCookie() {
const user = useUser()
return useQuery(
[UserApiNames.RefreshCookie],
async () => {
const result = await refreshCookie()
if (result?.code === 200) {
setCookies(result.cookie)
}
return result
},
{
refetchInterval: 1000 * 60 * 30,
enabled: !!user.data?.profile?.userId,
}
)
}
export function useDailyCheckIn() {
const user = useUser()
return useQuery(
[UserApiNames.DailyCheckIn],
async () => {
try {
Promise.allSettled([dailyCheckIn(0), dailyCheckIn(1)])
return 'ok'
} catch (e: any) {
return 'error'
}
},
{
refetchInterval: 1000 * 60 * 30,
enabled: !!user.data?.profile?.userId,
}
)
}
export const useIsLoggedIn = () => { export const useIsLoggedIn = () => {
const { data, isLoading } = useUser() const { data, isLoading } = useUser()
if (isLoading) return true if (isLoading) return true

View File

@ -10,9 +10,7 @@ import {
} from '@/shared/api/Playlists' } from '@/shared/api/Playlists'
// 歌单详情 // 歌单详情
export function fetchPlaylist( export function fetchPlaylist(params: FetchPlaylistParams): Promise<FetchPlaylistResponse> {
params: FetchPlaylistParams
): Promise<FetchPlaylistResponse> {
if (!params.s) params.s = 0 // 网易云默认返回8个收藏者这里设置为0减少返回的JSON体积 if (!params.s) params.s = 0 // 网易云默认返回8个收藏者这里设置为0减少返回的JSON体积
return request({ return request({
url: '/playlist/detail', url: '/playlist/detail',
@ -46,9 +44,7 @@ export function fetchDailyRecommendPlaylists(): Promise<FetchDailyRecommendPlayl
}) })
} }
export function likeAPlaylist( export function likeAPlaylist(params: LikeAPlaylistParams): Promise<LikeAPlaylistResponse> {
params: LikeAPlaylistParams
): Promise<LikeAPlaylistResponse> {
return request({ return request({
url: '/playlist/subscribe', url: '/playlist/subscribe',
method: 'post', method: 'post',

View File

@ -12,14 +12,10 @@ import {
FetchListenedRecordsResponse, FetchListenedRecordsResponse,
FetchUserVideosResponse, FetchUserVideosResponse,
FetchUserVideosParams, FetchUserVideosParams,
DailyCheckInResponse,
} from '@/shared/api/User' } from '@/shared/api/User'
/** // 获取用户详情
*
* 说明 : 登录后调用此接口 , id,
* - uid : 用户 id
* @param {number} uid
*/
export function userDetail(uid: number) { export function userDetail(uid: number) {
return request({ return request({
url: '/user/detail', url: '/user/detail',
@ -53,6 +49,7 @@ export function fetchUserPlaylists(
}) })
} }
// 获取用户收藏的歌曲ID列表
export function fetchUserLikedTracksIDs( export function fetchUserLikedTracksIDs(
params: FetchUserLikedTracksIDsParams params: FetchUserLikedTracksIDsParams
): Promise<FetchUserLikedTracksIDsResponse> { ): Promise<FetchUserLikedTracksIDsResponse> {
@ -102,9 +99,9 @@ export function fetchListenedRecords(
* - type: , 0, 0 ,1 web/PC * - type: , 0, 0 ,1 web/PC
* @param {number} type * @param {number} type
*/ */
export function dailySignin(type = 0) { export function dailyCheckIn(type = 0): Promise<DailyCheckInResponse> {
return request({ return request({
url: '/daily_signin', url: '/daily/signin',
method: 'post', method: 'post',
params: { params: {
type, type,
@ -113,9 +110,7 @@ export function dailySignin(type = 0) {
}) })
} }
export function fetchUserAlbums( export function fetchUserAlbums(params: FetchUserAlbumsParams): Promise<FetchUserAlbumsResponse> {
params: FetchUserAlbumsParams
): Promise<FetchUserAlbumsResponse> {
return request({ return request({
url: '/album/sublist', url: '/album/sublist',
method: 'get', method: 'get',

View File

@ -96,6 +96,10 @@ const MenuItem = ({
` `
)} )}
></div> ></div>
{/* 增加三角形避免斜着移动到submenu时意外关闭菜单 */}
<div className='absolute -right-8 -bottom-6 h-12 w-12 rotate-45'></div>
<div className='absolute -right-8 -top-6 h-12 w-12 rotate-45'></div>
</> </>
)} )}
</div> </div>

View File

@ -73,13 +73,21 @@ const Playlist = ({ playlist }: { playlist: Playlist }) => {
}, [playlist.id]) }, [playlist.id])
return ( return (
<Image <div className='group relative'>
onClick={goTo} <Image
key={playlist.id} onClick={goTo}
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')} key={playlist.id}
className='aspect-square rounded-24' src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
onMouseOver={prefetch} className='aspect-square rounded-24'
/> onMouseOver={prefetch}
/>
{/* Hover mask layer */}
<div className='pointer-events-none absolute inset-0 w-full bg-gradient-to-b from-transparent to-black opacity-0 transition-all duration-400 group-hover:opacity-100 '></div>
{/* Name */}
<div className='pointer-events-none absolute bottom-0 p-3 text-sm font-medium text-neutral-300 opacity-0 transition-all duration-400 group-hover:opacity-100'>
{playlist.name}
</div>
</div>
) )
} }

View File

@ -46,11 +46,7 @@ const CoverRow = ({
return ( return (
<div className={className}> <div className={className}>
{/* Title */} {/* Title */}
{title && ( {title && <h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>{title}</h4>}
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
{title}
</h4>
)}
<Virtuoso <Virtuoso
className='no-scrollbar' className='no-scrollbar'
@ -66,20 +62,14 @@ const CoverRow = ({
Footer: () => <div className='h-16'></div>, Footer: () => <div className='h-16'></div>,
}} }}
itemContent={(index, row) => ( itemContent={(index, row) => (
<div <div key={index} className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'>
key={index}
className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'
>
{row.map((item: Item) => ( {row.map((item: Item) => (
<img <img
onClick={() => goTo(item.id)} onClick={() => goTo(item.id)}
key={item.id} key={item.id}
alt={item.name} alt={item.name}
src={resizeImage( src={resizeImage(
item?.picUrl || item?.picUrl || (item as Playlist)?.coverImgUrl || item?.picUrl || '',
(item as Playlist)?.coverImgUrl ||
item?.picUrl ||
'',
'md' 'md'
)} )}
className='aspect-square w-full rounded-24' className='aspect-square w-full rounded-24'

View File

@ -22,11 +22,7 @@ const sizes = {
}, },
} as const } as const
const CoverWall = ({ const CoverWall = ({ albums }: { albums: { id: number; coverUrl: string; large: boolean }[] }) => {
albums,
}: {
albums: { id: number; coverUrl: string; large: boolean }[]
}) => {
const navigate = useNavigate() const navigate = useNavigate()
const breakpoint = useBreakpoint() const breakpoint = useBreakpoint()
@ -41,10 +37,7 @@ const CoverWall = ({
> >
{albums.map(album => ( {albums.map(album => (
<Image <Image
src={resizeImage( src={resizeImage(album.coverUrl, sizes[album.large ? 'large' : 'small'][breakpoint])}
album.coverUrl,
sizes[album.large ? 'large' : 'small'][breakpoint]
)}
key={album.id} key={album.id}
className={cx( className={cx(
'aspect-square h-full w-full rounded-20 lg:rounded-24', 'aspect-square h-full w-full rounded-20 lg:rounded-24',

View File

@ -1,27 +1,16 @@
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import useIsMobile from '@/web/hooks/useIsMobile'
const Devtool = () => { const Devtool = () => {
const isMobile = useIsMobile()
return ( return (
<ReactQueryDevtools <ReactQueryDevtools
initialIsOpen={false} initialIsOpen={false}
toggleButtonProps={{ toggleButtonProps={{
style: { style: {
position: 'fixed', position: 'fixed',
...(isMobile top: 36,
? { right: 148,
top: 0, bottom: 'atuo',
right: 0, left: 'auto',
bottom: 'auto',
left: 'atuo',
}
: {
top: 36,
right: 148,
bottom: 'atuo',
left: 'auto',
}),
}, },
}} }}
/> />

View 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

View File

@ -11,9 +11,7 @@ const ErrorBoundary = ({ children }: { children: ReactNode }) => {
> >
<div className='app-region-no-drag'> <div className='app-region-no-drag'>
<p>Something went wrong:</p> <p>Something went wrong:</p>
<pre className='mb-2 text-18 dark:text-white'> <pre className='mb-2 text-18 dark:text-white'>{error.toString()}</pre>
{error.toString()}
</pre>
<div className='max-h-72 max-w-2xl overflow-scroll whitespace-pre-line rounded-12 border border-white/10 px-3 py-2 dark:text-white/50'> <div className='max-h-72 max-w-2xl overflow-scroll whitespace-pre-line rounded-12 border border-white/10 px-3 py-2 dark:text-white/50'>
{componentStack?.trim()} {componentStack?.trim()}
</div> </div>

View File

@ -101,8 +101,7 @@ const ImageDesktop = ({
} }
const ImageMobile = (props: Props) => { const ImageMobile = (props: Props) => {
const { src, className, srcSet, sizes, lazyLoad, onClick, onMouseOver } = const { src, className, srcSet, sizes, lazyLoad, onClick, onMouseOver } = props
props
return ( return (
<div <div
onClick={onClick} onClick={onClick}

View File

@ -21,11 +21,8 @@ const Layout = () => {
<div <div
id='layout' id='layout'
className={cx( className={cx(
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black', 'relative grid h-screen select-none overflow-hidden bg-black',
window.env?.isElectron && !fullscreen && 'rounded-24', window.env?.isElectron && !fullscreen && 'rounded-24'
css`
min-width: 720px;
`
)} )}
> >
<BlurBackground /> <BlurBackground />
@ -47,7 +44,15 @@ const Layout = () => {
<ContextMenus /> <ContextMenus />
{/* {window.env?.isElectron && <Airplay />} */} {/* Border */}
<div
className={cx(
'pointer-events-none fixed inset-0 z-50 rounded-24',
css`
box-shadow: inset 0px 0px 0px 1px rgba(255, 255, 255, 0.06);
`
)}
></div>
</div> </div>
) )
} }

View File

@ -94,10 +94,7 @@ const LoginWithQRCode = () => {
) )
const text = useMemo( const text = useMemo(
() => () => (key?.data?.unikey ? `https://music.163.com/login?codekey=${key.data.unikey}` : ''),
key?.data?.unikey
? `https://music.163.com/login?codekey=${key.data.unikey}`
: '',
[key?.data?.unikey] [key?.data?.unikey]
) )

View File

@ -6,9 +6,7 @@ import { MotionConfig, motion } from 'framer-motion'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import Icon from '../Icon' import Icon from '../Icon'
import { State as PlayerState } from '@/web/utils/player' import { State as PlayerState } from '@/web/utils/player'
import useUserLikedTracksIDs, { import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
const LikeButton = () => { const LikeButton = () => {
const { track } = useSnapshot(player) const { track } = useSnapshot(player)
@ -38,9 +36,7 @@ const Controls = () => {
<motion.div <motion.div
className={cx( className={cx(
'fixed bottom-0 right-0 flex', 'fixed bottom-0 right-0 flex',
mini mini ? 'flex-col items-center justify-between' : 'items-center justify-between',
? 'flex-col items-center justify-between'
: 'items-center justify-between',
mini mini
? css` ? css`
right: 24px; right: 24px;
@ -85,11 +81,7 @@ const Controls = () => {
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20' className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
> >
<Icon <Icon
name={ name={[PlayerState.Playing, PlayerState.Loading].includes(state) ? 'pause' : 'play'}
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
className='h-6 w-6 ' className='h-6 w-6 '
/> />
</motion.button> </motion.button>

View File

@ -7,6 +7,7 @@ import persistedUiStates from '@/web/states/persistedUiStates'
import Controls from './Controls' import Controls from './Controls'
import Cover from './Cover' import Cover from './Cover'
import Progress from './Progress' import Progress from './Progress'
import { ease } from '@/web/utils/const'
const NowPlaying = () => { const NowPlaying = () => {
const { track } = useSnapshot(player) const { track } = useSnapshot(player)
@ -21,6 +22,7 @@ const NowPlaying = () => {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ ease, duration: 0.4 }}
className={cx( className={cx(
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border', 'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
css` css`

View File

@ -25,6 +25,7 @@ const Player = () => {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ ease, duration: 0.4 }}
> >
<PlayingNext /> <PlayingNext />
</motion.div> </motion.div>

View File

@ -8,9 +8,7 @@ import { resizeImage } from '@/web/utils/common'
import { motion, PanInfo } from 'framer-motion' import { motion, PanInfo } from 'framer-motion'
import { useLockBodyScroll } from 'react-use' import { useLockBodyScroll } from 'react-use'
import { useState } from 'react' import { useState } from 'react'
import useUserLikedTracksIDs, { import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import uiStates from '@/web/states/uiStates' import uiStates from '@/web/states/uiStates'
import { ease } from '@/web/utils/const' import { ease } from '@/web/utils/const'
@ -27,10 +25,7 @@ const LikeButton = () => {
className='flex h-full items-center' className='flex h-full items-center'
onClick={() => track?.id && likeATrack.mutateAsync(track.id)} onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
> >
<Icon <Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7 text-white/10' />
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7 text-white/10'
/>
</button> </button>
) )
} }
@ -42,10 +37,7 @@ const PlayerMobile = () => {
useLockBodyScroll(locked) useLockBodyScroll(locked)
const { mobileShowPlayingNext } = useSnapshot(uiStates) const { mobileShowPlayingNext } = useSnapshot(uiStates)
const onDragEnd = ( const onDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
event: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo
) => {
console.log(JSON.stringify(info)) console.log(JSON.stringify(info))
const { x, y } = info.offset const { x, y } = info.offset
const offset = 100 const offset = 100
@ -107,9 +99,7 @@ const PlayerMobile = () => {
className='flex h-full flex-grow items-center ' className='flex h-full flex-grow items-center '
> >
<div className='flex-shrink-0'> <div className='flex-shrink-0'>
<div className='line-clamp-1 text-14 font-bold text-white'> <div className='line-clamp-1 text-14 font-bold text-white'>{track?.name}</div>
{track?.name}
</div>
<div className='line-clamp-1 mt-1 text-12 font-bold text-white/60'> <div className='line-clamp-1 mt-1 text-12 font-bold text-white/60'>
{track?.ar?.map(a => a.name).join(', ')} {track?.ar?.map(a => a.name).join(', ')}
</div> </div>
@ -143,10 +133,7 @@ const PlayerMobile = () => {
onClick={() => player.playOrPause()} onClick={() => player.playOrPause()}
className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5' className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5'
> >
<Icon <Icon name={state === 'playing' ? 'pause' : 'play'} className='h-6 w-6 text-white/80' />
name={state === 'playing' ? 'pause' : 'play'}
className='h-6 w-6 text-white/80'
/>
</button> </button>
</div> </div>
) )

View File

@ -67,10 +67,7 @@ const PlayingNextMobile = () => {
` `
)} )}
> >
<Icon <Icon name='player-handler' className='mb-5 h-2.5 rotate-180 text-brand-700' />
name='player-handler'
className='mb-5 h-2.5 rotate-180 text-brand-700'
/>
</motion.div> </motion.div>
{/* List */} {/* List */}

View File

@ -1,21 +1,17 @@
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import React, { ReactNode, Suspense } from 'react' import React, { lazy, Suspense } from 'react'
import VideoPlayer from './VideoPlayer' import VideoPlayer from './VideoPlayer'
const My = React.lazy(() => import('@/web/pages/My')) const My = lazy(() => import('@/web/pages/My'))
const Discover = React.lazy(() => import('@/web/pages/Discover')) const Discover = lazy(() => import('@/web/pages/Discover'))
const Browse = React.lazy(() => import('@/web/pages/Browse')) const Browse = lazy(() => import('@/web/pages/Browse'))
const Album = React.lazy(() => import('@/web/pages/Album')) const Album = lazy(() => import('@/web/pages/Album'))
const Playlist = React.lazy(() => import('@/web/pages/Playlist')) const Playlist = lazy(() => import('@/web/pages/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/Artist')) const Artist = lazy(() => import('@/web/pages/Artist'))
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics')) const Lyrics = lazy(() => import('@/web/pages/Lyrics'))
const Search = React.lazy(() => import('@/web/pages/Search')) const Search = lazy(() => import('@/web/pages/Search'))
const Settings = React.lazy(() => import('@/web/pages/Settings')) const Settings = lazy(() => import('@/web/pages/Settings'))
const lazy = (component: ReactNode) => {
return <Suspense>{component}</Suspense>
}
const Router = () => { const Router = () => {
const location = useLocation() const location = useLocation()
@ -24,16 +20,16 @@ const Router = () => {
<AnimatePresence mode='wait'> <AnimatePresence mode='wait'>
<VideoPlayer /> <VideoPlayer />
<Routes location={location} key={location.pathname}> <Routes location={location} key={location.pathname}>
<Route path='/' element={lazy(<My />)} /> <Route path='/' element={<My />} />
<Route path='/discover' element={lazy(<Discover />)} /> <Route path='/discover' element={<Discover />} />
<Route path='/browse' element={lazy(<Browse />)} /> <Route path='/browse' element={<Browse />} />
<Route path='/album/:id' element={lazy(<Album />)} /> <Route path='/album/:id' element={<Album />} />
<Route path='/playlist/:id' element={lazy(<Playlist />)} /> <Route path='/playlist/:id' element={<Playlist />} />
<Route path='/artist/:id' element={lazy(<Artist />)} /> <Route path='/artist/:id' element={<Artist />} />
<Route path='/settings' element={lazy(<Settings />)} /> <Route path='/settings' element={<Settings />} />
<Route path='/lyrics' element={lazy(<Lyrics />)} /> <Route path='/lyrics' element={<Lyrics />} />
<Route path='/search/:keywords' element={lazy(<Search />)}> <Route path='/search/:keywords' element={<Search />}>
<Route path=':type' element={lazy(<Search />)} /> <Route path=':type' element={<Search />} />
</Route> </Route>
</Routes> </Routes>
</AnimatePresence> </AnimatePresence>

View File

@ -23,8 +23,7 @@ const Slider = ({
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [draggingValue, setDraggingValue] = useState(value) const [draggingValue, setDraggingValue] = useState(value)
const memoedValue = useMemo( const memoedValue = useMemo(
() => () => (isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value),
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded] [isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
) )
@ -50,8 +49,7 @@ const Slider = ({
* Handle slider click event * Handle slider click event
*/ */
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => (e: React.MouseEvent<HTMLDivElement>) => onChange(getNewValue({ x: e.clientX, y: e.clientY })),
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
[getNewValue, onChange] [getNewValue, onChange]
) )
@ -69,22 +67,14 @@ const Slider = ({
const handlePointerMove = (e: { clientX: number; clientY: number }) => { const handlePointerMove = (e: { clientX: number; clientY: number }) => {
if (!isDragging) return if (!isDragging) return
const newValue = getNewValue({ x: e.clientX, y: e.clientY }) const newValue = getNewValue({ x: e.clientX, y: e.clientY })
onlyCallOnChangeAfterDragEnded onlyCallOnChangeAfterDragEnded ? setDraggingValue(newValue) : onChange(newValue)
? setDraggingValue(newValue)
: onChange(newValue)
} }
document.addEventListener('pointermove', handlePointerMove) document.addEventListener('pointermove', handlePointerMove)
return () => { return () => {
document.removeEventListener('pointermove', handlePointerMove) document.removeEventListener('pointermove', handlePointerMove)
} }
}, [ }, [isDragging, onChange, setDraggingValue, onlyCallOnChangeAfterDragEnded, getNewValue])
isDragging,
onChange,
setDraggingValue,
onlyCallOnChangeAfterDragEnded,
getNewValue,
])
/** /**
* Handle pointer up events * Handle pointer up events
@ -102,28 +92,18 @@ const Slider = ({
return () => { return () => {
document.removeEventListener('pointerup', handlePointerUp) document.removeEventListener('pointerup', handlePointerUp)
} }
}, [ }, [isDragging, setIsDragging, onlyCallOnChangeAfterDragEnded, draggingValue, onChange])
isDragging,
setIsDragging,
onlyCallOnChangeAfterDragEnded,
draggingValue,
onChange,
])
/** /**
* Track and thumb styles * Track and thumb styles
*/ */
const usedTrackStyle = useMemo(() => { const usedTrackStyle = useMemo(() => {
const percentage = `${(memoedValue / max) * 100}%` const percentage = `${(memoedValue / max) * 100}%`
return orientation === 'horizontal' return orientation === 'horizontal' ? { width: percentage } : { height: percentage }
? { width: percentage }
: { height: percentage }
}, [max, memoedValue, orientation]) }, [max, memoedValue, orientation])
const thumbStyle = useMemo(() => { const thumbStyle = useMemo(() => {
const percentage = `${(memoedValue / max) * 100}%` const percentage = `${(memoedValue / max) * 100}%`
return orientation === 'horizontal' return orientation === 'horizontal' ? { left: percentage } : { bottom: percentage }
? { left: percentage }
: { bottom: percentage }
}, [max, memoedValue, orientation]) }, [max, memoedValue, orientation])
return ( return (
@ -159,9 +139,7 @@ const Slider = ({
<div <div
className={cx( className={cx(
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white', 'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
isDragging || alwaysShowThumb isDragging || alwaysShowThumb ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100',
orientation === 'horizontal' && '-translate-x-1', orientation === 'horizontal' && '-translate-x-1',
orientation === 'vertical' && 'translate-y-1' orientation === 'vertical' && 'translate-y-1'
)} )}

View File

@ -3,12 +3,14 @@ import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer' import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useState } from 'react' import { useState } from 'react'
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import uiStates from '../states/uiStates'
const Controls = () => { const Controls = () => {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => { useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
setIsMaximized(value) setIsMaximized(value)
uiStates.fullscreen = value
}) })
const minimize = () => { const minimize = () => {
@ -38,10 +40,7 @@ const Controls = () => {
<Icon className='h-3 w-3' name='windows-minimize' /> <Icon className='h-3 w-3' name='windows-minimize' />
</button> </button>
<button onClick={maxRestore} className={classNames}> <button onClick={maxRestore} className={classNames}>
<Icon <Icon className='h-3 w-3' name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'} />
className='h-3 w-3'
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
/>
</button> </button>
<button <button
onClick={close} onClick={close}

View File

@ -1,7 +1,11 @@
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import Icon from '../Icon' import Icon from '../Icon'
import { resizeImage } from '@/web/utils/common' import { resizeImage } from '@/web/utils/common'
import useUser, { useMutationLogout } from '@/web/api/hooks/useUser' import useUser, {
useDailyCheckIn,
useMutationLogout,
useRefreshCookie,
} from '@/web/api/hooks/useUser'
import uiStates from '@/web/states/uiStates' import uiStates from '@/web/states/uiStates'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import BasicContextMenu from '../ContextMenus/BasicContextMenu' import BasicContextMenu from '../ContextMenus/BasicContextMenu'
@ -15,6 +19,9 @@ const Avatar = ({ className }: { className?: string }) => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
useRefreshCookie()
useDailyCheckIn()
const avatarUrl = user?.profile?.avatarUrl const avatarUrl = user?.profile?.avatarUrl
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm') ? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
: '' : ''

View File

@ -8,8 +8,7 @@ const TrafficLight = () => {
return <></> return <></>
} }
const className = const className = 'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
return ( return (
<div className='flex'> <div className='flex'>
<div className={className}></div> <div className={className}></div>

View File

@ -13,7 +13,7 @@ const delay = ['-100ms', '-500ms', '-1200ms', '-1000ms', '-700ms']
const Wave = ({ playing }: { playing: boolean }) => { const Wave = ({ playing }: { playing: boolean }) => {
return ( return (
<div className='grid h-3 grid-cols-5 items-end gap-0.5'> <div className='grid h-3 flex-shrink-0 grid-cols-5 items-end gap-0.5'>
{[...new Array(5).keys()].map(i => ( {[...new Array(5).keys()].map(i => (
<div <div
key={i} key={i}

View File

@ -14,16 +14,10 @@ declare global {
channel: T, channel: T,
params?: IpcChannelsParams[T] params?: IpcChannelsParams[T]
) => Promise<IpcChannelsReturns[T]> ) => Promise<IpcChannelsReturns[T]>
send: <T extends keyof IpcChannelsParams>( send: <T extends keyof IpcChannelsParams>(channel: T, params?: IpcChannelsParams[T]) => void
channel: T,
params?: IpcChannelsParams[T]
) => void
on: <T extends keyof IpcChannelsParams>( on: <T extends keyof IpcChannelsParams>(
channel: T, channel: T,
listener: ( listener: (event: Electron.IpcRendererEvent, value: IpcChannelsReturns[T]) => void
event: Electron.IpcRendererEvent,
value: IpcChannelsReturns[T]
) => void
) => void ) => void
} }
env?: { env?: {

View File

@ -1,15 +1,11 @@
import { useState, useEffect, RefObject } from 'react' import { useState, useEffect, RefObject } from 'react'
const useIntersectionObserver = ( const useIntersectionObserver = (element: RefObject<Element>): { onScreen: boolean } => {
element: RefObject<Element>
): { onScreen: boolean } => {
const [onScreen, setOnScreen] = useState(false) const [onScreen, setOnScreen] = useState(false)
useEffect(() => { useEffect(() => {
if (element.current) { if (element.current) {
const observer = new IntersectionObserver(([entry]) => const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting))
setOnScreen(entry.isIntersecting)
)
observer.observe(element.current) observer.observe(element.current)
return () => { return () => {
observer.disconnect() observer.disconnect()

View File

@ -43,13 +43,10 @@ const useScroll = (
const arrivedState: ArrivedState = { const arrivedState: ArrivedState = {
left: target.scrollLeft <= 0 + (offset?.left || 0), left: target.scrollLeft <= 0 + (offset?.left || 0),
right: right: target.scrollLeft + target.clientWidth >= target.scrollWidth - (offset?.right || 0),
target.scrollLeft + target.clientWidth >=
target.scrollWidth - (offset?.right || 0),
top: target.scrollTop <= 0 + (offset?.top || 0), top: target.scrollTop <= 0 + (offset?.top || 0),
bottom: bottom:
target.scrollTop + target.clientHeight >= target.scrollTop + target.clientHeight >= target.scrollHeight - (offset?.bottom || 0),
target.scrollHeight - (offset?.bottom || 0),
} }
setScroll({ setScroll({
@ -59,9 +56,7 @@ const useScroll = (
}) })
} }
const readHandleScroll = throttle const readHandleScroll = throttle ? lodashThrottle(handleScroll, throttle) : handleScroll
? lodashThrottle(handleScroll, throttle)
: handleScroll
const element = 'current' in ref ? ref?.current : ref const element = 'current' in ref ? ref?.current : ref
element?.addEventListener('scroll', readHandleScroll) element?.addEventListener('scroll', readHandleScroll)

View File

@ -68,14 +68,15 @@
"general": "General", "general": "General",
"appearance": "Appearance", "appearance": "Appearance",
"player": "Player", "player": "Player",
"lyrics": "Lyrics",
"lab": "Lab", "lab": "Lab",
"general-choose-language": "Choose Language", "general-choose-language": "Choose Language",
"player-youtube-unlock": "YouTube Unlock" "player-youtube-unlock": "YouTube Unlock",
"player-find-alternative-track-on-youtube-if-not-available-on-netease": "Find alternative track on YouTube if not available on NetEase.",
"about": "About"
}, },
"context-menu": { "context-menu": {
"share": "Share", "share": "Share",
"copy-netease-link": "Copy Netease Link", "copy-netease-link": "Copy NetEase Link",
"add-to-playlist": "Add to playlist", "add-to-playlist": "Add to playlist",
"add-to-liked-tracks": "Add to Liked Tracks", "add-to-liked-tracks": "Add to Liked Tracks",
"go-to-album": "Go to album", "go-to-album": "Go to album",
@ -94,11 +95,5 @@
"listen": "Listen", "listen": "Listen",
"latest-releases": "Latest Releases", "latest-releases": "Latest Releases",
"popular": "Popular" "popular": "Popular"
},
"menu-bar": {
"discover": "DISCOVER",
"explore": "EXPLORE",
"lyrics": "LYRICS",
"my-music": "MY MUSIC"
} }
} }

View File

@ -68,9 +68,11 @@
"appearance": "外观", "appearance": "外观",
"general": "通用", "general": "通用",
"lab": "实验室", "lab": "实验室",
"lyrics": "歌词",
"player": "播放", "player": "播放",
"general-choose-language": "选择语言" "general-choose-language": "选择语言",
"player-find-alternative-track-on-youtube-if-not-available-on-netease": "当播放的歌曲无版权或无法播放时,自动从 YouTube 寻找替代音频。",
"player-youtube-unlock": "YouTube 解锁",
"about": "关于"
}, },
"context-menu": { "context-menu": {
"share": "分享", "share": "分享",
@ -93,11 +95,5 @@
"listen": "播放", "listen": "播放",
"latest-releases": "最新发行", "latest-releases": "最新发行",
"popular": "热门歌曲" "popular": "热门歌曲"
},
"menu-bar": {
"discover": "发现",
"explore": "浏览",
"lyrics": "歌词",
"my-music": "我的"
} }
} }

View File

@ -1,22 +1,28 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"
/>
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>R3PLAY</title>
</head>
<head> <body>
<meta charset="UTF-8" /> <div id="root">
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" /> <div
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" /> id="placeholder"
<meta http-equiv="Content-Security-Policy" style="background-color: #000; position: fixed; inset: 0; border-radius: 24px"
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;" /> ></div>
<meta name="apple-mobile-web-app-capable" content="yes" /> </div>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <script type="module" src="/main.tsx"></script>
<title>R3PLAY</title> </body>
</head>
<body>
<div id="root">
<div id="placeholder" style="background-color: #000; position: fixed; inset: 0; border-radius: 24px;"></div>
</div>
<script type="module" src="/main.tsx"></script>
</body>
</html> </html>

View File

@ -1,9 +1,5 @@
import player from '@/web/states/player' import player from '@/web/states/player'
import { import { IpcChannels, IpcChannelsReturns, IpcChannelsParams } from '@/shared/IpcChannels'
IpcChannels,
IpcChannelsReturns,
IpcChannelsParams,
} from '@/shared/IpcChannels'
import uiStates from './states/uiStates' import uiStates from './states/uiStates'
const on = <T extends keyof IpcChannelsParams>( const on = <T extends keyof IpcChannelsParams>(

View File

@ -13,7 +13,7 @@
"analyze:css": "npx windicss-analysis", "analyze:css": "npx windicss-analysis",
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html", "analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js", "generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
"api:netease": "npx NeteaseCloudMusicApi@latest" "api:netease": "PORT=30001 npx NeteaseCloudMusicApi@latest"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@ -23,15 +23,15 @@
"@sentry/react": "^7.29.0", "@sentry/react": "^7.29.0",
"@sentry/tracing": "^7.29.0", "@sentry/tracing": "^7.29.0",
"@tailwindcss/container-queries": "^0.1.0", "@tailwindcss/container-queries": "^0.1.0",
"@tanstack/react-query": "^4.20.9", "@tanstack/react-query": "^4.26.1",
"@tanstack/react-query-devtools": "^4.20.9", "@tanstack/react-query-devtools": "^4.26.1",
"ahooks": "^3.7.4", "ahooks": "^3.7.4",
"axios": "^1.2.2", "axios": "^1.2.2",
"color.js": "^1.2.0", "color.js": "^1.2.0",
"colord": "^2.9.3", "colord": "^2.9.3",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"framer-motion": "^8.1.7", "framer-motion": "^8.1.7",
"hls.js": "^1.2.9", "hls.js": "^1.3.5",
"howler": "^2.2.3", "howler": "^2.2.3",
"i18next": "^22.4.9", "i18next": "^22.4.9",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
@ -58,7 +58,7 @@
"@types/qrcode": "^1.4.2", "@types/qrcode": "^1.4.2",
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.0.1", "@vitejs/plugin-react-swc": "^3.2.0",
"@vitest/ui": "^0.26.3", "@vitest/ui": "^0.26.3",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"c8": "^7.12.0", "c8": "^7.12.0",
@ -71,8 +71,8 @@
"rollup-plugin-visualizer": "^5.9.0", "rollup-plugin-visualizer": "^5.9.0",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"typescript": "*", "typescript": "*",
"vite": "^4.0.4", "vite": "^4.2.0",
"vite-plugin-pwa": "^0.14.1", "vite-plugin-pwa": "^0.14.4",
"vite-plugin-svg-icons": "^2.0.1", "vite-plugin-svg-icons": "^2.0.1",
"vitest": "^0.26.3" "vitest": "^0.26.3"
} }

View File

@ -5,7 +5,7 @@ import TrackListHeader from '@/web/components/TrackListHeader'
import player from '@/web/states/player' import player from '@/web/states/player'
import { formatDuration } from '@/web/utils/common' import { formatDuration } from '@/web/utils/common'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useMemo } from 'react' import { useCallback, useMemo } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -68,18 +68,21 @@ const Header = () => {
return !!userLikedAlbums?.data?.find(item => item.id === id) return !!userLikedAlbums?.data?.find(item => item.id === id)
}, [params.id, userLikedAlbums?.data]) }, [params.id, userLikedAlbums?.data])
const onPlay = async (trackID: number | null = null) => { const onPlay = useCallback(
if (!album?.id) { async (trackID: number | null = null) => {
toast('无法播放专辑,该专辑不存在') if (!album?.id) {
return toast('无法播放专辑,该专辑不存在')
} return
player.playAlbum(album.id, trackID) }
} player.playAlbum(album.id, trackID)
},
[album?.id]
)
const likeAAlbum = useMutationLikeAAlbum() const likeAAlbum = useMutationLikeAAlbum()
const onLike = async () => { const onLike = useCallback(async () => {
likeAAlbum.mutateAsync(album?.id || Number(params.id)) likeAAlbum.mutateAsync(album?.id || Number(params.id))
} }, [likeAAlbum.mutateAsync, album?.id, params.id])
return ( return (
<TrackListHeader <TrackListHeader

View File

@ -14,12 +14,9 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
if (!albums) return [] if (!albums) return []
const allReleases = albums?.hotAlbums || [] const allReleases = albums?.hotAlbums || []
const filteredAlbums = allReleases.filter( const filteredAlbums = allReleases.filter(
album => album => ['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
)
const singles = allReleases.filter(
album => album.type === 'Single' || album.size === 1
) )
const singles = allReleases.filter(album => album.type === 'Single' || album.size === 1)
const qualifiedAlbums = [...filteredAlbums, ...singles] const qualifiedAlbums = [...filteredAlbums, ...singles]
@ -41,10 +38,7 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
} }
// 去除 remix 专辑 // 去除 remix 专辑
if ( if (a.name.toLowerCase().includes('remix)') || a.name.toLowerCase().includes('remixes)')) {
a.name.toLowerCase().includes('remix)') ||
a.name.toLowerCase().includes('remixes)')
) {
return return
} }

View File

@ -1,11 +1,8 @@
import useUserArtists, { import useUserArtists, { useMutationLikeAArtist } from '@/web/api/hooks/useUserArtists'
useMutationLikeAArtist,
} from '@/web/api/hooks/useUserArtists'
import Icon from '@/web/components/Icon' import Icon from '@/web/components/Icon'
import { openContextMenu } from '@/web/states/contextMenus' import { openContextMenu } from '@/web/states/contextMenus'
import player from '@/web/states/player' import player from '@/web/states/player'
import { cx } from '@emotion/css' import { cx } from '@emotion/css'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
@ -50,10 +47,7 @@ const Actions = ({ isLoading }: { isLoading: boolean }) => {
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 ' : 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
)} )}
> >
<Icon <Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7'
/>
</button> </button>
</div> </div>

View File

@ -59,7 +59,7 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
className={cx( className={cx(
'line-clamp-5 mt-6 text-14 font-bold text-transparent', 'line-clamp-5 mt-6 text-14 font-bold text-transparent',
css` css`
height: 86px; min-height: 85px;
` `
)} )}
> >
@ -68,15 +68,14 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
) : ( ) : (
<div <div
className={cx( className={cx(
'line-clamp-5 mt-6 overflow-hidden whitespace-pre-wrap text-14 font-bold text-white/40 transition-colors duration-500 hover:text-white/60' 'line-clamp-5 mt-6 overflow-hidden whitespace-pre-wrap text-14 font-bold text-white/40 transition-colors duration-500 hover:text-white/60',
// css` css`
// height: 86px; height: 85px;
// ` `
)} )}
onClick={() => setIsOpenDescription(true)} onClick={() => setIsOpenDescription(true)}
> dangerouslySetInnerHTML={{ __html: description }}
{description} ></div>
</div>
))} ))}
<DescriptionViewer <DescriptionViewer

View File

@ -104,11 +104,7 @@ const LatestRelease = () => {
return ( return (
<> <>
{!isLoadingVideos && !isLoadingAlbums && ( {!isLoadingVideos && !isLoadingAlbums && (
<motion.div <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className='mx-2.5 lg:mx-0'>
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='mx-2.5 lg:mx-0'
>
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'> <div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
{t`artist.latest-releases`} {t`artist.latest-releases`}
</div> </div>

View File

@ -37,9 +37,7 @@ const Track = ({
<div <div
className={cx( className={cx(
'line-clamp-1 text-16 font-medium ', 'line-clamp-1 text-16 font-medium ',
isPlaying isPlaying ? 'text-brand-700' : 'text-neutral-700 dark:text-neutral-200'
? 'text-brand-700'
: 'text-neutral-700 dark:text-neutral-200'
)} )}
> >
{track?.name} {track?.name}
@ -71,9 +69,7 @@ const Popular = () => {
return ( return (
<div> <div>
<div className='mb-4 text-12 font-medium uppercase text-neutral-300'> <div className='mb-4 text-12 font-medium uppercase text-neutral-300'>{t`artist.popular`}</div>
{t`artist.popular`}
</div>
<div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'> <div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'>
{tracks?.slice(0, 9)?.map(t => ( {tracks?.slice(0, 9)?.map(t => (

View File

@ -1,9 +1,6 @@
import CoverWall from '@/web/components/CoverWall' import CoverWall from '@/web/components/CoverWall'
import PageTransition from '@/web/components/PageTransition' import PageTransition from '@/web/components/PageTransition'
import { import { fetchPlaylistWithReactQuery, fetchFromCache } from '@/web/api/hooks/usePlaylist'
fetchPlaylistWithReactQuery,
fetchFromCache,
} from '@/web/api/hooks/usePlaylist'
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks' import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
import { sampleSize } from 'lodash-es' import { sampleSize } from 'lodash-es'
import { FetchPlaylistResponse } from '@/shared/api/Playlists' import { FetchPlaylistResponse } from '@/shared/api/Playlists'
@ -39,9 +36,7 @@ const getAlbumsFromAPI = async () => {
) )
let ids: number[] = [] let ids: number[] = []
playlists.forEach(playlist => playlists.forEach(playlist => playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id)))
playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id))
)
if (!ids.length) return [] if (!ids.length) return []
ids = sampleSize(ids, 100) ids = sampleSize(ids, 100)
@ -77,8 +72,7 @@ const Discover = () => {
const { data: albums } = useQuery( const { data: albums } = useQuery(
['DiscoveryAlbums'], ['DiscoveryAlbums'],
async () => { async () => {
const albumsInLocalStorageTime = const albumsInLocalStorageTime = localStorage.getItem('discoverAlbumsTime')
localStorage.getItem('discoverAlbumsTime')
if ( if (
!albumsInLocalStorageTime || !albumsInLocalStorageTime ||
Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次 Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次

View File

@ -19,6 +19,7 @@ import VideoRow from '@/web/components/VideoRow'
import useUserVideos from '@/web/api/hooks/useUserVideos' import useUserVideos from '@/web/api/hooks/useUserVideos'
import persistedUiStates from '@/web/states/persistedUiStates' import persistedUiStates from '@/web/states/persistedUiStates'
import settings from '@/web/states/settings' import settings from '@/web/states/settings'
import useUser from '@/web/api/hooks/useUser'
const collections = ['playlists', 'albums', 'artists', 'videos'] as const const collections = ['playlists', 'albums', 'artists', 'videos'] as const
type Collection = typeof collections[number] type Collection = typeof collections[number]
@ -31,8 +32,37 @@ const Albums = () => {
const Playlists = () => { const Playlists = () => {
const { data: playlists } = useUserPlaylists() const { data: playlists } = useUserPlaylists()
const p = useMemo(() => playlists?.playlist?.slice(1), [playlists]) const user = useUser()
return <CoverRow playlists={p} /> const myPlaylists = useMemo(
() => playlists?.playlist?.slice(1).filter(p => p.userId === user?.data?.account?.id),
[playlists, user]
)
const otherPlaylists = useMemo(
() => playlists?.playlist?.slice(1).filter(p => p.userId !== user?.data?.account?.id),
[playlists, user]
)
return (
<div>
{/* My playlists */}
{myPlaylists && (
<>
<div className='mb-4 mt-2 text-14 font-medium uppercase text-neutral-400'>
Created BY ME
</div>
<CoverRow playlists={myPlaylists} />
</>
)}
{/* Other playlists */}
{otherPlaylists && (
<>
<div className='mb-4 mt-8 text-14 font-medium uppercase text-neutral-400'>
Created BY OTHERS
</div>
<CoverRow playlists={otherPlaylists} />
</>
)}
</div>
)
} }
const Artists = () => { const Artists = () => {
@ -50,14 +80,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings) const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
const tabs: { id: Collection; name: string }[] = [ const tabs: { id: Collection; name: string }[] = [
{
id: 'playlists',
name: t`common.playlist_other`,
},
{ {
id: 'albums', id: 'albums',
name: t`common.album_other`, name: t`common.album_other`,
}, },
{
id: 'playlists',
name: t`common.playlist_other`,
},
{ {
id: 'artists', id: 'artists',
name: t`common.artist_other`, name: t`common.artist_other`,
@ -75,7 +105,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
} }
return ( return (
<> <div className='relative'>
{/* Topbar background */} {/* Topbar background */}
<AnimatePresence> <AnimatePresence>
{showBg && ( {showBg && (
@ -84,14 +114,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className={cx( className={cx(
'pointer-events-none fixed top-0 right-0 left-10 z-10', 'pointer-events-none absolute right-0 left-0 z-10',
css` css`
height: 230px; height: 230px;
background-repeat: repeat; background-repeat: repeat;
` `
)} )}
style={{ style={{
right: `${minimizePlayer ? 0 : playerWidth + 32}px`, top: '-132px',
backgroundImage: `url(${topbarBackground})`, backgroundImage: `url(${topbarBackground})`,
}} }}
></motion.div> ></motion.div>
@ -115,7 +145,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
top: `${topbarHeight}px`, top: `${topbarHeight}px`,
}} }}
/> />
</> </div>
) )
} }
@ -131,7 +161,7 @@ const Collections = () => {
}, 500) }, 500)
return ( return (
<div> <motion.div layout>
<CollectionTabs showBg={isScrollReachBottom} /> <CollectionTabs showBg={isScrollReachBottom} />
<div <div
className={cx('no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0')} className={cx('no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0')}
@ -146,7 +176,7 @@ const Collections = () => {
{selectedTab === 'videos' && <Videos />} {selectedTab === 'videos' && <Videos />}
</div> </div>
<div ref={observePoint}></div> <div ref={observePoint}></div>
</div> </motion.div>
) )
} }

View File

@ -3,6 +3,7 @@ import PageTransition from '@/web/components/PageTransition'
import RecentlyListened from './RecentlyListened' import RecentlyListened from './RecentlyListened'
import Collections from './Collections' import Collections from './Collections'
import { useIsLoggedIn } from '@/web/api/hooks/useUser' import { useIsLoggedIn } from '@/web/api/hooks/useUser'
import { LayoutGroup, motion } from 'framer-motion'
function PleaseLogin() { function PleaseLogin() {
return <></> return <></>
@ -13,11 +14,13 @@ const My = () => {
return ( return (
<PageTransition> <PageTransition>
{isLoggedIn ? ( {isLoggedIn ? (
<div className='grid grid-cols-1 gap-10'> <LayoutGroup>
<PlayLikedSongsCard /> <div className='grid grid-cols-1 gap-10'>
<RecentlyListened /> <PlayLikedSongsCard />
<Collections /> <RecentlyListened />
</div> <Collections />
</div>
</LayoutGroup>
) : ( ) : (
<PleaseLogin /> <PleaseLogin />
)} )}

View File

@ -14,6 +14,7 @@ import { resizeImage } from '@/web/utils/common'
import { breakpoint as bp } from '@/web/utils/const' import { breakpoint as bp } from '@/web/utils/const'
import useUser from '@/web/api/hooks/useUser' import useUser from '@/web/api/hooks/useUser'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { motion } from 'framer-motion'
const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => { const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -104,7 +105,8 @@ const PlayLikedSongsCard = () => {
}, [likedSongsPlaylist?.playlist?.tracks, sampledTracks]) }, [likedSongsPlaylist?.playlist?.tracks, sampledTracks])
return ( return (
<div <motion.div
layout
className={cx( className={cx(
'mx-2.5 flex flex-col justify-between rounded-24 p-8 dark:bg-white/10 lg:mx-0', 'mx-2.5 flex flex-col justify-between rounded-24 p-8 dark:bg-white/10 lg:mx-0',
css` css`
@ -141,7 +143,7 @@ const PlayLikedSongsCard = () => {
<Icon name='forward' className='h-7 w-7 ' /> <Icon name='forward' className='h-7 w-7 ' />
</button> </button>
</div> </div>
</div> </motion.div>
) )
} }

View File

@ -1,13 +1,14 @@
import useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords' import useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords'
import useArtists from '@/web/api/hooks/useArtists' import useArtists from '@/web/api/hooks/useArtists'
import { useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import ArtistRow from '@/web/components/ArtistRow' import ArtistRow from '@/web/components/ArtistRow'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AnimatePresence, motion } from 'framer-motion'
const RecentlyListened = () => { const RecentlyListened = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: listenedRecords } = useUserListenedRecords({ type: 'week' }) const { data: listenedRecords, isLoading } = useUserListenedRecords({ type: 'week' })
const recentListenedArtistsIDs = useMemo(() => { const recentListenedArtistsIDs = useMemo(() => {
const artists: { const artists: {
id: number id: number
@ -31,10 +32,26 @@ const RecentlyListened = () => {
.slice(0, 5) .slice(0, 5)
.map(artist => artist.id) .map(artist => artist.id)
}, [listenedRecords]) }, [listenedRecords])
const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs) const { data: recentListenedArtists, isLoading: isLoadingArtistsDetail } =
const artist = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists]) useArtists(recentListenedArtistsIDs)
const artists = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists])
return <ArtistRow artists={artist} placeholderRow={1} title={t`my.recently-listened`} /> const show = useMemo(() => {
if (listenedRecords?.weekData?.length === 0) return false
if (isLoading || isLoadingArtistsDetail) return true
if (artists?.length) return true
return false
}, [isLoading, artists, listenedRecords, isLoadingArtistsDetail])
return (
<AnimatePresence>
{show && (
<motion.div layout exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<ArtistRow artists={artists} placeholderRow={1} title={t`my.recently-listened`} />
</motion.div>
)}
</AnimatePresence>
)
} }
export default RecentlyListened export default RecentlyListened

View File

@ -1,8 +1,6 @@
import usePlaylist from '@/web/api/hooks/usePlaylist' import usePlaylist from '@/web/api/hooks/usePlaylist'
import useUser from '@/web/api/hooks/useUser' import useUser from '@/web/api/hooks/useUser'
import useUserPlaylists, { import useUserPlaylists, { useMutationLikeAPlaylist } from '@/web/api/hooks/useUserPlaylists'
useMutationLikeAPlaylist,
} from '@/web/api/hooks/useUserPlaylists'
import TrackListHeader from '@/web/components/TrackListHeader' import TrackListHeader from '@/web/components/TrackListHeader'
import player from '@/web/states/player' import player from '@/web/states/player'
import { formatDate } from '@/web/utils/common' import { formatDate } from '@/web/utils/common'
@ -32,8 +30,7 @@ const Header = () => {
const extraInfo = useMemo(() => { const extraInfo = useMemo(() => {
return ( return (
<> <>
Updated at {formatDate(playlist?.updateTime || 0, 'en')} ·{' '} Updated at {formatDate(playlist?.updateTime || 0, 'en')} · {playlist?.trackCount} tracks
{playlist?.trackCount} tracks
</> </>
) )
}, [playlist]) }, [playlist])
@ -64,8 +61,7 @@ const Header = () => {
extraInfo, extraInfo,
cover, cover,
isLiked, isLiked,
onLike: onLike: user?.account?.id === playlist?.creator?.userId ? undefined : onLike,
user?.account?.id === playlist?.creator?.userId ? undefined : onLike,
onPlay, onPlay,
}} }}
/> />

View File

@ -4,6 +4,7 @@ import TrackList from './TrackList'
import player from '@/web/states/player' import player from '@/web/states/player'
import usePlaylist from '@/web/api/hooks/usePlaylist' import usePlaylist from '@/web/api/hooks/usePlaylist'
import Header from './Header' import Header from './Header'
import useTracks from '@/web/api/hooks/useTracks'
const Playlist = () => { const Playlist = () => {
const params = useParams() const params = useParams()
@ -11,6 +12,13 @@ const Playlist = () => {
id: Number(params.id), id: Number(params.id),
}) })
// TODO: 分页加载
const { data: playlistTracks } = useTracks({
ids: playlist?.playlist?.trackIds?.map(t => t.id) ?? [],
})
console.log(playlistTracks)
const onPlay = async (trackID: number | null = null) => { const onPlay = async (trackID: number | null = null) => {
await player.playPlaylist(playlist?.playlist?.id, trackID) await player.playPlaylist(playlist?.playlist?.id, trackID)
} }
@ -20,7 +28,7 @@ const Playlist = () => {
<Header /> <Header />
<div className='pb-10'> <div className='pb-10'>
<TrackList <TrackList
tracks={playlist?.playlist?.tracks ?? []} tracks={playlistTracks?.songs ?? playlist?.playlist?.tracks ?? []}
onPlay={onPlay} onPlay={onPlay}
className='z-10 mt-10' className='z-10 mt-10'
/> />

View File

@ -5,6 +5,7 @@ import player from '@/web/states/player'
import { formatDuration, resizeImage } from '@/web/utils/common' import { formatDuration, resizeImage } from '@/web/utils/common'
import { State as PlayerState } from '@/web/utils/player' import { State as PlayerState } from '@/web/utils/player'
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import { Fragment } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
@ -58,7 +59,17 @@ const Track = ({
)} )}
</div> </div>
<div className='line-clamp-1 mt-1 text-14 font-bold text-white/30'> <div className='line-clamp-1 mt-1 text-14 font-bold text-white/30'>
{track?.ar.map(a => a.name).join(', ')} {track?.ar.map((a, index) => (
<Fragment key={a.id}>
{index > 0 && ', '}
<NavLink
className='transition-all duration-300 hover:text-white/70'
to={`/artist/${a.id}`}
>
{a.name}
</NavLink>
</Fragment>
))}
</div> </div>
</div> </div>
@ -102,7 +113,7 @@ function TrackList({
placeholderRows?: number placeholderRows?: number
}) { }) {
const { trackID, state } = useSnapshot(player) const { trackID, state } = useSnapshot(player)
const playingTrackIndex = tracks?.findIndex(track => track.id === trackID) ?? -1 const playingTrack = tracks?.find(track => track.id === trackID)
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => { const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
if (isLoading) return if (isLoading) return
@ -129,7 +140,7 @@ function TrackList({
key={track.id} key={track.id}
track={track} track={track}
index={index} index={index}
playingTrackIndex={playingTrackIndex} playingTrackID={playingTrack?.id || 0}
state={state} state={state}
handleClick={handleClick} handleClick={handleClick}
/> />

View File

@ -13,7 +13,7 @@ function Player() {
} }
function FindTrackOnYouTube() { function FindTrackOnYouTube() {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const { enableFindTrackOnYouTube, httpProxyForYouTube } = useSnapshot(settings) const { enableFindTrackOnYouTube, httpProxyForYouTube } = useSnapshot(settings)
@ -21,12 +21,18 @@ function FindTrackOnYouTube() {
<div> <div>
<BlockTitle>{t`settings.player-youtube-unlock`}</BlockTitle> <BlockTitle>{t`settings.player-youtube-unlock`}</BlockTitle>
<BlockDescription> <BlockDescription>
Find alternative track on YouTube if not available on NetEase. {t`settings.player-find-alternative-track-on-youtube-if-not-available-on-netease`}
{i18n.language === 'zh-CN' && (
<>
<br />
Clash for Windows TUN Mode ClashX Pro
</>
)}
</BlockDescription> </BlockDescription>
{/* Switch */} {/* Switch */}
<Option> <Option>
<OptionText>Enable YouTube Unlock </OptionText> <OptionText>Enable YouTube Unlock</OptionText>
<Switch <Switch
enabled={enableFindTrackOnYouTube} enabled={enableFindTrackOnYouTube}
onChange={value => (settings.enableFindTrackOnYouTube = value)} onChange={value => (settings.enableFindTrackOnYouTube = value)}

View File

@ -1,7 +1,7 @@
import useUser from '@/web/api/hooks/useUser' import useUser from '@/web/api/hooks/useUser'
import Appearance from './Appearance' import Appearance from './Appearance'
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import { useState } from 'react' import { useEffect, useState } from 'react'
import UserCard from './UserCard' import UserCard from './UserCard'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { motion, useAnimationControls } from 'framer-motion' import { motion, useAnimationControls } from 'framer-motion'
@ -10,7 +10,7 @@ import Player from './Player'
import PageTransition from '@/web/components/PageTransition' import PageTransition from '@/web/components/PageTransition'
import { ease } from '@/web/utils/const' import { ease } from '@/web/utils/const'
export const categoryIds = ['general', 'appearance', 'player', 'lyrics', 'lab'] as const export const categoryIds = ['general', 'appearance', 'player', 'lab', 'about'] as const
export type Category = typeof categoryIds[number] export type Category = typeof categoryIds[number]
const Sidebar = ({ const Sidebar = ({
@ -25,22 +25,22 @@ const Sidebar = ({
{ name: t`settings.general`, id: 'general' }, { name: t`settings.general`, id: 'general' },
{ name: t`settings.appearance`, id: 'appearance' }, { name: t`settings.appearance`, id: 'appearance' },
{ name: t`settings.player`, id: 'player' }, { name: t`settings.player`, id: 'player' },
{ name: t`settings.lyrics`, id: 'lyrics' },
{ name: t`settings.lab`, id: 'lab' }, { name: t`settings.lab`, id: 'lab' },
{ name: t`settings.about`, id: 'about' },
] ]
const animation = useAnimationControls()
const onClick = (categoryId: Category) => { // Indicator animation
setActiveCategory(categoryId) const indicatorAnimation = useAnimationControls()
const index = categories.findIndex(category => category.id === categoryId) useEffect(() => {
animation.start({ y: index * 40 + 11.5 }) const index = categories.findIndex(category => category.id === activeCategory)
} indicatorAnimation.start({ y: index * 40 + 11.5 })
}, [activeCategory])
return ( return (
<div className='relative'> <div className='relative'>
<motion.div <motion.div
initial={{ y: 11.5 }} initial={{ y: 11.5 }}
animate={animation} animate={indicatorAnimation}
transition={{ type: 'spring', duration: 0.6, bounce: 0.36 }} transition={{ type: 'spring', duration: 0.6, bounce: 0.36 }}
className='absolute top-0 left-3 mr-2 h-4 w-1 rounded-full bg-brand-700' className='absolute top-0 left-3 mr-2 h-4 w-1 rounded-full bg-brand-700'
></motion.div> ></motion.div>
@ -48,7 +48,7 @@ const Sidebar = ({
{categories.map(category => ( {categories.map(category => (
<motion.div <motion.div
key={category.id} key={category.id}
onClick={() => onClick(category.id)} onClick={() => setActiveCategory(category.id)}
initial={{ x: activeCategory === category.id ? 12 : 0 }} initial={{ x: activeCategory === category.id ? 12 : 0 }}
animate={{ x: activeCategory === category.id ? 12 : 0 }} animate={{ x: activeCategory === category.id ? 12 : 0 }}
className={cx( className={cx(
@ -71,8 +71,8 @@ const Settings = () => {
{ id: 'general', component: <General /> }, { id: 'general', component: <General /> },
{ id: 'appearance', component: <Appearance /> }, { id: 'appearance', component: <Appearance /> },
{ id: 'player', component: <Player /> }, { id: 'player', component: <Player /> },
{ id: 'lyrics', component: <span className='text-white'></span> },
{ id: 'lab', component: <span className='text-white'></span> }, { id: 'lab', component: <span className='text-white'></span> },
{ id: 'about', component: <span className='text-white'></span> },
] ]
return ( return (

View File

@ -20,10 +20,7 @@ const replaceBrandColorWithCSSVar = () => {
if (decl?.parent?.selector?.includes('-blue-')) { if (decl?.parent?.selector?.includes('-blue-')) {
return return
} }
value = value.replace( value = value.replace(`rgb(${blue.rgb}`, `hsl(var(--brand-color-${blue.key})`)
`rgb(${blue.rgb}`,
`hsl(var(--brand-color-${blue.key})`
)
}) })
// if (decl.value !== value) { // if (decl.value !== value) {
// console.log({ // console.log({

View File

@ -11,9 +11,7 @@ class ScrollPositions {
const nestedPath = `/${pathname.split('/')[1]}` const nestedPath = `/${pathname.split('/')[1]}`
const restPath = pathname.split('/').slice(2).join('/') const restPath = pathname.split('/').slice(2).join('/')
if (this._nestedPaths.includes(nestedPath)) { if (this._nestedPaths.includes(nestedPath)) {
return this._positions?.[nestedPath]?.find( return this._positions?.[nestedPath]?.find(({ path }) => path === restPath)?.top
({ path }) => path === restPath
)?.top
} else { } else {
return this._generalPositions?.[pathname] return this._generalPositions?.[pathname]
} }
@ -30,14 +28,10 @@ class ScrollPositions {
} }
// set nested position // set nested position
const existsPath = this._positions[nestedPath].find( const existsPath = this._positions[nestedPath].find(p => p.path === restPath)
p => p.path === restPath
)
if (existsPath) { if (existsPath) {
existsPath.top = top existsPath.top = top
this._positions[nestedPath] = this._positions[nestedPath].filter( this._positions[nestedPath] = this._positions[nestedPath].filter(p => p.path !== restPath)
p => p.path !== restPath
)
this._positions[nestedPath].push(existsPath) this._positions[nestedPath].push(existsPath)
} else { } else {
this._positions[nestedPath].push({ path: restPath, top }) this._positions[nestedPath].push({ path: restPath, top })

View File

@ -118,10 +118,8 @@ module.exports = {
xl: '12px', xl: '12px',
'2xl': '20px', '2xl': '20px',
'3xl': '45px', '3xl': '45px',
} },
}, },
}, },
plugins: [ plugins: [require('@tailwindcss/container-queries')],
require('@tailwindcss/container-queries'),
],
} }

View File

@ -58,142 +58,142 @@ test('formatDuration', () => {
expect(formatDuration(0, 'zh-CN', 'hh[hr] mm[min]')).toBe('0 分钟') expect(formatDuration(0, 'zh-CN', 'hh[hr] mm[min]')).toBe('0 分钟')
}) })
describe('cacheCoverColor', () => { // describe('cacheCoverColor', () => {
test('cache with valid url', () => { // test('cache with valid url', () => {
vi.stubGlobal('ipcRenderer', { // vi.stubGlobal('ipcRenderer', {
send: (channel: IpcChannels, ...args: any[]) => { // send: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.CacheCoverColor) // expect(channel).toBe(IpcChannels.CacheCoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor) // expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ // expect(args[0].query).toEqual({
id: '109951165911363', // id: '109951165911363',
color: '#fff', // color: '#fff',
}) // })
}, // },
}) // })
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send') // const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
cacheCoverColor( // cacheCoverColor(
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256', // 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256',
'#fff' // '#fff'
) // )
expect(sendSpy).toHaveBeenCalledTimes(1) // expect(sendSpy).toHaveBeenCalledTimes(1)
vi.stubGlobal('ipcRenderer', undefined) // vi.stubGlobal('ipcRenderer', undefined)
}) // })
test('cache with invalid url', () => { // test('cache with invalid url', () => {
vi.stubGlobal('ipcRenderer', { // vi.stubGlobal('ipcRenderer', {
send: (channel: IpcChannels, ...args: any[]) => { // send: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.CacheCoverColor) // expect(channel).toBe(IpcChannels.CacheCoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor) // expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ // expect(args[0].query).toEqual({
id: '', // id: '',
color: '#fff', // color: '#fff',
}) // })
}, // },
}) // })
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send') // const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
cacheCoverColor('not a valid url', '#fff') // cacheCoverColor('not a valid url', '#fff')
expect(sendSpy).toHaveBeenCalledTimes(0) // expect(sendSpy).toHaveBeenCalledTimes(0)
vi.stubGlobal('ipcRenderer', undefined) // vi.stubGlobal('ipcRenderer', undefined)
}) // })
}) // })
test('calcCoverColor', async () => { // test('calcCoverColor', async () => {
vi.mock('color.js', () => { // vi.mock('color.js', () => {
return { // return {
average: vi.fn( // average: vi.fn(
() => // () =>
new Promise(resolve => { // new Promise(resolve => {
resolve('#fff') // resolve('#fff')
}) // })
), // ),
} // }
}) // })
vi.stubGlobal('ipcRenderer', { // vi.stubGlobal('ipcRenderer', {
send: (channel: IpcChannels, ...args: any[]) => { // send: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.CacheCoverColor) // expect(channel).toBe(IpcChannels.CacheCoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor) // expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ // expect(args[0].query).toEqual({
id: '109951165911363', // id: '109951165911363',
color: '#808080', // color: '#808080',
}) // })
}, // },
}) // })
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send') // const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
expect( // expect(
await calcCoverColor( // await calcCoverColor(
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256' // 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
) // )
).toBe('#808080') // ).toBe('#808080')
vi.stubGlobal('ipcRenderer', undefined) // vi.stubGlobal('ipcRenderer', undefined)
}) // })
describe('getCoverColor', () => { // describe('getCoverColor', () => {
test('hit cache', async () => { // test('hit cache', async () => {
vi.stubGlobal('ipcRenderer', { // vi.stubGlobal('ipcRenderer', {
sendSync: (channel: IpcChannels, ...args: any[]) => { // sendSync: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.GetApiCache) // expect(channel).toBe(IpcChannels.GetApiCache)
expect(args[0].api).toBe(CacheAPIs.CoverColor) // expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ // expect(args[0].query).toEqual({
id: '109951165911363', // id: '109951165911363',
}) // })
return '#fff' // return '#fff'
}, // },
}) // })
const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync') // const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync')
expect( // expect(
await getCoverColor( // await getCoverColor(
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256' // 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
) // )
).toBe('#fff') // ).toBe('#fff')
expect(sendSyncSpy).toHaveBeenCalledTimes(1) // expect(sendSyncSpy).toHaveBeenCalledTimes(1)
vi.stubGlobal('ipcRenderer', undefined) // vi.stubGlobal('ipcRenderer', undefined)
}) // })
test('did not hit cache', async () => { // test('did not hit cache', async () => {
vi.stubGlobal('ipcRenderer', { // vi.stubGlobal('ipcRenderer', {
sendSync: (channel: IpcChannels, ...args: any[]) => { // sendSync: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.GetApiCache) // expect(channel).toBe(IpcChannels.GetApiCache)
expect(args[0].api).toBe(CacheAPIs.CoverColor) // expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ // expect(args[0].query).toEqual({
id: '109951165911363', // id: '109951165911363',
}) // })
return undefined // return undefined
}, // },
send: () => { // send: () => {
// // //
}, // },
}) // })
const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync') // const sendSyncSpy = vi.spyOn(window.ipcRenderer as any, 'sendSync')
const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send') // const sendSpy = vi.spyOn(window.ipcRenderer as any, 'send')
expect( // expect(
await getCoverColor( // await getCoverColor(
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256' // 'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363.jpg?param=256y256'
) // )
).toBe('#808080') // ).toBe('#808080')
expect(sendSyncSpy).toHaveBeenCalledTimes(1) // expect(sendSyncSpy).toHaveBeenCalledTimes(1)
expect(sendSpy).toHaveBeenCalledTimes(1) // expect(sendSpy).toHaveBeenCalledTimes(1)
vi.stubGlobal('ipcRenderer', undefined) // vi.stubGlobal('ipcRenderer', undefined)
}) // })
test('invalid url', async () => { // test('invalid url', async () => {
expect(await getCoverColor('not a valid url')).toBe(undefined) // expect(await getCoverColor('not a valid url')).toBe(undefined)
}) // })
}) // })
test('storage', () => { test('storage', () => {
const mockLocalStorage: any = { const mockLocalStorage: any = {

View File

@ -24,11 +24,7 @@ describe('parseCookies', () => {
}) })
test('parse cookies with empty value, expires and double semicolon', () => { test('parse cookies with empty value, expires and double semicolon', () => {
expect( expect(parseCookies('test=; test2=test2; Expires=Wed, 21 Oct 2015 07:28:00 GMT;;')).toEqual([
parseCookies(
'test=; test2=test2; Expires=Wed, 21 Oct 2015 07:28:00 GMT;;'
)
).toEqual([
{ {
key: 'test', key: 'test',
value: '', value: '',
@ -153,9 +149,7 @@ describe('setCookies', () => {
) )
expect(Cookies.get('__remember_me')).toBe('true') expect(Cookies.get('__remember_me')).toBe('true')
expect(Cookies.get('__csrf')).toBe('78328f711c179391b096a67ad9d0f08b') expect(Cookies.get('__csrf')).toBe('78328f711c179391b096a67ad9d0f08b')
expect(Cookies.get('NMTID')).toBe( expect(Cookies.get('NMTID')).toBe('00OEhPKoaEluGvd9kgutai-iADpQkEAAAGAJz_PTQ')
'00OEhPKoaEluGvd9kgutai-iADpQkEAAAGAJz_PTQ'
)
expect(Cookies.get('MUSIC_R_T')).toBe(undefined) // because of path is not / expect(Cookies.get('MUSIC_R_T')).toBe(undefined) // because of path is not /
expect(Cookies.get('MUSIC_A_T')).toBe(undefined) // because of path is not / expect(Cookies.get('MUSIC_A_T')).toBe(undefined) // because of path is not /
}) })

View File

@ -168,7 +168,7 @@ export async function calcCoverColor(coverUrl: string) {
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
export const isPWA = export const isPWA =
(navigator as any).standalone || window.matchMedia('(display-mode: standalone)').matches () => (navigator as any).standalone || window.matchMedia('(display-mode: standalone)').matches
export const isIosPwa = isIOS && isPWA && isSafari export const isIosPwa = isIOS && isPWA() && isSafari
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

View File

@ -12,8 +12,7 @@ export function lyricParser(lrc: FetchLyricResponse) {
/** /**
* @see {@link https://regexr.com/6e52n} * @see {@link https://regexr.com/6e52n}
*/ */
const extractLrcRegex = const extractLrcRegex = /^(?<lyricTimestamps>(?:\[.+?\])+)(?!\[)(?<content>.+)$/gm
/^(?<lyricTimestamps>(?:\[.+?\])+)(?!\[)(?<content>.+)$/gm
const extractTimestampRegex = /\[(?<min>\d+):(?<sec>\d+)(?:\.|:)*(?<ms>\d+)*\]/g const extractTimestampRegex = /\[(?<min>\d+):(?<sec>\d+)(?:\.|:)*(?<ms>\d+)*\]/g
interface ParsedLyric { interface ParsedLyric {

View File

@ -1,8 +1,7 @@
export const changeTheme = (theme: 'light' | 'dark') => { export const changeTheme = (theme: 'light' | 'dark') => {
document.body.setAttribute('class', theme) document.body.setAttribute('class', theme)
if (!window.env?.isElectron) { if (!window.env?.isElectron) {
document.documentElement.style.background = document.documentElement.style.background = theme === 'dark' ? '#000' : '#fff'
theme === 'dark' ? '#000' : '#fff'
} }
} }

View File

@ -11,6 +11,8 @@ import { appName } from './utils/const'
dotenv.config({ path: join(__dirname, '../../.env') }) dotenv.config({ path: join(__dirname, '../../.env') })
const IS_ELECTRON = process.env.IS_ELECTRON const IS_ELECTRON = process.env.IS_ELECTRON
const ELECTRON_DEV_NETEASE_API_PORT = Number(process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001)
const ELECTRON_WEB_SERVER_PORT = Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710)
/** /**
* @see https://vitejs.dev/config/ * @see https://vitejs.dev/config/
@ -23,6 +25,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': join(__dirname, '..'), '@': join(__dirname, '..'),
'hls.js': 'hls.js/dist/hls.min.js',
}, },
}, },
plugins: [ plugins: [
@ -37,33 +40,35 @@ export default defineConfig({
/** /**
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html * @see https://vite-plugin-pwa.netlify.app/guide/generate.html
*/ */
VitePWA({ IS_ELECTRON
registerType: 'autoUpdate', ? VitePWA({
manifest: { registerType: 'autoUpdate',
name: appName, manifest: {
short_name: appName, name: appName,
description: 'Description of your app', short_name: appName,
theme_color: '#000', description: 'Description of your app',
icons: [ theme_color: '#000',
{ icons: [
src: 'pwa-192x192.png', {
sizes: '192x192', src: 'pwa-192x192.png',
type: 'image/png', sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
}, },
{ })
src: 'pwa-512x512.png', : undefined,
sizes: '512x512',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
},
}),
/** /**
* @see https://github.com/vbenjs/vite-plugin-svg-icons * @see https://github.com/vbenjs/vite-plugin-svg-icons
@ -90,22 +95,22 @@ export default defineConfig({
}, },
}, },
server: { server: {
port: Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710), port: ELECTRON_WEB_SERVER_PORT,
strictPort: IS_ELECTRON ? true : false, strictPort: IS_ELECTRON ? true : false,
proxy: { proxy: {
'/netease/': { '/netease/': {
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`, target: `http://127.0.0.1:${ELECTRON_DEV_NETEASE_API_PORT}`,
changeOrigin: true, changeOrigin: true,
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')), rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
}, },
'/r3play/': { '/r3play/': {
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`, target: `http://127.0.0.1:${ELECTRON_DEV_NETEASE_API_PORT}`,
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
preview: { preview: {
port: Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710), port: ELECTRON_WEB_SERVER_PORT,
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',

View File

@ -10,21 +10,13 @@ export type Conversion = {
const filenamesToType = (conversions: Conversion[]): Plugin => { const filenamesToType = (conversions: Conversion[]): Plugin => {
const generateTypes = async (conversion: Conversion) => { const generateTypes = async (conversion: Conversion) => {
const filenames = await fs.readdir(conversion.dictionary).catch(reason => { const filenames = await fs.readdir(conversion.dictionary).catch(reason => {
console.error( console.error('vite-plugin-filenames-to-type: unable to read directory. ', reason)
'vite-plugin-filenames-to-type: unable to read directory. ',
reason
)
return [] return []
}) })
if (!filenames.length) return if (!filenames.length) return
const iconNames = filenames.map( const iconNames = filenames.map(fileName => `'${fileName.replace(/\.[^.]+$/, '')}'`)
fileName => `'${fileName.replace(/\.[^.]+$/, '')}'` await fs.writeFile(conversion.typeFile, `export type IconNames = ${iconNames.join(' | ')}`)
)
await fs.writeFile(
conversion.typeFile,
`export type IconNames = ${iconNames.join(' | ')}`
)
} }
const findConversion = (filePath: string) => { const findConversion = (filePath: string) => {

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,11 @@
"rewrites": [ "rewrites": [
{ {
"source": "/netease/:match*", "source": "/netease/:match*",
"destination": "http://168.138.174.244:30001/:match*" "destination": "http://129.150.45.86:30001/:match*"
}, },
{ {
"source": "/r3play/:match*", "source": "/r3play/:match*",
"destination": "http://168.138.174.244:35530/:match*" "destination": "http://129.150.45.86:35530/:match*"
}, },
{ {
"source": "/(.*)", "source": "/(.*)",