mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 05:38:04 +00:00
feat: updates
This commit is contained in:
parent
9a52681687
commit
840a5b8e9b
104 changed files with 1645 additions and 13494 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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" (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
30
packages/server/src/routes/apple-music/check-token.ts
Normal file
30
packages/server/src/routes/apple-music/check-token.ts
Normal 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
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
||||||
<script>
|
|
||||||
window.global = window;
|
|
||||||
</script>
|
|
||||||
|
|
@ -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$/,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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专辑
|
||||||
|
|
|
||||||
|
|
@ -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小时缓存
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
packages/web/assets/icons/dropdown-triangle.svg
Normal file
3
packages/web/assets/icons/dropdown-triangle.svg
Normal 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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
82
packages/web/components/ArtworkViewer.tsx
Normal file
82
packages/web/components/ArtworkViewer.tsx
Normal 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
|
||||||
|
|
@ -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`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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`}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import LyricPanel from './LyricPanel'
|
|
||||||
|
|
||||||
export default LyricPanel
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 />)} />
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -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')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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`}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
9
packages/web/hooks/useSettings.ts
Normal file
9
packages/web/hooks/useSettings.ts
Normal 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
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
packages/web/hooks/useWindowFocus.ts
Normal file
26
packages/web/hooks/useWindowFocus.ts
Normal 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
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "分享",
|
||||||
|
|
|
||||||
11
packages/web/i18n/react-i18next.d.ts
vendored
11
packages/web/i18n/react-i18next.d.ts
vendored
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
packages/web/pages/Playlist/TrackList.tsx
Normal file
141
packages/web/pages/Playlist/TrackList.tsx
Normal 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
|
||||||
|
|
@ -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> "{keywords}"
|
<span className='text-white/40'>搜索</span> "{keywords}"
|
||||||
</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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
packages/web/pages/Settings/Controls.tsx
Normal file
108
packages/web/pages/Settings/Controls.tsx
Normal 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>
|
||||||
|
}
|
||||||
86
packages/web/pages/Settings/General.tsx
Normal file
86
packages/web/pages/Settings/General.tsx
Normal 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
|
||||||
53
packages/web/pages/Settings/Player.tsx
Normal file
53
packages/web/pages/Settings/Player.tsx
Normal 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
|
|
@ -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
|
|
||||||
44
packages/web/pages/Settings/UserCard.tsx
Normal file
44
packages/web/pages/Settings/UserCard.tsx
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue