mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2024-11-22 11:42:34 +08:00
feat: updates
This commit is contained in:
parent
9a52681687
commit
840a5b8e9b
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -57,3 +57,4 @@ vercel.json
|
|||
packages/web/bundle-stats-renderer.html
|
||||
packages/web/bundle-stats.html
|
||||
packages/web/storybook-static
|
||||
packages/desktop/prisma/client
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
},
|
||||
"packageManager": "pnpm@7.20.0",
|
||||
"scripts": {
|
||||
"postinstall": "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:web": "turbo run build:web",
|
||||
|
@ -20,16 +21,14 @@
|
|||
"pack:test": "turbo run build && turbo run pack:test",
|
||||
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\"",
|
||||
"storybook": "pnpm -F web storybook",
|
||||
"storybook:build": "pnpm -F web storybook:build"
|
||||
"format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.31.0",
|
||||
"prettier": "^2.8.1",
|
||||
"turbo": "^1.6.3",
|
||||
"typescript": "^4.9.4",
|
||||
"typescript": "^4.9.5",
|
||||
"tsx": "^3.12.1",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1"
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ module.exports = {
|
|||
npmRebuild: false,
|
||||
buildDependenciesFromSource: false,
|
||||
electronVersion,
|
||||
afterPack: './scripts/copySQLite3.js',
|
||||
forceCodeSigning: false,
|
||||
publish: [
|
||||
{
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import proxy from '@fastify/http-proxy'
|
||||
import { isDev } from '@/desktop/main/env'
|
||||
|
||||
async function appleMusic(fastify: FastifyInstance) {
|
||||
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',
|
||||
rewritePrefix: '/apple-music',
|
||||
})
|
||||
|
|
|
@ -9,6 +9,7 @@ import fs from 'fs'
|
|||
import youtube from '@/desktop/main/youtube'
|
||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { FetchTracksResponse } from '@/shared/api/Track'
|
||||
import store from '@/desktop/main/store'
|
||||
|
||||
const getAudioFromCache = async (id: number) => {
|
||||
// get from cache
|
||||
|
@ -76,47 +77,51 @@ const getAudioFromYouTube = async (id: number) => {
|
|||
const track = fetchTrackResult?.songs?.[0]
|
||||
if (!track) return
|
||||
|
||||
const data = await youtube.matchTrack(track.ar[0].name, track.name)
|
||||
if (!data) return
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: 'youtube',
|
||||
id,
|
||||
url: data.url,
|
||||
br: data.bitRate,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: 'opus',
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: 'opus',
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
try {
|
||||
const data = await youtube.matchTrack(track.ar[0].name, track.name)
|
||||
if (!data) return
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: 'youtube',
|
||||
id,
|
||||
url: data.url,
|
||||
br: data.bitRate,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: 'opus',
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: 'opus',
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
r3play: {
|
||||
youtube: data,
|
||||
},
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
r3play: {
|
||||
youtube: data,
|
||||
},
|
||||
},
|
||||
],
|
||||
code: 200,
|
||||
],
|
||||
code: 200,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('getAudioFromYouTube error', id, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -154,9 +159,11 @@ async function audio(fastify: FastifyInstance) {
|
|||
return
|
||||
}
|
||||
|
||||
const fromYoutube = getAudioFromYouTube(id)
|
||||
if (fromYoutube) {
|
||||
return fromYoutube
|
||||
if (store.get('settings.enableFindTrackOnYouTube')) {
|
||||
const fromYoutube = getAudioFromYouTube(id)
|
||||
if (fromYoutube) {
|
||||
return fromYoutube
|
||||
}
|
||||
}
|
||||
|
||||
// 是试听歌曲就把url删掉
|
||||
|
@ -181,11 +188,14 @@ async function audio(fastify: FastifyInstance) {
|
|||
fastify.post(
|
||||
`/${appName.toLowerCase()}/audio/:id`,
|
||||
async (
|
||||
req: FastifyRequest<{ Params: { id: string }; Querystring: { url: string } }>,
|
||||
req: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { url: string; bitrate: number }
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const id = Number(req.params.id)
|
||||
const { url } = req.query
|
||||
const { url, bitrate } = req.query
|
||||
if (isNaN(id)) {
|
||||
return reply.status(400).send({ error: 'Invalid param id' })
|
||||
}
|
||||
|
@ -200,7 +210,7 @@ async function audio(fastify: FastifyInstance) {
|
|||
}
|
||||
|
||||
try {
|
||||
await cache.setAudio(await data.toBuffer(), { id, url })
|
||||
await cache.setAudio(await data.toBuffer(), { id, url, bitrate })
|
||||
reply.status(200).send('Audio cached!')
|
||||
} catch (error) {
|
||||
reply.status(500).send({ error })
|
||||
|
|
|
@ -238,7 +238,7 @@ class Cache {
|
|||
return
|
||||
}
|
||||
|
||||
getAudio(filename: string, reply: FastifyReply) {
|
||||
async getAudio(filename: string, reply: FastifyReply) {
|
||||
if (!filename) {
|
||||
return reply.status(400).send({ error: 'No filename provided' })
|
||||
}
|
||||
|
@ -252,6 +252,7 @@ class Cache {
|
|||
fs.unlinkSync(path)
|
||||
return reply.status(404).send({ error: 'Audio not found' })
|
||||
}
|
||||
await prisma.audio.update({ where: { id }, data: { updatedAt: new Date() } })
|
||||
reply
|
||||
.status(206)
|
||||
.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`
|
||||
|
||||
try {
|
||||
|
@ -273,7 +277,7 @@ class Cache {
|
|||
}
|
||||
|
||||
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 =
|
||||
{
|
||||
'MPEG 1 Layer 3': 'mp3',
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Thumbar } from './windowsTaskbar'
|
|||
import fastFolderSize from 'fast-folder-size'
|
||||
import path from 'path'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import prisma from './prisma'
|
||||
|
||||
const on = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
|
@ -203,7 +204,7 @@ function initOtherIpcMain() {
|
|||
* 退出登陆
|
||||
*/
|
||||
handle(IpcChannels.Logout, async () => {
|
||||
// db.truncate(Tables.AccountData)
|
||||
await prisma.accountData.deleteMany({})
|
||||
return true
|
||||
})
|
||||
|
||||
|
|
|
@ -1,69 +1,218 @@
|
|||
import log from './log'
|
||||
import youtube, { Scraper, Video } from '@yimura/scraper'
|
||||
import ytdl from 'ytdl-core'
|
||||
import axios, { AxiosProxyConfig } from 'axios'
|
||||
import store from './store'
|
||||
import httpProxyAgent from 'http-proxy-agent'
|
||||
|
||||
class YoutubeDownloader {
|
||||
yt: Scraper
|
||||
|
||||
constructor() {
|
||||
// @ts-ignore
|
||||
this.yt = new youtube.default()
|
||||
//
|
||||
}
|
||||
|
||||
async search(keyword: string) {
|
||||
const result = await this.yt.search(keyword)
|
||||
return result?.videos
|
||||
}
|
||||
async search(keyword: string): Promise<
|
||||
{
|
||||
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,
|
||||
})
|
||||
|
||||
async matchTrack(artist: string, trackName: string) {
|
||||
console.time('[youtube] search')
|
||||
const videos = await this.search(`${artist} ${trackName} lyric audio`)
|
||||
console.timeEnd('[youtube] search')
|
||||
let video: Video | null = null
|
||||
|
||||
// 找官方频道最匹配的
|
||||
// videos.forEach(v => {
|
||||
// if (video) return
|
||||
// const channelName = v.channel.name.toLowerCase()
|
||||
// if (channelName !== artist.toLowerCase()) return
|
||||
// const title = v.title.toLowerCase()
|
||||
// if (!title.includes(trackName.toLowerCase())) return
|
||||
// if (!title.includes('audio') && !title.includes('lyric')) return
|
||||
// video = v
|
||||
// })
|
||||
|
||||
// TODO:找时长误差不超过2秒的
|
||||
|
||||
// 最后方案选搜索的第一个
|
||||
if (!video) {
|
||||
video = videos[0]
|
||||
if (webPage.status !== 200) {
|
||||
return []
|
||||
}
|
||||
|
||||
console.time('[youtube] getInfo')
|
||||
const info = await ytdl.getInfo('http://www.youtube.com/watch?v=' + video.id)
|
||||
console.timeEnd('[youtube] getInfo')
|
||||
let url = ''
|
||||
let bitRate = 0
|
||||
info.formats.forEach(video => {
|
||||
if (
|
||||
video.mimeType === `audio/webm; codecs="opus"` &&
|
||||
video.bitrate &&
|
||||
video.bitrate > bitRate
|
||||
) {
|
||||
url = video.url
|
||||
bitRate = video.bitrate
|
||||
// @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')
|
||||
const videos = await this.search(`${artist} ${trackName} audio`)
|
||||
console.timeEnd('[youtube] search')
|
||||
let video: {
|
||||
duration: number
|
||||
id: string
|
||||
title: string
|
||||
} | null = null
|
||||
|
||||
// 找官方频道最匹配的
|
||||
// videos.forEach(v => {
|
||||
// if (video) return
|
||||
// const channelName = v.channel.name.toLowerCase()
|
||||
// if (channelName !== artist.toLowerCase()) return
|
||||
// const title = v.title.toLowerCase()
|
||||
// if (!title.includes(trackName.toLowerCase())) return
|
||||
// if (!title.includes('audio') && !title.includes('lyric')) return
|
||||
// video = v
|
||||
// })
|
||||
|
||||
// TODO:找时长误差不超过2秒的
|
||||
|
||||
// 最后方案选搜索的第一个
|
||||
if (!video) {
|
||||
video = videos[0]
|
||||
}
|
||||
if (!video) return null
|
||||
|
||||
console.time('[youtube] getInfo')
|
||||
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')
|
||||
if (!info) return null
|
||||
let url = ''
|
||||
let bitRate = 0
|
||||
info.formats.forEach(video => {
|
||||
if (
|
||||
video.mimeType === `audio/webm; codecs="opus"` &&
|
||||
video.bitrate &&
|
||||
video.bitrate > bitRate
|
||||
) {
|
||||
url = video.url
|
||||
bitRate = video.bitrate
|
||||
}
|
||||
})
|
||||
const data = {
|
||||
url,
|
||||
bitRate,
|
||||
title: info.videoDetails.title,
|
||||
videoId: info.videoDetails.videoId,
|
||||
duration: info.videoDetails.lengthSeconds,
|
||||
channel: info.videoDetails.ownerChannelName,
|
||||
}
|
||||
log.info(`[youtube] matched `, 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)
|
||||
}
|
||||
})
|
||||
const data = {
|
||||
url,
|
||||
bitRate,
|
||||
title: info.videoDetails.title,
|
||||
videoId: info.videoDetails.videoId,
|
||||
duration: info.videoDetails.lengthSeconds,
|
||||
channel: info.videoDetails.ownerChannelName,
|
||||
}
|
||||
log.info(`[youtube] matched `, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
return axios.get('https://www.youtube.com', { timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,6 @@ CREATE TABLE IF NOT EXISTS "Audio" (
|
|||
"source" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"queriedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Lyrics" (
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
"prisma:db-push": "prisma db push"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^8.3.0",
|
||||
|
@ -37,6 +37,7 @@
|
|||
"electron-store": "^8.1.0",
|
||||
"fast-folder-size": "^1.7.1",
|
||||
"fastify": "^4.5.3",
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"prisma": "^4.8.1",
|
||||
"ytdl-core": "^4.11.2"
|
||||
|
|
|
@ -62,7 +62,6 @@ model Audio {
|
|||
source String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
queriedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Lyrics {
|
||||
|
|
|
@ -35,7 +35,6 @@ const options = {
|
|||
...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)),
|
||||
'electron',
|
||||
'NeteaseCloudMusicApi',
|
||||
'better-sqlite3',
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
"description": "This project was bootstrapped with Fastify-CLI.",
|
||||
"main": "app.ts",
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js",
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
|
|
|
@ -23,9 +23,10 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
Querystring: {
|
||||
neteaseId: string
|
||||
lang?: 'zh-CN' | 'en-US'
|
||||
noCache?: boolean
|
||||
}
|
||||
}>('/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
|
||||
const neteaseId = Number(neteaseIdString)
|
||||
|
@ -35,12 +36,14 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
}
|
||||
|
||||
// get from database
|
||||
const fromDB = await fastify.prisma.album.findFirst({
|
||||
where: { neteaseId: neteaseId },
|
||||
include: { editorialNote: { select: { en_US: true, zh_CN: true } } },
|
||||
})
|
||||
if (fromDB) {
|
||||
return fromDB as ResponseSchema
|
||||
if (!noCache) {
|
||||
const fromDB = await fastify.prisma.album.findFirst({
|
||||
where: { neteaseId: neteaseId },
|
||||
include: { editorialNote: { select: { en_US: true, zh_CN: true } } },
|
||||
})
|
||||
if (fromDB) {
|
||||
return fromDB as ResponseSchema
|
||||
}
|
||||
}
|
||||
|
||||
// get from netease
|
||||
|
@ -106,11 +109,10 @@ const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
neteaseName: albumName,
|
||||
neteaseArtistName: artist,
|
||||
}
|
||||
reply.send(data)
|
||||
|
||||
// save to database
|
||||
await fastify.prisma.album
|
||||
.create({
|
||||
if (!noCache) {
|
||||
await fastify.prisma.album.create({
|
||||
data: {
|
||||
...data,
|
||||
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: {
|
||||
neteaseId: string
|
||||
lang?: 'zh-CN' | 'en-US'
|
||||
noCache?: boolean
|
||||
}
|
||||
}>('/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
|
||||
const neteaseId = Number(neteaseIdString)
|
||||
|
@ -31,12 +32,14 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
}
|
||||
|
||||
// get from database
|
||||
const fromDB = await fastify.prisma.artist.findFirst({
|
||||
where: { neteaseId: neteaseId },
|
||||
include: { artistBio: { select: { en_US: true, zh_CN: true } } },
|
||||
})
|
||||
if (fromDB) {
|
||||
return fromDB as ResponseSchema
|
||||
if (!noCache) {
|
||||
const fromDB = await fastify.prisma.artist.findFirst({
|
||||
where: { neteaseId: neteaseId },
|
||||
include: { artistBio: { select: { en_US: true, zh_CN: true } } },
|
||||
})
|
||||
if (fromDB) {
|
||||
return fromDB as ResponseSchema
|
||||
}
|
||||
}
|
||||
|
||||
// get from netease
|
||||
|
@ -95,11 +98,9 @@ const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
artwork: artist?.attributes?.artwork?.url,
|
||||
}
|
||||
|
||||
reply.send(data)
|
||||
|
||||
// save to database
|
||||
await fastify.prisma.artist
|
||||
.create({
|
||||
if (!noCache) {
|
||||
await fastify.prisma.artist.create({
|
||||
data: {
|
||||
...data,
|
||||
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'
|
||||
import { FetchAudioSourceResponse, FetchLyricResponse, FetchTracksResponse } from './api/Track'
|
||||
import { FetchPlaylistResponse, FetchRecommendedPlaylistsResponse } from './api/Playlists'
|
||||
import { AppleMusicAlbum, AppleMusicArtist } from 'AppleMusic'
|
||||
import { AppleMusicAlbum, AppleMusicArtist } from './AppleMusic'
|
||||
|
||||
export enum CacheAPIs {
|
||||
Album = 'album',
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
interface FetchAppleMusicAlbumParams {
|
||||
export interface FetchAppleMusicAlbumParams {
|
||||
neteaseId: number | string
|
||||
lang?: 'zh-CN' | 'en-US'
|
||||
}
|
||||
|
||||
interface FetchAppleMusicAlbumResponse {
|
||||
export interface FetchAppleMusicAlbumResponse {
|
||||
id: number
|
||||
neteaseId: number
|
||||
name: string
|
||||
|
@ -16,12 +16,12 @@ interface FetchAppleMusicAlbumResponse {
|
|||
}
|
||||
}
|
||||
|
||||
interface FetchAppleMusicArtistParams {
|
||||
export interface FetchAppleMusicArtistParams {
|
||||
neteaseId: number | string
|
||||
lang?: 'zh-CN' | 'en-US'
|
||||
}
|
||||
|
||||
interface FetchAppleMusicArtistResponse {
|
||||
export interface FetchAppleMusicArtistResponse {
|
||||
id: number
|
||||
neteaseId: number
|
||||
editorialVideo: string
|
||||
|
|
|
@ -36,7 +36,6 @@ export interface NeteaseTablesStructures {
|
|||
| 'qq'
|
||||
| 'bilibili'
|
||||
| 'joox'
|
||||
queriedAt: number
|
||||
}
|
||||
[NeteaseTables.Lyric]: CommonTableStructure
|
||||
[NeteaseTables.Playlist]: CommonTableStructure
|
||||
|
|
|
@ -12,7 +12,6 @@ export interface ReplayTableStructures {
|
|||
[ReplayTables.CoverColor]: {
|
||||
id: number
|
||||
color: string
|
||||
queriedAt: number
|
||||
}
|
||||
[ReplayTables.AppData]: {
|
||||
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'
|
||||
|
||||
// AppleMusic专辑
|
||||
|
|
|
@ -65,7 +65,7 @@ export function fetchAudioSourceWithReactQuery(params: FetchAudioSourceParams) {
|
|||
return fetchAudioSource(params)
|
||||
},
|
||||
{
|
||||
retry: 3,
|
||||
retry: 1,
|
||||
staleTime: 0, // TODO: Web版1小时缓存
|
||||
}
|
||||
)
|
||||
|
|
|
@ -3,7 +3,7 @@ import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
|
|||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { logout } from '../auth'
|
||||
import { logout as logoutAPI } from '../auth'
|
||||
import { removeAllCookies } from '@/web/utils/cookie'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
|
||||
|
@ -31,12 +31,19 @@ export default function useUser() {
|
|||
)
|
||||
}
|
||||
|
||||
export const useMutationLogout = () => {
|
||||
const { refetch } = useUser()
|
||||
return useMutation(async () => {
|
||||
await logout()
|
||||
removeAllCookies()
|
||||
await window.ipcRenderer?.invoke(IpcChannels.Logout)
|
||||
await refetch()
|
||||
})
|
||||
export const useIsLoggedIn = () => {
|
||||
const { data, isLoading } = useUser()
|
||||
if (isLoading) return true
|
||||
return !!data?.profile?.userId
|
||||
}
|
||||
|
||||
export const logout = async () => {
|
||||
await logoutAPI()
|
||||
removeAllCookies()
|
||||
await window.ipcRenderer?.invoke(IpcChannels.Logout)
|
||||
await reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
|
||||
}
|
||||
|
||||
export const useMutationLogout = () => {
|
||||
return useMutation(logout)
|
||||
}
|
||||
|
|
|
@ -19,9 +19,7 @@ export function fetchMV(params: FetchMVParams): Promise<FetchMVResponse> {
|
|||
}
|
||||
|
||||
// mv 地址
|
||||
export function fetchMVUrl(
|
||||
params: FetchMVUrlParams
|
||||
): Promise<FetchMVUrlResponse> {
|
||||
export function fetchMVUrl(params: FetchMVUrlParams): Promise<FetchMVUrlResponse> {
|
||||
return request({
|
||||
url: '/mv/url',
|
||||
method: 'get',
|
||||
|
@ -34,7 +32,7 @@ export function fetchMVUrl(
|
|||
* 说明 : 调用此接口 , 传入 mvid 可获取相似 mv
|
||||
* @param {number} mvid
|
||||
*/
|
||||
export function simiMv(mvid) {
|
||||
export function simiMv(mvid: string | number) {
|
||||
return request({
|
||||
url: '/simi/mv',
|
||||
method: 'get',
|
||||
|
|
|
@ -7,8 +7,8 @@ const request: AxiosInstance = axios.create({
|
|||
timeout: 15000,
|
||||
})
|
||||
|
||||
export async function cacheAudio(id: number, audio: string) {
|
||||
const file = await axios.get(audio, { responseType: 'arraybuffer' })
|
||||
export async function cacheAudio(id: number, audioUrl: string, bitrate?: number) {
|
||||
const file = await axios.get(audioUrl, { responseType: 'arraybuffer' })
|
||||
if (file.status !== 200 && file.status !== 206) return
|
||||
|
||||
const formData = new FormData()
|
||||
|
@ -20,7 +20,8 @@ export async function cacheAudio(id: number, audio: string) {
|
|||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
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">
|
||||
<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"/>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
|
|
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,
|
||||
}: {
|
||||
artists: Artist[] | undefined
|
||||
title?: string
|
||||
title?: string | null
|
||||
className?: string
|
||||
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, {
|
||||
useMutationLikeAArtist,
|
||||
} from '@/web/api/hooks/useUserArtists'
|
||||
import useUserArtists, { useMutationLikeAArtist } from '@/web/api/hooks/useUserArtists'
|
||||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
@ -13,8 +11,7 @@ import BasicContextMenu from './BasicContextMenu'
|
|||
const ArtistContextMenu = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus)
|
||||
const likeAArtist = useMutationLikeAArtist()
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
|
@ -63,19 +60,15 @@ const ArtistContextMenu = () => {
|
|||
type: 'item',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/artist?id=${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`https://music.163.com/#/artist?id=${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
label: t`context-menu.copy-r3play-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/artist/${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`${window.location.origin}/artist/${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from 'react'
|
|||
import { useClickAway } from 'react-use'
|
||||
import useLockMainScroll from '@/web/hooks/useLockMainScroll'
|
||||
import useMeasure from 'react-use-measure'
|
||||
import { ContextMenuItem } from './MenuItem'
|
||||
import { ContextMenuItem } from './types'
|
||||
import MenuPanel from './MenuPanel'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ContextMenuPosition } from './types'
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ForwardedRef, forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import MenuItem from './MenuItem'
|
||||
import { ContextMenuItem, ContextMenuPosition } from './types'
|
||||
|
@ -36,7 +30,7 @@ const MenuPanel = forwardRef(
|
|||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'fixed select-none',
|
||||
'app-region-no-drag fixed select-none',
|
||||
isSubmenu ? 'submenu z-30 px-1' : 'z-20'
|
||||
)}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
|
@ -77,9 +71,7 @@ const MenuPanel = forwardRef(
|
|||
|
||||
{/* Submenu */}
|
||||
<SubMenu
|
||||
items={
|
||||
submenuProps?.index ? items[submenuProps?.index]?.items : undefined
|
||||
}
|
||||
items={submenuProps?.index ? items[submenuProps?.index]?.items : undefined}
|
||||
itemRect={submenuProps?.itemRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
@ -118,9 +110,7 @@ const SubMenu = ({
|
|||
const x = isRightSide ? item.x + item.width : item.x - submenu.width
|
||||
|
||||
const isTopSide = item.y - 10 + submenu.height <= window.innerHeight
|
||||
const y = isTopSide
|
||||
? item.y - 10
|
||||
: item.y + item.height + 10 - submenu.height
|
||||
const y = isTopSide ? item.y - 10 : item.y + item.height + 10 - submenu.height
|
||||
|
||||
const transformOriginTable = {
|
||||
top: {
|
||||
|
@ -137,9 +127,7 @@ const SubMenu = ({
|
|||
x,
|
||||
y,
|
||||
transformOrigin:
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][
|
||||
isRightSide ? 'right' : 'left'
|
||||
],
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][isRightSide ? 'right' : 'left'],
|
||||
})
|
||||
}, [itemRect])
|
||||
|
||||
|
|
|
@ -15,8 +15,7 @@ const TrackContextMenu = () => {
|
|||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { type, dataSourceID, target, cursorPosition, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const { type, dataSourceID, target, cursorPosition, options } = useSnapshot(contextMenus)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
@ -84,19 +83,15 @@ const TrackContextMenu = () => {
|
|||
type: 'item',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`https://music.163.com/#/album?id=${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
label: t`context-menu.copy-r3play-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
|
|
|
@ -6,7 +6,7 @@ export interface ContextMenuPosition {
|
|||
|
||||
export interface ContextMenuItem {
|
||||
type: 'item' | 'submenu' | 'divider'
|
||||
label?: string
|
||||
label?: string | null
|
||||
onClick?: (e: MouseEvent) => void
|
||||
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>
|
||||
|
||||
{/* Description */}
|
||||
|
||||
<div
|
||||
className={css`
|
||||
mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩
|
||||
|
@ -73,7 +72,7 @@ function DescriptionViewer({
|
|||
)}
|
||||
>
|
||||
<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'
|
||||
></p>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
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}`
|
||||
return (
|
||||
<svg aria-hidden='true' className={className}>
|
||||
<svg aria-hidden='true' className={className} style={style}>
|
||||
<use href={symbolId} fill='currentColor' />
|
||||
</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 LoginWithQRCode from './LoginWithQRCode'
|
||||
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'
|
||||
|
||||
const OR = ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => {
|
||||
|
@ -38,6 +38,7 @@ const Login = () => {
|
|||
const { t } = useTranslation()
|
||||
|
||||
const { data: user, isLoading: isLoadingUser } = useUser()
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
const { loginType } = useSnapshot(persistedUiStates)
|
||||
const { showLoginPanel } = useSnapshot(uiStates)
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!user?.account && !isLoadingUser) {
|
||||
if (!isLoggedIn) {
|
||||
uiStates.showLoginPanel = true
|
||||
}
|
||||
}, [user?.account, isLoadingUser])
|
||||
}, [isLoggedIn])
|
||||
|
||||
const animateCard = useAnimation()
|
||||
const handleSwitchCard = async () => {
|
||||
|
|
|
@ -23,21 +23,16 @@ const LoginWithPhoneOrEmail = () => {
|
|||
const { t, i18n } = useTranslation()
|
||||
const isZH = i18n.language.startsWith('zh')
|
||||
|
||||
const { loginPhoneCountryCode, loginType: persistedLoginType } =
|
||||
useSnapshot(persistedUiStates)
|
||||
const { loginPhoneCountryCode, loginType: persistedLoginType } = useSnapshot(persistedUiStates)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [countryCode, setCountryCode] = useState<string>(
|
||||
loginPhoneCountryCode || '+86'
|
||||
)
|
||||
const [countryCode, setCountryCode] = useState<string>(loginPhoneCountryCode || '+86')
|
||||
const [phone, setPhone] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [loginType, setLoginType] = useState<'phone' | 'email'>(
|
||||
persistedLoginType === 'email' ? 'email' : 'phone'
|
||||
)
|
||||
|
||||
const handleAfterLogin = (
|
||||
result: LoginWithEmailResponse | LoginWithPhoneResponse
|
||||
) => {
|
||||
const handleAfterLogin = (result: LoginWithEmailResponse | LoginWithPhoneResponse) => {
|
||||
if (result?.code !== 200) return
|
||||
setCookies(result.cookie)
|
||||
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
|
||||
|
@ -76,11 +71,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
toast.error('Please enter password')
|
||||
return
|
||||
}
|
||||
if (
|
||||
email.match(
|
||||
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
|
||||
) == null
|
||||
) {
|
||||
if (email.match(/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/) == null) {
|
||||
toast.error('Please use netease email')
|
||||
return
|
||||
}
|
||||
|
@ -238,9 +229,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
|
||||
{/* Login button */}
|
||||
<div
|
||||
onClick={() =>
|
||||
loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin()
|
||||
}
|
||||
onClick={() => (loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin())}
|
||||
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
|
||||
>
|
||||
{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 useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import settings from '../states/settings'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
@ -81,9 +83,8 @@ const Tabs = () => {
|
|||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const controls = useAnimation()
|
||||
const [active, setActive] = useState<string>(
|
||||
location.pathname || tabs[0].path
|
||||
)
|
||||
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
||||
const [active, setActive] = useState<string>(location.pathname || tabs[0].path)
|
||||
|
||||
const animate = async (path: string) => {
|
||||
await controls.start((p: string) =>
|
||||
|
@ -94,40 +95,45 @@ const Tabs = () => {
|
|||
|
||||
return (
|
||||
<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 => (
|
||||
<motion.div
|
||||
key={tab.name}
|
||||
animate={controls}
|
||||
transition={{ ease, duration: 0.18 }}
|
||||
onMouseDown={() => {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(20)
|
||||
}
|
||||
animate(tab.path)
|
||||
}}
|
||||
onClick={() => {
|
||||
setActive(tab.path)
|
||||
navigate(tab.path)
|
||||
}}
|
||||
custom={tab.path}
|
||||
variants={{
|
||||
scale: { scale: 0.8 },
|
||||
reset: { scale: 1 },
|
||||
}}
|
||||
className={cx(
|
||||
active === tab.path
|
||||
? 'text-brand-600 dark:text-brand-700'
|
||||
: 'lg:hover:text-black lg:dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
{tabs
|
||||
.filter(tab => {
|
||||
if (!displayPlaylistsFromNeteaseMusic && tab.name === 'BROWSE') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(tab => (
|
||||
<motion.div
|
||||
key={tab.name}
|
||||
animate={controls}
|
||||
transition={{ ease, duration: 0.18 }}
|
||||
onMouseDown={() => {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(20)
|
||||
}
|
||||
animate(tab.path)
|
||||
}}
|
||||
onClick={() => {
|
||||
setActive(tab.path)
|
||||
navigate(tab.path)
|
||||
}}
|
||||
custom={tab.path}
|
||||
variants={{
|
||||
scale: { scale: 0.8 },
|
||||
reset: { scale: 1 },
|
||||
}}
|
||||
className={cx(
|
||||
'app-region-no-drag h-10 w-10 transition-colors duration-500'
|
||||
active === tab.path
|
||||
? 'text-brand-600 dark:text-brand-700'
|
||||
: 'lg:hover:text-black lg:dark:hover:text-white'
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className={cx('app-region-no-drag h-10 w-10 transition-colors duration-500')}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</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 { openContextMenu } from '@/web/states/contextMenus'
|
||||
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 { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
{t`player.queue`}
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div onClick={() => toast('开发中')} className='mr-2'>
|
||||
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
<div onClick={() => toast('开发中')}>
|
||||
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<RepeatButton />
|
||||
<ShuffleButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -11,6 +11,7 @@ const Playlist = React.lazy(() => import('@/web/pages/Playlist'))
|
|||
const Artist = React.lazy(() => import('@/web/pages/Artist'))
|
||||
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics'))
|
||||
const Search = React.lazy(() => import('@/web/pages/Search'))
|
||||
const Settings = React.lazy(() => import('@/web/pages/Settings'))
|
||||
|
||||
const lazy = (component: ReactNode) => {
|
||||
return <Suspense>{component}</Suspense>
|
||||
|
@ -29,7 +30,7 @@ const Router = () => {
|
|||
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
||||
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
||||
{/* <Route path='/settings' element={lazy(<Settings />)} /> */}
|
||||
<Route path='/settings' element={lazy(<Settings />)} />
|
||||
<Route path='/lyrics' element={lazy(<Lyrics />)} />
|
||||
<Route path='/search/:keywords' 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'
|
||||
|
||||
const Tabs = ({
|
||||
function Tabs<T>({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
|
@ -8,19 +8,19 @@ const Tabs = ({
|
|||
style,
|
||||
}: {
|
||||
tabs: {
|
||||
id: string
|
||||
id: T
|
||||
name: string
|
||||
}[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
onChange: (id: T) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) => {
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('no-scrollbar flex overflow-y-auto', className)} style={style}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
key={tab.id as string}
|
||||
className={cx(
|
||||
'mr-2.5 rounded-12 py-3 px-6 text-16 font-medium backdrop-blur transition duration-500',
|
||||
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 toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const Avatar = ({ className }: { className?: string }) => {
|
||||
const { data: user } = useUser()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
|
@ -36,10 +38,7 @@ const Avatar = ({ className }: { className?: string }) => {
|
|||
}
|
||||
setShowMenu(true)
|
||||
}}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
className={cx('app-region-no-drag rounded-full', className || 'h-12 w-12')}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{avatarRef.current && showMenu && (
|
||||
|
@ -63,7 +62,7 @@ const Avatar = ({ className }: { className?: string }) => {
|
|||
type: 'item',
|
||||
label: t`settings.settings`,
|
||||
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 { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
@ -10,6 +10,20 @@ import { useClickAway, useDebounce } from 'react-use'
|
|||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
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 = ({
|
||||
searchText,
|
||||
isInputFocused,
|
||||
|
@ -144,7 +158,7 @@ const SearchBox = () => {
|
|||
`
|
||||
)}
|
||||
>
|
||||
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||
<SearchIcon />
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder={t`search.search`}
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const SettingsButton = ({ className }: { className?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<button
|
||||
onClick={() => toast('开发中')}
|
||||
onClick={() => navigate('/settings')}
|
||||
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',
|
||||
className
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { formatDuration } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Wave from './Wave'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
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 regexifyString from 'regexify-string'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
const Actions = ({ track }: { track: Track }) => {
|
||||
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'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'
|
||||
}
|
||||
name={likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</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 = ({
|
||||
tracks,
|
||||
onPlay,
|
||||
|
@ -105,7 +172,6 @@ const TrackList = ({
|
|||
isLoading?: boolean
|
||||
placeholderRows?: number
|
||||
}) => {
|
||||
const { track: playingTrack, state } = useSnapshot(player)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
|
@ -133,66 +199,27 @@ const TrackList = ({
|
|||
return (
|
||||
<div className={className}>
|
||||
{(isLoading ? [] : tracks)?.map(track => (
|
||||
<Track key={track.id} track={track} handleClick={handleClick} />
|
||||
))}
|
||||
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(index => (
|
||||
<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'
|
||||
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'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 lg:mr-6'>
|
||||
{String(track.no).padStart(2, '0')}
|
||||
</div>
|
||||
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>00</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 className='flex flex-grow items-center text-transparent'>
|
||||
<span className='mr-4 rounded-full bg-white/10'>PLACEHOLDER1234567</span>
|
||||
</div>
|
||||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right lg:block'>
|
||||
{formatDuration(track.dt, 'en-US', 'hh:mm:ss')}
|
||||
<div className='hidden text-right text-transparent lg:block'>
|
||||
<span className='rounded-full bg-white/10'>00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(
|
||||
index => (
|
||||
<div
|
||||
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'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>
|
||||
00
|
||||
</div>
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex flex-grow items-center text-transparent'>
|
||||
<span className='mr-4 rounded-full bg-white/10'>
|
||||
PLACEHOLDER1234567
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right text-transparent lg:block'>
|
||||
<span className='rounded-full bg-white/10'>00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,29 +1,40 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/Image'
|
||||
import { memo, useEffect } from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from '@/web/components/VideoCover'
|
||||
import ArtworkViewer from '../ArtworkViewer'
|
||||
import useSettings from '@/web/hooks/useSettings'
|
||||
|
||||
const Cover = memo(
|
||||
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
const Cover = memo(({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative aspect-square w-full overflow-hidden rounded-24 '>
|
||||
<Image
|
||||
className='absolute inset-0'
|
||||
src={resizeImage(cover || '', 'lg')}
|
||||
/>
|
||||
const [isOpenArtworkViewer, setIsOpenArtworkViewer] = useState(false)
|
||||
|
||||
{videoCover && <VideoCover source={videoCover} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (cover) setIsOpenArtworkViewer(true)
|
||||
}}
|
||||
className='relative aspect-square w-full overflow-hidden rounded-24'
|
||||
>
|
||||
<Image className='absolute inset-0' src={resizeImage(cover || '', 'lg')} />
|
||||
|
||||
{videoCover && <VideoCover source={videoCover} />}
|
||||
</div>
|
||||
|
||||
<ArtworkViewer
|
||||
type='album'
|
||||
artwork={cover || ''}
|
||||
isOpen={isOpenArtworkViewer}
|
||||
onClose={() => setIsOpenArtworkViewer(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
Cover.displayName = '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 Hls from 'hls.js'
|
||||
import { injectGlobal } from '@emotion/css'
|
||||
import { isIOS, isSafari } from '@/web/utils/common'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '../states/uiStates'
|
||||
import useWindowFocus from '../hooks/useWindowFocus'
|
||||
import useSettings from '../hooks/useSettings'
|
||||
|
||||
const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>()
|
||||
const windowFocus = useWindowFocus()
|
||||
const { playAnimatedArtworkFromApple } = useSettings()
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported() && videoRef.current) {
|
||||
if (source && Hls.isSupported() && videoRef.current && playAnimatedArtworkFromApple) {
|
||||
if (hls.current) hls.current.destroy()
|
||||
hls.current = new Hls()
|
||||
hls.current.loadSource(source)
|
||||
|
@ -24,12 +27,12 @@ const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }
|
|||
// Pause video cover when playing another video
|
||||
const { playingVideoID, isPauseVideos } = useSnapshot(uiStates)
|
||||
useEffect(() => {
|
||||
if (playingVideoID || isPauseVideos) {
|
||||
if (playingVideoID || isPauseVideos || !windowFocus) {
|
||||
videoRef?.current?.pause()
|
||||
} else {
|
||||
videoRef?.current?.play()
|
||||
}
|
||||
}, [playingVideoID, isPauseVideos])
|
||||
}, [playingVideoID, isPauseVideos, windowFocus])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import useBreakpoint from './useBreakpoint'
|
||||
|
||||
const useIsMobile = () => {
|
||||
const breakpoint = useBreakpoint()
|
||||
return ['sm', 'md'].includes(breakpoint)
|
||||
// const breakpoint = useBreakpoint()
|
||||
// return ['sm', 'md'].includes(breakpoint)
|
||||
return false
|
||||
}
|
||||
|
||||
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 { useQuery } from '@tanstack/react-query'
|
||||
import { appName } from '../utils/const'
|
||||
import useSettings from './useSettings'
|
||||
|
||||
export default function useVideoCover(props: {
|
||||
id?: number
|
||||
|
@ -8,24 +9,22 @@ export default function useVideoCover(props: {
|
|||
artist?: string
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const { playAnimatedArtworkFromApple } = useSettings()
|
||||
const { id, name, artist, enabled = true } = props
|
||||
return useQuery(
|
||||
['useVideoCover', props],
|
||||
async () => {
|
||||
if (!id || !name || !artist) return
|
||||
|
||||
const fromRemote = await axios.get(
|
||||
`/${appName.toLowerCase()}/video-cover`,
|
||||
{
|
||||
params: props,
|
||||
}
|
||||
)
|
||||
const fromRemote = await axios.get(`/${appName.toLowerCase()}/video-cover`, {
|
||||
params: props,
|
||||
})
|
||||
if (fromRemote?.data?.url) {
|
||||
return fromRemote.data.url
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!name && !!artist && enabled,
|
||||
enabled: !!id && !!name && !!artist && enabled && !!playAnimatedArtworkFromApple,
|
||||
refetchOnWindowFocus: 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 zhCN from './locales/zh-cn.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 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
|
||||
try {
|
||||
const settings = JSON.parse(localStorage.getItem('settings') || '{}')
|
||||
|
@ -28,13 +37,14 @@ export const getLanguage = () => {
|
|||
}
|
||||
|
||||
i18next.use(initReactI18next).init({
|
||||
returnNull: false,
|
||||
resources: {
|
||||
'en-US': { translation: enUS },
|
||||
'zh-CN': { translation: zhCN },
|
||||
},
|
||||
lng: getLanguage(),
|
||||
// lng: 'zh-CN',
|
||||
lng: getInitLanguage(),
|
||||
fallbackLng: 'en-US',
|
||||
supportedLngs: supportedLanguages,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
|
|
@ -64,7 +64,14 @@
|
|||
"recently-listened": "RECENTLY LISTENED"
|
||||
},
|
||||
"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": {
|
||||
"share": "Share",
|
||||
|
|
|
@ -64,7 +64,13 @@
|
|||
"recently-listened": "最近播放"
|
||||
},
|
||||
"settings": {
|
||||
"settings": "设置"
|
||||
"settings": "设置",
|
||||
"appearance": "外观",
|
||||
"general": "通用",
|
||||
"lab": "实验室",
|
||||
"lyrics": "歌词",
|
||||
"player": "播放",
|
||||
"general-choose-language": "选择语言"
|
||||
},
|
||||
"context-menu": {
|
||||
"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",
|
||||
"analyze:css": "npx windicss-analysis",
|
||||
"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",
|
||||
"api:netease": "npx NeteaseCloudMusicApi@latest"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.10.5",
|
||||
|
@ -35,7 +33,7 @@
|
|||
"framer-motion": "^8.1.7",
|
||||
"hls.js": "^1.2.9",
|
||||
"howler": "^2.2.3",
|
||||
"i18next": "^21.9.1",
|
||||
"i18next": "^22.4.9",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
|
@ -44,7 +42,7 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-ga4": "^1.4.1",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^11.18.4",
|
||||
"react-i18next": "^12.1.5",
|
||||
"react-router-dom": "^6.6.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-use-measure": "^2.1.1",
|
||||
|
@ -52,15 +50,6 @@
|
|||
"valtio": "^1.8.0"
|
||||
},
|
||||
"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",
|
||||
"@types/howler": "^2.2.7",
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
|
@ -80,7 +69,6 @@
|
|||
"prettier": "*",
|
||||
"prettier-plugin-tailwindcss": "*",
|
||||
"rollup-plugin-visualizer": "^5.9.0",
|
||||
"storybook-tailwind-dark-mode": "^1.0.12",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "*",
|
||||
"vite": "^4.0.4",
|
||||
|
|
|
@ -2,7 +2,6 @@ import useAlbum from '@/web/api/hooks/useAlbum'
|
|||
import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import TrackListHeader from '@/web/components/TrackListHeader'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import dayjs from 'dayjs'
|
||||
|
@ -11,6 +10,7 @@ import toast from 'react-hot-toast'
|
|||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAppleMusicAlbum from '@/web/api/hooks/useAppleMusicAlbum'
|
||||
import { SupportedLanguage } from '@/web/i18n/i18n'
|
||||
|
||||
const Header = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
@ -35,14 +35,21 @@ const Header = () => {
|
|||
const title = album?.name
|
||||
const creatorName = album?.artist.name
|
||||
const creatorLink = `/artist/${album?.artist.id}`
|
||||
const description = isLoadingAppleMusicAlbum
|
||||
? ''
|
||||
: appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_')] ||
|
||||
album?.description ||
|
||||
appleMusicAlbum?.editorialNote?.en_US
|
||||
const description = useMemo(() => {
|
||||
if (isLoadingAppleMusicAlbum) return ''
|
||||
const fromApple =
|
||||
appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_') as 'zh_CN' | '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 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 (
|
||||
<>
|
||||
{album?.mark === 1056768 && (
|
||||
|
@ -58,7 +65,7 @@ const Header = () => {
|
|||
const isLiked = useMemo(() => {
|
||||
const id = Number(params.id)
|
||||
if (!id) return false
|
||||
return !!userLikedAlbums?.data.find(item => item.id === id)
|
||||
return !!userLikedAlbums?.data?.find(item => item.id === id)
|
||||
}, [params.id, userLikedAlbums?.data])
|
||||
|
||||
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 { cx, css } from '@emotion/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import i18next from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import DescriptionViewer from '@/web/components/DescriptionViewer'
|
||||
|
||||
|
@ -17,7 +16,7 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
|
|||
const [isOpenDescription, setIsOpenDescription] = useState(false)
|
||||
const description =
|
||||
artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] ||
|
||||
artist?.briefDesc ||
|
||||
(i18n.language === 'zh-CN' && artist?.briefDesc) ||
|
||||
artistFromApple?.artistBio?.en_US
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const Lyrics = () => {
|
||||
return <div className='text-white'>开发中</div>
|
||||
return <div className='text-white'>歌词页面开发中</div>
|
||||
}
|
||||
|
||||
export default Lyrics
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||
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 useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||
|
@ -18,6 +18,10 @@ import { useTranslation } from 'react-i18next'
|
|||
import VideoRow from '@/web/components/VideoRow'
|
||||
import useUserVideos from '@/web/api/hooks/useUserVideos'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import settings from '@/web/states/settings'
|
||||
|
||||
const collections = ['playlists', 'albums', 'artists', 'videos'] as const
|
||||
type Collection = typeof collections[number]
|
||||
|
||||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums()
|
||||
|
@ -43,8 +47,9 @@ const Videos = () => {
|
|||
|
||||
const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
||||
|
||||
const tabs = [
|
||||
const tabs: { id: Collection; name: string }[] = [
|
||||
{
|
||||
id: 'playlists',
|
||||
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 setSelectedTab = (id: 'playlists' | 'albums' | 'artists' | 'videos') => {
|
||||
uiStates.librarySelectedTab = id
|
||||
const setSelectedTab = (id: Collection) => {
|
||||
persistedUiStates.librarySelectedTab = id
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -94,9 +99,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
|||
</AnimatePresence>
|
||||
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
tabs={tabs.filter(tab => {
|
||||
if (!displayPlaylistsFromNeteaseMusic && tab.id === 'playlists') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})}
|
||||
value={selectedTab}
|
||||
onChange={(id: string) => {
|
||||
onChange={(id: Collection) => {
|
||||
setSelectedTab(id)
|
||||
scrollToBottom(true)
|
||||
}}
|
||||
|
@ -110,7 +120,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
|||
}
|
||||
|
||||
const Collections = () => {
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(persistedUiStates)
|
||||
|
||||
const observePoint = useRef<HTMLDivElement | null>(null)
|
||||
const { onScreen: isScrollReachBottom } = useIntersectionObserver(observePoint)
|
||||
|
|
|
@ -1,17 +1,26 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import PlayLikedSongsCard from './PlayLikedSongsCard'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import RecentlyListened from './RecentlyListened'
|
||||
import Collections from './Collections'
|
||||
import { useIsLoggedIn } from '@/web/api/hooks/useUser'
|
||||
|
||||
function PleaseLogin() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const My = () => {
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className='grid grid-cols-1 gap-10'>
|
||||
<PlayLikedSongsCard />
|
||||
<RecentlyListened />
|
||||
<Collections />
|
||||
</div>
|
||||
{isLoggedIn ? (
|
||||
<div className='grid grid-cols-1 gap-10'>
|
||||
<PlayLikedSongsCard />
|
||||
<RecentlyListened />
|
||||
<Collections />
|
||||
</div>
|
||||
) : (
|
||||
<PleaseLogin />
|
||||
)}
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -41,23 +41,17 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-5',
|
||||
css`
|
||||
height: 86px;
|
||||
${bp.lg} {
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
'line-clamp-4'
|
||||
// css`
|
||||
// height: 86px;
|
||||
// `
|
||||
)}
|
||||
>
|
||||
<div className='mb-3.5 text-18 font-medium text-white/70'>
|
||||
{t('my.xxxs-liked-tracks', { nickname: user?.profile?.nickname })}
|
||||
</div>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
className='text-18 font-medium text-white/20'
|
||||
>
|
||||
<div key={`${index}-${line}`} className='text-18 font-medium text-white/20'>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
|
@ -66,21 +60,15 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
}
|
||||
|
||||
const Covers = memo(({ tracks }: { tracks: Track[] }) => {
|
||||
const navigate = useNavigate()
|
||||
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'>
|
||||
{tracks.map(track => (
|
||||
<Image
|
||||
src={resizeImage(track.al.picUrl || '', 'md')}
|
||||
className={cx(
|
||||
'aspect-square rounded-24',
|
||||
css`
|
||||
${bp.lg} {
|
||||
height: 125px;
|
||||
width: 125px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
className={cx('aspect-square rounded-24 lg:h-32 lg:w-32')}
|
||||
key={track.id}
|
||||
onClick={() => navigate(`/album/${track.al.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -142,9 +130,7 @@ const PlayLikedSongsCard = () => {
|
|||
{t`my.playNow`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)
|
||||
}
|
||||
onClick={() => navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)}
|
||||
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',
|
||||
css`
|
||||
|
|
|
@ -32,18 +32,9 @@ const RecentlyListened = () => {
|
|||
.map(artist => artist.id)
|
||||
}, [listenedRecords])
|
||||
const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs)
|
||||
const artist = useMemo(
|
||||
() => recentListenedArtists?.map(a => a.artist),
|
||||
[recentListenedArtists]
|
||||
)
|
||||
const artist = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists])
|
||||
|
||||
return (
|
||||
<ArtistRow
|
||||
artists={artist}
|
||||
placeholderRow={1}
|
||||
title={t`my.recently-listened`}
|
||||
/>
|
||||
)
|
||||
return <ArtistRow artists={artist} placeholderRow={1} title={t`my.recently-listened`} />
|
||||
}
|
||||
|
||||
export default RecentlyListened
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import TrackList from '@/web/components/TrackList'
|
||||
import TrackList from './TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import Header from './Header'
|
||||
|
@ -18,11 +18,13 @@ const Playlist = () => {
|
|||
return (
|
||||
<PageTransition>
|
||||
<Header />
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
<div className='pb-10'>
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
onClick={() => navigate(`/artist/${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='h-12 w-12 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<img src={resizeImage(artist.img1v1Url, 'xs')} className='mr-4 h-14 w-14 rounded-full' />
|
||||
<div>
|
||||
<div className='text-lg font-semibold dark:text-white'>
|
||||
{artist.name}
|
||||
</div>
|
||||
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
|
||||
艺人
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-neutral-200'>{artist.name}</div>
|
||||
<div className='mt-0.5 text-sm font-semibold text-white/30'>艺人</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
@ -48,19 +39,12 @@ const Albums = ({ albums }: { albums: Album[] }) => {
|
|||
<div
|
||||
onClick={() => navigate(`/album/${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='h-12 w-12 rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
<img src={resizeImage(album.picUrl, 'xs')} className='mr-4 h-14 w-14 rounded-lg' />
|
||||
<div>
|
||||
<div className='text-lg font-semibold dark:text-white'>
|
||||
{album.name}
|
||||
</div>
|
||||
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
|
||||
<div className='text-lg font-semibold text-neutral-200'>{album.name}</div>
|
||||
<div className='mt-0.5 text-sm font-semibold text-white/30'>
|
||||
专辑 · {album?.artist.name} · {dayjs(album.publishTime).year()}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -99,14 +83,12 @@ const Track = ({
|
|||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 text-16 font-medium ',
|
||||
isPlaying
|
||||
? 'text-brand-700'
|
||||
: 'text-neutral-700 dark:text-neutral-200'
|
||||
isPlaying ? 'text-brand-700' : 'text-neutral-700 dark:text-neutral-200'
|
||||
)}
|
||||
>
|
||||
{track?.name}
|
||||
</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(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -118,9 +100,7 @@ const Search = () => {
|
|||
const { keywords = '', type = 'all' } = useParams()
|
||||
|
||||
const searchType: keyof typeof SearchTypes =
|
||||
type.toUpperCase() in SearchTypes
|
||||
? (type.toUpperCase() as keyof typeof SearchTypes)
|
||||
: 'All'
|
||||
type.toUpperCase() in SearchTypes ? (type.toUpperCase() as keyof typeof SearchTypes) : 'All'
|
||||
|
||||
const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery(
|
||||
[SearchApiNames.MultiMatchSearch, keywords],
|
||||
|
@ -172,32 +152,37 @@ const Search = () => {
|
|||
return (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Best match */}
|
||||
{bestMatch.length !== 0 && (
|
||||
<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'>
|
||||
{bestMatch.map(match => (
|
||||
<div
|
||||
onClick={() => navigateBestMatch(match)}
|
||||
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
|
||||
src={resizeImage(match.picUrl, 'xs')}
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={resizeImage(match.picUrl, 'xs')}
|
||||
className={cx(
|
||||
'mr-6 h-20 w-20',
|
||||
(match as Artist).occupation === '歌手' ? 'rounded-full' : 'rounded-xl'
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className='text-xl font-semibold dark:text-white'>
|
||||
{match.name}
|
||||
</div>
|
||||
<div className='mt-0.5 font-medium text-gray-500 dark:text-gray-400'>
|
||||
{(match as Artist).occupation === '歌手' ? '艺人' : '专辑'}
|
||||
<div className='text-xl font-semibold text-neutral-200'>{match.name}</div>
|
||||
<div className='mt-0.5 font-medium text-white/30'>
|
||||
{(match as Artist).occupation === '歌手'
|
||||
? '艺人'
|
||||
: `专辑 · ${(match as Album).artist.name} · ${dayjs(
|
||||
match.publishTime
|
||||
).year()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -210,21 +195,21 @@ const Search = () => {
|
|||
<div className='grid grid-cols-2 gap-6'>
|
||||
{searchResult?.result?.artist?.artists && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResult?.result?.album?.albums && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResult?.result?.song?.songs && (
|
||||
<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'>
|
||||
{searchResult.result.song.songs.map(track => (
|
||||
<Track key={track.id} track={track} onPlay={handlePlayTracks} />
|
||||
|
|
|
@ -37,15 +37,10 @@ const AccentColor = () => {
|
|||
{Object.entries(colors).map(([color, bg]) => (
|
||||
<div
|
||||
key={color}
|
||||
className={cx(
|
||||
bg,
|
||||
'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full'
|
||||
)}
|
||||
className={cx(bg, 'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full')}
|
||||
onClick={() => changeColor(color)}
|
||||
>
|
||||
{color === accentColor && (
|
||||
<div className='h-1.5 w-1.5 rounded-full bg-white'></div>
|
||||
)}
|
||||
{color === accentColor && <div className='h-1.5 w-1.5 rounded-full bg-white'></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -55,23 +50,19 @@ const AccentColor = () => {
|
|||
|
||||
const Theme = () => {
|
||||
return (
|
||||
<div className='mt-4'>
|
||||
<div className='mb-2 dark:text-white'>主题</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<>
|
||||
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>主题</div>
|
||||
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
|
||||
<AccentColor />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Appearance = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
|
||||
主题
|
||||
</div>
|
||||
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
|
||||
|
||||
<AccentColor />
|
||||
<Theme />
|
||||
<span className='text-white'>开发中</span>
|
||||
{/* <Theme /> */}
|
||||
</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
|
||||
loginType: 'phone' | 'email' | 'qrCode'
|
||||
minimizePlayer: boolean
|
||||
librarySelectedTab: 'playlists' | 'albums' | 'artists' | 'videos'
|
||||
}
|
||||
|
||||
const initPersistedUiStates: PersistedUiStates = {
|
||||
loginPhoneCountryCode: '+86',
|
||||
loginType: 'qrCode',
|
||||
minimizePlayer: false,
|
||||
librarySelectedTab: 'albums',
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'persistedUiStates'
|
||||
|
@ -24,9 +26,7 @@ if (statesInStorage) {
|
|||
}
|
||||
}
|
||||
|
||||
const persistedUiStates = proxy<PersistedUiStates>(
|
||||
merge(initPersistedUiStates, sates)
|
||||
)
|
||||
const persistedUiStates = proxy<PersistedUiStates>(merge(initPersistedUiStates, sates))
|
||||
|
||||
subscribe(persistedUiStates, () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(persistedUiStates))
|
||||
|
|
|
@ -1,41 +1,33 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { merge } from 'lodash-es'
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import i18n, { getLanguage, supportedLanguages } from '../i18n/i18n'
|
||||
import i18n, { getInitLanguage, SupportedLanguage, supportedLanguages } from '../i18n/i18n'
|
||||
|
||||
interface Settings {
|
||||
accentColor: string
|
||||
language: typeof supportedLanguages[number]
|
||||
unm: {
|
||||
enabled: boolean
|
||||
sources: Array<
|
||||
'migu' | 'kuwo' | 'kugou' | 'ytdl' | 'qq' | 'bilibili' | 'joox'
|
||||
>
|
||||
searchMode: 'order-first' | 'fast-first'
|
||||
proxy: null | {
|
||||
protocol: 'http' | 'https' | 'socks5'
|
||||
host: string
|
||||
port: number
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
cookies: {
|
||||
qq?: string
|
||||
joox?: string
|
||||
language: SupportedLanguage
|
||||
enableFindTrackOnYouTube: boolean
|
||||
httpProxyForYouTube?: {
|
||||
host: string
|
||||
port: number
|
||||
protocol: 'http' | 'https'
|
||||
auth?: {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
}
|
||||
playAnimatedArtworkFromApple: boolean
|
||||
priorityDisplayOfAlbumArtistDescriptionFromAppleMusic: boolean
|
||||
displayPlaylistsFromNeteaseMusic: boolean
|
||||
}
|
||||
|
||||
const initSettings: Settings = {
|
||||
accentColor: 'blue',
|
||||
language: getLanguage(),
|
||||
unm: {
|
||||
enabled: true,
|
||||
sources: ['migu'],
|
||||
searchMode: 'order-first',
|
||||
proxy: null,
|
||||
cookies: {},
|
||||
},
|
||||
accentColor: 'green',
|
||||
language: getInitLanguage(),
|
||||
enableFindTrackOnYouTube: false,
|
||||
playAnimatedArtworkFromApple: true,
|
||||
priorityDisplayOfAlbumArtistDescriptionFromAppleMusic: true,
|
||||
displayPlaylistsFromNeteaseMusic: true,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'settings'
|
||||
|
@ -50,15 +42,11 @@ try {
|
|||
const settings = proxy<Settings>(merge(initSettings, statesInStorage))
|
||||
|
||||
subscribe(settings, () => {
|
||||
if (
|
||||
settings.language !== i18n.language &&
|
||||
supportedLanguages.includes(settings.language)
|
||||
) {
|
||||
if (settings.language !== i18n.language && supportedLanguages.includes(settings.language)) {
|
||||
i18n.changeLanguage(settings.language)
|
||||
}
|
||||
|
||||
window.ipcRenderer?.send(IpcChannels.SyncSettings, settings)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
window.ipcRenderer?.send(IpcChannels.SyncSettings, JSON.parse(JSON.stringify(settings)))
|
||||
})
|
||||
|
||||
export default settings
|
||||
|
|
|
@ -5,7 +5,6 @@ interface UIStates {
|
|||
showLyricPanel: boolean
|
||||
showLoginPanel: boolean
|
||||
hideTopbarBackground: boolean
|
||||
librarySelectedTab: 'playlists' | 'albums' | 'artists' | 'videos'
|
||||
mobileShowPlayingNext: boolean
|
||||
blurBackgroundImage: string | null
|
||||
fullscreen: boolean
|
||||
|
@ -17,7 +16,6 @@ const initUIStates: UIStates = {
|
|||
showLyricPanel: false,
|
||||
showLoginPanel: false,
|
||||
hideTopbarBackground: false,
|
||||
librarySelectedTab: 'playlists',
|
||||
mobileShowPlayingNext: false,
|
||||
blurBackgroundImage: null,
|
||||
fullscreen: false,
|
||||
|
|
|
@ -46,15 +46,15 @@ test('formatDuration', () => {
|
|||
expect(formatDuration(3600000)).toBe('1:00:00')
|
||||
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-TW', 'hh[hr] mm[min]')).toBe('1 小時')
|
||||
expect(formatDuration(3700000, 'en', 'hh[hr] mm[min]')).toBe('1 hr 1 min')
|
||||
// expect(formatDuration(3600000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時')
|
||||
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-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, '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 分钟')
|
||||
})
|
||||
|
||||
|
@ -86,7 +86,7 @@ describe('cacheCoverColor', () => {
|
|||
vi.stubGlobal('ipcRenderer', {
|
||||
send: (channel: IpcChannels, ...args: any[]) => {
|
||||
expect(channel).toBe(IpcChannels.CacheCoverColor)
|
||||
expect(args[0].api).toBe(APIs.CoverColor)
|
||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||
expect(args[0].query).toEqual({
|
||||
id: '',
|
||||
color: '#fff',
|
||||
|
@ -117,7 +117,7 @@ test('calcCoverColor', async () => {
|
|||
vi.stubGlobal('ipcRenderer', {
|
||||
send: (channel: IpcChannels, ...args: any[]) => {
|
||||
expect(channel).toBe(IpcChannels.CacheCoverColor)
|
||||
expect(args[0].api).toBe(APIs.CoverColor)
|
||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||
expect(args[0].query).toEqual({
|
||||
id: '109951165911363',
|
||||
color: '#808080',
|
||||
|
@ -141,7 +141,7 @@ describe('getCoverColor', () => {
|
|||
vi.stubGlobal('ipcRenderer', {
|
||||
sendSync: (channel: IpcChannels, ...args: any[]) => {
|
||||
expect(channel).toBe(IpcChannels.GetApiCache)
|
||||
expect(args[0].api).toBe(APIs.CoverColor)
|
||||
expect(args[0].api).toBe(CacheAPIs.CoverColor)
|
||||
expect(args[0].query).toEqual({
|
||||
id: '109951165911363',
|
||||
})
|
||||
|
|
|
@ -2,7 +2,11 @@
|
|||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
|
@ -17,9 +21,17 @@
|
|||
"jsx": "react-jsx",
|
||||
"baseUrl": "../",
|
||||
"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 { average } from 'color.js'
|
||||
import { colord } from 'colord'
|
||||
import { supportedLanguages } from '../i18n/i18n'
|
||||
import { SupportedLanguage } from '../i18n/i18n'
|
||||
|
||||
/**
|
||||
* @description 调整网易云和苹果音乐封面图片大小
|
||||
|
@ -73,7 +73,7 @@ export function formatDate(
|
|||
*/
|
||||
export function formatDuration(
|
||||
milliseconds: number,
|
||||
locale: typeof supportedLanguages[number] = 'zh-CN',
|
||||
locale: SupportedLanguage = 'zh-CN',
|
||||
format: 'hh:mm:ss' | 'hh[hr] mm[min]' = 'hh:mm:ss'
|
||||
): string {
|
||||
dayjs.extend(duration)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user