feat: updates

This commit is contained in:
qier222 2023-03-03 03:12:27 +08:00
parent 9a52681687
commit 840a5b8e9b
No known key found for this signature in database
104 changed files with 1645 additions and 13494 deletions

1
.gitignore vendored
View file

@ -57,3 +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

View file

@ -13,6 +13,7 @@
}, },
"packageManager": "pnpm@7.20.0", "packageManager": "pnpm@7.20.0",
"scripts": { "scripts": {
"postinstall": "turbo run post-install --parallel --no-cache",
"install": "turbo run post-install --parallel --no-cache", "install": "turbo run post-install --parallel --no-cache",
"build": "cross-env-shell IS_ELECTRON=yes turbo run build", "build": "cross-env-shell IS_ELECTRON=yes turbo run build",
"build:web": "turbo run build:web", "build:web": "turbo run build:web",
@ -20,16 +21,14 @@
"pack:test": "turbo run build && turbo run pack:test", "pack:test": "turbo run build && turbo run pack:test",
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel", "dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\"", "format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\""
"storybook": "pnpm -F web storybook",
"storybook:build": "pnpm -F web storybook:build"
}, },
"devDependencies": { "devDependencies": {
"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.6.3",
"typescript": "^4.9.4", "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

@ -18,7 +18,6 @@ module.exports = {
npmRebuild: false, npmRebuild: false,
buildDependenciesFromSource: false, buildDependenciesFromSource: false,
electronVersion, electronVersion,
afterPack: './scripts/copySQLite3.js',
forceCodeSigning: false, forceCodeSigning: false,
publish: [ publish: [
{ {

View file

@ -1,9 +1,10 @@
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'
async function appleMusic(fastify: FastifyInstance) { async function appleMusic(fastify: FastifyInstance) {
fastify.register(proxy, { fastify.register(proxy, {
upstream: 'http://168.138.174.244:35530/', upstream: isDev ? 'http://127.0.0.1:35530/' : 'http://168.138.174.244:35530/',
prefix: '/r3play/apple-music', prefix: '/r3play/apple-music',
rewritePrefix: '/apple-music', rewritePrefix: '/apple-music',
}) })

View file

@ -9,6 +9,7 @@ import fs from 'fs'
import youtube from '@/desktop/main/youtube' import youtube from '@/desktop/main/youtube'
import { CacheAPIs } from '@/shared/CacheAPIs' import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchTracksResponse } from '@/shared/api/Track' import { FetchTracksResponse } from '@/shared/api/Track'
import store from '@/desktop/main/store'
const getAudioFromCache = async (id: number) => { const getAudioFromCache = async (id: number) => {
// get from cache // get from cache
@ -76,6 +77,7 @@ const getAudioFromYouTube = async (id: number) => {
const track = fetchTrackResult?.songs?.[0] const track = fetchTrackResult?.songs?.[0]
if (!track) return if (!track) return
try {
const data = await youtube.matchTrack(track.ar[0].name, track.name) const data = await youtube.matchTrack(track.ar[0].name, track.name)
if (!data) return if (!data) return
return { return {
@ -118,6 +120,9 @@ const getAudioFromYouTube = async (id: number) => {
], ],
code: 200, code: 200,
} }
} catch (e) {
log.error('getAudioFromYouTube error', id, e)
}
} }
async function audio(fastify: FastifyInstance) { async function audio(fastify: FastifyInstance) {
@ -154,10 +159,12 @@ async function audio(fastify: FastifyInstance) {
return return
} }
if (store.get('settings.enableFindTrackOnYouTube')) {
const fromYoutube = getAudioFromYouTube(id) const fromYoutube = getAudioFromYouTube(id)
if (fromYoutube) { if (fromYoutube) {
return fromYoutube return fromYoutube
} }
}
// 是试听歌曲就把url删掉 // 是试听歌曲就把url删掉
if (fromNetease?.data?.[0].freeTrialInfo) { if (fromNetease?.data?.[0].freeTrialInfo) {
@ -181,11 +188,14 @@ async function audio(fastify: FastifyInstance) {
fastify.post( fastify.post(
`/${appName.toLowerCase()}/audio/:id`, `/${appName.toLowerCase()}/audio/:id`,
async ( async (
req: FastifyRequest<{ Params: { id: string }; Querystring: { url: string } }>, req: FastifyRequest<{
Params: { id: string }
Querystring: { url: string; bitrate: number }
}>,
reply reply
) => { ) => {
const id = Number(req.params.id) const id = Number(req.params.id)
const { url } = req.query const { url, bitrate } = req.query
if (isNaN(id)) { if (isNaN(id)) {
return reply.status(400).send({ error: 'Invalid param id' }) return reply.status(400).send({ error: 'Invalid param id' })
} }
@ -200,7 +210,7 @@ async function audio(fastify: FastifyInstance) {
} }
try { try {
await cache.setAudio(await data.toBuffer(), { id, url }) await cache.setAudio(await data.toBuffer(), { id, url, bitrate })
reply.status(200).send('Audio cached!') reply.status(200).send('Audio cached!')
} catch (error) { } catch (error) {
reply.status(500).send({ error }) reply.status(500).send({ error })

View file

@ -238,7 +238,7 @@ class Cache {
return return
} }
getAudio(filename: string, reply: FastifyReply) { async getAudio(filename: string, reply: FastifyReply) {
if (!filename) { if (!filename) {
return reply.status(400).send({ error: 'No filename provided' }) return reply.status(400).send({ error: 'No filename provided' })
} }
@ -252,6 +252,7 @@ class Cache {
fs.unlinkSync(path) fs.unlinkSync(path)
return reply.status(404).send({ error: 'Audio not found' }) return reply.status(404).send({ error: 'Audio not found' })
} }
await prisma.audio.update({ where: { id }, data: { updatedAt: new Date() } })
reply reply
.status(206) .status(206)
.header('Accept-Ranges', 'bytes') .header('Accept-Ranges', 'bytes')
@ -263,7 +264,10 @@ class Cache {
} }
} }
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) { async setAudio(
buffer: Buffer,
{ id, url, bitrate }: { id: number; url: string; bitrate: number }
) {
const path = `${app.getPath('userData')}/audio_cache` const path = `${app.getPath('userData')}/audio_cache`
try { try {
@ -273,7 +277,7 @@ class Cache {
} }
const meta = await musicMetadata.parseBuffer(buffer) const meta = await musicMetadata.parseBuffer(buffer)
const bitRate = (meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0) / 1000 const bitRate = ~~((meta.format.bitrate || bitrate || 0) / 1000)
const format = const format =
{ {
'MPEG 1 Layer 3': 'mp3', 'MPEG 1 Layer 3': 'mp3',

View file

@ -12,6 +12,7 @@ import { Thumbar } from './windowsTaskbar'
import fastFolderSize from 'fast-folder-size' import fastFolderSize from 'fast-folder-size'
import path from 'path' import path from 'path'
import prettyBytes from 'pretty-bytes' import prettyBytes from 'pretty-bytes'
import prisma from './prisma'
const on = <T extends keyof IpcChannelsParams>( const on = <T extends keyof IpcChannelsParams>(
channel: T, channel: T,
@ -203,7 +204,7 @@ function initOtherIpcMain() {
* 退 * 退
*/ */
handle(IpcChannels.Logout, async () => { handle(IpcChannels.Logout, async () => {
// db.truncate(Tables.AccountData) await prisma.accountData.deleteMany({})
return true return true
}) })

View file

@ -1,25 +1,152 @@
import log from './log' import log from './log'
import youtube, { Scraper, Video } from '@yimura/scraper'
import ytdl from 'ytdl-core' import ytdl from 'ytdl-core'
import axios, { AxiosProxyConfig } from 'axios'
import store from './store'
import httpProxyAgent from 'http-proxy-agent'
class YoutubeDownloader { class YoutubeDownloader {
yt: Scraper
constructor() { constructor() {
// @ts-ignore //
this.yt = new youtube.default()
} }
async search(keyword: string) { async search(keyword: string): Promise<
const result = await this.yt.search(keyword) {
return result?.videos duration: number
id: string
title: string
}[]
> {
let proxy: AxiosProxyConfig | false = false
if (store.get('settings.httpProxyForYouTube')) {
const host = store.get('settings.httpProxyForYouTube.host') as string | undefined
const port = store.get('settings.httpProxyForYouTube.port') as number | undefined
const auth = store.get('settings.httpProxyForYouTube.auth') as any | undefined
const protocol = store.get('settings.httpProxyForYouTube.protocol') as string | undefined
if (host && port) {
proxy = { host, port, auth, protocol }
}
}
// proxy = { host: '127.0.0.1', port: 8888, protocol: 'http' }
const webPage = await axios.get(`https://www.youtube.com/results`, {
params: {
search_query: keyword,
sp: 'EgIQAQ==',
},
headers: { 'Accept-Language': 'en-US' },
timeout: 5000,
proxy,
})
if (webPage.status !== 200) {
return []
} }
async matchTrack(artist: string, trackName: string) { // @credit https://www.npmjs.com/package/@yimura/scraper
function _parseData(data) {
const results = {
channels: [],
playlists: [],
streams: [],
videos: [],
}
const isVideo = item => item.videoRenderer && item.videoRenderer.lengthText
const getVideoData = item => {
const vRender = item.videoRenderer
const compress = key => {
return (key && key['runs'] ? key['runs'].map(v => v.text) : []).join('')
}
const parseDuration = vRender => {
if (!vRender.lengthText?.simpleText) return 0
const nums = vRender.lengthText.simpleText.split(':')
let time = nums.reduce((a, t) => 60 * a + +t) * 1e3
return time
}
return {
duration: parseDuration(vRender),
id: vRender.videoId,
title: compress(vRender.title),
}
}
for (const item of data) {
if (isVideo(item)) results.videos.push(getVideoData(item))
}
return results
}
function _extractData(json) {
json = json.contents.twoColumnSearchResultsRenderer.primaryContents
let contents = []
if (json.sectionListRenderer) {
contents = json.sectionListRenderer.contents
.filter(item =>
item?.itemSectionRenderer?.contents.filter(
x => x.videoRenderer || x.playlistRenderer || x.channelRenderer
)
)
.shift().itemSectionRenderer.contents
}
if (json.richGridRenderer) {
contents = json.richGridRenderer.contents
.filter(item => item.richItemRenderer && item.richItemRenderer.content)
.map(item => item.richItemRenderer.content)
}
return contents
}
function _getSearchData(webPage: string) {
const startString = 'var ytInitialData = '
const start = webPage.indexOf(startString)
const end = webPage.indexOf(';</script>', start)
const data = webPage.substring(start + startString.length, end)
try {
return JSON.parse(data)
} catch (e) {
throw new Error(
'Failed to parse YouTube search data. YouTube might have updated their site or no results returned.'
)
}
}
const parsedJson = _getSearchData(webPage.data)
const extracted = _extractData(parsedJson)
const parsed = _parseData(extracted)
return parsed?.videos
}
async matchTrack(
artist: string,
trackName: string
): Promise<{
url: string
bitRate: number
title: string
videoId: string
duration: string
channel: string
}> {
const match = async () => {
console.time('[youtube] search') console.time('[youtube] search')
const videos = await this.search(`${artist} ${trackName} lyric audio`) const videos = await this.search(`${artist} ${trackName} audio`)
console.timeEnd('[youtube] search') console.timeEnd('[youtube] search')
let video: Video | null = null let video: {
duration: number
id: string
title: string
} | null = null
// 找官方频道最匹配的 // 找官方频道最匹配的
// videos.forEach(v => { // videos.forEach(v => {
@ -38,10 +165,16 @@ class YoutubeDownloader {
if (!video) { if (!video) {
video = videos[0] video = videos[0]
} }
if (!video) return null
console.time('[youtube] getInfo') console.time('[youtube] getInfo')
const info = await ytdl.getInfo('http://www.youtube.com/watch?v=' + video.id) const proxy = 'http://127.0.0.1:8888'
const agent = httpProxyAgent(proxy)
const info = await ytdl.getInfo(video.id, {
// requestOptions: { agent },
})
console.timeEnd('[youtube] getInfo') console.timeEnd('[youtube] getInfo')
if (!info) return null
let url = '' let url = ''
let bitRate = 0 let bitRate = 0
info.formats.forEach(video => { info.formats.forEach(video => {
@ -65,6 +198,22 @@ class YoutubeDownloader {
log.info(`[youtube] matched `, data) log.info(`[youtube] matched `, data)
return data return data
} }
return new Promise(async (resolve, reject) => {
setTimeout(() => reject('youtube match timeout'), 10000)
try {
const result = await match()
if (result) resolve(result)
} catch (e) {
log.error(`[youtube] matchTrack error`, e)
reject(e)
}
})
}
async testConnection() {
return axios.get('https://www.youtube.com', { timeout: 5000 })
}
} }
const youtubeDownloader = new YoutubeDownloader() const youtubeDownloader = new YoutubeDownloader()

View file

@ -52,7 +52,6 @@ CREATE TABLE IF NOT EXISTS "Audio" (
"source" TEXT NOT NULL, "source" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL,
"queriedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS "Lyrics" ( CREATE TABLE IF NOT EXISTS "Lyrics" (

View file

@ -19,7 +19,7 @@
"prisma:db-push": "prisma db push" "prisma:db-push": "prisma db push"
}, },
"engines": { "engines": {
"node": "^14.13.1 || >=16.0.0" "node": ">=16.0.0"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^8.3.0", "@fastify/cookie": "^8.3.0",
@ -37,6 +37,7 @@
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"fast-folder-size": "^1.7.1", "fast-folder-size": "^1.7.1",
"fastify": "^4.5.3", "fastify": "^4.5.3",
"http-proxy-agent": "^5.0.0",
"pretty-bytes": "^6.0.0", "pretty-bytes": "^6.0.0",
"prisma": "^4.8.1", "prisma": "^4.8.1",
"ytdl-core": "^4.11.2" "ytdl-core": "^4.11.2"

View file

@ -62,7 +62,6 @@ model Audio {
source String source String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
queriedAt DateTime @default(now())
} }
model Lyrics { model Lyrics {

View file

@ -35,7 +35,6 @@ const options = {
...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)), ...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)),
'electron', 'electron',
'NeteaseCloudMusicApi', 'NeteaseCloudMusicApi',
'better-sqlite3',
], ],
} }

View file

@ -4,6 +4,7 @@
"description": "This project was bootstrapped with Fastify-CLI.", "description": "This project was bootstrapped with Fastify-CLI.",
"main": "app.ts", "main": "app.ts",
"scripts": { "scripts": {
"postinstall": "prisma generate",
"start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js", "start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js",
"build": "tsc", "build": "tsc",
"watch": "tsc -w", "watch": "tsc -w",

View file

@ -23,9 +23,10 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
Querystring: { Querystring: {
neteaseId: string neteaseId: string
lang?: 'zh-CN' | 'en-US' lang?: 'zh-CN' | 'en-US'
noCache?: boolean
} }
}>('/album', opts, async function (request, reply): Promise<ResponseSchema | undefined> { }>('/album', opts, async function (request, reply): Promise<ResponseSchema | undefined> {
const { neteaseId: neteaseIdString, lang = 'en-US' } = request.query const { neteaseId: neteaseIdString, lang = 'en-US', noCache = false } = request.query
// validate neteaseAlbumID // validate neteaseAlbumID
const neteaseId = Number(neteaseIdString) const neteaseId = Number(neteaseIdString)
@ -35,6 +36,7 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
} }
// get from database // get from database
if (!noCache) {
const fromDB = await fastify.prisma.album.findFirst({ const fromDB = await fastify.prisma.album.findFirst({
where: { neteaseId: neteaseId }, where: { neteaseId: neteaseId },
include: { editorialNote: { select: { en_US: true, zh_CN: true } } }, include: { editorialNote: { select: { en_US: true, zh_CN: true } } },
@ -42,6 +44,7 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
if (fromDB) { if (fromDB) {
return fromDB as ResponseSchema return fromDB as ResponseSchema
} }
}
// get from netease // get from netease
const { body: neteaseAlbum } = (await getAlbum({ id: neteaseId })) as any const { body: neteaseAlbum } = (await getAlbum({ id: neteaseId })) as any
@ -106,11 +109,10 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
neteaseName: albumName, neteaseName: albumName,
neteaseArtistName: artist, neteaseArtistName: artist,
} }
reply.send(data)
// save to database // save to database
await fastify.prisma.album if (!noCache) {
.create({ await fastify.prisma.album.create({
data: { data: {
...data, ...data,
editorialNote: { editorialNote: {
@ -121,9 +123,9 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
}, },
}, },
}) })
.catch(e => console.error(e)) }
return return data
}) })
} }

View file

@ -19,9 +19,10 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
Querystring: { Querystring: {
neteaseId: string neteaseId: string
lang?: 'zh-CN' | 'en-US' lang?: 'zh-CN' | 'en-US'
noCache?: boolean
} }
}>('/artist', async function (request, reply): Promise<ResponseSchema | undefined> { }>('/artist', async function (request, reply): Promise<ResponseSchema | undefined> {
const { neteaseId: neteaseIdString, lang = 'en-US' } = request.query const { neteaseId: neteaseIdString, lang = 'en-US', noCache = false } = request.query
// validate neteaseId // validate neteaseId
const neteaseId = Number(neteaseIdString) const neteaseId = Number(neteaseIdString)
@ -31,6 +32,7 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
} }
// get from database // get from database
if (!noCache) {
const fromDB = await fastify.prisma.artist.findFirst({ const fromDB = await fastify.prisma.artist.findFirst({
where: { neteaseId: neteaseId }, where: { neteaseId: neteaseId },
include: { artistBio: { select: { en_US: true, zh_CN: true } } }, include: { artistBio: { select: { en_US: true, zh_CN: true } } },
@ -38,6 +40,7 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
if (fromDB) { if (fromDB) {
return fromDB as ResponseSchema return fromDB as ResponseSchema
} }
}
// get from netease // get from netease
const { body: neteaseArtist } = (await getArtistDetail({ id: neteaseId })) as any const { body: neteaseArtist } = (await getArtistDetail({ id: neteaseId })) as any
@ -95,11 +98,9 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
artwork: artist?.attributes?.artwork?.url, artwork: artist?.attributes?.artwork?.url,
} }
reply.send(data)
// save to database // save to database
await fastify.prisma.artist if (!noCache) {
.create({ await fastify.prisma.artist.create({
data: { data: {
...data, ...data,
artistBio: { artistBio: {
@ -110,7 +111,9 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
}, },
}, },
}) })
.catch(e => console.error(e)) }
return data
}) })
} }

View file

@ -0,0 +1,30 @@
import { FastifyPluginAsync } from 'fastify'
import appleMusicRequest from '../../utils/appleMusicRequest'
type ResponseSchema = {
status: 'OK' | 'Expired'
}
const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get('/check-token', opts, async function (request, reply): Promise<
ResponseSchema | undefined
> {
const result = await appleMusicRequest({
method: 'GET',
url: '/search',
params: {
term: `Taylor Swift evermore`,
types: 'albums',
'fields[albums]': 'artistName,artwork,name,copyright,editorialVideo,editorialNotes',
limit: '1',
l: 'en-us',
},
})
return {
status: result?.results?.album ? 'OK' : 'Expired',
}
})
}
export default album

View file

@ -14,7 +14,7 @@ import {
} from './api/User' } from './api/User'
import { FetchAudioSourceResponse, FetchLyricResponse, FetchTracksResponse } from './api/Track' import { FetchAudioSourceResponse, FetchLyricResponse, FetchTracksResponse } from './api/Track'
import { FetchPlaylistResponse, FetchRecommendedPlaylistsResponse } from './api/Playlists' import { FetchPlaylistResponse, FetchRecommendedPlaylistsResponse } from './api/Playlists'
import { AppleMusicAlbum, AppleMusicArtist } from 'AppleMusic' import { AppleMusicAlbum, AppleMusicArtist } from './AppleMusic'
export enum CacheAPIs { export enum CacheAPIs {
Album = 'album', Album = 'album',

View file

@ -1,9 +1,9 @@
interface FetchAppleMusicAlbumParams { export interface FetchAppleMusicAlbumParams {
neteaseId: number | string neteaseId: number | string
lang?: 'zh-CN' | 'en-US' lang?: 'zh-CN' | 'en-US'
} }
interface FetchAppleMusicAlbumResponse { export interface FetchAppleMusicAlbumResponse {
id: number id: number
neteaseId: number neteaseId: number
name: string name: string
@ -16,12 +16,12 @@ interface FetchAppleMusicAlbumResponse {
} }
} }
interface FetchAppleMusicArtistParams { export interface FetchAppleMusicArtistParams {
neteaseId: number | string neteaseId: number | string
lang?: 'zh-CN' | 'en-US' lang?: 'zh-CN' | 'en-US'
} }
interface FetchAppleMusicArtistResponse { export interface FetchAppleMusicArtistResponse {
id: number id: number
neteaseId: number neteaseId: number
editorialVideo: string editorialVideo: string

View file

@ -36,7 +36,6 @@ export interface NeteaseTablesStructures {
| 'qq' | 'qq'
| 'bilibili' | 'bilibili'
| 'joox' | 'joox'
queriedAt: number
} }
[NeteaseTables.Lyric]: CommonTableStructure [NeteaseTables.Lyric]: CommonTableStructure
[NeteaseTables.Playlist]: CommonTableStructure [NeteaseTables.Playlist]: CommonTableStructure

View file

@ -12,7 +12,6 @@ export interface ReplayTableStructures {
[ReplayTables.CoverColor]: { [ReplayTables.CoverColor]: {
id: number id: number
color: string color: string
queriedAt: number
} }
[ReplayTables.AppData]: { [ReplayTables.AppData]: {
value: string value: string

View file

@ -1,39 +0,0 @@
const { mergeConfig } = require('vite')
const { join } = require('path')
const { createSvgIconsPlugin } = require('vite-plugin-svg-icons')
module.exports = {
stories: [
'../components/**/*.stories.mdx',
'../components/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-postcss',
'storybook-tailwind-dark-mode',
],
framework: '@storybook/react',
core: {
builder: '@storybook/builder-vite',
},
viteFinal(config) {
return mergeConfig(config, {
plugins: [
/**
* @see https://github.com/vbenjs/vite-plugin-svg-icons
*/
createSvgIconsPlugin({
iconDirs: [join(__dirname, '../assets/icons')],
symbolId: 'icon-[name]',
}),
],
resolve: {
alias: {
'@': join(__dirname, '../../'),
},
},
})
},
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
<script>
window.global = window;
</script>

View file

@ -1,17 +0,0 @@
import 'virtual:svg-icons-register'
import '../styles/global.css'
import '../styles/accentColor.css'
import viewports from './viewports'
export const parameters = {
viewport: {
viewports,
},
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}

View file

@ -1,130 +0,0 @@
export default {
iphone5: {
name: 'iPhone 5',
styles: {
height: '568px',
width: '320px',
},
type: 'mobile',
},
iphone6: {
name: 'iPhone 6 / iPhone SE 2',
styles: {
height: '667px',
width: '375px',
},
type: 'mobile',
},
iphone8p: {
name: 'iPhone 8 Plus',
styles: {
height: '736px',
width: '414px',
},
type: 'mobile',
},
iphonex: {
name: 'iPhone X / iPhone 12 mini',
styles: {
height: '812px',
width: '375px',
},
type: 'mobile',
},
iphonexr: {
name: 'iPhone XR / iPhone XS Max',
styles: {
height: '896px',
width: '414px',
},
type: 'mobile',
},
iphone12: {
name: 'iPhone 12',
styles: {
height: '844px',
width: '390px',
},
type: 'mobile',
},
iphone12promax: {
name: 'iPhone 12 Pro Max',
styles: {
height: '926px',
width: '428px',
},
type: 'mobile',
},
ipad: {
name: 'iPad',
styles: {
height: '1024px',
width: '768px',
},
type: 'tablet',
},
ipad10p: {
name: 'iPad Pro 10.5-in',
styles: {
height: '1112px',
width: '834px',
},
type: 'tablet',
},
ipad12p: {
name: 'iPad Pro 12.9-in',
styles: {
height: '1366px',
width: '1024px',
},
type: 'tablet',
},
galaxys5: {
name: 'Galaxy S5',
styles: {
height: '640px',
width: '360px',
},
type: 'mobile',
},
galaxys9: {
name: 'Galaxy S9',
styles: {
height: '740px',
width: '360px',
},
type: 'mobile',
},
nexus5x: {
name: 'Nexus 5X',
styles: {
height: '660px',
width: '412px',
},
type: 'mobile',
},
nexus6p: {
name: 'Nexus 6P',
styles: {
height: '732px',
width: '412px',
},
type: 'mobile',
},
pixel: {
name: 'Pixel',
styles: {
height: '960px',
width: '540px',
},
type: 'mobile',
},
pixelxl: {
name: 'Pixel XL',
styles: {
height: '1280px',
width: '720px',
},
type: 'mobile',
},
}

View file

@ -1,3 +1,9 @@
import {
FetchAppleMusicAlbumParams,
FetchAppleMusicAlbumResponse,
FetchAppleMusicArtistParams,
FetchAppleMusicArtistResponse,
} from '@/shared/api/AppleMusic'
import request from '../utils/request' import request from '../utils/request'
// AppleMusic专辑 // AppleMusic专辑

View file

@ -65,7 +65,7 @@ export function fetchAudioSourceWithReactQuery(params: FetchAudioSourceParams) {
return fetchAudioSource(params) return fetchAudioSource(params)
}, },
{ {
retry: 3, retry: 1,
staleTime: 0, // TODO: Web版1小时缓存 staleTime: 0, // TODO: Web版1小时缓存
} }
) )

View file

@ -3,7 +3,7 @@ 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 } from '../auth' import { logout as logoutAPI } from '../auth'
import { removeAllCookies } from '@/web/utils/cookie' import { removeAllCookies } from '@/web/utils/cookie'
import reactQueryClient from '@/web/utils/reactQueryClient' import reactQueryClient from '@/web/utils/reactQueryClient'
@ -31,12 +31,19 @@ export default function useUser() {
) )
} }
export const useMutationLogout = () => { export const useIsLoggedIn = () => {
const { refetch } = useUser() const { data, isLoading } = useUser()
return useMutation(async () => { if (isLoading) return true
await logout() return !!data?.profile?.userId
}
export const logout = async () => {
await logoutAPI()
removeAllCookies() removeAllCookies()
await window.ipcRenderer?.invoke(IpcChannels.Logout) await window.ipcRenderer?.invoke(IpcChannels.Logout)
await refetch() await reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
}) }
export const useMutationLogout = () => {
return useMutation(logout)
} }

View file

@ -19,9 +19,7 @@ export function fetchMV(params: FetchMVParams): Promise<FetchMVResponse> {
} }
// mv 地址 // mv 地址
export function fetchMVUrl( export function fetchMVUrl(params: FetchMVUrlParams): Promise<FetchMVUrlResponse> {
params: FetchMVUrlParams
): Promise<FetchMVUrlResponse> {
return request({ return request({
url: '/mv/url', url: '/mv/url',
method: 'get', method: 'get',
@ -34,7 +32,7 @@ export function fetchMVUrl(
* 说明 : 调用此接口 , mvid mv * 说明 : 调用此接口 , mvid mv
* @param {number} mvid * @param {number} mvid
*/ */
export function simiMv(mvid) { export function simiMv(mvid: string | number) {
return request({ return request({
url: '/simi/mv', url: '/simi/mv',
method: 'get', method: 'get',

View file

@ -7,8 +7,8 @@ const request: AxiosInstance = axios.create({
timeout: 15000, timeout: 15000,
}) })
export async function cacheAudio(id: number, audio: string) { export async function cacheAudio(id: number, audioUrl: string, bitrate?: number) {
const file = await axios.get(audio, { responseType: 'arraybuffer' }) const file = await axios.get(audioUrl, { responseType: 'arraybuffer' })
if (file.status !== 200 && file.status !== 206) return if (file.status !== 200 && file.status !== 206) return
const formData = new FormData() const formData = new FormData()
@ -20,7 +20,8 @@ export async function cacheAudio(id: number, audio: string) {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
params: { params: {
url: audio, url: audioUrl,
bitrate,
}, },
}) })
} }

View file

@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.66699 1.72266L2.09863 1.72266C1.26855 1.72266 0.765601 2.29883 0.765601 3.0459C0.765601 3.27539 0.834001 3.51465 0.956101 3.72949L4.74512 10.3311C4.99902 10.7754 5.43359 11 5.88281 11C6.33203 11 6.77148 10.7754 7.02051 10.3311L10.8096 3.72949C10.9414 3.50977 11 3.27539 11 3.0459C11 2.29883 10.4971 1.72266 9.66699 1.72266Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View file

@ -1,3 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6963 7.8793C11.8739 8.02633 12.1261 8.02633 12.3037 7.8793L13.6433 6.76983C15.2993 5.39827 17.7553 5.95672 18.7038 7.9205C19.345 9.24819 19.0937 10.8517 18.0798 11.9014L12.3437 17.8397C12.1539 18.0362 11.8461 18.0362 11.6563 17.8397L5.92022 11.9014C4.90633 10.8517 4.65498 9.24819 5.29622 7.9205C6.24467 5.95672 8.70067 5.39827 10.3567 6.76983L11.6963 7.8793ZM12 5.55297L12.4286 5.19799C15.0513 3.02586 18.9408 3.91027 20.4429 7.02028C21.4584 9.12294 21.0603 11.6624 19.4547 13.3247L13.7186 19.263C12.7694 20.2457 11.2305 20.2457 10.2814 19.263L4.54533 13.3247C2.93965 11.6624 2.54158 9.12294 3.55711 7.02028C5.05915 3.91027 8.9487 3.02586 11.5714 5.19799L12 5.55297Z" fill="currentColor"/> <g clip-path="url(#clip0_2_10)">
<path d="M5 10.9594C5 14.777 8.23566 18.5319 13.3474 21.7581C13.5378 21.8746 13.8097 22 14 22C14.1904 22 14.4622 21.8746 14.6616 21.7581C19.7644 18.5319 23 14.777 23 10.9594C23 7.78704 20.7976 5.54666 17.8611 5.54666C16.1843 5.54666 14.8248 6.33528 14 7.54508C13.1934 6.34424 11.8157 5.54666 10.139 5.54666C7.20242 5.54666 5 7.78704 5 10.9594ZM6.45922 10.9594C6.45922 8.57566 8.01813 6.98947 10.1209 6.98947C11.8248 6.98947 12.8036 8.03797 13.3837 8.93412C13.6284 9.29258 13.7825 9.39116 14 9.39116C14.2175 9.39116 14.3535 9.28362 14.6164 8.93412C15.2417 8.05589 16.1843 6.98947 17.8792 6.98947C19.9819 6.98947 21.5408 8.57566 21.5408 10.9594C21.5408 14.2931 17.9789 17.8867 14.1904 20.378C14.0997 20.4407 14.0363 20.4855 14 20.4855C13.9638 20.4855 13.9003 20.4407 13.8187 20.378C10.0212 17.8867 6.45922 14.2931 6.45922 10.9594Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_2_10">
<rect width="18" height="17" fill="white" transform="translate(5 5)"/>
</clipPath>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-pause"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_35)">
<path d="M7.69918 19H9.75492C10.5393 19 10.9541 18.6047 10.9541 17.8484V6.14303C10.9541 5.36096 10.5393 5 9.75492 5H7.69918C6.91475 5 6.5 5.39533 6.5 6.14303V17.8484C6.5 18.6047 6.91475 19 7.69918 19ZM14.2541 19H16.3008C17.0943 19 17.5 18.6047 17.5 17.8484V6.14303C17.5 5.36096 17.0943 5 16.3008 5H14.2541C13.4607 5 13.0459 5.39533 13.0459 6.14303V17.8484C13.0459 18.6047 13.4607 19 14.2541 19Z" fill="currentColor" fill-opacity="0.85"/>
</g>
<defs>
<clipPath id="clip0_2_35">
<rect width="11" height="14" fill="white" transform="translate(6.5 5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 706 B

Before After
Before After

View file

@ -62,7 +62,7 @@ const ArtistRow = ({
placeholderRow, placeholderRow,
}: { }: {
artists: Artist[] | undefined artists: Artist[] | undefined
title?: string title?: string | null
className?: string className?: string
placeholderRow?: number placeholderRow?: number
}) => { }) => {

View file

@ -0,0 +1,82 @@
import { css, cx } from '@emotion/css'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import uiStates from '../states/uiStates'
import { resizeImage } from '../utils/common'
import { ease } from '../utils/const'
import Icon from './Icon'
function ArtworkViewer({
type,
artwork,
isOpen,
onClose,
}: {
type: 'album' | 'playlist'
artwork: string
isOpen: boolean
onClose: () => void
}) {
useEffect(() => {
uiStates.isPauseVideos = isOpen
}, [isOpen])
return createPortal(
<>
{/* Blur bg */}
<AnimatePresence>
{isOpen && (
<motion.div
className='fixed inset-0 z-30 bg-black/70 backdrop-blur-3xl lg:rounded-24'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3, delay: 0.3 } }}
transition={{ ease }}
></motion.div>
)}
</AnimatePresence>
{/* Content */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.3, delay: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
transition={{ ease }}
className={cx('fixed inset-0 z-30 flex flex-col items-center justify-center')}
onClick={onClose}
>
<div className='relative'>
<img
src={resizeImage(artwork, 'lg')}
className={cx(
'rounded-24',
css`
height: 65vh;
width: 65vh;
`
)}
onClick={e => e.stopPropagation()}
/>
{/* Close button */}
<div className='absolute -bottom-24 flex w-full justify-center'>
<div
onClick={onClose}
className='flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
>
<Icon name='x' className='h-6 w-6' />
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>,
document.body
)
}
export default ArtworkViewer

View file

@ -1,6 +1,4 @@
import useUserArtists, { import useUserArtists, { useMutationLikeAArtist } from '@/web/api/hooks/useUserArtists'
useMutationLikeAArtist,
} from '@/web/api/hooks/useUserArtists'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus' import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -13,8 +11,7 @@ import BasicContextMenu from './BasicContextMenu'
const ArtistContextMenu = () => { const ArtistContextMenu = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { cursorPosition, type, dataSourceID, target, options } = const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus)
useSnapshot(contextMenus)
const likeAArtist = useMutationLikeAArtist() const likeAArtist = useMutationLikeAArtist()
const [, copyToClipboard] = useCopyToClipboard() const [, copyToClipboard] = useCopyToClipboard()
@ -63,19 +60,15 @@ const ArtistContextMenu = () => {
type: 'item', type: 'item',
label: t`context-menu.copy-netease-link`, label: t`context-menu.copy-netease-link`,
onClick: () => { onClick: () => {
copyToClipboard( copyToClipboard(`https://music.163.com/#/artist?id=${dataSourceID}`)
`https://music.163.com/#/artist?id=${dataSourceID}`
)
toast.success(t`toasts.copied`) toast.success(t`toasts.copied`)
}, },
}, },
{ {
type: 'item', type: 'item',
label: 'Copy YPM Link', label: t`context-menu.copy-r3play-link`,
onClick: () => { onClick: () => {
copyToClipboard( copyToClipboard(`${window.location.origin}/artist/${dataSourceID}`)
`${window.location.origin}/artist/${dataSourceID}`
)
toast.success(t`toasts.copied`) toast.success(t`toasts.copied`)
}, },
}, },

View file

@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from 'react'
import { useClickAway } from 'react-use' import { useClickAway } from 'react-use'
import useLockMainScroll from '@/web/hooks/useLockMainScroll' import useLockMainScroll from '@/web/hooks/useLockMainScroll'
import useMeasure from 'react-use-measure' import useMeasure from 'react-use-measure'
import { ContextMenuItem } from './MenuItem' import { ContextMenuItem } from './types'
import MenuPanel from './MenuPanel' import MenuPanel from './MenuPanel'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { ContextMenuPosition } from './types' import { ContextMenuPosition } from './types'

View file

@ -1,11 +1,5 @@
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import { import { ForwardedRef, forwardRef, useLayoutEffect, useRef, useState } from 'react'
ForwardedRef,
forwardRef,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import MenuItem from './MenuItem' import MenuItem from './MenuItem'
import { ContextMenuItem, ContextMenuPosition } from './types' import { ContextMenuItem, ContextMenuPosition } from './types'
@ -36,7 +30,7 @@ const MenuPanel = forwardRef(
<div <div
ref={ref} ref={ref}
className={cx( className={cx(
'fixed select-none', 'app-region-no-drag fixed select-none',
isSubmenu ? 'submenu z-30 px-1' : 'z-20' isSubmenu ? 'submenu z-30 px-1' : 'z-20'
)} )}
style={{ left: position.x, top: position.y }} style={{ left: position.x, top: position.y }}
@ -77,9 +71,7 @@ const MenuPanel = forwardRef(
{/* Submenu */} {/* Submenu */}
<SubMenu <SubMenu
items={ items={submenuProps?.index ? items[submenuProps?.index]?.items : undefined}
submenuProps?.index ? items[submenuProps?.index]?.items : undefined
}
itemRect={submenuProps?.itemRect} itemRect={submenuProps?.itemRect}
onClose={onClose} onClose={onClose}
/> />
@ -118,9 +110,7 @@ const SubMenu = ({
const x = isRightSide ? item.x + item.width : item.x - submenu.width const x = isRightSide ? item.x + item.width : item.x - submenu.width
const isTopSide = item.y - 10 + submenu.height <= window.innerHeight const isTopSide = item.y - 10 + submenu.height <= window.innerHeight
const y = isTopSide const y = isTopSide ? item.y - 10 : item.y + item.height + 10 - submenu.height
? item.y - 10
: item.y + item.height + 10 - submenu.height
const transformOriginTable = { const transformOriginTable = {
top: { top: {
@ -137,9 +127,7 @@ const SubMenu = ({
x, x,
y, y,
transformOrigin: transformOrigin:
transformOriginTable[isTopSide ? 'top' : 'bottom'][ transformOriginTable[isTopSide ? 'top' : 'bottom'][isRightSide ? 'right' : 'left'],
isRightSide ? 'right' : 'left'
],
}) })
}, [itemRect]) }, [itemRect])

View file

@ -15,8 +15,7 @@ const TrackContextMenu = () => {
const [, copyToClipboard] = useCopyToClipboard() const [, copyToClipboard] = useCopyToClipboard()
const { type, dataSourceID, target, cursorPosition, options } = const { type, dataSourceID, target, cursorPosition, options } = useSnapshot(contextMenus)
useSnapshot(contextMenus)
return ( return (
<AnimatePresence> <AnimatePresence>
@ -84,19 +83,15 @@ const TrackContextMenu = () => {
type: 'item', type: 'item',
label: t`context-menu.copy-netease-link`, label: t`context-menu.copy-netease-link`,
onClick: () => { onClick: () => {
copyToClipboard( copyToClipboard(`https://music.163.com/#/album?id=${dataSourceID}`)
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success(t`toasts.copied`) toast.success(t`toasts.copied`)
}, },
}, },
{ {
type: 'item', type: 'item',
label: 'Copy YPM Link', label: t`context-menu.copy-r3play-link`,
onClick: () => { onClick: () => {
copyToClipboard( copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
`${window.location.origin}/album/${dataSourceID}`
)
toast.success(t`toasts.copied`) toast.success(t`toasts.copied`)
}, },
}, },

View file

@ -6,7 +6,7 @@ export interface ContextMenuPosition {
export interface ContextMenuItem { export interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider' type: 'item' | 'submenu' | 'divider'
label?: string label?: string | null
onClick?: (e: MouseEvent) => void onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[] items?: ContextMenuItem[]
} }

View file

@ -1,21 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import CoverWall from './CoverWall'
import { shuffle } from 'lodash-es'
import { covers } from '../../.storybook/mock/tracks'
import { resizeImage } from '@/web/utils/common'
export default {
title: 'Components/CoverWall',
component: CoverWall,
} as ComponentMeta<typeof CoverWall>
const Template: ComponentStory<typeof CoverWall> = args => (
<div className='rounded-3xl bg-[#F8F8F8] p-10 dark:bg-black'>
<CoverWall
covers={shuffle(covers.map(c => resizeImage(c, 'lg'))).slice(0, 31)}
/>
</div>
)
export const Default = Template.bind({})

View file

@ -53,7 +53,6 @@ function DescriptionViewer({
</div> </div>
{/* Description */} {/* Description */}
<div <div
className={css` className={css`
mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩 mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩
@ -73,7 +72,7 @@ function DescriptionViewer({
)} )}
> >
<p <p
dangerouslySetInnerHTML={{ __html: description + description }} dangerouslySetInnerHTML={{ __html: description }}
className='mt-8 whitespace-pre-wrap pb-8 text-16 font-bold leading-6 text-neutral-200' className='mt-8 whitespace-pre-wrap pb-8 text-16 font-bold leading-6 text-neutral-200'
></p> ></p>
</div> </div>

View file

@ -1,9 +1,17 @@
import { IconNames } from './iconNamesType' import { IconNames } from './iconNamesType'
const Icon = ({ name, className }: { name: IconNames; className?: string }) => { const Icon = ({
name,
className,
style,
}: {
name: IconNames
className?: string
style?: React.CSSProperties
}) => {
const symbolId = `#icon-${name}` const symbolId = `#icon-${name}`
return ( return (
<svg aria-hidden='true' className={className}> <svg aria-hidden='true' className={className} style={style}>
<use href={symbolId} fill='currentColor' /> <use href={symbolId} fill='currentColor' />
</svg> </svg>
) )

View file

@ -1 +1 @@
export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'fullscreen-enter' | 'fullscreen-exit' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'video-settings' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x' export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'dropdown-triangle' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'fullscreen-enter' | 'fullscreen-exit' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'video-settings' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'

View file

@ -8,7 +8,7 @@ import Icon from '@/web/components/Icon'
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail' import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import LoginWithQRCode from './LoginWithQRCode' import LoginWithQRCode from './LoginWithQRCode'
import persistedUiStates from '@/web/states/persistedUiStates' import persistedUiStates from '@/web/states/persistedUiStates'
import useUser from '@/web/api/hooks/useUser' import useUser, { useIsLoggedIn } from '@/web/api/hooks/useUser'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const OR = ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => { const OR = ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => {
@ -38,6 +38,7 @@ const Login = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: user, isLoading: isLoadingUser } = useUser() const { data: user, isLoading: isLoadingUser } = useUser()
const isLoggedIn = useIsLoggedIn()
const { loginType } = useSnapshot(persistedUiStates) const { loginType } = useSnapshot(persistedUiStates)
const { showLoginPanel } = useSnapshot(uiStates) const { showLoginPanel } = useSnapshot(uiStates)
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>( const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
@ -46,10 +47,10 @@ const Login = () => {
// Show login panel when user first loads the page and not logged in // Show login panel when user first loads the page and not logged in
useEffect(() => { useEffect(() => {
if (!user?.account && !isLoadingUser) { if (!isLoggedIn) {
uiStates.showLoginPanel = true uiStates.showLoginPanel = true
} }
}, [user?.account, isLoadingUser]) }, [isLoggedIn])
const animateCard = useAnimation() const animateCard = useAnimation()
const handleSwitchCard = async () => { const handleSwitchCard = async () => {

View file

@ -23,21 +23,16 @@ const LoginWithPhoneOrEmail = () => {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const isZH = i18n.language.startsWith('zh') const isZH = i18n.language.startsWith('zh')
const { loginPhoneCountryCode, loginType: persistedLoginType } = const { loginPhoneCountryCode, loginType: persistedLoginType } = useSnapshot(persistedUiStates)
useSnapshot(persistedUiStates)
const [email, setEmail] = useState<string>('') const [email, setEmail] = useState<string>('')
const [countryCode, setCountryCode] = useState<string>( const [countryCode, setCountryCode] = useState<string>(loginPhoneCountryCode || '+86')
loginPhoneCountryCode || '+86'
)
const [phone, setPhone] = useState<string>('') const [phone, setPhone] = useState<string>('')
const [password, setPassword] = useState<string>('') const [password, setPassword] = useState<string>('')
const [loginType, setLoginType] = useState<'phone' | 'email'>( const [loginType, setLoginType] = useState<'phone' | 'email'>(
persistedLoginType === 'email' ? 'email' : 'phone' persistedLoginType === 'email' ? 'email' : 'phone'
) )
const handleAfterLogin = ( const handleAfterLogin = (result: LoginWithEmailResponse | LoginWithPhoneResponse) => {
result: LoginWithEmailResponse | LoginWithPhoneResponse
) => {
if (result?.code !== 200) return if (result?.code !== 200) return
setCookies(result.cookie) setCookies(result.cookie)
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount]) reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
@ -76,11 +71,7 @@ const LoginWithPhoneOrEmail = () => {
toast.error('Please enter password') toast.error('Please enter password')
return return
} }
if ( if (email.match(/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/) == null) {
email.match(
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
) == null
) {
toast.error('Please use netease email') toast.error('Please use netease email')
return return
} }
@ -238,9 +229,7 @@ const LoginWithPhoneOrEmail = () => {
{/* Login button */} {/* Login button */}
<div <div
onClick={() => onClick={() => (loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin())}
loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin()
}
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white' className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
> >
{t`auth.login`} {t`auth.login`}

View file

@ -1,115 +0,0 @@
import useLyric from '@/web/api/hooks/useLyric'
import player from '@/web/states/player'
import { motion } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { cx } from '@emotion/css'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
const lyric = useMemo(() => {
return lyricRaw && lyricParser(lyricRaw)
}, [lyricRaw])
const progress = playerSnapshot.progress + 0.3
const currentLine = useMemo(() => {
const index =
(lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
return {
index: index < 1 ? 0 : index,
time: lyric?.lyric?.[index]?.time ?? 0,
}
}, [lyric?.lyric, progress])
const displayLines = useMemo(() => {
const index = currentLine.index
const lines =
lyric?.lyric.slice(index === 0 ? 0 : index - 1, currentLine.index + 7) ??
[]
if (index === 0) {
lines.unshift({
time: 0,
content: '',
rawTime: '[00:00:00]',
})
}
return lines
}, [currentLine.index, lyric?.lyric])
const variants = {
initial: { opacity: [0, 0.2], y: ['24%', 0] },
current: {
opacity: 1,
y: 0,
transition: {
ease: [0.5, 0.2, 0.2, 0.8],
duration: 0.7,
},
},
rest: (index: number) => ({
opacity: 0.2,
y: 0,
transition: {
delay: index * 0.04,
ease: [0.5, 0.2, 0.2, 0.8],
duration: 0.7,
},
}),
exit: {
opacity: 0,
y: -132,
height: 0,
paddingTop: 0,
paddingBottom: 0,
transition: {
duration: 0.7,
ease: [0.5, 0.2, 0.2, 0.8],
},
},
}
return (
<div
className={cx(
'max-h-screen cursor-default overflow-hidden font-semibold',
className
)}
style={{
paddingTop: 'calc(100vh / 7 * 3)',
paddingBottom: 'calc(100vh / 7 * 3)',
fontSize: 'calc(100vw * 0.0264)',
lineHeight: 'calc(100vw * 0.032)',
}}
>
{displayLines.map(({ content, time }, index) => {
return (
<motion.div
key={`${String(index)}-${String(time)}`}
custom={index}
variants={variants}
initial={'initial'}
animate={
time === currentLine.time
? 'current'
: time < currentLine.time
? 'exit'
: 'rest'
}
layout
className={cx('max-w-[78%] py-[calc(100vw_*_0.0111)] text-white')}
>
{content}
</motion.div>
)
})}
</div>
)
}
export default Lyric

View file

@ -1,101 +0,0 @@
import useLyric from '@/web/api/hooks/useLyric'
import player from '@/web/states/player'
import { motion, useMotionValue } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useWindowSize } from 'react-use'
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import { cx } from '@emotion/css'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
const lyric = useMemo(() => {
return lyricRaw && lyricParser(lyricRaw)
}, [lyricRaw])
const [progress, setProgress] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setProgress(player.howler.seek() + 0.3)
}, 300)
return () => clearInterval(timer)
}, [])
const currentIndex = useMemo(() => {
return (lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
}, [lyric?.lyric, progress])
const y = useMotionValue(1000)
const { height: windowHight } = useWindowSize()
useLayoutEffect(() => {
const top = (
document.getElementById('lyrics')?.children?.[currentIndex] as any
)?.offsetTop
if (top) {
y.set((windowHight / 9) * 4 - top)
}
}, [currentIndex, windowHight, y])
useEffect(() => {
y.set(0)
}, [track, y])
return (
<div
className={cx(
'max-h-screen cursor-default select-none overflow-hidden font-semibold',
className
)}
style={{
paddingTop: 'calc(100vh / 9 * 4)',
paddingBottom: 'calc(100vh / 9 * 4)',
fontSize: 'calc(100vw * 0.0264)',
lineHeight: 'calc(100vw * 0.032)',
}}
id='lyrics'
>
{lyric?.lyric.map(({ content, time }, index) => {
return (
<motion.div
id={String(time)}
key={`${String(index)}-${String(time)}`}
className={cx(
'max-w-[78%] py-[calc(100vw_*_0.0111)] text-white duration-700'
)}
style={{
y,
opacity:
index === currentIndex
? 1
: index > currentIndex && index < currentIndex + 8
? 0.2
: 0,
transitionProperty:
index > currentIndex - 2 && index < currentIndex + 8
? 'transform, opacity'
: 'none',
transitionTimingFunction:
index > currentIndex - 2 && index < currentIndex + 8
? 'cubic-bezier(0.5, 0.2, 0.2, 0.8)'
: 'none',
transitionDelay: `${
index < currentIndex + 8 && index > currentIndex
? 0.04 * (index - currentIndex)
: 0
}s`,
}}
>
{content}
</motion.div>
)
})}
</div>
)
}
export default Lyric

View file

@ -1,72 +0,0 @@
import Player from './Player'
import player from '@/web/states/player'
import { getCoverColor } from '@/web/utils/common'
import { colord } from 'colord'
import IconButton from '../IconButton'
import Icon from '../Icon'
import Lyric from './Lyric'
import { motion, AnimatePresence } from 'framer-motion'
import Lyric2 from './Lyric2'
import useCoverColor from '@/web/hooks/useCoverColor'
import { cx } from '@emotion/css'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
const LyricPanel = () => {
const stateSnapshot = useSnapshot(player)
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
return (
<AnimatePresence>
{stateSnapshot.uiStates.showLyricPanel && (
<motion.div
initial={{
y: '100%',
}}
animate={{
y: 0,
transition: {
ease: 'easeIn',
duration: 0.4,
},
}}
exit={{
y: '100%',
transition: {
ease: 'easeIn',
duration: 0.4,
},
}}
className={cx(
'fixed inset-0 z-40 grid grid-cols-[repeat(13,_minmax(0,_1fr))] gap-[8%] bg-gray-800'
)}
style={{
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
}}
>
{/* Drag area */}
<div className='app-region-drag absolute top-0 right-0 left-0 h-16'></div>
<Player className='col-span-6' />
{/* <Lyric className='col-span-7' /> */}
<Lyric2 className='col-span-7' />
<div className='absolute bottom-3.5 right-7 text-white'>
<IconButton
onClick={() => {
//
}}
>
<Icon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
export default LyricPanel

View file

@ -1,146 +0,0 @@
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import ArtistInline from '../ArtistsInline'
import Cover from '../Cover'
import IconButton from '../IconButton'
import Icon from '../Icon'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import { cx } from '@emotion/css'
const PlayingTrack = () => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const navigate = useNavigate()
const toAlbum = () => {
const id = track?.al?.id
if (!id) return
navigate(`/album/${id}`)
}
const trackListSource = useMemo(
() => playerSnapshot.trackListSource,
[playerSnapshot.trackListSource]
)
const hasListSource = playerSnapshot.mode !== PlayerMode.FM && trackListSource?.type
const toTrackListSource = () => {
if (!hasListSource) return
navigate(`/${trackListSource.type}/${trackListSource.id}`)
}
const toArtist = (id: number) => {
navigate(`/artist/${id}`)
}
return (
<div>
<div
onClick={toTrackListSource}
className={cx(
'line-clamp-1 text-[22px] font-semibold text-white',
hasListSource && 'hover:underline'
)}
>
{track?.name}
</div>
<div className='line-clamp-1 -mt-0.5 inline-flex max-h-7 text-white opacity-60'>
<ArtistInline artists={track?.ar ?? []} onClick={toArtist} />
{!!track?.al?.id && (
<span>
{' '}
-{' '}
<span onClick={toAlbum} className='hover:underline'>
{track?.al.name}
</span>
</span>
)}
</div>
</div>
)
}
const LikeButton = ({ track }: { track: Track | undefined | null }) => {
const { data: userLikedSongs } = useUserLikedTracksIDs()
const mutationLikeATrack = useMutationLikeATrack()
return (
<div className='mr-1 '>
<IconButton onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}>
<Icon
className='h-6 w-6 text-white'
name={track?.id && userLikedSongs?.ids?.includes(track.id) ? 'heart' : 'heart-outline'}
/>
</IconButton>
</div>
)
}
const Controls = () => {
const playerSnapshot = useSnapshot(player)
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
return (
<div className='flex items-center justify-center gap-2 text-white'>
{mode === PlayerMode.TrackList && (
<IconButton onClick={() => track && player.prevTrack()} disabled={!track}>
<Icon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<Icon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
onClick={() => track && player.playOrPause()}
disabled={!track}
className='after:rounded-xl'
>
<Icon
className='h-7 w-7'
name={[PlayerState.Playing, PlayerState.Loading].includes(state) ? 'pause' : 'play'}
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<Icon className='h-6 w-6' name='next' />
</IconButton>
</div>
)
}
const Player = ({ className }: { className?: string }) => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return (
<div className={cx('flex w-full items-center justify-end', className)}>
<div className='relative w-[74%]'>
<Cover
imageUrl={resizeImage(track?.al.picUrl ?? '', 'lg')}
roundedClass='rounded-2xl'
alwaysShowShadow={true}
/>
<div className='absolute -bottom-32 right-0 left-0'>
<div className='mt-6 flex cursor-default justify-between'>
<PlayingTrack />
<LikeButton track={track} />
</div>
<Controls />
</div>
</div>
</div>
)
}
export default Player

View file

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

View file

@ -6,6 +6,8 @@ import { useAnimation, motion } from 'framer-motion'
import { ease } from '@/web/utils/const' import { ease } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile' import useIsMobile from '@/web/hooks/useIsMobile'
import { breakpoint as bp } from '@/web/utils/const' import { breakpoint as bp } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import settings from '../states/settings'
const tabs = [ const tabs = [
{ {
@ -81,9 +83,8 @@ const Tabs = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const controls = useAnimation() const controls = useAnimation()
const [active, setActive] = useState<string>( const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
location.pathname || tabs[0].path const [active, setActive] = useState<string>(location.pathname || tabs[0].path)
)
const animate = async (path: string) => { const animate = async (path: string) => {
await controls.start((p: string) => await controls.start((p: string) =>
@ -94,7 +95,14 @@ const Tabs = () => {
return ( return (
<div className='grid grid-cols-4 justify-items-center text-black/10 dark:text-white/20 lg:grid-cols-1 lg:gap-12'> <div className='grid grid-cols-4 justify-items-center text-black/10 dark:text-white/20 lg:grid-cols-1 lg:gap-12'>
{tabs.map(tab => ( {tabs
.filter(tab => {
if (!displayPlaylistsFromNeteaseMusic && tab.name === 'BROWSE') {
return false
}
return true
})
.map(tab => (
<motion.div <motion.div
key={tab.name} key={tab.name}
animate={controls} animate={controls}
@ -122,9 +130,7 @@ const Tabs = () => {
> >
<Icon <Icon
name={tab.icon} name={tab.icon}
className={cx( className={cx('app-region-no-drag h-10 w-10 transition-colors duration-500')}
'app-region-no-drag h-10 w-10 transition-colors duration-500'
)}
/> />
</motion.div> </motion.div>
))} ))}

View file

@ -1,23 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import NowPlaying from './NowPlaying'
import tracks from '@/web/.storybook/mock/tracks'
import { sample } from 'lodash-es'
export default {
title: 'Components/NowPlaying',
component: NowPlaying,
parameters: {
viewport: {
defaultViewport: 'iphone8p',
},
},
} as ComponentMeta<typeof NowPlaying>
const Template: ComponentStory<typeof NowPlaying> = args => (
<div className='fixed inset-0 bg-[#F8F8F8] p-4 dark:bg-black'>
<NowPlaying track={sample(tracks)} />
</div>
)
export const Default = Template.bind({})

View file

@ -1,21 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import PlayingNext from './PlayingNext'
export default {
title: 'Components/PlayingNext',
component: PlayingNext,
parameters: {
viewport: {
defaultViewport: 'iphone6',
},
},
} as ComponentMeta<typeof PlayingNext>
const Template: ComponentStory<typeof PlayingNext> = args => (
<div className='fixed inset-0 bg-[#F8F8F8] p-4 dark:bg-black'>
<PlayingNext />
</div>
)
export const Default = Template.bind({})

View file

@ -13,26 +13,74 @@ import { Virtuoso } from 'react-virtuoso'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { openContextMenu } from '@/web/states/contextMenus' import { openContextMenu } from '@/web/states/contextMenus'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useHoverLightSpot from '../hooks/useHoverLightSpot'
import { motion } from 'framer-motion'
import { useState } from 'react'
const RepeatButton = () => {
const { buttonRef, buttonStyle } = useHoverLightSpot()
const [repeat, setRepeat] = useState(false)
return (
<motion.button
ref={buttonRef}
onClick={() => {
setRepeat(!repeat)
toast('开发中')
}}
className={cx(
'group relative transition duration-300 ease-linear',
repeat
? 'text-brand-700 hover:text-brand-400'
: 'text-neutral-300 opacity-40 hover:opacity-100'
)}
style={buttonStyle}
>
<div className='absolute top-1/2 left-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 blur group-hover:opacity-100'></div>
<Icon name='repeat-1' className='h-7 w-7' />
</motion.button>
)
}
const ShuffleButton = () => {
const { buttonRef, buttonStyle } = useHoverLightSpot()
const [shuffle, setShuffle] = useState(false)
return (
<motion.button
ref={buttonRef}
onClick={() => {
setShuffle(!shuffle)
toast('开发中')
}}
className={cx(
'group relative transition duration-300 ease-linear',
shuffle
? 'text-brand-700 hover:text-brand-400'
: 'text-neutral-300 opacity-40 hover:opacity-100'
)}
style={buttonStyle}
>
<Icon name='shuffle' className='h-7 w-7' />
<div className='absolute top-1/2 left-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 blur group-hover:opacity-100'></div>
</motion.button>
)
}
const Header = () => { const Header = () => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div <div
className={cx( className={cx(
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300 lg:px-0' 'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold lg:px-0'
)} )}
> >
<div className='flex'> <div className='flex text-neutral-300'>
<div className='mr-2 h-4 w-1 rounded-full bg-brand-700'></div> <div className='mr-2 h-4 w-1 rounded-full bg-brand-700'></div>
{t`player.queue`} {t`player.queue`}
</div> </div>
<div className='flex'> <div className='flex gap-2'>
<div onClick={() => toast('开发中')} className='mr-2'> <RepeatButton />
<Icon name='repeat-1' className='h-7 w-7 opacity-40' /> <ShuffleButton />
</div>
<div onClick={() => toast('开发中')}>
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
</div>
</div> </div>
</div> </div>
) )

View file

@ -11,6 +11,7 @@ const Playlist = React.lazy(() => import('@/web/pages/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/Artist')) const Artist = React.lazy(() => import('@/web/pages/Artist'))
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics')) const Lyrics = React.lazy(() => import('@/web/pages/Lyrics'))
const Search = React.lazy(() => import('@/web/pages/Search')) const Search = React.lazy(() => import('@/web/pages/Search'))
const Settings = React.lazy(() => import('@/web/pages/Settings'))
const lazy = (component: ReactNode) => { const lazy = (component: ReactNode) => {
return <Suspense>{component}</Suspense> return <Suspense>{component}</Suspense>
@ -29,7 +30,7 @@ const Router = () => {
<Route path='/album/:id' element={lazy(<Album />)} /> <Route path='/album/:id' element={lazy(<Album />)} />
<Route path='/playlist/:id' element={lazy(<Playlist />)} /> <Route path='/playlist/:id' element={lazy(<Playlist />)} />
<Route path='/artist/:id' element={lazy(<Artist />)} /> <Route path='/artist/:id' element={lazy(<Artist />)} />
{/* <Route path='/settings' element={lazy(<Settings />)} /> */} <Route path='/settings' element={lazy(<Settings />)} />
<Route path='/lyrics' element={lazy(<Lyrics />)} /> <Route path='/lyrics' element={lazy(<Lyrics />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}> <Route path='/search/:keywords' element={lazy(<Search />)}>
<Route path=':type' element={lazy(<Search />)} /> <Route path=':type' element={lazy(<Search />)} />

View file

@ -1,16 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import Sidebar from './MenuBar'
export default {
title: 'Components/Sidebar',
component: Sidebar,
} as ComponentMeta<typeof Sidebar>
const Template: ComponentStory<typeof Sidebar> = args => (
<div className='h-[calc(100vh_-_32px)] w-min rounded-l-3xl bg-[#F8F8F8] dark:bg-black'>
<Sidebar />
</div>
)
export const Default = Template.bind({})

View file

@ -1,44 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import Slider from './Slider'
import { useArgs } from '@storybook/client-api'
import { cx } from '@emotion/css'
export default {
title: 'Basic/Slider',
component: Slider,
args: {
value: 50,
min: 0,
max: 100,
onlyCallOnChangeAfterDragEnded: false,
orientation: 'horizontal',
alwaysShowTrack: false,
alwaysShowThumb: false,
},
} as ComponentMeta<typeof Slider>
const Template: ComponentStory<typeof Slider> = args => {
const [, updateArgs] = useArgs()
return (
<div
className={cx(
'h-full rounded-24 bg-[#F8F8F8] dark:bg-black',
args.orientation === 'horizontal' && 'py-4 px-5',
args.orientation === 'vertical' && 'h-64 w-min py-5 px-4'
)}
>
<Slider {...args} onChange={value => updateArgs({ value })} />
</div>
)
}
export const Default = Template.bind({})
export const Vertical = Template.bind({})
Vertical.args = {
orientation: 'vertical',
alwaysShowTrack: true,
alwaysShowThumb: true,
}

View file

@ -1,48 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import Slider from './SliderNative'
import { useArgs } from '@storybook/client-api'
import { cx } from '@emotion/css'
export default {
title: 'Basic/Slider (Native Input)',
component: Slider,
args: {
value: 50,
min: 0,
max: 100,
onlyCallOnChangeAfterDragEnded: false,
orientation: 'horizontal',
alwaysShowTrack: false,
alwaysShowThumb: false,
},
} as ComponentMeta<typeof Slider>
const Template: ComponentStory<typeof Slider> = args => {
const [, updateArgs] = useArgs()
return (
<div
className={cx(
'h-full rounded-24 bg-[#F8F8F8] dark:bg-black',
args.orientation === 'horizontal' && 'py-4 px-5',
args.orientation === 'vertical' && 'h-64 w-min py-5 px-4'
)}
>
<Slider {...args} onChange={value => updateArgs({ value })} />
</div>
)
}
export const Default = Template.bind({})
Default.args = {
alwaysShowTrack: true,
alwaysShowThumb: true,
}
export const Vertical = Template.bind({})
Vertical.args = {
orientation: 'vertical',
alwaysShowTrack: true,
alwaysShowThumb: true,
}

View file

@ -1,6 +1,6 @@
import { cx } from '@emotion/css' import { cx } from '@emotion/css'
const Tabs = ({ function Tabs<T>({
tabs, tabs,
value, value,
onChange, onChange,
@ -8,19 +8,19 @@ const Tabs = ({
style, style,
}: { }: {
tabs: { tabs: {
id: string id: T
name: string name: string
}[] }[]
value: string value: string
onChange: (id: string) => void onChange: (id: T) => void
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
}) => { }) {
return ( return (
<div className={cx('no-scrollbar flex overflow-y-auto', className)} style={style}> <div className={cx('no-scrollbar flex overflow-y-auto', className)} style={style}>
{tabs.map(tab => ( {tabs.map(tab => (
<div <div
key={tab.id} key={tab.id as string}
className={cx( className={cx(
'mr-2.5 rounded-12 py-3 px-6 text-16 font-medium backdrop-blur transition duration-500', 'mr-2.5 rounded-12 py-3 px-6 text-16 font-medium backdrop-blur transition duration-500',
value === tab.id value === tab.id

View file

@ -1,16 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import Topbar from './Topbar/TopbarDesktop'
export default {
title: 'Components/Topbar',
component: Topbar,
} as ComponentMeta<typeof Topbar>
const Template: ComponentStory<typeof Topbar> = args => (
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] px-11 dark:bg-black'>
<Topbar />
</div>
)
export const Default = Template.bind({})

View file

@ -8,10 +8,12 @@ import BasicContextMenu from '../ContextMenus/BasicContextMenu'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
const Avatar = ({ className }: { className?: string }) => { const Avatar = ({ className }: { className?: string }) => {
const { data: user } = useUser() const { data: user } = useUser()
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const avatarUrl = user?.profile?.avatarUrl const avatarUrl = user?.profile?.avatarUrl
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm') ? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
@ -36,10 +38,7 @@ const Avatar = ({ className }: { className?: string }) => {
} }
setShowMenu(true) setShowMenu(true)
}} }}
className={cx( className={cx('app-region-no-drag rounded-full', className || 'h-12 w-12')}
'app-region-no-drag rounded-full',
className || 'h-12 w-12'
)}
/> />
<AnimatePresence> <AnimatePresence>
{avatarRef.current && showMenu && ( {avatarRef.current && showMenu && (
@ -63,7 +62,7 @@ const Avatar = ({ className }: { className?: string }) => {
type: 'item', type: 'item',
label: t`settings.settings`, label: t`settings.settings`,
onClick: () => { onClick: () => {
toast('开发中') navigate('/settings')
}, },
}, },
{ {

View file

@ -1,4 +1,4 @@
import { css, cx } from '@emotion/css' import { css, cx, keyframes } from '@emotion/css'
import Icon from '../Icon' import Icon from '../Icon'
import { breakpoint as bp } from '@/web/utils/const' import { breakpoint as bp } from '@/web/utils/const'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@ -10,6 +10,20 @@ import { useClickAway, useDebounce } from 'react-use'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const bounce = keyframes`
from { transform: rotate(0deg) translateX(1px) rotate(0deg) }
to { transform: rotate(360deg) translateX(1px) rotate(-360deg) }
`
function SearchIcon({ isSearching }: { isSearching: boolean }) {
return (
<div
// style={{ animation: `${bounce} 1.2s linear infinite` }}
>
<Icon name='search' className='mr-2.5 h-7 w-7' />
</div>
)
}
const SearchSuggestions = ({ const SearchSuggestions = ({
searchText, searchText,
isInputFocused, isInputFocused,
@ -144,7 +158,7 @@ const SearchBox = () => {
` `
)} )}
> >
<Icon name='search' className='mr-2.5 h-7 w-7' /> <SearchIcon />
<input <input
ref={inputRef} ref={inputRef}
placeholder={t`search.search`} placeholder={t`search.search`}

View file

@ -1,11 +1,13 @@
import Icon from '@/web/components/Icon' import Icon from '@/web/components/Icon'
import { cx } from '@emotion/css' import { cx } from '@emotion/css'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
const SettingsButton = ({ className }: { className?: string }) => { const SettingsButton = ({ className }: { className?: string }) => {
const navigate = useNavigate()
return ( return (
<button <button
onClick={() => toast('开发中')} onClick={() => navigate('/settings')}
className={cx( className={cx(
'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300', 'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300',
className className

View file

@ -1,16 +1,16 @@
import { formatDuration } from '@/web/utils/common' import { formatDuration } from '@/web/utils/common'
import { cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import player from '@/web/states/player' import player from '@/web/states/player'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import Wave from './Wave' import Wave from './Wave'
import Icon from '@/web/components/Icon' import Icon from '@/web/components/Icon'
import useIsMobile from '@/web/hooks/useIsMobile' import useIsMobile from '@/web/hooks/useIsMobile'
import useUserLikedTracksIDs, { import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { memo, useEffect, useState } from 'react' import { memo, useEffect, useMemo, useState } from 'react'
import contextMenus, { openContextMenu } from '@/web/states/contextMenus' import contextMenus, { openContextMenu } from '@/web/states/contextMenus'
import regexifyString from 'regexify-string'
import { NavLink } from 'react-router-dom'
const Actions = ({ track }: { track: Track }) => { const Actions = ({ track }: { track: Track }) => {
const { data: likedTracksIDs } = useUserLikedTracksIDs() const { data: likedTracksIDs } = useUserLikedTracksIDs()
@ -81,9 +81,7 @@ const Actions = ({ track }: { track: Track }) => {
className='flex h-10 w-10 items-center justify-center rounded-full text-white/40 transition duration-400 hover:bg-white/20 hover:text-white/70' className='flex h-10 w-10 items-center justify-center rounded-full text-white/40 transition duration-400 hover:bg-white/20 hover:text-white/70'
> >
<Icon <Icon
name={ name={likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'}
likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'
}
className='h-5 w-5' className='h-5 w-5'
/> />
</div> </div>
@ -92,6 +90,75 @@ const Actions = ({ track }: { track: Track }) => {
) )
} }
function Track({
track,
handleClick,
}: {
track: Track
handleClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
}) {
const { track: playingTrack, state } = useSnapshot(player)
return (
<div
key={track.id}
onClick={e => handleClick(e, track.id)}
onContextMenu={e => handleClick(e, track.id)}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
>
{/* Track no */}
<div className='mr-3 lg:mr-6'>
{playingTrack?.id === track.id ? (
<span className='inline-block'>
<Wave playing={state === 'playing'} />
</span>
) : (
String(track.no).padStart(2, '0')
)}
</div>
{/* Track name */}
<div className='flex flex-grow items-center'>
<span className='line-clamp-1'>{track?.name}</span>
{/* Explicit symbol */}
{[1318912, 1310848].includes(track.mark) && (
<Icon name='explicit' className='ml-2 mr-1 mt-px h-3.5 w-3.5 text-white/20' />
)}
{/* Other artists */}
{track?.ar?.length > 1 && (
<div className='text-white/20'>
<span className='px-1'>-</span>
{track.ar.slice(1).map((artist, index) => (
<span key={artist.id}>
<NavLink
to={`/artist/${artist.id}`}
className='text-white/20 transition duration-300 hover:text-white/40'
>
{artist.name}
</NavLink>
{index !== track.ar.length - 2 && ', '}
</span>
))}
</div>
)}
</div>
{/* Desktop menu */}
<Actions track={track} />
{/* Mobile menu */}
<div className='lg:hidden'>
<div className='h-10 w-10 rounded-full bg-night-900'></div>
</div>
{/* Track duration */}
<div className='hidden text-right lg:block'>
{formatDuration(track.dt, 'en-US', 'hh:mm:ss')}
</div>
</div>
)
}
const TrackList = ({ const TrackList = ({
tracks, tracks,
onPlay, onPlay,
@ -105,7 +172,6 @@ const TrackList = ({
isLoading?: boolean isLoading?: boolean
placeholderRows?: number placeholderRows?: number
}) => { }) => {
const { track: playingTrack, state } = useSnapshot(player)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => { const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
@ -133,57 +199,19 @@ const TrackList = ({
return ( return (
<div className={className}> <div className={className}>
{(isLoading ? [] : tracks)?.map(track => ( {(isLoading ? [] : tracks)?.map(track => (
<div <Track key={track.id} track={track} handleClick={handleClick} />
key={track.id}
onClick={e => handleClick(e, track.id)}
onContextMenu={e => handleClick(e, track.id)}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
>
{/* Track no */}
<div className='mr-3 lg:mr-6'>
{String(track.no).padStart(2, '0')}
</div>
{/* Track name */}
<div className='flex flex-grow items-center'>
<span className='line-clamp-1 mr-4'>{track.name}</span>
{playingTrack?.id === track.id && (
<span className='mr-4 inline-block'>
<Wave playing={state === 'playing'} />
</span>
)}
</div>
{/* Desktop menu */}
<Actions track={track} />
{/* Mobile menu */}
<div className='lg:hidden'>
<div className='h-10 w-10 rounded-full bg-night-900'></div>
</div>
{/* Track duration */}
<div className='hidden text-right lg:block'>
{formatDuration(track.dt, 'en-US', 'hh:mm:ss')}
</div>
</div>
))} ))}
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map( {(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(index => (
index => (
<div <div
key={index} key={index}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out' className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
> >
{/* Track no */} {/* Track no */}
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'> <div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>00</div>
00
</div>
{/* Track name */} {/* Track name */}
<div className='flex flex-grow items-center text-transparent'> <div className='flex flex-grow items-center text-transparent'>
<span className='mr-4 rounded-full bg-white/10'> <span className='mr-4 rounded-full bg-white/10'>PLACEHOLDER1234567</span>
PLACEHOLDER1234567
</span>
</div> </div>
{/* Track duration */} {/* Track duration */}
@ -191,8 +219,7 @@ const TrackList = ({
<span className='rounded-full bg-white/10'>00:00</span> <span className='rounded-full bg-white/10'>00:00</span>
</div> </div>
</div> </div>
) ))}
)}
</div> </div>
) )
} }

View file

@ -1,29 +1,40 @@
import { resizeImage } from '@/web/utils/common' import { resizeImage } from '@/web/utils/common'
import Image from '@/web/components/Image' import Image from '@/web/components/Image'
import { memo, useEffect } from 'react' import { memo, useEffect, useState } from 'react'
import uiStates from '@/web/states/uiStates' import uiStates from '@/web/states/uiStates'
import VideoCover from '@/web/components/VideoCover' import VideoCover from '@/web/components/VideoCover'
import ArtworkViewer from '../ArtworkViewer'
import useSettings from '@/web/hooks/useSettings'
const Cover = memo( const Cover = memo(({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
useEffect(() => { useEffect(() => {
if (cover) uiStates.blurBackgroundImage = cover if (cover) uiStates.blurBackgroundImage = cover
}, [cover]) }, [cover])
const [isOpenArtworkViewer, setIsOpenArtworkViewer] = useState(false)
return ( return (
<> <>
<div className='relative aspect-square w-full overflow-hidden rounded-24 '> <div
<Image onClick={() => {
className='absolute inset-0' if (cover) setIsOpenArtworkViewer(true)
src={resizeImage(cover || '', 'lg')} }}
/> className='relative aspect-square w-full overflow-hidden rounded-24'
>
<Image className='absolute inset-0' src={resizeImage(cover || '', 'lg')} />
{videoCover && <VideoCover source={videoCover} />} {videoCover && <VideoCover source={videoCover} />}
</div> </div>
<ArtworkViewer
type='album'
artwork={cover || ''}
isOpen={isOpenArtworkViewer}
onClose={() => setIsOpenArtworkViewer(false)}
/>
</> </>
) )
} })
)
Cover.displayName = 'Cover' Cover.displayName = 'Cover'
export default Cover export default Cover

View file

@ -1,16 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import TrackListHeader from './TrackListHeader'
export default {
title: 'Components/TrackListHeader',
component: TrackListHeader,
} as ComponentMeta<typeof TrackListHeader>
const Template: ComponentStory<typeof TrackListHeader> = args => (
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] p-10 dark:bg-black'>
<TrackListHeader />
</div>
)
export const Default = Template.bind({})

View file

@ -1,17 +1,20 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import Hls from 'hls.js' import Hls from 'hls.js'
import { injectGlobal } from '@emotion/css'
import { isIOS, isSafari } from '@/web/utils/common' import { isIOS, isSafari } from '@/web/utils/common'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import uiStates from '../states/uiStates' import uiStates from '../states/uiStates'
import useWindowFocus from '../hooks/useWindowFocus'
import useSettings from '../hooks/useSettings'
const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => { const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>() const hls = useRef<Hls>()
const windowFocus = useWindowFocus()
const { playAnimatedArtworkFromApple } = useSettings()
useEffect(() => { useEffect(() => {
if (source && Hls.isSupported() && videoRef.current) { if (source && Hls.isSupported() && videoRef.current && playAnimatedArtworkFromApple) {
if (hls.current) hls.current.destroy() if (hls.current) hls.current.destroy()
hls.current = new Hls() hls.current = new Hls()
hls.current.loadSource(source) hls.current.loadSource(source)
@ -24,12 +27,12 @@ const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }
// Pause video cover when playing another video // Pause video cover when playing another video
const { playingVideoID, isPauseVideos } = useSnapshot(uiStates) const { playingVideoID, isPauseVideos } = useSnapshot(uiStates)
useEffect(() => { useEffect(() => {
if (playingVideoID || isPauseVideos) { if (playingVideoID || isPauseVideos || !windowFocus) {
videoRef?.current?.pause() videoRef?.current?.pause()
} else { } else {
videoRef?.current?.play() videoRef?.current?.play()
} }
}, [playingVideoID, isPauseVideos]) }, [playingVideoID, isPauseVideos, windowFocus])
return ( return (
<motion.div <motion.div

View file

@ -1,8 +1,9 @@
import useBreakpoint from './useBreakpoint' import useBreakpoint from './useBreakpoint'
const useIsMobile = () => { const useIsMobile = () => {
const breakpoint = useBreakpoint() // const breakpoint = useBreakpoint()
return ['sm', 'md'].includes(breakpoint) // return ['sm', 'md'].includes(breakpoint)
return false
} }
export default useIsMobile export default useIsMobile

View file

@ -0,0 +1,9 @@
import { useSnapshot } from 'valtio'
import settings from '../states/settings'
function useSettings() {
const settingsState = useSnapshot(settings)
return settingsState
}
export default useSettings

View file

@ -1,6 +1,7 @@
import axios from 'axios' import axios from 'axios'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { appName } from '../utils/const' import { appName } from '../utils/const'
import useSettings from './useSettings'
export default function useVideoCover(props: { export default function useVideoCover(props: {
id?: number id?: number
@ -8,24 +9,22 @@ export default function useVideoCover(props: {
artist?: string artist?: string
enabled?: boolean enabled?: boolean
}) { }) {
const { playAnimatedArtworkFromApple } = useSettings()
const { id, name, artist, enabled = true } = props const { id, name, artist, enabled = true } = props
return useQuery( return useQuery(
['useVideoCover', props], ['useVideoCover', props],
async () => { async () => {
if (!id || !name || !artist) return if (!id || !name || !artist) return
const fromRemote = await axios.get( const fromRemote = await axios.get(`/${appName.toLowerCase()}/video-cover`, {
`/${appName.toLowerCase()}/video-cover`,
{
params: props, params: props,
} })
)
if (fromRemote?.data?.url) { if (fromRemote?.data?.url) {
return fromRemote.data.url return fromRemote.data.url
} }
}, },
{ {
enabled: !!id && !!name && !!artist && enabled, enabled: !!id && !!name && !!artist && enabled && !!playAnimatedArtworkFromApple,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchInterval: false, refetchInterval: false,
} }

View file

@ -0,0 +1,26 @@
import { useState, useEffect } from 'react'
const hasFocus = () => typeof document !== 'undefined' && document.hasFocus()
const useWindowFocus = () => {
const [focused, setFocused] = useState(hasFocus)
useEffect(() => {
setFocused(hasFocus())
const onFocus = () => setFocused(true)
const onBlur = () => setFocused(false)
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('focus', onFocus)
window.removeEventListener('blur', onBlur)
}
}, [])
return focused
}
export default useWindowFocus

View file

@ -2,12 +2,21 @@ import i18next from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import zhCN from './locales/zh-cn.json' import zhCN from './locales/zh-cn.json'
import enUS from './locales/en-us.json' import enUS from './locales/en-us.json'
import { subscribe } from 'valtio'
import settings from '../states/settings'
export const supportedLanguages = ['zh-CN', 'en-US'] as const export const supportedLanguages = ['zh-CN', 'en-US'] as const
export type SupportedLanguage = typeof supportedLanguages[number]
export const getLanguage = () => { declare module 'react-i18next' {
interface CustomTypeOptions {
returnNull: false
resources: {
'en-US': typeof enUS
'zh-CN': typeof enUS
}
}
}
export const getInitLanguage = () => {
// Get language from settings // Get language from settings
try { try {
const settings = JSON.parse(localStorage.getItem('settings') || '{}') const settings = JSON.parse(localStorage.getItem('settings') || '{}')
@ -28,13 +37,14 @@ export const getLanguage = () => {
} }
i18next.use(initReactI18next).init({ i18next.use(initReactI18next).init({
returnNull: false,
resources: { resources: {
'en-US': { translation: enUS }, 'en-US': { translation: enUS },
'zh-CN': { translation: zhCN }, 'zh-CN': { translation: zhCN },
}, },
lng: getLanguage(), lng: getInitLanguage(),
// lng: 'zh-CN',
fallbackLng: 'en-US', fallbackLng: 'en-US',
supportedLngs: supportedLanguages,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },

View file

@ -64,7 +64,14 @@
"recently-listened": "RECENTLY LISTENED" "recently-listened": "RECENTLY LISTENED"
}, },
"settings": { "settings": {
"settings": "Settings" "settings": "Settings",
"general": "General",
"appearance": "Appearance",
"player": "Player",
"lyrics": "Lyrics",
"lab": "Lab",
"general-choose-language": "Choose Language",
"player-youtube-unlock": "YouTube Unlock"
}, },
"context-menu": { "context-menu": {
"share": "Share", "share": "Share",

View file

@ -64,7 +64,13 @@
"recently-listened": "最近播放" "recently-listened": "最近播放"
}, },
"settings": { "settings": {
"settings": "设置" "settings": "设置",
"appearance": "外观",
"general": "通用",
"lab": "实验室",
"lyrics": "歌词",
"player": "播放",
"general-choose-language": "选择语言"
}, },
"context-menu": { "context-menu": {
"share": "分享", "share": "分享",

View file

@ -1,11 +0,0 @@
import 'react-i18next'
import enUS from './locales/en-us.json'
declare module 'react-i18next' {
interface CustomTypeOptions {
resources: {
'en-US': typeof enUS
'zh-CN': typeof enUS
}
}
}

View file

@ -12,13 +12,11 @@
"test:types": "tsc --noEmit --project ./tsconfig.json", "test:types": "tsc --noEmit --project ./tsconfig.json",
"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",
"storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook",
"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": "npx NeteaseCloudMusicApi@latest"
}, },
"engines": { "engines": {
"node": "^14.13.1 || >=16.0.0" "node": ">=16.0.0"
}, },
"dependencies": { "dependencies": {
"@emotion/css": "^11.10.5", "@emotion/css": "^11.10.5",
@ -35,7 +33,7 @@
"framer-motion": "^8.1.7", "framer-motion": "^8.1.7",
"hls.js": "^1.2.9", "hls.js": "^1.2.9",
"howler": "^2.2.3", "howler": "^2.2.3",
"i18next": "^21.9.1", "i18next": "^22.4.9",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
@ -44,7 +42,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-ga4": "^1.4.1", "react-ga4": "^1.4.1",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-i18next": "^11.18.4", "react-i18next": "^12.1.5",
"react-router-dom": "^6.6.1", "react-router-dom": "^6.6.1",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"react-use-measure": "^2.1.1", "react-use-measure": "^2.1.1",
@ -52,15 +50,6 @@
"valtio": "^1.8.0" "valtio": "^1.8.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "^6.5.5",
"@storybook/addon-essentials": "^6.5.5",
"@storybook/addon-interactions": "^6.5.5",
"@storybook/addon-links": "^6.5.5",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/addon-viewport": "^6.5.5",
"@storybook/builder-vite": "^0.1.35",
"@storybook/react": "^6.5.5",
"@storybook/testing-library": "^0.0.11",
"@testing-library/react": "^13.3.0", "@testing-library/react": "^13.3.0",
"@types/howler": "^2.2.7", "@types/howler": "^2.2.7",
"@types/js-cookie": "^3.0.2", "@types/js-cookie": "^3.0.2",
@ -80,7 +69,6 @@
"prettier": "*", "prettier": "*",
"prettier-plugin-tailwindcss": "*", "prettier-plugin-tailwindcss": "*",
"rollup-plugin-visualizer": "^5.9.0", "rollup-plugin-visualizer": "^5.9.0",
"storybook-tailwind-dark-mode": "^1.0.12",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"typescript": "*", "typescript": "*",
"vite": "^4.0.4", "vite": "^4.0.4",

View file

@ -2,7 +2,6 @@ import useAlbum from '@/web/api/hooks/useAlbum'
import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums' import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
import Icon from '@/web/components/Icon' import Icon from '@/web/components/Icon'
import TrackListHeader from '@/web/components/TrackListHeader' import TrackListHeader from '@/web/components/TrackListHeader'
import useVideoCover from '@/web/hooks/useVideoCover'
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'
@ -11,6 +10,7 @@ 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'
import useAppleMusicAlbum from '@/web/api/hooks/useAppleMusicAlbum' import useAppleMusicAlbum from '@/web/api/hooks/useAppleMusicAlbum'
import { SupportedLanguage } from '@/web/i18n/i18n'
const Header = () => { const Header = () => {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
@ -35,14 +35,21 @@ const Header = () => {
const title = album?.name const title = album?.name
const creatorName = album?.artist.name const creatorName = album?.artist.name
const creatorLink = `/artist/${album?.artist.id}` const creatorLink = `/artist/${album?.artist.id}`
const description = isLoadingAppleMusicAlbum const description = useMemo(() => {
? '' if (isLoadingAppleMusicAlbum) return ''
: appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_')] || const fromApple =
album?.description || appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_') as 'zh_CN' | 'en_US']
appleMusicAlbum?.editorialNote?.en_US if (fromApple) return fromApple
if (i18n.language === 'zh-CN' && album?.description) return album?.description
return appleMusicAlbum?.editorialNote?.en_US
}, [isLoadingAppleMusicAlbum, appleMusicAlbum, i18n.language, appleMusicAlbum])
const extraInfo = useMemo(() => { const extraInfo = useMemo(() => {
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0 const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
const albumDuration = formatDuration(duration, i18n.language, 'hh[hr] mm[min]') const albumDuration = formatDuration(
duration,
i18n.language as SupportedLanguage,
'hh[hr] mm[min]'
)
return ( return (
<> <>
{album?.mark === 1056768 && ( {album?.mark === 1056768 && (
@ -58,7 +65,7 @@ const Header = () => {
const isLiked = useMemo(() => { const isLiked = useMemo(() => {
const id = Number(params.id) const id = Number(params.id)
if (!id) return false if (!id) return false
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 = async (trackID: number | null = null) => {

View file

@ -2,7 +2,6 @@ import useIsMobile from '@/web/hooks/useIsMobile'
import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist' import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist'
import { cx, css } from '@emotion/css' import { cx, css } from '@emotion/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import i18next from 'i18next'
import { useState } from 'react' import { useState } from 'react'
import DescriptionViewer from '@/web/components/DescriptionViewer' import DescriptionViewer from '@/web/components/DescriptionViewer'
@ -17,7 +16,7 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
const [isOpenDescription, setIsOpenDescription] = useState(false) const [isOpenDescription, setIsOpenDescription] = useState(false)
const description = const description =
artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] || artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] ||
artist?.briefDesc || (i18n.language === 'zh-CN' && artist?.briefDesc) ||
artistFromApple?.artistBio?.en_US artistFromApple?.artistBio?.en_US
return ( return (

View file

@ -1,5 +1,5 @@
const Lyrics = () => { const Lyrics = () => {
return <div className='text-white'></div> return <div className='text-white'></div>
} }
export default Lyrics export default Lyrics

View file

@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import useUserArtists from '@/web/api/hooks/useUserArtists' import useUserArtists from '@/web/api/hooks/useUserArtists'
import Tabs from '@/web/components/Tabs' import Tabs from '@/web/components/Tabs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useMemo, useRef } from 'react'
import CoverRow from '@/web/components/CoverRow' import CoverRow from '@/web/components/CoverRow'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists' import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import useUserAlbums from '@/web/api/hooks/useUserAlbums' import useUserAlbums from '@/web/api/hooks/useUserAlbums'
@ -18,6 +18,10 @@ import { useTranslation } from 'react-i18next'
import VideoRow from '@/web/components/VideoRow' 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'
const collections = ['playlists', 'albums', 'artists', 'videos'] as const
type Collection = typeof collections[number]
const Albums = () => { const Albums = () => {
const { data: albums } = useUserAlbums() const { data: albums } = useUserAlbums()
@ -43,8 +47,9 @@ const Videos = () => {
const CollectionTabs = ({ showBg }: { showBg: boolean }) => { const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
const tabs = [ const tabs: { id: Collection; name: string }[] = [
{ {
id: 'playlists', id: 'playlists',
name: t`common.playlist_other`, name: t`common.playlist_other`,
@ -63,10 +68,10 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
}, },
] ]
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates) const { librarySelectedTab: selectedTab } = useSnapshot(persistedUiStates)
const { minimizePlayer } = useSnapshot(persistedUiStates) const { minimizePlayer } = useSnapshot(persistedUiStates)
const setSelectedTab = (id: 'playlists' | 'albums' | 'artists' | 'videos') => { const setSelectedTab = (id: Collection) => {
uiStates.librarySelectedTab = id persistedUiStates.librarySelectedTab = id
} }
return ( return (
@ -94,9 +99,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
</AnimatePresence> </AnimatePresence>
<Tabs <Tabs
tabs={tabs} tabs={tabs.filter(tab => {
if (!displayPlaylistsFromNeteaseMusic && tab.id === 'playlists') {
return false
}
return true
})}
value={selectedTab} value={selectedTab}
onChange={(id: string) => { onChange={(id: Collection) => {
setSelectedTab(id) setSelectedTab(id)
scrollToBottom(true) scrollToBottom(true)
}} }}
@ -110,7 +120,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
} }
const Collections = () => { const Collections = () => {
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates) const { librarySelectedTab: selectedTab } = useSnapshot(persistedUiStates)
const observePoint = useRef<HTMLDivElement | null>(null) const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen: isScrollReachBottom } = useIntersectionObserver(observePoint) const { onScreen: isScrollReachBottom } = useIntersectionObserver(observePoint)

View file

@ -1,17 +1,26 @@
import { css, cx } from '@emotion/css'
import PlayLikedSongsCard from './PlayLikedSongsCard' import PlayLikedSongsCard from './PlayLikedSongsCard'
import PageTransition from '@/web/components/PageTransition' 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'
function PleaseLogin() {
return <></>
}
const My = () => { const My = () => {
const isLoggedIn = useIsLoggedIn()
return ( return (
<PageTransition> <PageTransition>
{isLoggedIn ? (
<div className='grid grid-cols-1 gap-10'> <div className='grid grid-cols-1 gap-10'>
<PlayLikedSongsCard /> <PlayLikedSongsCard />
<RecentlyListened /> <RecentlyListened />
<Collections /> <Collections />
</div> </div>
) : (
<PleaseLogin />
)}
</PageTransition> </PageTransition>
) )
} }

View file

@ -41,23 +41,17 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
return ( return (
<div <div
className={cx( className={cx(
'line-clamp-5', 'line-clamp-4'
css` // css`
height: 86px; // height: 86px;
${bp.lg} { // `
height: auto;
}
`
)} )}
> >
<div className='mb-3.5 text-18 font-medium text-white/70'> <div className='mb-3.5 text-18 font-medium text-white/70'>
{t('my.xxxs-liked-tracks', { nickname: user?.profile?.nickname })} {t('my.xxxs-liked-tracks', { nickname: user?.profile?.nickname })}
</div> </div>
{lyricLines.map((line, index) => ( {lyricLines.map((line, index) => (
<div <div key={`${index}-${line}`} className='text-18 font-medium text-white/20'>
key={`${index}-${line}`}
className='text-18 font-medium text-white/20'
>
{line} {line}
</div> </div>
))} ))}
@ -66,21 +60,15 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
} }
const Covers = memo(({ tracks }: { tracks: Track[] }) => { const Covers = memo(({ tracks }: { tracks: Track[] }) => {
const navigate = useNavigate()
return ( return (
<div className='mt-6 grid w-full flex-shrink-0 grid-cols-3 gap-2.5 lg:mt-0 lg:ml-8 lg:w-auto'> <div className='mt-6 grid w-full flex-shrink-0 grid-cols-3 gap-2.5 lg:mt-0 lg:ml-8 lg:w-auto'>
{tracks.map(track => ( {tracks.map(track => (
<Image <Image
src={resizeImage(track.al.picUrl || '', 'md')} src={resizeImage(track.al.picUrl || '', 'md')}
className={cx( className={cx('aspect-square rounded-24 lg:h-32 lg:w-32')}
'aspect-square rounded-24',
css`
${bp.lg} {
height: 125px;
width: 125px;
}
`
)}
key={track.id} key={track.id}
onClick={() => navigate(`/album/${track.al.id}`)}
/> />
))} ))}
</div> </div>
@ -142,9 +130,7 @@ const PlayLikedSongsCard = () => {
{t`my.playNow`} {t`my.playNow`}
</button> </button>
<button <button
onClick={() => onClick={() => navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)}
navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)
}
className={cx( className={cx(
'flex items-center justify-center rounded-full bg-white/10 text-night-400 transition duration-400 hover:bg-white/20 hover:text-neutral-300', 'flex items-center justify-center rounded-full bg-white/10 text-night-400 transition duration-400 hover:bg-white/20 hover:text-neutral-300',
css` css`

View file

@ -32,18 +32,9 @@ const RecentlyListened = () => {
.map(artist => artist.id) .map(artist => artist.id)
}, [listenedRecords]) }, [listenedRecords])
const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs) const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs)
const artist = useMemo( const artist = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists])
() => recentListenedArtists?.map(a => a.artist),
[recentListenedArtists]
)
return ( return <ArtistRow artists={artist} placeholderRow={1} title={t`my.recently-listened`} />
<ArtistRow
artists={artist}
placeholderRow={1}
title={t`my.recently-listened`}
/>
)
} }
export default RecentlyListened export default RecentlyListened

View file

@ -1,6 +1,6 @@
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import PageTransition from '@/web/components/PageTransition' import PageTransition from '@/web/components/PageTransition'
import TrackList from '@/web/components/TrackList' 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'
@ -18,11 +18,13 @@ const Playlist = () => {
return ( return (
<PageTransition> <PageTransition>
<Header /> <Header />
<div className='pb-10'>
<TrackList <TrackList
tracks={playlist?.playlist?.tracks ?? []} tracks={playlist?.playlist?.tracks ?? []}
onPlay={onPlay} onPlay={onPlay}
className='z-10 mt-10' className='z-10 mt-10'
/> />
</div>
</PageTransition> </PageTransition>
) )
} }

View file

@ -0,0 +1,141 @@
import Icon from '@/web/components/Icon'
import Wave from '@/web/components/Wave'
import { openContextMenu } from '@/web/states/contextMenus'
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 { NavLink } from 'react-router-dom'
import { useSnapshot } from 'valtio'
const Track = ({
track,
index,
playingTrackID,
state,
handleClick,
}: {
track?: Track
index: number
playingTrackID: number
state: PlayerState
handleClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
}) => {
return (
<div
className={cx(
'mb-5 grid',
css`
grid-template-columns: 3fr 2fr 1fr;
`
)}
onClick={e => track && handleClick(e, track.id)}
onContextMenu={e => track && handleClick(e, track.id)}
>
{/* Right part */}
<div className='flex items-center'>
{/* Cover */}
<img
alt='Cover'
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
src={resizeImage(track?.al?.picUrl || '', 'sm')}
/>
{/* Track Name and Artists */}
<div className='mr-3'>
<div
className={cx(
'line-clamp-1 flex items-center text-16 font-medium',
playingTrackID === track?.id
? 'text-brand-700'
: 'text-neutral-700 dark:text-neutral-200'
)}
>
{track?.name}
{[1318912, 1310848].includes(track?.mark || 0) && (
<Icon name='explicit' className='ml-2 mt-px mr-4 h-3.5 w-3.5 text-white/20' />
)}
</div>
<div className='line-clamp-1 mt-1 text-14 font-bold text-white/30'>
{track?.ar.map(a => a.name).join(', ')}
</div>
</div>
{/* Wave icon */}
{playingTrackID === track?.id && (
<div className='ml-5'>
<Wave playing={state === 'playing'} />
</div>
)}
</div>
{/* Album Name */}
<div className='flex items-center'>
<NavLink
to={`/album/${track?.al.id}`}
className='line-clamp-1 text-14 font-bold text-white/40 transition-colors duration-300 hover:text-white/70'
>
{track?.al?.name}
</NavLink>
</div>
{/* Duration */}
<div className='line-clamp-1 flex items-center justify-end text-14 font-bold text-white/40'>
{formatDuration(track?.dt || 0, 'en-US', 'hh:mm:ss')}
</div>
</div>
)
}
function TrackList({
tracks,
onPlay,
className,
isLoading,
placeholderRows = 12,
}: {
tracks?: Track[]
onPlay: (id: number) => void
className?: string
isLoading?: boolean
placeholderRows?: number
}) {
const { trackID, state } = useSnapshot(player)
const playingTrackIndex = tracks?.findIndex(track => track.id === trackID) ?? -1
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
if (isLoading) return
if (e.type === 'contextmenu') {
e.preventDefault()
openContextMenu({
event: e,
type: 'track',
dataSourceID: trackID,
options: {
useCursorPosition: true,
},
})
return
}
if (e.detail === 2) onPlay?.(trackID)
}
return (
<div className={className}>
{tracks?.map((track, index) => (
<Track
key={track.id}
track={track}
index={index}
playingTrackIndex={playingTrackIndex}
state={state}
handleClick={handleClick}
/>
))}
</div>
)
}
export default TrackList

View file

@ -18,21 +18,12 @@ const Artists = ({ artists }: { artists: Artist[] }) => {
<div <div
onClick={() => navigate(`/artist/${artist.id}`)} onClick={() => navigate(`/artist/${artist.id}`)}
key={artist.id} key={artist.id}
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10' className='flex items-center py-2.5'
> >
<div className='mr-4 h-14 w-14'> <img src={resizeImage(artist.img1v1Url, 'xs')} className='mr-4 h-14 w-14 rounded-full' />
<img
src={resizeImage(artist.img1v1Url, 'xs')}
className='h-12 w-12 rounded-full'
/>
</div>
<div> <div>
<div className='text-lg font-semibold dark:text-white'> <div className='text-lg font-semibold text-neutral-200'>{artist.name}</div>
{artist.name} <div className='mt-0.5 text-sm font-semibold text-white/30'></div>
</div>
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
</div>
</div> </div>
</div> </div>
))} ))}
@ -48,19 +39,12 @@ const Albums = ({ albums }: { albums: Album[] }) => {
<div <div
onClick={() => navigate(`/album/${album.id}`)} onClick={() => navigate(`/album/${album.id}`)}
key={album.id} key={album.id}
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10' className='flex items-center py-2.5 text-neutral-200'
> >
<div className='mr-4 h-14 w-14'> <img src={resizeImage(album.picUrl, 'xs')} className='mr-4 h-14 w-14 rounded-lg' />
<img
src={resizeImage(album.picUrl, 'xs')}
className='h-12 w-12 rounded-lg'
/>
</div>
<div> <div>
<div className='text-lg font-semibold dark:text-white'> <div className='text-lg font-semibold text-neutral-200'>{album.name}</div>
{album.name} <div className='mt-0.5 text-sm font-semibold text-white/30'>
</div>
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
· {album?.artist.name} · {dayjs(album.publishTime).year()} · {album?.artist.name} · {dayjs(album.publishTime).year()}
</div> </div>
</div> </div>
@ -99,14 +83,12 @@ 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}
</div> </div>
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'> <div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 text-white/30'>
{track?.ar.map(a => a.name).join(', ')} {track?.ar.map(a => a.name).join(', ')}
</div> </div>
</div> </div>
@ -118,9 +100,7 @@ const Search = () => {
const { keywords = '', type = 'all' } = useParams() const { keywords = '', type = 'all' } = useParams()
const searchType: keyof typeof SearchTypes = const searchType: keyof typeof SearchTypes =
type.toUpperCase() in SearchTypes type.toUpperCase() in SearchTypes ? (type.toUpperCase() as keyof typeof SearchTypes) : 'All'
? (type.toUpperCase() as keyof typeof SearchTypes)
: 'All'
const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery( const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery(
[SearchApiNames.MultiMatchSearch, keywords], [SearchApiNames.MultiMatchSearch, keywords],
@ -172,32 +152,37 @@ const Search = () => {
return ( return (
<div> <div>
<div className='mt-6 mb-8 text-4xl font-semibold dark:text-white'> <div className='mt-6 mb-8 text-4xl font-semibold dark:text-white'>
<span className='text-gray-500'></span> &quot;{keywords}&quot; <span className='text-white/40'></span> &quot;{keywords}&quot;
</div> </div>
{/* Best match */} {/* Best match */}
{bestMatch.length !== 0 && ( {bestMatch.length !== 0 && (
<div className='mb-6'> <div className='mb-6'>
<div className='mb-2 text-sm font-medium text-gray-400'></div> {/* mx-2.5 mb-6 text-12 font-medium uppercase dark:text-neutral-300 lg:mx-0 lg:text-14
lg:font-bold */}
<div className='mb-2 text-14 font-bold uppercase text-neutral-300'></div>
<div className='grid grid-cols-2'> <div className='grid grid-cols-2'>
{bestMatch.map(match => ( {bestMatch.map(match => (
<div <div
onClick={() => navigateBestMatch(match)} onClick={() => navigateBestMatch(match)}
key={`${match.id}${match.picUrl}`} key={`${match.id}${match.picUrl}`}
className='btn-hover-animation flex items-center p-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10' className='btn-hover-animation flex items-center py-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
> >
<div className='mr-6 h-24 w-24'>
<img <img
src={resizeImage(match.picUrl, 'xs')} src={resizeImage(match.picUrl, 'xs')}
className='h-12 w-12 rounded-full' className={cx(
'mr-6 h-20 w-20',
(match as Artist).occupation === '歌手' ? 'rounded-full' : 'rounded-xl'
)}
/> />
</div>
<div> <div>
<div className='text-xl font-semibold dark:text-white'> <div className='text-xl font-semibold text-neutral-200'>{match.name}</div>
{match.name} <div className='mt-0.5 font-medium text-white/30'>
</div> {(match as Artist).occupation === '歌手'
<div className='mt-0.5 font-medium text-gray-500 dark:text-gray-400'> ? '艺人'
{(match as Artist).occupation === '歌手' ? '艺人' : '专辑'} : `专辑 · ${(match as Album).artist.name} · ${dayjs(
match.publishTime
).year()}`}
</div> </div>
</div> </div>
</div> </div>
@ -210,21 +195,21 @@ const Search = () => {
<div className='grid grid-cols-2 gap-6'> <div className='grid grid-cols-2 gap-6'>
{searchResult?.result?.artist?.artists && ( {searchResult?.result?.artist?.artists && (
<div> <div>
<div className='mb-2 text-sm font-medium text-gray-400'></div> <div className='mb-2 text-14 font-bold uppercase text-neutral-300'></div>
<Artists artists={searchResult.result.artist.artists} /> <Artists artists={searchResult.result.artist.artists} />
</div> </div>
)} )}
{searchResult?.result?.album?.albums && ( {searchResult?.result?.album?.albums && (
<div> <div>
<div className='mb-2 text-sm font-medium text-gray-400'></div> <div className='mb-2 text-14 font-bold uppercase text-neutral-300'></div>
<Albums albums={searchResult.result.album.albums} /> <Albums albums={searchResult.result.album.albums} />
</div> </div>
)} )}
{searchResult?.result?.song?.songs && ( {searchResult?.result?.song?.songs && (
<div className='col-span-2'> <div className='col-span-2'>
<div className='mb-2 text-sm font-medium text-gray-400'></div> <div className='mb-2 text-14 font-bold uppercase text-neutral-300'></div>
<div className='mt-4 grid grid-cols-3 grid-rows-3 gap-5 gap-y-6 overflow-hidden pb-12'> <div className='mt-4 grid grid-cols-3 grid-rows-3 gap-5 gap-y-6 overflow-hidden pb-12'>
{searchResult.result.song.songs.map(track => ( {searchResult.result.song.songs.map(track => (
<Track key={track.id} track={track} onPlay={handlePlayTracks} /> <Track key={track.id} track={track} onPlay={handlePlayTracks} />

View file

@ -37,15 +37,10 @@ const AccentColor = () => {
{Object.entries(colors).map(([color, bg]) => ( {Object.entries(colors).map(([color, bg]) => (
<div <div
key={color} key={color}
className={cx( className={cx(bg, 'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full')}
bg,
'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full'
)}
onClick={() => changeColor(color)} onClick={() => changeColor(color)}
> >
{color === accentColor && ( {color === accentColor && <div className='h-1.5 w-1.5 rounded-full bg-white'></div>}
<div className='h-1.5 w-1.5 rounded-full bg-white'></div>
)}
</div> </div>
))} ))}
</div> </div>
@ -55,23 +50,19 @@ const AccentColor = () => {
const Theme = () => { const Theme = () => {
return ( return (
<div className='mt-4'> <>
<div className='mb-2 dark:text-white'></div> <div className='text-xl font-medium text-gray-800 dark:text-white/70'></div>
<div></div> <div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
</div> <AccentColor />
</>
) )
} }
const Appearance = () => { const Appearance = () => {
return ( return (
<div> <div>
<div className='text-xl font-medium text-gray-800 dark:text-white/70'> <span className='text-white'></span>
{/* <Theme /> */}
</div>
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
<AccentColor />
<Theme />
</div> </div>
) )
} }

View file

@ -0,0 +1,108 @@
import Icon from '@/web/components/Icon'
import { cx } from '@emotion/css'
import { motion } from 'framer-motion'
export function Switch({
enabled,
onChange,
}: {
enabled: boolean
onChange: (enabled: boolean) => void
}) {
return (
<motion.div
className={cx(
'flex w-11 items-center justify-start rounded-full p-1 transition-colors duration-500',
enabled ? 'bg-brand-700' : 'bg-white/10'
)}
onClick={() => onChange(!enabled)}
>
<motion.div
animate={{ x: enabled ? 16 : 0 }}
className='h-5 w-5 rounded-full bg-white shadow-sm'
></motion.div>
</motion.div>
)
}
export function Select<T extends string>({
options,
value,
onChange,
}: {
options: { name: string; value: T }[]
value: T
onChange: (value: T) => void
}) {
return (
<div className='relative inline-block rounded-md bg-neutral-800 font-medium text-neutral-400'>
<select
onChange={e => onChange(e.target.value as T)}
value={value}
className='h-full w-full appearance-none bg-transparent py-1 pr-7 pl-3 focus:outline-none'
>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.name}
</option>
))}
</select>
<Icon
name='dropdown-triangle'
className='pointer-events-none absolute right-2.5 h-2.5 w-2.5 text-white/15'
style={{ top: '11px' }}
/>
</div>
)
}
export function Input({
value,
onChange,
type = 'text',
}: {
value: string
onChange: (value: string) => void
type?: 'text' | 'password' | 'number'
}) {
return (
<div className=''>
<div className='mb-1 text-14 font-medium text-white/30'>Host</div>
<div className='inline-block rounded-md bg-neutral-800 font-medium text-neutral-400'>
<input
className='appearance-none bg-transparent py-1 px-3'
onChange={e => onChange(e.target.value)}
{...{ type, value }}
/>
</div>
</div>
)
}
export function Button({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button
onClick={onClick}
className='rounded-md bg-neutral-800 py-1 px-3 font-medium text-neutral-400 transition-colors duration-300 hover:bg-neutral-700 hover:text-neutral-300'
>
{children}
</button>
)
}
export function BlockTitle({ children }: { children: React.ReactNode }) {
return <div className='text-21 font-medium text-neutral-100'>{children}</div>
}
export function BlockDescription({ children }: { children: React.ReactNode }) {
return <div className='my-1 text-16 font-medium text-white/30'>{children}</div>
}
export function Option({ children }: { children: React.ReactNode }) {
return <div className='my-3 flex items-center justify-between'>{children}</div>
}
export function OptionText({ children }: { children: React.ReactNode }) {
return <div className='text-16 font-medium text-neutral-400'>{children}</div>
}

View file

@ -0,0 +1,86 @@
import { SupportedLanguage } from '@/web/i18n/i18n'
import persistedUiStates from '@/web/states/persistedUiStates'
import settings from '@/web/states/settings'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { BlockTitle, OptionText, Select, Option, Switch } from './Controls'
function General() {
return (
<div>
<Language />
<AppleMusic />
<NeteaseMusic />
</div>
)
}
function Language() {
const { t } = useTranslation()
const supportedLanguages: { name: string; value: SupportedLanguage }[] = [
{ name: 'English', value: 'en-US' },
{ name: '简体中文', value: 'zh-CN' },
]
const { language } = useSnapshot(settings)
const setLanguage = (language: SupportedLanguage) => {
settings.language = language
}
return (
<>
<BlockTitle>Language</BlockTitle>
<Option>
<OptionText>{t`settings.general-choose-language`}</OptionText>
<Select options={supportedLanguages} value={language} onChange={setLanguage} />
</Option>
</>
)
}
function AppleMusic() {
const { playAnimatedArtworkFromApple, priorityDisplayOfAlbumArtistDescriptionFromAppleMusic } =
useSnapshot(settings)
return (
<div className='mt-7'>
<BlockTitle>Apple Music</BlockTitle>
<Option>
<OptionText>Play Animated Artwork from Apple Music</OptionText>
<Switch
enabled={playAnimatedArtworkFromApple}
onChange={v => (settings.playAnimatedArtworkFromApple = v)}
/>
</Option>
<Option>
<OptionText>Priority Display of Album/Artist Description from Apple Music</OptionText>
<Switch
enabled={priorityDisplayOfAlbumArtistDescriptionFromAppleMusic}
onChange={v => (settings.priorityDisplayOfAlbumArtistDescriptionFromAppleMusic = v)}
/>
</Option>
</div>
)
}
function NeteaseMusic() {
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
return (
<div className='mt-7'>
<BlockTitle>Netease Music</BlockTitle>
<Option>
<OptionText>Display Playlists from Netease Music</OptionText>
<Switch
enabled={displayPlaylistsFromNeteaseMusic}
onChange={v => {
settings.displayPlaylistsFromNeteaseMusic = v
if (persistedUiStates.librarySelectedTab === 'playlists') {
persistedUiStates.librarySelectedTab = 'albums'
}
}}
/>
</Option>
</div>
)
}
export default General

View file

@ -0,0 +1,53 @@
import settings from '@/web/states/settings'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { BlockDescription, BlockTitle, Button, Option, OptionText, Switch } from './Controls'
function Player() {
return (
<div>
<FindTrackOnYouTube />
</div>
)
}
function FindTrackOnYouTube() {
const { t } = useTranslation()
const { enableFindTrackOnYouTube, httpProxyForYouTube } = useSnapshot(settings)
return (
<div>
<BlockTitle>{t`settings.player-youtube-unlock`}</BlockTitle>
<BlockDescription>
Find alternative track on YouTube if not available on NetEase.
</BlockDescription>
{/* Switch */}
<Option>
<OptionText>Enable YouTube Unlock </OptionText>
<Switch
enabled={enableFindTrackOnYouTube}
onChange={value => (settings.enableFindTrackOnYouTube = value)}
/>
</Option>
{/* Proxy */}
{/* <Option>
<OptionText>
HTTP Proxy config for connecting to YouTube {httpProxyForYouTube?.host && '(Configured)'}
</OptionText>
<Button
onClick={() => {
toast('开发中')
}}
>
Edit
</Button>
</Option> */}
</div>
)
}
export default Player

File diff suppressed because one or more lines are too long

View file

@ -1,44 +0,0 @@
const UnblockNeteaseMusic = () => {
return (
<div>
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
UnblockNeteaseMusic
</div>
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
<div>
:
<div>
<input type='checkbox' id='migu' value='migu' />
<label htmlFor='migu'>migu</label>
</div>
<div>
<input type='checkbox' id='youtube' value='youtube' />
<label htmlFor='youtube'>youtube</label>
</div>
<div>
<input type='checkbox' id='kugou' value='kugou' />
<label htmlFor='kugou'>kugou</label>
</div>
<div>
<input type='checkbox' id='kuwo' value='kuwo' />
<label htmlFor='kuwo'>kuwo</label>
</div>
<div>
<input type='checkbox' id='qq' value='qq' />
<label htmlFor='qq'>qq</label>
</div>
<div>
<input type='checkbox' id='bilibili' value='bilibili' />
<label htmlFor='bilibili'>bilibili</label>
</div>
<div>
<input type='checkbox' id='joox' value='joox' />
<label htmlFor='joox'>joox</label>
</div>
</div>
</div>
)
}
export default UnblockNeteaseMusic

File diff suppressed because one or more lines are too long

View file

@ -5,12 +5,14 @@ interface PersistedUiStates {
loginPhoneCountryCode: string loginPhoneCountryCode: string
loginType: 'phone' | 'email' | 'qrCode' loginType: 'phone' | 'email' | 'qrCode'
minimizePlayer: boolean minimizePlayer: boolean
librarySelectedTab: 'playlists' | 'albums' | 'artists' | 'videos'
} }
const initPersistedUiStates: PersistedUiStates = { const initPersistedUiStates: PersistedUiStates = {
loginPhoneCountryCode: '+86', loginPhoneCountryCode: '+86',
loginType: 'qrCode', loginType: 'qrCode',
minimizePlayer: false, minimizePlayer: false,
librarySelectedTab: 'albums',
} }
const STORAGE_KEY = 'persistedUiStates' const STORAGE_KEY = 'persistedUiStates'
@ -24,9 +26,7 @@ if (statesInStorage) {
} }
} }
const persistedUiStates = proxy<PersistedUiStates>( const persistedUiStates = proxy<PersistedUiStates>(merge(initPersistedUiStates, sates))
merge(initPersistedUiStates, sates)
)
subscribe(persistedUiStates, () => { subscribe(persistedUiStates, () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(persistedUiStates)) localStorage.setItem(STORAGE_KEY, JSON.stringify(persistedUiStates))

View file

@ -1,41 +1,33 @@
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { merge } from 'lodash-es' import { merge } from 'lodash-es'
import { proxy, subscribe } from 'valtio' import { proxy, subscribe } from 'valtio'
import i18n, { getLanguage, supportedLanguages } from '../i18n/i18n' import i18n, { getInitLanguage, SupportedLanguage, supportedLanguages } from '../i18n/i18n'
interface Settings { interface Settings {
accentColor: string accentColor: string
language: typeof supportedLanguages[number] language: SupportedLanguage
unm: { enableFindTrackOnYouTube: boolean
enabled: boolean httpProxyForYouTube?: {
sources: Array<
'migu' | 'kuwo' | 'kugou' | 'ytdl' | 'qq' | 'bilibili' | 'joox'
>
searchMode: 'order-first' | 'fast-first'
proxy: null | {
protocol: 'http' | 'https' | 'socks5'
host: string host: string
port: number port: number
username?: string protocol: 'http' | 'https'
password?: string auth?: {
} username: string
cookies: { password: string
qq?: string
joox?: string
} }
} }
playAnimatedArtworkFromApple: boolean
priorityDisplayOfAlbumArtistDescriptionFromAppleMusic: boolean
displayPlaylistsFromNeteaseMusic: boolean
} }
const initSettings: Settings = { const initSettings: Settings = {
accentColor: 'blue', accentColor: 'green',
language: getLanguage(), language: getInitLanguage(),
unm: { enableFindTrackOnYouTube: false,
enabled: true, playAnimatedArtworkFromApple: true,
sources: ['migu'], priorityDisplayOfAlbumArtistDescriptionFromAppleMusic: true,
searchMode: 'order-first', displayPlaylistsFromNeteaseMusic: true,
proxy: null,
cookies: {},
},
} }
const STORAGE_KEY = 'settings' const STORAGE_KEY = 'settings'
@ -50,15 +42,11 @@ try {
const settings = proxy<Settings>(merge(initSettings, statesInStorage)) const settings = proxy<Settings>(merge(initSettings, statesInStorage))
subscribe(settings, () => { subscribe(settings, () => {
if ( if (settings.language !== i18n.language && supportedLanguages.includes(settings.language)) {
settings.language !== i18n.language &&
supportedLanguages.includes(settings.language)
) {
i18n.changeLanguage(settings.language) i18n.changeLanguage(settings.language)
} }
window.ipcRenderer?.send(IpcChannels.SyncSettings, settings)
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
window.ipcRenderer?.send(IpcChannels.SyncSettings, JSON.parse(JSON.stringify(settings)))
}) })
export default settings export default settings

View file

@ -5,7 +5,6 @@ interface UIStates {
showLyricPanel: boolean showLyricPanel: boolean
showLoginPanel: boolean showLoginPanel: boolean
hideTopbarBackground: boolean hideTopbarBackground: boolean
librarySelectedTab: 'playlists' | 'albums' | 'artists' | 'videos'
mobileShowPlayingNext: boolean mobileShowPlayingNext: boolean
blurBackgroundImage: string | null blurBackgroundImage: string | null
fullscreen: boolean fullscreen: boolean
@ -17,7 +16,6 @@ const initUIStates: UIStates = {
showLyricPanel: false, showLyricPanel: false,
showLoginPanel: false, showLoginPanel: false,
hideTopbarBackground: false, hideTopbarBackground: false,
librarySelectedTab: 'playlists',
mobileShowPlayingNext: false, mobileShowPlayingNext: false,
blurBackgroundImage: null, blurBackgroundImage: null,
fullscreen: false, fullscreen: false,

View file

@ -46,15 +46,15 @@ test('formatDuration', () => {
expect(formatDuration(3600000)).toBe('1:00:00') expect(formatDuration(3600000)).toBe('1:00:00')
expect(formatDuration(3700000)).toBe('1:01:40') expect(formatDuration(3700000)).toBe('1:01:40')
expect(formatDuration(3600000, 'en', 'hh[hr] mm[min]')).toBe('1 hr') expect(formatDuration(3600000, 'en-US', 'hh[hr] mm[min]')).toBe('1 hr')
expect(formatDuration(3600000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时') expect(formatDuration(3600000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时')
expect(formatDuration(3600000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時') // expect(formatDuration(3600000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時')
expect(formatDuration(3700000, 'en', 'hh[hr] mm[min]')).toBe('1 hr 1 min') expect(formatDuration(3700000, 'en-US', 'hh[hr] mm[min]')).toBe('1 hr 1 min')
expect(formatDuration(3700000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时 1 分钟') expect(formatDuration(3700000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时 1 分钟')
expect(formatDuration(3700000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時 1 分鐘') // expect(formatDuration(3700000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時 1 分鐘')
expect(formatDuration(0)).toBe('0:00') expect(formatDuration(0)).toBe('0:00')
expect(formatDuration(0, 'en', 'hh[hr] mm[min]')).toBe('0 min') expect(formatDuration(0, 'en-US', 'hh[hr] mm[min]')).toBe('0 min')
expect(formatDuration(0, 'zh-CN', 'hh[hr] mm[min]')).toBe('0 分钟') expect(formatDuration(0, 'zh-CN', 'hh[hr] mm[min]')).toBe('0 分钟')
}) })
@ -86,7 +86,7 @@ describe('cacheCoverColor', () => {
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(APIs.CoverColor) expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ expect(args[0].query).toEqual({
id: '', id: '',
color: '#fff', color: '#fff',
@ -117,7 +117,7 @@ test('calcCoverColor', async () => {
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(APIs.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',
@ -141,7 +141,7 @@ describe('getCoverColor', () => {
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(APIs.CoverColor) expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({ expect(args[0].query).toEqual({
id: '109951165911363', id: '109951165911363',
}) })

View file

@ -2,7 +2,11 @@
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": false, "esModuleInterop": false,
@ -17,9 +21,17 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": "../", "baseUrl": "../",
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
}, },
"types": ["vite-plugin-svg-icons/client"] "types": [
"vite-plugin-svg-icons/client"
]
}, },
"include": ["./**/*.ts", "./**/*.tsx", "../shared/**/*.ts"] "include": [
"./**/*.ts",
"./**/*.tsx",
"../shared/**/*.ts"
]
} }

View file

@ -4,7 +4,7 @@ import duration from 'dayjs/plugin/duration'
import { CacheAPIs } from '@/shared/CacheAPIs' import { CacheAPIs } from '@/shared/CacheAPIs'
import { average } from 'color.js' import { average } from 'color.js'
import { colord } from 'colord' import { colord } from 'colord'
import { supportedLanguages } from '../i18n/i18n' import { SupportedLanguage } from '../i18n/i18n'
/** /**
* @description * @description
@ -73,7 +73,7 @@ export function formatDate(
*/ */
export function formatDuration( export function formatDuration(
milliseconds: number, milliseconds: number,
locale: typeof supportedLanguages[number] = 'zh-CN', locale: SupportedLanguage = 'zh-CN',
format: 'hh:mm:ss' | 'hh[hr] mm[min]' = 'hh:mm:ss' format: 'hh:mm:ss' | 'hh[hr] mm[min]' = 'hh:mm:ss'
): string { ): string {
dayjs.extend(duration) dayjs.extend(duration)

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