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.html
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 页面的链接。
## Credit
Designed by [JACKCRING](https://jackcring.com)
<!-- ## 🖼️ 截图 -->
<!-- ![lyrics][lyrics-screenshot]

View File

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

View File

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

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 fastifyMultipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import fastify from 'fastify'
import path from 'path'
import { isProd } from '../env'
import log from '../log'
import netease from './routes/netease/netease'
import appleMusic from './routes/r3play/appleMusic'
import audio from './routes/r3play/audio'
log.info('[electron] appServer/appServer.ts')
const initAppServer = async () => {
const server = fastify({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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'>
<p>Something went wrong:</p>
<pre className='mb-2 text-18 dark:text-white'>
{error.toString()}
</pre>
<pre className='mb-2 text-18 dark:text-white'>{error.toString()}</pre>
<div className='max-h-72 max-w-2xl overflow-scroll whitespace-pre-line rounded-12 border border-white/10 px-3 py-2 dark:text-white/50'>
{componentStack?.trim()}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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