feat: updates

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

1
.gitignore vendored
View File

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

View File

@ -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"
}

View File

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

View File

@ -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',
})

View File

@ -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 })

View File

@ -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',

View File

@ -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
})

View File

@ -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 })
}
}

View File

@ -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" (

View File

@ -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"

View File

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

View File

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

View File

@ -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",

View File

@ -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
})
}

View File

@ -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
})
}

View File

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

View File

@ -14,7 +14,7 @@ import {
} from './api/User'
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',

View File

@ -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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { 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)
}

View File

@ -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',

View File

@ -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,
},
})
}

View File

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

After

Width:  |  Height:  |  Size: 462 B

View File

@ -1,3 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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

View File

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

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 706 B

View File

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

View File

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

View File

@ -1,6 +1,4 @@
import useUserArtists, {
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`)
},
},

View File

@ -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'

View File

@ -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])

View File

@ -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`)
},
},

View File

@ -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[]
}

View File

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

View File

@ -53,7 +53,6 @@ function DescriptionViewer({
</div>
{/* 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>

View File

@ -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>
)

View File

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

View File

@ -8,7 +8,7 @@ import Icon from '@/web/components/Icon'
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import 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 () => {

View File

@ -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`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,8 @@ import { useAnimation, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import 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>
)
}

View File

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

View File

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

View File

@ -13,26 +13,74 @@ import { Virtuoso } from 'react-virtuoso'
import toast from 'react-hot-toast'
import { 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>
)

View File

@ -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 />)} />

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { cx } from '@emotion/css'
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

View File

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

View File

@ -8,10 +8,12 @@ import BasicContextMenu from '../ContextMenus/BasicContextMenu'
import { AnimatePresence } from 'framer-motion'
import 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')
},
},
{

View File

@ -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`}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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

View File

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

View File

@ -1,17 +1,20 @@
import { useEffect, useRef } from 'react'
import 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

View File

@ -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

View File

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

View File

@ -1,6 +1,7 @@
import axios from 'axios'
import { 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,
}

View File

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

View File

@ -2,12 +2,21 @@ import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import 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,
},

View File

@ -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",

View File

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

View File

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

View File

@ -12,13 +12,11 @@
"test:types": "tsc --noEmit --project ./tsconfig.json",
"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",

View File

@ -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) => {

View File

@ -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 (

View File

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

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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`

View File

@ -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

View File

@ -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>
)
}

View File

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

View File

@ -18,21 +18,12 @@ const Artists = ({ artists }: { artists: Artist[] }) => {
<div
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> &quot;{keywords}&quot;
<span className='text-white/40'></span> &quot;{keywords}&quot;
</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} />

View File

@ -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>
)
}

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@ -5,12 +5,14 @@ interface PersistedUiStates {
loginPhoneCountryCode: string
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))

View File

@ -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

View File

@ -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,

View File

@ -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',
})

View File

@ -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"
]
}

View File

@ -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