feat: updates

This commit is contained in:
qier222 2022-10-28 20:29:04 +08:00
parent a1b0bcf4d3
commit 884f3df41a
No known key found for this signature in database
198 changed files with 4572 additions and 5336 deletions

25
.vscode/i18n-ally-custom-framework.yml vendored Normal file
View File

@ -0,0 +1,25 @@
# .vscode/i18n-ally-custom-framework.yml
# An array of strings which contain Language Ids defined by VS Code
# You can check avaliable language ids here: https://code.visualstudio.com/docs/languages/overview#_language-id
languageIds:
- json
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
usageMatchRegex:
# The following example shows how to detect `t("your.i18n.keys")`
# the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well
- 't`({key})`'
# An array of strings containing refactor templates.
# The "$1" will be replaced by the keypath specified.
# Optional: uncomment the following two lines to use
# refactorTemplates:
# - i18n.get("$1")
# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: false

View File

@ -16,8 +16,8 @@
"install": "turbo run post-install --parallel --no-cache",
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
"build:web": "turbo run build:web",
"pack": "turbo run build pack",
"pack:test": "turbo run pack:test",
"pack": "turbo run build && turbo run pack",
"pack:test": "turbo run build && turbo run pack:test",
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
@ -28,7 +28,7 @@
"cross-env": "^7.0.3",
"eslint": "^8.21.0",
"prettier": "^2.7.1",
"turbo": "^1.4.2",
"turbo": "^1.6.1",
"typescript": "^4.7.4"
}
}

View File

@ -16,8 +16,10 @@ module.exports = {
buildResources: 'build',
},
npmRebuild: false,
buildDependenciesFromSource: true,
buildDependenciesFromSource: false,
electronVersion,
afterPack: './scripts/copySQLite3.js',
forceCodeSigning: false,
publish: [
{
provider: 'github',
@ -34,10 +36,6 @@ module.exports = {
arch: ['x64'],
},
// {
// target: 'nsis',
// arch: ['arm64'],
// },
// {
// target: 'portable',
// arch: ['x64'],
// },
@ -59,16 +57,13 @@ module.exports = {
target: [
{
target: 'dmg',
arch: [
'x64',
'arm64',
// 'universal'
],
arch: ['x64', 'arm64', 'universal'],
},
],
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
darkModeSupport: true,
category: 'public.app-category.music',
identity: null,
},
dmg: {
icon: 'build/icons/icon.icns',
@ -124,7 +119,6 @@ module.exports = {
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
'!**/*.{map,debug.min.js}',
'!**/dist/binary',
{
from: './dist',
to: './main',

View File

@ -6,7 +6,7 @@ const headers = {
Authority: 'amp-api.music.apple.com',
Accept: '*/*',
Authorization:
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjQ2NjU1MDgwLCJleHAiOjE2NjIyMDcwODB9.pyOrt2FmP0cHkzYtO8KiEzQL2t1qpRszzxIYbLH7faXSddo6PQei771Ja3aGwGVU4hD99lZAw7bwat60iBcGiQ',
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjYxNDQwNDMyLCJleHAiOjE2NzY5OTI0MzIsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.z4BMv9_O4MpMK2iFhYkDqPsx53soPSnlXXK3jm99pHqGOrZADvTgEUw2U7_B1W0MAtFiWBYhYcGvWrzaOig6Bw',
Referer: 'https://music.apple.com/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
@ -14,6 +14,7 @@ const headers = {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36',
'Accept-Encoding': 'gzip',
Origin: 'https://music.apple.com',
}
export const getAlbum = async ({
@ -43,6 +44,7 @@ export const getAlbum = async ({
const albums: AppleMusicAlbum[] | undefined =
searchResult?.data?.results?.albums?.data
const album =
albums?.find(
a =>
@ -72,12 +74,13 @@ export const getArtist = async (
platform: 'web',
limit: '1',
l: 'en-us', // TODO: get from settings
with: 'serverBubbles',
},
}).catch(e => {
log.debug('[appleMusic] Search artist error', e)
})
const artist = searchResult?.data?.results?.artists?.data?.[0]
const artist = searchResult?.data?.results?.artist?.data?.[0]
if (
artist &&
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()

View File

@ -5,7 +5,7 @@ import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs'
import { APIs, APIsParams } from '@/shared/CacheAPIs'
import { TablesStructures } from './db'
class Cache {

View File

@ -0,0 +1,342 @@
import { db, Tables } from './db'
import type { FetchTracksResponse } from '@/shared/api/Track'
import { app } from 'electron'
import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs'
import { TablesStructures } from './db'
class Cache {
constructor() {
//
}
async set(api: string, data: any, query: any = {}) {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
if (!data) return
db.upsert(Tables.AccountData, {
id: api,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Track: {
const res = data as FetchTracksResponse
if (!res.songs) return
const tracks = res.songs.map(t => ({
id: t.id,
json: JSON.stringify(t),
updatedAt: Date.now(),
}))
db.upsertMany(Tables.Track, tracks)
break
}
case APIs.Album: {
if (!data.album) return
data.album.songs = data.songs
db.upsert(Tables.Album, {
id: data.album.id,
json: JSON.stringify(data.album),
updatedAt: Date.now(),
})
break
}
case APIs.Playlist: {
if (!data.playlist) return
db.upsert(Tables.Playlist, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artist: {
if (!data.artist) return
db.upsert(Tables.Artist, {
id: data.artist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.ArtistAlbum: {
if (!data.hotAlbums) return
db.createMany(
Tables.Album,
data.hotAlbums.map((a: Album) => ({
id: a.id,
json: JSON.stringify(a),
updatedAt: Date.now(),
}))
)
const modifiedData = {
...data,
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
}
db.upsert(Tables.ArtistAlbum, {
id: data.artist.id,
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case APIs.Lyric: {
if (!data.lrc) return
db.upsert(Tables.Lyric, {
id: query.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.CoverColor: {
if (!data.id || !data.color) return
if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
return
}
db.upsert(Tables.CoverColor, {
id: data.id,
color: data.color,
queriedAt: Date.now(),
})
break
}
case APIs.AppleMusicAlbum: {
if (!data.id) return
db.upsert(Tables.AppleMusicAlbum, {
id: data.id,
json: data.album ? JSON.stringify(data.album) : 'no',
updatedAt: Date.now(),
})
break
}
case APIs.AppleMusicArtist: {
if (!data) return
db.upsert(Tables.AppleMusicArtist, {
id: data.id,
json: data.artist ? JSON.stringify(data.artist) : 'no',
updatedAt: Date.now(),
})
break
}
}
}
get<T extends keyof APIsParams>(api: T, params: any): any {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
const data = db.find(Tables.AccountData, api)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Track: {
const ids: number[] = params?.ids
.split(',')
.map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = db.findMany(Tables.Track, ids)
if (tracksRaw.length !== ids.length) {
return
}
const tracks = ids.map(id => {
const track = tracksRaw.find(t => t.id === Number(id)) as any
return JSON.parse(track.json)
})
return {
code: 200,
songs: tracks,
privileges: {},
}
}
case APIs.Album: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Album, params.id)
if (data?.json)
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(data.json),
}
break
}
case APIs.Playlist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Playlist, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Artist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Artist, params.id)
const fromAppleData = db.find(Tables.AppleMusicArtist, params.id)
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
const fromNetease = data?.json && JSON.parse(data.json)
if (fromNetease && fromApple && fromApple !== 'no') {
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
}
return fromNetease ? fromNetease : undefined
}
case APIs.ArtistAlbum: {
if (isNaN(Number(params?.id))) return
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
case APIs.Lyric: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Lyric, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.CoverColor: {
if (isNaN(Number(params?.id))) return
return db.find(Tables.CoverColor, params.id)?.color
}
case APIs.Artists: {
if (!params.ids?.length) return
const artists = db.findMany(Tables.Artist, params.ids)
if (artists.length !== params.ids.length) return
const result = artists.map(a => JSON.parse(a.json))
result.sort((a, b) => {
const indexA: number = params.ids.indexOf(a.artist.id)
const indexB: number = params.ids.indexOf(b.artist.id)
return indexA - indexB
})
return result
}
case APIs.AppleMusicAlbum: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.AppleMusicAlbum, params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
break
}
case APIs.AppleMusicArtist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.AppleMusicArtist, params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
break
}
}
}
getForExpress(api: string, req: Request) {
// Get track detail cache
if (api === APIs.Track) {
const cache = this.get(api, req.query)
if (cache) {
log.debug(`[cache] Cache hit for ${req.path}`)
return cache
}
}
}
getAudio(fileName: string, res: Response) {
if (!fileName) {
return res.status(400).send({ error: 'No filename provided' })
}
const id = Number(fileName.split('-')[0])
try {
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
const audio = fs.readFileSync(path)
if (audio.byteLength === 0) {
db.delete(Tables.Audio, id)
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
}
res
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader(
'Content-Range',
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
)
.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
fs.statSync(path)
} catch (e) {
fs.mkdirSync(path)
}
const meta = await musicMetadata.parseBuffer(buffer)
const br =
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
if (url.includes('migu.cn')) source = 'migu'
if (url.includes('kuwo.cn')) source = 'kuwo'
if (url.includes('bilivideo.com')) source = 'bilibili'
// TODO: missing kugou qq joox
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert(Tables.Audio, {
id,
br,
type: type as TablesStructures[Tables.Audio]['type'],
source,
queriedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
})
}
}
export default new Cache()

View File

@ -0,0 +1,344 @@
import db from './surrealdb'
import type { FetchTracksResponse } from '@/shared/api/Track'
import { app } from 'electron'
import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams } from '@/shared/CacheAPIs'
// import { TablesStructures } from './db'
class Cache {
constructor() {
//
}
async set(api: string, data: any, query: any = {}) {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
if (!data) return
db.upsert('netease', 'accountData', api, {
json: JSON.stringify(data),
})
break
}
case APIs.Track: {
const res = data as FetchTracksResponse
if (!res.songs) return
const tracks = res.songs.map(t => ({
key: t.id,
data: {
json: JSON.stringify(t),
},
}))
db.upsertMany('netease', 'track', tracks)
break
}
case APIs.Album: {
if (!data.album) return
data.album.songs = data.song
db.upsert('netease', 'album', data.album.id, data.album)
break
}
case APIs.Playlist: {
if (!data.playlist) return
db.upsert('netease', 'playlist', data.playlist.id, {
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artist: {
if (!data.artist) return
db.upsert('netease', 'artist', data.artist.id, {
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.ArtistAlbum: {
if (!data.hotAlbums) return
db.upsertMany(
'netease',
'album',
data.hotAlbums.map((a: Album) => ({
key: a.id,
data: {
json: JSON.stringify(a),
updatedAt: Date.now(),
},
}))
)
const modifiedData = {
...data,
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
}
db.upsert('netease', 'artistAlbums', data.artist.id, {
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case APIs.Lyric: {
if (!data.lrc) return
db.upsert('netease', 'lyric', query.id, {
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
// case APIs.CoverColor: {
// if (!data.id || !data.color) return
// if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
// return
// }
// db.upsert(Tables.CoverColor, {
// id: data.id,
// color: data.color,
// queriedAt: Date.now(),
// })
// break
// }
case APIs.AppleMusicAlbum: {
if (!data.id) return
db.upsert('appleMusic', 'album', data.id, {
json: data.album ? JSON.stringify(data.album) : 'no',
// updatedAt: Date.now(),
})
break
}
case APIs.AppleMusicArtist: {
if (!data) return
db.upsert('appleMusic', 'artist', data.id, {
json: data.artist ? JSON.stringify(data.artist) : 'no',
// updatedAt: Date.now(),
})
break
}
}
}
async get<T extends keyof APIsParams>(api: T, params: any): Promise<any> {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
const data = await db.find('netease', 'accountData', api)
if (!data?.[0]?.json) return
return JSON.parse(data[0].json)
}
case APIs.Track: {
const ids: number[] = params?.ids
.split(',')
.map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = await db.findMany('netease', 'track', ids)
if (!tracksRaw || tracksRaw.length !== ids.length) {
return
}
const tracks = ids.map(id => {
const track = tracksRaw.find(t => t.id === Number(id)) as any
return JSON.parse(track.json)
})
return {
code: 200,
songs: tracks,
privileges: {},
}
}
case APIs.Album: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'album', params.id)
const json = data?.[0]?.json
if (!json) return
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(json),
}
}
case APIs.Playlist: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'playlist', params.id)
if (!data?.[0]?.json) return
return JSON.parse(data[0].json)
}
case APIs.Artist: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'artist', params.id)
const fromAppleData = await db.find('appleMusic', 'artist', params.id)
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
const fromNetease = data?.json && JSON.parse(data.json)
if (fromNetease && fromApple && fromApple !== 'no') {
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
}
return fromNetease || undefined
}
case APIs.ArtistAlbum: {
if (isNaN(Number(params?.id))) return
const artistAlbumsRaw = await db.find(
'netease',
'artistAlbums',
params.id
)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = await db.findMany(
'netease',
'album',
artistAlbums.hotAlbums
)
if (albumsRaw?.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
case APIs.Lyric: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'lyric', params.id)
if (data?.json) return JSON.parse(data.json)
return
}
// case APIs.CoverColor: {
// if (isNaN(Number(params?.id))) return
// return await db.find(Tables.CoverColor, params.id)?.color
// }
case APIs.Artists: {
if (!params.ids?.length) return
const artists = await db.findMany('netease', 'artist', params.ids)
if (artists?.length !== params.ids.length) return
const result = artists?.map(a => JSON.parse(a.json))
result?.sort((a, b) => {
const indexA: number = params.ids.indexOf(a.artist.id)
const indexB: number = params.ids.indexOf(b.artist.id)
return indexA - indexB
})
return result
}
case APIs.AppleMusicAlbum: {
if (isNaN(Number(params?.id))) return
const data = await db.find('appleMusic', 'album', params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
return
}
case APIs.AppleMusicArtist: {
if (isNaN(Number(params?.id))) return
const data = await db.find('appleMusic', 'artist', params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
return
}
}
}
getForExpress(api: string, req: Request) {
// Get track detail cache
if (api === APIs.Track) {
const cache = this.get(api, req.query)
if (cache) {
log.debug(`[cache] Cache hit for ${req.path}`)
return cache
}
}
}
getAudio(fileName: string, res: Response) {
if (!fileName) {
return res.status(400).send({ error: 'No filename provided' })
}
const id = Number(fileName.split('-')[0])
try {
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
const audio = fs.readFileSync(path)
// if audio file is empty, delete it
if (audio.byteLength === 0) {
db.delete('netease', 'audio', id)
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
}
res
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader(
'Content-Range',
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
)
.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
fs.statSync(path)
} catch (e) {
fs.mkdirSync(path)
}
const meta = await musicMetadata.parseBuffer(buffer)
const br =
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
// let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
let source = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
if (url.includes('migu.cn')) source = 'migu'
if (url.includes('kuwo.cn')) source = 'kuwo'
if (url.includes('bilivideo.com')) source = 'bilibili'
// TODO: missing kugou qq joox
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert('netease', 'audio', id, {
id,
br,
type,
source,
queriedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
})
}
}
export default new Cache()

View File

@ -3,9 +3,10 @@ import { app } from 'electron'
import fs from 'fs'
import SQLite3 from 'better-sqlite3'
import log from './log'
import { createFileIfNotExist, dirname } from './utils'
import { createFileIfNotExist, dirname, isProd } from './utils'
import pkg from '../../../package.json'
import { compare, validate } from 'compare-versions'
import os from 'os'
export const enum Tables {
Track = 'Track',
@ -83,19 +84,43 @@ class DB {
constructor() {
log.info('[db] Initializing database...')
createFileIfNotExist(this.dbFilePath)
try {
createFileIfNotExist(this.dbFilePath)
this.sqlite = new SQLite3(this.dbFilePath, {
nativeBinding: path.join(
__dirname,
`./binary/better_sqlite3_${process.arch}.node`
this.sqlite = new SQLite3(this.dbFilePath, {
nativeBinding: this.getBinPath(),
})
this.sqlite.pragma('auto_vacuum = FULL')
this.initTables()
this.migrate()
log.info('[db] Database initialized.')
} catch (e) {
log.error('[db] Database initialization failed.')
log.error(e)
}
}
private getBinPath() {
console
const devBinPath = path.resolve(
app.getPath('userData'),
`../../bin/better_sqlite3_${os.platform}_${os.arch}.node`
)
const prodBinPaths = {
darwin: path.resolve(
app.getPath('exe'),
`../../Resources/bin/better_sqlite3.node`
),
})
this.sqlite.pragma('auto_vacuum = FULL')
this.initTables()
this.migrate()
log.info('[db] Database initialized.')
win32: path.resolve(
app.getPath('exe'),
`../resources/bin/better_sqlite3.node`
),
linux: '',
}
return isProd
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']
: devBinPath
}
initTables() {

View File

@ -17,6 +17,7 @@ import { createTaskbar, Thumbar } from './windowsTaskbar'
import { createMenu } from './menu'
import { isDev, isWindows, isLinux, isMac } from './utils'
import store from './store'
// import './surrealdb'
// import Airplay from './airplay'
class Main {
@ -91,7 +92,7 @@ class Main {
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
trafficLightPosition: { x: 24, y: 24 },
frame: false,
backgroundColor: '#000',
transparent: true,
show: false,
}
if (store.get('window')) {

View File

@ -1,5 +1,5 @@
import { BrowserWindow, ipcMain, app } from 'electron'
import { db, Tables } from './db'
// import { db, Tables } from './db'
import { IpcChannels, IpcChannelsParams } from '@/shared/IpcChannels'
import cache from './cache'
import log from './log'
@ -67,9 +67,9 @@ function initWindowIpcMain(win: BrowserWindow | null) {
win?.setSize(1440, 1024, true)
})
on(IpcChannels.IsMaximized, e => {
handle(IpcChannels.IsMaximized, () => {
if (!win) return
e.returnValue = win.isMaximized()
return win.isMaximized()
})
}
@ -118,23 +118,36 @@ function initOtherIpcMain() {
* API缓存
*/
on(IpcChannels.ClearAPICache, () => {
db.truncate(Tables.Track)
db.truncate(Tables.Album)
db.truncate(Tables.Artist)
db.truncate(Tables.Playlist)
db.truncate(Tables.ArtistAlbum)
db.truncate(Tables.AccountData)
db.truncate(Tables.Audio)
db.vacuum()
// db.truncate(Tables.Track)
// db.truncate(Tables.Album)
// db.truncate(Tables.Artist)
// db.truncate(Tables.Playlist)
// db.truncate(Tables.ArtistAlbum)
// db.truncate(Tables.AccountData)
// db.truncate(Tables.Audio)
// db.vacuum()
})
/**
* Get API cache
*/
on(IpcChannels.GetApiCacheSync, (event, args) => {
// on(IpcChannels.GetApiCache, (event, args) => {
// const { api, query } = args
// const data = cache.get(api, query)
// event.returnValue = data
// })
handle(IpcChannels.GetApiCache, async (event, args) => {
const { api, query } = args
const data = cache.get(api, query)
event.returnValue = data
if (api !== 'user/account') {
return null
}
try {
const data = await cache.get(api, query)
return data
} catch {
return null
}
})
/**
@ -193,35 +206,42 @@ function initOtherIpcMain() {
event.returnValue = artist === 'no' ? undefined : artist
})
/**
* 退
*/
handle(IpcChannels.Logout, async () => {
// db.truncate(Tables.AccountData)
return true
})
/**
* tables到json文件便table大小dev环境
*/
if (process.env.NODE_ENV === 'development') {
on(IpcChannels.DevDbExportJson, () => {
const tables = [
Tables.ArtistAlbum,
Tables.Playlist,
Tables.Album,
Tables.Track,
Tables.Artist,
Tables.Audio,
Tables.AccountData,
Tables.Lyric,
]
tables.forEach(table => {
const data = db.findAll(table)
fs.writeFile(
`./tmp/${table}.json`,
JSON.stringify(data),
function (err) {
if (err) {
return console.log(err)
}
console.log('The file was saved!')
}
)
})
})
// on(IpcChannels.DevDbExportJson, () => {
// const tables = [
// Tables.ArtistAlbum,
// Tables.Playlist,
// Tables.Album,
// Tables.Track,
// Tables.Artist,
// Tables.Audio,
// Tables.AccountData,
// Tables.Lyric,
// ]
// tables.forEach(table => {
// const data = db.findAll(table)
// fs.writeFile(
// `./tmp/${table}.json`,
// JSON.stringify(data),
// function (err) {
// if (err) {
// return console.log(err)
// }
// console.log('The file was saved!')
// }
// )
// })
// })
}
}

View File

@ -12,7 +12,6 @@ if (isProd) {
}
contextBridge.exposeInMainWorld('ipcRenderer', {
sendSync: ipcRenderer.sendSync,
invoke: ipcRenderer.invoke,
send: ipcRenderer.send,
on: (

View File

@ -6,20 +6,20 @@ import cache from './cache'
import fileUpload from 'express-fileupload'
import path from 'path'
import fs from 'fs'
import { db, Tables } from './db'
// import { db, Tables } from './db'
import { app } from 'electron'
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
import UNM from '@unblockneteasemusic/rust-napi'
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
import { isProd } from './utils'
import { APIs } from '@/shared/CacheAPIs'
import history from 'connect-history-api-fallback'
import { db, Tables } from './db'
class Server {
port = Number(
isProd
? process.env.ELECTRON_WEB_SERVER_PORT ?? 42710
: process.env.ELECTRON_DEV_NETEASE_API_PORT ?? 3000
? process.env.ELECTRON_WEB_SERVER_PORT || 42710
: process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
)
app = express()
// eslint-disable-next-line @typescript-eslint/no-var-requires
@ -152,7 +152,7 @@ class Server {
}
}
const unmExecutor = new UNM.Executor()
// const unmExecutor = new UNM.Executor()
const getFromUNM = async (id: number, req: Request) => {
log.debug('[server] Fetching audio url from UNM')
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })
@ -269,15 +269,15 @@ class Server {
return
}
try {
const fromUNM = await getFromUNM(id, req)
if (fromUNM) {
res.status(200).send(fromUNM)
return
}
} catch (error) {
log.error(`[server] getFromUNM failed: ${String(error)}`)
}
// try {
// const fromUNM = await getFromUNM(id, req)
// if (fromUNM) {
// res.status(200).send(fromUNM)
// return
// }
// } catch (error) {
// log.error(`[server] getFromUNM failed: ${String(error)}`)
// }
if (fromNetease?.data?.[0].freeTrialInfo) {
fromNetease.data[0].url = ''

View File

@ -0,0 +1,301 @@
import axios, { AxiosInstance } from 'axios'
import { app, ipcMain } from 'electron'
import path from 'path'
import { Get } from 'type-fest'
import { $, fs } from 'zx'
import log from './log'
import { flatten } from 'lodash'
// surreal start --bind 127.0.0.1:37421 --user user --pass pass --log trace file:///Users/max/Developer/GitHub/replay/tmp/UserData/api_cache/surreal
interface Databases {
appleMusic: {
artist: {
key: string
data: {
json: string
}
}
album: {
key: string
data: {
json: string
}
}
}
replay: {
appData: {
id: 'appVersion' | 'skippedVersion'
value: string
}
}
netease: {
track: {
id: number
json: string
updatedAt: number
}
artist: {
id: number
json: string
updatedAt: number
}
album: {
id: number
json: string
updatedAt: number
}
artistAlbums: {
id: number
json: string
updatedAt: number
}
lyric: {
id: number
json: string
updatedAt: number
}
playlist: {
id: number
json: string
updatedAt: number
}
accountData: {
id: string
json: string
updatedAt: number
}
audio: {
id: number
br: number
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
source:
| 'unknown'
| 'netease'
| 'migu'
| 'kuwo'
| 'kugou'
| 'youtube'
| 'qq'
| 'bilibili'
| 'joox'
queriedAt: number
}
}
}
interface SurrealSuccessResult<R> {
time: string
status: 'OK' | 'ERR'
result?: R[]
detail?: string
}
interface SurrealErrorResult {
code: 400
details: string
description: string
information: string
}
class Surreal {
private port = 37421
private username = 'user'
private password = 'pass'
private request: AxiosInstance
constructor() {
this.start()
this.request = axios.create({
baseURL: `http://127.0.0.1:${this.port}`,
timeout: 15000,
auth: {
username: this.username,
password: this.password,
},
headers: {
NS: 'replay',
Accept: 'application/json',
},
responseType: 'json',
})
}
getSurrealBinPath() {
return path.join(__dirname, `./binary/surreal`)
}
getDatabasePath() {
return path.resolve(app.getPath('userData'), './api_cache/surreal')
}
getKey(table: string, key: string) {
if (key.includes('/')) {
return `${table}:⟨${key}`
}
return `${table}:${key}`
}
async query<R>(
database: keyof Databases,
query: string
): Promise<R[] | undefined> {
type DBResponse =
| SurrealSuccessResult<R>
| Array<SurrealSuccessResult<R>>
| SurrealErrorResult
const result = await this.request
.post<DBResponse | undefined>('/sql', query, {
headers: { DB: database },
})
.catch(e => {
log.error(
`[surreal] Axios Error: ${e}, response: ${JSON.stringify(
e.response.data,
null,
2
)}`
)
})
if (!result?.data) {
log.error(`[surreal] No result`)
return []
}
const data = result.data
if (Array.isArray(data)) {
return flatten(data.map(item => item?.result).filter(Boolean) as R[][])
}
if ('status' in data) {
if (data.status === 'OK') {
return data.result
}
if (data.status === 'ERR') {
log.error(`[surreal] ${data.detail}`)
throw new Error(`[surreal] query error: ${data.detail}`)
}
}
if ('code' in data && data.code !== 400) {
throw new Error(`[surreal] query error: ${data.description}`)
}
throw new Error('[surreal] query error: unknown error')
}
async start() {
log.info(`[surreal] Starting surreal, listen on 127.0.0.1:${this.port}`)
await $`${this.getSurrealBinPath()} start --bind 127.0.0.1:${
this.port
} --user ${this.username} --pass ${
this.password
} --log warn file://${this.getDatabasePath()}`
}
async create<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>,
data: Get<Databases[D][T], 'data'>
) {
const result = await this.query<Get<Databases[D][T], 'data'>>(
database,
`CREATE ${String(table)}:(${String(key)}) CONTENT ${JSON.stringify(data)}`
)
return result?.[0]
}
async upsert<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>,
data: Get<Databases[D][T], 'data'>
) {
fs.writeFile(
'tmp.json',
`INSERT INTO ${String(table)} ${JSON.stringify({ ...data, id: key })}`
)
const result = await this.query<Get<Databases[D][T], 'data'>>(
database,
`INSERT INTO ${String(table)} ${JSON.stringify({ ...data, id: key })} `
)
return result?.[0]
}
upsertMany<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
data: {
key: Get<Databases[D][T], 'key'>
data: Get<Databases[D][T], 'data'>
}[]
) {
const queries = data.map(query => {
return `INSERT INTO ${String(table)} ${JSON.stringify(query.data)};`
})
return this.query<Get<Databases[D][T], 'data'>>(database, queries.join(' '))
}
async find<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>
) {
return this.query<Get<Databases[D][T], 'data'>>(
database,
`SELECT * FROM ${String(table)} WHERE id = "${this.getKey(
String(table),
String(key)
)}" LIMIT 1`
) as Promise<Get<Databases[D][T], 'data'>[]>
}
async findMany<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
keys: Get<Databases[D][T], 'key'>[]
) {
const idsQuery = keys
.map(key => `id = "${this.getKey(String(table), String(key))}"`)
.join(' OR ')
return this.query<Get<Databases[D][T], 'data'>>(
database,
`SELECT * FROM ${String(table)} WHERE ${idsQuery} TIMEOUT 5s`
)
}
async delete<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>
) {
try {
await this.query(
database,
`SELECT ${this.getKey(String(table), String(key))}`
)
return true
} catch (error) {
return false
}
}
async deleteTable<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T
) {
try {
await this.query(database, `DELETE ${String(table)}`)
return true
} catch (error) {
return false
}
}
}
const surreal = new Surreal()
export default surreal

View File

@ -23,7 +23,6 @@
},
"dependencies": {
"@sentry/electron": "^3.0.7",
"@unblockneteasemusic/rust-napi": "^0.3.0",
"NeteaseCloudMusicApi": "^4.6.7",
"better-sqlite3": "7.6.2",
"change-case": "^4.1.2",
@ -36,17 +35,16 @@
"express": "^4.18.1",
"fast-folder-size": "^1.7.0",
"pretty-bytes": "^6.0.0",
"type-fest": "^3.0.0",
"zx": "^7.0.8"
},
"devDependencies": {
"@electron/universal": "1.3.0",
"@types/better-sqlite3": "^7.6.0",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13",
"@types/express-fileupload": "^1.2.3",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"@vitejs/plugin-react": "^2.0.0",
"@vitest/ui": "^0.20.3",
"axios": "^0.27.2",
"cross-env": "^7.0.3",
@ -68,8 +66,5 @@
"typescript": "*",
"vitest": "^0.20.3",
"wait-on": "^6.0.1"
},
"resolutions": {
"@electron/universal": "1.3.0"
}
}

View File

@ -37,7 +37,7 @@ const options = {
'electron',
'NeteaseCloudMusicApi',
'better-sqlite3',
'@unblockneteasemusic/rust-napi',
// '@unblockneteasemusic/rust-napi',
],
}

View File

@ -7,7 +7,7 @@ const releases = require('electron-releases')
const pkg = require(`${process.cwd()}/package.json`)
const axios = require('axios')
const { execSync } = require('child_process')
const path = require('path')
const { resolve } = require('path')
const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
@ -29,14 +29,15 @@ if (!electronModuleVersion) {
}
const argv = minimist(process.argv.slice(2))
const projectDir = path.resolve(process.cwd(), '../../')
const distDir = `${projectDir}/packages/desktop/dist/binary`
const projectDir = resolve(process.cwd(), '../../')
const tmpDir = resolve(projectDir, `./tmp/better-sqlite3`)
const binDir = resolve(projectDir, `./tmp/bin`)
console.log(pc.cyan(`projectDir=${projectDir}`))
console.log(pc.cyan(`distDir=${distDir}`))
console.log(pc.cyan(`binDir=${binDir}`))
if (!fs.existsSync(distDir)) {
console.log(pc.cyan(`Creating dist/binary directory: ${distDir}`))
fs.mkdirSync(distDir, {
if (!fs.existsSync(binDir)) {
console.log(pc.cyan(`Creating dist/binary directory: ${binDir}`))
fs.mkdirSync(binDir, {
recursive: true,
})
}
@ -47,7 +48,6 @@ const download = async arch => {
console.log(pc.red('No electron module version found! Skip download.'))
return false
}
const tmpDir = `${projectDir}/tmp/better-sqlite3`
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
const zipFileName = `${fileName}.tar.gz`
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
@ -63,7 +63,9 @@ const download = async arch => {
url,
responseType: 'stream',
}).then(response => {
response.data.pipe(fs.createWriteStream(`${tmpDir}/${zipFileName}`))
response.data.pipe(
fs.createWriteStream(resolve(tmpDir, `./${zipFileName}`))
)
return true
})
} catch (e) {
@ -80,8 +82,8 @@ const download = async arch => {
try {
fs.copyFileSync(
`${tmpDir}/build/Release/better_sqlite3.node`,
`${distDir}/better_sqlite3_${arch}.node`
resolve(tmpDir, './build/Release/better_sqlite3.node'),
resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`)
)
} catch (e) {
console.log(pc.red('Copy failed! Skip copy.', e))
@ -89,7 +91,7 @@ const download = async arch => {
}
try {
fs.rmSync(`${tmpDir}/build`, { recursive: true, force: true })
fs.rmSync(resolve(tmpDir, `./build`), { recursive: true, force: true })
} catch (e) {
console.log(pc.red('Delete failed! Skip delete.'))
return false
@ -113,8 +115,15 @@ const build = async arch => {
})
.then(() => {
console.info('Build succeeded')
const from = `${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`
const to = `${distDir}/better_sqlite3_${arch}.node`
const from = resolve(
projectDir,
`./node_modules/better-sqlite3/build/Release/better_sqlite3.node`
)
const to = resolve(
binDir,
`./better_sqlite3_${process.platform}_${arch}.node`
)
console.info(`copy ${from} to ${to}`)
fs.copyFileSync(from, to)
})
@ -130,7 +139,9 @@ const main = async () => {
if (argv.arm64) await build('arm64')
if (argv.arm) await build('arm')
} else {
if (isWindows || isMac) {
if (isWindows) {
await build('x64')
} else if (isMac) {
await build('x64')
await build('arm64')
} else if (isLinux) {

View File

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path')
const pc = require('picocolors')
const fs = require('fs')
const archs = ['ia32', 'x64', 'armv7l', 'arm64', 'universal']
const projectDir = path.resolve(process.cwd(), '../../')
const binDir = `${projectDir}/tmp/bin`
console.log(pc.cyan(`projectDir=${projectDir}`))
console.log(pc.cyan(`binDir=${binDir}`))
exports.default = async function (context) {
// console.log(context)
const platform = context.electronPlatformName
const arch = archs?.[context.arch]
// Mac
if (platform === 'darwin') {
if (arch === 'universal') return // Skip universal we already copy binary for x64 and arm64
if (arch !== 'x64' && arch !== 'arm64') return // Skip other archs
const from = `${binDir}/better_sqlite3_darwin_${arch}.node`
const to = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app/Contents/Resources/bin/better_sqlite3.node`
console.info(`copy ${from} to ${to}`)
const toFolder = to.replace('/better_sqlite3.node', '')
if (!fs.existsSync(toFolder)) {
fs.mkdirSync(toFolder, {
recursive: true,
})
}
try {
fs.copyFileSync(from, to)
} catch (e) {
console.log(pc.red('Copy failed! Process stopped.'))
throw e
}
}
if (platform === 'win32') {
if (arch !== 'x64') return // Skip other archs
const from = `${binDir}/better_sqlite3_win32_${arch}.node`
const to = `${context.appOutDir}/resources/bin/better_sqlite3.node`
console.info(`copy ${from} to ${to}`)
const toFolder = to.replace('/better_sqlite3.node', '')
if (!fs.existsSync(toFolder)) {
fs.mkdirSync(toFolder, {
recursive: true,
})
}
try {
fs.copyFileSync(from, to)
} catch (e) {
console.log(pc.red('Copy failed! Process stopped.'))
throw e
}
}
}

65
packages/server/.gitignore vendored Normal file
View File

@ -0,0 +1,65 @@
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# 0x
profile-*
# mac files
.DS_Store
# vim swap files
*.swp
# webstorm
.idea
# vscode
.vscode
*code-workspace
# clinic
profile*
*clinic*
*flamegraph*
# generated code
examples/typescript-server.js
test/types/index.js
# compiled app
dist

4
packages/server/.taprc Normal file
View File

@ -0,0 +1,4 @@
test-env: [
TS_NODE_FILES=true,
TS_NODE_PROJECT=./test/tsconfig.json
]

23
packages/server/README.md Normal file
View File

@ -0,0 +1,23 @@
# Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli)
This project was bootstrapped with Fastify-CLI.
## Available Scripts
In the project directory, you can run:
### `npm run dev`
To start the app in dev mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
### `npm start`
For production mode
### `npm run test`
Run the test cases.
## Learn More
To learn Fastify, check out the [Fastify documentation](https://www.fastify.io/docs/latest/).

41
packages/server/fly.toml Normal file
View File

@ -0,0 +1,41 @@
# fly.toml file generated for ypm on 2022-09-06T00:57:21+08:00
app = "ypm"
kill_signal = "SIGINT"
kill_timeout = 5
processes = ["npm run start"]
[build]
builder = "heroku/buildpacks:20"
[env]
PORT = "8080"
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
http_checks = []
internal_port = 35530
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"

View File

@ -0,0 +1,37 @@
{
"name": "server",
"version": "1.0.0",
"description": "This project was bootstrapped with Fastify-CLI.",
"main": "app.ts",
"directories": {
"test": "test"
},
"scripts": {
"test": "npm run build:ts && tsc -p test/tsconfig.json && tap --ts \"test/**/*.test.ts\"",
"start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js",
"build:ts": "tsc",
"watch:ts": "tsc -w",
"dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"",
"dev:start": "fastify start --ignore-watch=.ts$ -w --port 35530 --address 0.0.0.0 -l info -P dist/app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/autoload": "^5.0.0",
"@fastify/sensible": "^4.1.0",
"axios": "^0.27.2",
"fastify": "^4.0.0",
"fastify-cli": "^4.4.0",
"fastify-plugin": "^3.0.0"
},
"devDependencies": {
"@types/node": "^18.0.0",
"@types/tap": "^15.0.5",
"concurrently": "^7.0.0",
"fastify-tsconfig": "^1.0.1",
"tap": "^16.1.0",
"ts-node": "^10.4.0",
"typescript": "^4.5.4"
}
}

View File

@ -0,0 +1,35 @@
import { join } from 'path';
import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload';
import { FastifyPluginAsync } from 'fastify';
export type AppOptions = {
// Place your custom options for app below here.
} & Partial<AutoloadPluginOptions>;
const app: FastifyPluginAsync<AppOptions> = async (
fastify,
opts
): Promise<void> => {
// Place here your custom code!
// Do not touch the following lines
// This loads all plugins defined in plugins
// those should be support plugins that are reused
// through your application
void fastify.register(AutoLoad, {
dir: join(__dirname, 'plugins'),
options: opts
})
// This loads all plugins defined in routes
// define your routes in one of these
void fastify.register(AutoLoad, {
dir: join(__dirname, 'routes'),
options: opts
})
};
export default app;
export { app }

View File

@ -0,0 +1,16 @@
# Plugins Folder
Plugins define behavior that is common to all the routes in your
application. Authentication, caching, templates, and all the other cross
cutting concerns should be handled by plugins placed in this folder.
Files in this folder are typically defined through the
[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module,
making them non-encapsulated. They can define decorators and set hooks
that will then be used in the rest of your application.
Check out:
* [The hitchhiker's guide to plugins](https://www.fastify.io/docs/latest/Guides/Plugins-Guide/)
* [Fastify decorators](https://www.fastify.io/docs/latest/Reference/Decorators/).
* [Fastify lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/).

View File

@ -0,0 +1,13 @@
import fp from 'fastify-plugin'
import sensible, { SensibleOptions } from '@fastify/sensible'
/**
* This plugins adds some utilities to handle http errors
*
* @see https://github.com/fastify/fastify-sensible
*/
export default fp<SensibleOptions>(async (fastify, opts) => {
fastify.register(sensible, {
errorHandler: false
})
})

View File

@ -0,0 +1,20 @@
import fp from 'fastify-plugin'
export interface SupportPluginOptions {
// Specify Support plugin options here
}
// The use of fastify-plugin is required to be able
// to export the decorators to the outer scope
export default fp<SupportPluginOptions>(async (fastify, opts) => {
fastify.decorate('someSupport', function () {
return 'hugs'
})
})
// When using .decorate you have to specify added properties for Typescript
declare module 'fastify' {
export interface FastifyInstance {
someSupport(): string;
}
}

View File

@ -0,0 +1,38 @@
import { FastifyPluginAsync } from 'fastify'
import appleMusicRequest from '../../utils/appleMusicRequest'
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get<{
Querystring: {
name: string
artist: string
lang: 'zh-CN' | 'en-US'
}
}>('/album', async function (request, reply) {
const { name, lang, artist } = request.query
const fromApple = await appleMusicRequest({
method: 'GET',
url: '/search',
params: {
term: name,
types: 'albums',
'fields[albums]': 'artistName,name,editorialVideo,editorialNotes',
limit: '1',
l: lang.toLowerCase(),
},
})
const albums = fromApple?.results?.album?.data
const album =
albums?.find(
(a: any) =>
a.attributes.name.toLowerCase() === name.toLowerCase() &&
a.attributes.artistName.toLowerCase() === artist.toLowerCase()
) || albums?.[0]
return album
})
}
export default example

View File

@ -0,0 +1,46 @@
import { FastifyPluginAsync } from 'fastify'
import appleMusicRequest from '../../utils/appleMusicRequest'
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get<{
Querystring: {
name: string
lang: 'zh-CN' | 'en-US'
}
}>('/artist', async function (request, reply) {
const { name, lang } = request.query
if (!name) {
return {
code: 400,
message: 'params "name" is required',
}
}
const fromApple = await appleMusicRequest({
method: 'GET',
url: '/search',
params: {
term: name,
types: 'artists',
'fields[artists]': 'url,name,artwork,editorialVideo,artistBio',
'omit[resource:artists]': 'relationships',
platform: 'web',
limit: '1',
l: lang?.toLowerCase() || 'en-us',
with: 'serverBubbles',
},
})
const artist = fromApple?.results?.artist?.data?.[0]
if (
artist &&
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()
) {
return artist
}
})
}
export default example

View File

@ -0,0 +1,9 @@
import { FastifyPluginAsync } from 'fastify'
const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return { root: true }
})
}
export default root;

View File

@ -0,0 +1,57 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from 'axios'
export const baseURL = 'https://amp-api.music.apple.com/v1/catalog/us'
export const headers = {
Authority: 'amp-api.music.apple.com',
Accept: '*/*',
Authorization:
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjYxNDQwNDMyLCJleHAiOjE2NzY5OTI0MzIsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.z4BMv9_O4MpMK2iFhYkDqPsx53soPSnlXXK3jm99pHqGOrZADvTgEUw2U7_B1W0MAtFiWBYhYcGvWrzaOig6Bw',
Referer: 'https://music.apple.com/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36',
'Accept-Encoding': 'gzip',
Origin: 'https://music.apple.com',
}
export const params = {
platform: 'web',
with: 'serverBubbles',
}
const service: AxiosInstance = axios.create({
baseURL,
withCredentials: true,
timeout: 15000,
})
service.interceptors.request.use((config: AxiosRequestConfig) => {
config.headers = { ...headers, ...config.headers }
config.params = { ...params, ...config.params }
return config
})
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response
return res
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
const appleMusicRequest = async (config: AxiosRequestConfig) => {
const { data } = await service.request(config)
return data as any
}
export default appleMusicRequest

View File

@ -0,0 +1,35 @@
// This file contains code that we reuse between our tests.
import Fastify from 'fastify'
import fp from 'fastify-plugin'
import App from '../src/app'
import * as tap from 'tap';
export type Test = typeof tap['Test']['prototype'];
// Fill in this config with all the configurations
// needed for testing the application
async function config () {
return {}
}
// Automatically build and tear down our instance
async function build (t: Test) {
const app = Fastify()
// fastify-plugin ensures that all decorators
// are exposed for testing purposes, this is
// different from the production setup
void app.register(fp(App), await config())
await app.ready();
// Tear down our app after we are done
t.teardown(() => void app.close())
return app
}
export {
config,
build
}

View File

@ -0,0 +1,11 @@
import { test } from 'tap'
import Fastify from 'fastify'
import Support from '../../src/plugins/support'
test('support works standalone', async (t) => {
const fastify = Fastify()
void fastify.register(Support)
await fastify.ready()
t.equal(fastify.someSupport(), 'hugs')
})

View File

@ -0,0 +1,12 @@
import { test } from 'tap'
import { build } from '../helper'
test('example is loaded', async (t) => {
const app = await build(t)
const res = await app.inject({
url: '/example'
})
t.equal(res.payload, 'this is an example')
})

View File

@ -0,0 +1,11 @@
import { test } from 'tap'
import { build } from '../helper'
test('default root route', async (t) => {
const app = await build(t)
const res = await app.inject({
url: '/'
})
t.same(JSON.parse(res.payload), { root: true })
})

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true
},
"include": ["../src/**/*.ts", "**/*.ts"]
}

View File

@ -0,0 +1,8 @@
{
"extends": "fastify-tsconfig",
"compilerOptions": {
"outDir": "dist",
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

View File

@ -9,7 +9,7 @@ export const enum IpcChannels {
Close = 'Close',
IsMaximized = 'IsMaximized',
FullscreenStateChange = 'FullscreenStateChange',
GetApiCacheSync = 'GetApiCacheSync',
GetApiCache = 'GetApiCache',
DevDbExportJson = 'DevDbExportJson',
CacheCoverColor = 'CacheCoverColor',
SetTrayTooltip = 'SetTrayTooltip',
@ -26,6 +26,7 @@ export const enum IpcChannels {
ResetWindowSize = 'ResetWindowSize',
GetAlbumFromAppleMusic = 'GetAlbumFromAppleMusic',
GetArtistFromAppleMusic = 'GetArtistFromAppleMusic',
Logout = 'Logout',
}
// ipcMain.on params
@ -36,7 +37,7 @@ export interface IpcChannelsParams {
[IpcChannels.Close]: void
[IpcChannels.IsMaximized]: void
[IpcChannels.FullscreenStateChange]: void
[IpcChannels.GetApiCacheSync]: {
[IpcChannels.GetApiCache]: {
api: APIs
query?: any
}
@ -68,6 +69,7 @@ export interface IpcChannelsParams {
artist: string
}
[IpcChannels.GetArtistFromAppleMusic]: { id: number; name: string }
[IpcChannels.Logout]: void
}
// ipcRenderer.on params
@ -78,7 +80,7 @@ export interface IpcChannelsReturns {
[IpcChannels.Close]: void
[IpcChannels.IsMaximized]: boolean
[IpcChannels.FullscreenStateChange]: boolean
[IpcChannels.GetApiCacheSync]: any
[IpcChannels.GetApiCache]: any
[IpcChannels.DevDbExportJson]: void
[IpcChannels.CacheCoverColor]: void
[IpcChannels.SetTrayTooltip]: void
@ -92,4 +94,5 @@ export interface IpcChannelsReturns {
[IpcChannels.GetAudioCacheSize]: void
[IpcChannels.GetAlbumFromAppleMusic]: AppleMusicAlbum | undefined
[IpcChannels.GetArtistFromAppleMusic]: AppleMusicArtist | undefined
[IpcChannels.Logout]: void
}

View File

@ -0,0 +1,15 @@
export const enum AppleMusicTables {
Album = 'Album',
Artist = 'Artist',
}
export interface AppleMusicTablesStructures {
[AppleMusicTables.Album]: {
id: string
json: string
}
[AppleMusicTables.Artist]: {
id: string
json: string
}
}

View File

@ -0,0 +1,44 @@
export const enum NeteaseTables {
AccountData = 'AccountData',
Album = 'Album',
Artist = 'Artist',
ArtistAlbum = 'ArtistAlbum',
Audio = 'Audio',
Lyric = 'Lyric',
Playlist = 'Playlist',
Track = 'Track',
}
interface CommonTableStructure {
id: number
json: string
updatedAt: number
}
export interface NeteaseTablesStructures {
[NeteaseTables.AccountData]: {
id: string
json: string
updatedAt: number
}
[NeteaseTables.Album]: CommonTableStructure
[NeteaseTables.Artist]: CommonTableStructure
[NeteaseTables.ArtistAlbum]: CommonTableStructure
[NeteaseTables.Audio]: {
id: number
br: number
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
source:
| 'unknown'
| 'netease'
| 'migu'
| 'kuwo'
| 'kugou'
| 'youtube'
| 'qq'
| 'bilibili'
| 'joox'
queriedAt: number
}
[NeteaseTables.Lyric]: CommonTableStructure
[NeteaseTables.Playlist]: CommonTableStructure
[NeteaseTables.Track]: CommonTableStructure
}

View File

@ -0,0 +1,20 @@
export const enum ReplayTables {
CoverColor = 'CoverColor',
AppData = 'AppData',
}
export interface ReplayTableKeys {
[ReplayTables.CoverColor]: number
[ReplayTables.AppData]: 'appVersion' | 'skippedVersion'
}
export interface ReplayTableStructures {
[ReplayTables.CoverColor]: {
id: number
color: string
queriedAt: number
}
[ReplayTables.AppData]: {
value: string
}
}

View File

@ -1,44 +1,23 @@
import { Toaster } from 'react-hot-toast'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from 'react-query/devtools'
import Player from '@/web/components/Player'
import Sidebar from '@/web/components/Sidebar'
import reactQueryClient from '@/web/utils/reactQueryClient'
import Main from '@/web/components/Main'
import TitleBar from '@/web/components/TitleBar'
import Lyric from '@/web/components/Lyric'
import IpcRendererReact from '@/web/IpcRendererReact'
import Layout from '@/web/components/Layout'
import Devtool from '@/web/components/Devtool'
import ErrorBoundary from '@/web/components/ErrorBoundary'
import useIsMobile from '@/web/hooks/useIsMobile'
import LayoutMobile from '@/web/components/LayoutMobile'
import ScrollRestoration from '@/web/components/ScrollRestoration'
import Toaster from './components/Toaster'
const App = () => {
const isMobile = useIsMobile()
return (
<QueryClientProvider client={reactQueryClient}>
{window.env?.isEnableTitlebar && <TitleBar />}
<div id='layout' className='grid select-none grid-cols-[16rem_auto]'>
<Sidebar />
<Main />
<Player />
</div>
<Lyric />
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
<ErrorBoundary>
{isMobile ? <LayoutMobile /> : <Layout />}
<Toaster />
<ScrollRestoration />
<IpcRendererReact />
{/* Devtool */}
<ReactQueryDevtools
initialIsOpen={false}
toggleButtonProps={{
style: {
position: 'fixed',
right: '0',
left: 'auto',
bottom: '4rem',
},
}}
/>
</QueryClientProvider>
<Devtool />
</ErrorBoundary>
)
}

View File

@ -1,25 +0,0 @@
import TitleBar from '@/web/components/TitleBar'
import IpcRendererReact from '@/web/IpcRendererReact'
import Layout from '@/web/components/New/Layout'
import Devtool from '@/web/components/New/Devtool'
import ErrorBoundary from '@/web/components/New/ErrorBoundary'
import useIsMobile from '@/web/hooks/useIsMobile'
import LayoutMobile from '@/web/components/New/LayoutMobile'
import ScrollRestoration from '@/web/components/New/ScrollRestoration'
import Toaster from './components/New/Toaster'
const App = () => {
const isMobile = useIsMobile()
return (
<ErrorBoundary>
{isMobile ? <LayoutMobile /> : <Layout />}
<Toaster />
<ScrollRestoration />
<IpcRendererReact />
<Devtool />
</ErrorBoundary>
)
}
export default App

View File

@ -7,7 +7,7 @@ import {
AlbumApiNames,
FetchAlbumResponse,
} from '@/shared/api/Album'
import { QueryOptions, useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
const fetch = async (params: FetchAlbumParams) => {
const album = await fetchAlbum(params)
@ -17,22 +17,34 @@ const fetch = async (params: FetchAlbumParams) => {
return album
}
const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
const fetchFromCache = async (
params: FetchAlbumParams
): Promise<FetchAlbumResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Album,
query: params,
})
export default function useAlbum(
params: FetchAlbumParams
// queryOptions?: QueryOptions
) {
return useQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
enabled: !!params.id,
staleTime: 24 * 60 * 60 * 1000, // 24 hours
placeholderData: () => fetchFromCache(params),
// ...queryOptions,
})
export default function useAlbum(params: FetchAlbumParams) {
const key = [AlbumApiNames.FetchAlbum, params]
return useQuery(
key,
() => {
// fetch from cache as placeholder
fetchFromCache(params).then(cache => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData && cache) {
reactQueryClient.setQueryData(key, cache)
}
})
return fetch(params)
},
{
enabled: !!params.id,
staleTime: 24 * 60 * 60 * 1000, // 24 hours
}
)
}
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
@ -46,7 +58,7 @@ export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
}
export async function prefetchAlbum(params: FetchAlbumParams) {
if (fetchFromCache(params)) return
if (await fetchFromCache(params)) return
await reactQueryClient.prefetchQuery(
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),

View File

@ -9,22 +9,32 @@ import {
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
const fetchFromCache = (id: number): FetchArtistResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
const fetchFromCache = async (
params: FetchArtistParams
): Promise<FetchArtistResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Artist,
query: {
id,
},
query: params,
})
export default function useArtist(params: FetchArtistParams) {
const key = [ArtistApiNames.FetchArtist, params]
return useQuery(
[ArtistApiNames.FetchArtist, params],
() => fetchArtist(params),
key,
() => {
// fetch from cache as placeholder
fetchFromCache(params).then(cache => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData && cache) {
reactQueryClient.setQueryData(key, cache)
}
})
return fetchArtist(params)
},
{
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 5 * 60 * 1000, // 5 mins
placeholderData: () => fetchFromCache(params.id),
}
)
}
@ -40,7 +50,7 @@ export function fetchArtistWithReactQuery(params: FetchArtistParams) {
}
export async function prefetchArtist(params: FetchArtistParams) {
if (fetchFromCache(params.id)) return
if (await fetchFromCache(params)) return
await reactQueryClient.prefetchQuery(
[ArtistApiNames.FetchArtist, params],
() => fetchArtist(params),

View File

@ -1,30 +1,35 @@
import { fetchArtistAlbums } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchArtistAlbumsParams,
ArtistApiNames,
FetchArtistAlbumsResponse,
} from '@/shared/api/Artist'
import { FetchArtistAlbumsParams, ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
const key = [ArtistApiNames.FetchArtistAlbums, params]
return useQuery(
[ArtistApiNames.FetchArtistAlbums, params],
key,
async () => {
const data = await fetchArtistAlbums(params)
return data
},
{
enabled: !!params.id && params.id !== 0,
staleTime: 3600000,
placeholderData: (): FetchArtistAlbumsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
// fetch from cache as placeholder
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.ArtistAlbum,
query: {
id: params.id,
},
}),
})
.then(cache => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData && cache) {
reactQueryClient.setQueryData(key, cache)
}
})
return fetchArtistAlbums(params)
},
{
enabled: !!params.id && params.id !== 0,
staleTime: 3600000,
}
)
}

View File

@ -18,13 +18,6 @@ export default function useArtistMV(params: FetchArtistMVParams) {
{
enabled: !!params.id && params.id !== 0,
staleTime: 3600000,
// placeholderData: (): FetchArtistMVResponse =>
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
// api: APIs.ArtistAlbum,
// query: {
// id: params.id,
// },
// }),
}
)
}

View File

@ -1,27 +1,39 @@
import { fetchArtist } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchArtistParams,
ArtistApiNames,
FetchArtistResponse,
} from '@/shared/api/Artist'
import { ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useArtists(ids: number[]) {
return useQuery(
['fetchArtists', ids],
() => Promise.all(ids.map(id => fetchArtist({ id }))),
() =>
Promise.all(
ids.map(async id => {
const queryData = reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id },
])
if (queryData) return queryData
const cache = await window.ipcRenderer?.invoke(
IpcChannels.GetApiCache,
{
api: APIs.Artist,
query: {
id,
},
}
)
if (cache) return cache
return fetchArtist({ id })
})
),
{
enabled: !!ids && ids.length > 0,
staleTime: 5 * 60 * 1000, // 5 mins
initialData: (): FetchArtistResponse[] =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Artists,
query: {
ids,
},
}),
}
)
}

View File

@ -1,18 +1,25 @@
import { fetchLyric } from '@/web/api/track'
import reactQueryClient from '@/web/utils/reactQueryClient'
import {
FetchLyricParams,
FetchLyricResponse,
TrackApiNames,
} from '@/shared/api/Track'
import { FetchLyricParams, TrackApiNames } from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
export default function useLyric(params: FetchLyricParams) {
const key = [TrackApiNames.FetchLyric, params]
return useQuery(
[TrackApiNames.FetchLyric, params],
() => {
key,
async () => {
// fetch from cache as initial data
const cache = window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Lyric,
query: {
id: params.id,
},
})
if (cache) return cache
return fetchLyric(params)
},
{
@ -20,18 +27,11 @@ export default function useLyric(params: FetchLyricParams) {
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
initialData: (): FetchLyricResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Lyric,
query: {
id: params.id,
},
}),
}
)
}
export function fetchTracksWithReactQuery(params: FetchLyricParams) {
export function fetchLyricWithReactQuery(params: FetchLyricParams) {
return reactQueryClient.fetchQuery(
[TrackApiNames.FetchLyric, params],
() => {

View File

@ -13,13 +13,6 @@ export default function useMV(params: FetchMVParams) {
return useQuery([MVApiNames.FetchMV, params], () => fetchMV(params), {
enabled: !!params.mvid && params.mvid > 0 && !isNaN(Number(params.mvid)),
staleTime: 5 * 60 * 1000, // 5 mins
// placeholderData: (): FetchMVResponse =>
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
// api: APIs.SimilarArtist,
// query: {
// id: params.id,
// },
// }),
})
}

View File

@ -13,20 +13,32 @@ const fetch = (params: FetchPlaylistParams) => {
return fetchPlaylist(params)
}
export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
export const fetchFromCache = async (
params: FetchPlaylistParams
): Promise<FetchPlaylistResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Playlist,
query: { id },
query: params,
})
export default function usePlaylist(params: FetchPlaylistParams) {
const key = [PlaylistApiNames.FetchPlaylist, params]
return useQuery(
[PlaylistApiNames.FetchPlaylist, params],
() => fetch(params),
key,
async () => {
// fetch from cache as placeholder
fetchFromCache(params).then(cache => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData && cache) {
reactQueryClient.setQueryData(key, cache)
}
})
return fetch(params)
},
{
enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))),
refetchOnWindowFocus: true,
placeholderData: () => fetchFromCache(params.id),
}
)
}
@ -42,7 +54,7 @@ export function fetchPlaylistWithReactQuery(params: FetchPlaylistParams) {
}
export async function prefetchPlaylist(params: FetchPlaylistParams) {
if (fetchFromCache(params.id)) return
if (await fetchFromCache(params)) return
await reactQueryClient.prefetchQuery(
[PlaylistApiNames.FetchPlaylist, params],
() => fetch(params),

View File

@ -1,28 +1,35 @@
import { fetchSimilarArtists } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchSimilarArtistsParams,
ArtistApiNames,
FetchSimilarArtistsResponse,
} from '@/shared/api/Artist'
import { FetchSimilarArtistsParams, ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
const key = [ArtistApiNames.FetchSimilarArtists, params]
return useQuery(
[ArtistApiNames.FetchSimilarArtists, params],
() => fetchSimilarArtists(params),
{
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 5 * 60 * 1000, // 5 mins
retry: 0,
placeholderData: (): FetchSimilarArtistsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
key,
() => {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.SimilarArtist,
query: {
id: params.id,
},
}),
})
.then(cache => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData && cache) {
reactQueryClient.setQueryData(key, cache)
}
})
return fetchSimilarArtists(params)
},
{
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 5 * 60 * 1000, // 5 mins
retry: 0,
}
)
}

View File

@ -14,20 +14,23 @@ import { useQuery } from '@tanstack/react-query'
export default function useTracks(params: FetchTracksParams) {
return useQuery(
[TrackApiNames.FetchTracks, params],
() => {
async () => {
// fetch from cache as initial data
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Track,
query: {
ids: params.ids.join(','),
},
})
if (cache) return cache
return fetchTracks(params)
},
{
enabled: params.ids.length !== 0,
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
initialData: (): FetchTracksResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Track,
query: {
ids: params.ids.join(','),
},
}),
}
)
}
@ -35,7 +38,14 @@ export default function useTracks(params: FetchTracksParams) {
export function fetchTracksWithReactQuery(params: FetchTracksParams) {
return reactQueryClient.fetchQuery(
[TrackApiNames.FetchTracks, params],
() => {
async () => {
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Track,
query: {
ids: params.ids.join(','),
},
})
if (cache) return cache as FetchTracksResponse
return fetchTracks(params)
},
{

View File

@ -2,14 +2,41 @@ import { fetchUserAccount } from '@/web/api/user'
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { logout } from '../auth'
import { removeAllCookies } from '@/web/utils/cookie'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useUser() {
return useQuery([UserApiNames.FetchUserAccount], fetchUserAccount, {
refetchOnWindowFocus: true,
placeholderData: (): FetchUserAccountResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.UserAccount,
}),
const key = [UserApiNames.FetchUserAccount]
return useQuery(
key,
async () => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserAccount,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
})
}
return fetchUserAccount()
},
{
refetchOnWindowFocus: true,
}
)
}
export const useMutationLogout = () => {
const { refetch } = useUser()
return useMutation(async () => {
await logout()
removeAllCookies()
await window.ipcRenderer?.invoke(IpcChannels.Logout)
await refetch()
})
}

View File

@ -17,16 +17,26 @@ import { AlbumApiNames, FetchAlbumResponse } from '@/shared/api/Album'
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
const { data: user } = useUser()
const uid = user?.profile?.userId ?? 0
const key = [UserApiNames.FetchUserAlbums, uid]
return useQuery(
[UserApiNames.FetchUserAlbums, uid],
() => fetchUserAlbums(params),
key,
() => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserAlbums,
query: params,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
})
}
return fetchUserAlbums(params)
},
{
refetchOnWindowFocus: true,
placeholderData: (): FetchUserAlbumsResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.UserAlbums,
query: params,
}),
}
)
}

View File

@ -9,22 +9,32 @@ import { ArtistApiNames, FetchArtistResponse } from '@/shared/api/Artist'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { cloneDeep } from 'lodash-es'
const KEYS = {
useUserArtists: [UserApiNames.FetchUserArtists],
}
export default function useUserArtists() {
return useQuery(KEYS.useUserArtists, fetchUserArtists, {
refetchOnWindowFocus: true,
placeholderData: (): FetchUserArtistsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.UserArtists,
}),
})
const key = [UserApiNames.FetchUserArtists]
return useQuery(
key,
() => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserArtists,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
})
}
return fetchUserArtists()
},
{
refetchOnWindowFocus: true,
}
)
}
export const useMutationLikeAArtist = () => {
const { data: userLikedArtists } = useUserArtists()
const key = [UserApiNames.FetchUserArtists]
return useMutation(
async (artistID: number) => {
@ -41,16 +51,16 @@ export const useMutationLikeAArtist = () => {
{
onMutate: async artistID => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await reactQueryClient.cancelQueries(KEYS.useUserArtists)
await reactQueryClient.cancelQueries(key)
// 如果还未获取用户收藏的歌手列表,则获取一次
if (!reactQueryClient.getQueryData(KEYS.useUserArtists)) {
await reactQueryClient.fetchQuery(KEYS.useUserArtists)
if (!reactQueryClient.getQueryData(key)) {
await reactQueryClient.fetchQuery(key)
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
KEYS.useUserArtists
key
) as FetchUserArtistsResponse
const isLiked = !!previousData?.data.find(a => a.id === artistID)
@ -83,10 +93,10 @@ export const useMutationLikeAArtist = () => {
newLikedArtists.data.unshift(artist.artist)
// Optimistically update to the new value
reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists)
reactQueryClient.setQueriesData(key, newLikedArtists)
}
reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists)
reactQueryClient.setQueriesData(key, newLikedArtists)
// Return a context object with the snapshotted value
return { previousData }
@ -94,10 +104,7 @@ export const useMutationLikeAArtist = () => {
// If the mutation fails, use the context returned from onMutate to roll back
onSettled: (data, error, artistID, context) => {
if (data?.code !== 200) {
reactQueryClient.setQueryData(
KEYS.useUserArtists,
(context as any).previousData
)
reactQueryClient.setQueryData(key, (context as any).previousData)
toast((error as any).toString())
}
},

View File

@ -15,20 +15,30 @@ import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useUserLikedTracksIDs() {
const { data: user } = useUser()
const uid = user?.account?.id ?? 0
const key = [UserApiNames.FetchUserLikedTracksIds, uid]
return useQuery(
[UserApiNames.FetchUserLikedTracksIds, uid],
() => fetchUserLikedTracksIDs({ uid }),
key,
() => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.Likelist,
query: {
uid,
},
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
})
}
return fetchUserLikedTracksIDs({ uid })
},
{
enabled: !!(uid && uid !== 0),
refetchOnWindowFocus: true,
placeholderData: (): FetchUserLikedTracksIDsResponse | undefined =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.Likelist,
query: {
uid,
},
}),
}
)
}

View File

@ -4,27 +4,37 @@ import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
import useUser from './useUser'
import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useUserListenedRecords(params: {
type: 'week' | 'all'
}) {
const { data: user } = useUser()
const uid = user?.account?.id || 0
const key = [UserApiNames.FetchListenedRecords]
return useQuery(
[UserApiNames.FetchListenedRecords],
() =>
fetchListenedRecords({
key,
() => {
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.ListenedRecords,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
})
}
return fetchListenedRecords({
uid,
type: params.type === 'week' ? 1 : 0,
}),
})
},
{
refetchOnWindowFocus: false,
enabled: !!uid,
placeholderData: (): FetchListenedRecordsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.ListenedRecords,
}),
}
)
}

View File

@ -20,14 +20,30 @@ export default function useUserPlaylists() {
limit: 2000,
}
const key = [UserApiNames.FetchUserPlaylists, uid]
return useQuery(
[UserApiNames.FetchUserPlaylists, uid],
key,
async () => {
if (!params.uid) {
throw new Error('请登录后再请求用户收藏的歌单')
}
const data = await fetchUserPlaylists(params)
return data
const existsQueryData = reactQueryClient.getQueryData(key)
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserPlaylist,
query: {
uid: params.uid,
},
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
})
}
return fetchUserPlaylists(params)
},
{
enabled: !!(
@ -36,13 +52,6 @@ export default function useUserPlaylists() {
params.offset !== undefined
),
refetchOnWindowFocus: true,
placeholderData: (): FetchUserPlaylistsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.UserPlaylist,
query: {
uid: params.uid,
},
}),
}
)
}

View File

@ -0,0 +1,5 @@
<svg width="5" height="8" viewBox="0 0 5 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.88905 3.64645L0.853518 0.610913C0.538536 0.29593 -3.50403e-05 0.519014 -3.50403e-05 0.964466L-3.5251e-05 7.03553C-3.5251e-05 7.48099 0.538536 7.70407 0.853518 7.38909L3.88905 4.35355C4.08431 4.15829 4.08431 3.84171 3.88905 3.64645Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@ -1,4 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="5" width="4" height="14" rx="1.1" stroke="currentColor" stroke-width="2"/>
<rect x="14" y="5" width="4" height="14" rx="1.1" stroke="currentColor" stroke-width="2"/>
</svg>
<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>

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 313 B

View File

@ -1,51 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { cx } from '@emotion/css'
const ArtistInline = ({
artists,
className,
disableLink,
onClick,
}: {
artists: Artist[]
className?: string
disableLink?: boolean
onClick?: (artistId: number) => void
}) => {
if (!artists) return <div></div>
const navigate = useNavigate()
const handleClick = (id: number) => {
if (id === 0 || disableLink) return
if (!onClick) {
navigate(`/artist/${id}`)
} else {
onClick(id)
}
}
return (
<div
className={cx(
!className?.includes('line-clamp') && 'line-clamp-1',
className
)}
>
{artists.map((artist, index) => (
<span key={`${artist.id}-${artist.name}`}>
<span
onClick={() => handleClick(artist.id)}
className={cx({
'hover:underline': !!artist.id && !disableLink,
})}
>
{artist.name}
</span>
{index < artists.length - 1 ? ', ' : ''}&nbsp;
</span>
))}
</div>
)
}
export default ArtistInline

View File

@ -1,41 +0,0 @@
import { resizeImage } from '../utils/common'
import useUser from '@/web/api/hooks/useUser'
import Icon from './Icon'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
const Avatar = ({ size }: { size?: string }) => {
const navigate = useNavigate()
const { data: user } = useUser()
const avatarUrl = user?.profile?.avatarUrl
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
: ''
return (
<>
{avatarUrl ? (
<img
src={avatarUrl}
onClick={() => navigate('/login')}
className={cx(
'app-region-no-drag rounded-full bg-gray-100 dark:bg-gray-700',
size || 'h-9 w-9'
)}
/>
) : (
<div onClick={() => navigate('/login')}>
<Icon
name='user'
className={cx(
'rounded-full bg-black/[.06] p-1 text-gray-500 dark:bg-white/5',
size || 'h-9 w-9'
)}
/>
</div>
)}
</>
)
}
export default Avatar

View File

@ -6,7 +6,7 @@ import uiStates from '@/web/states/uiStates'
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { useLocation } from 'react-router-dom'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
const BlurBackground = () => {
const isMobile = useIsMobile()
@ -18,19 +18,29 @@ const BlurBackground = () => {
uiStates.blurBackgroundImage = null
}, [location.pathname])
const onLoad = async () => {
animate.start({ opacity: 1 })
}
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
setIsLoaded(false)
}, [blurBackgroundImage])
useEffect(() => {
if (!isMobile && blurBackgroundImage && hideTopbarBackground && isLoaded) {
animate.start({ opacity: 1 })
} else {
animate.start({ opacity: 0 })
}
}, [animate, blurBackgroundImage, hideTopbarBackground, isLoaded, isMobile])
return (
<AnimatePresence>
{!isMobile && blurBackgroundImage && hideTopbarBackground && (
<motion.img
initial={{ opacity: 0 }}
animate={animate}
exit={{ opacity: 0 }}
transition={{ ease }}
onLoad={onLoad}
<motion.div
initial={{ opacity: 0 }}
animate={animate}
exit={{ opacity: 0 }}
transition={{ ease }}
>
<img
onLoad={() => setIsLoaded(true)}
className={cx(
'absolute z-0 object-cover opacity-70',
css`
@ -41,9 +51,9 @@ const BlurBackground = () => {
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(blurBackgroundImage, 'sm')}
src={resizeImage(blurBackgroundImage || '', 'sm')}
/>
)}
</motion.div>
</AnimatePresence>
)
}

View File

@ -1,47 +0,0 @@
import { ReactNode } from 'react'
import { cx } from '@emotion/css'
export enum Color {
Primary = 'primary',
Gray = 'gray',
}
export enum Shape {
Default = 'default',
Square = 'square',
}
const Button = ({
children,
onClick,
color = Color.Primary,
iconColor = Color.Primary,
isSkelton = false,
}: {
children: ReactNode
onClick: () => void
color?: Color
iconColor?: Color
isSkelton?: boolean
}) => {
return (
<button
onClick={onClick}
className={cx(
'btn-pressed-animation flex cursor-default items-center rounded-20 px-4 py-1.5 text-lg font-medium',
{
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
'text-brand-500 dark:text-white': iconColor === Color.Primary,
'bg-gray-100 dark:bg-gray-700': color === Color.Gray,
'text-gray-600 dark:text-gray-400': iconColor === Color.Gray,
'animate-pulse bg-gray-100 !text-transparent dark:bg-gray-800':
isSkelton,
}
)}
>
{children}
</button>
)
}
export default Button

View File

@ -6,11 +6,14 @@ import player from '@/web/states/player'
import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const AlbumContextMenu = () => {
const { t } = useTranslation()
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const likeAAlbum = useMutationLikeAAlbum()
@ -34,7 +37,7 @@ const AlbumContextMenu = () => {
items={[
{
type: 'item',
label: 'Add to Queue',
label: t`context-menu.add-to-queue`,
onClick: () => {
toast('开发中')
@ -63,7 +66,7 @@ const AlbumContextMenu = () => {
},
{
type: 'item',
label: 'Add to playlist',
label: t`context-menu.add-to-playlist`,
onClick: () => {
toast('开发中')
},
@ -73,16 +76,16 @@ const AlbumContextMenu = () => {
},
{
type: 'submenu',
label: 'Share',
label: t`context-menu.share`,
items: [
{
type: 'item',
label: 'Copy Netease Link',
label: t`context-menu.copy-netease-link`,
onClick: () => {
copyToClipboard(
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success('Copied')
toast.success(t`toasts.copied`)
},
},
{
@ -92,7 +95,7 @@ const AlbumContextMenu = () => {
copyToClipboard(
`${window.location.origin}/album/${dataSourceID}`
)
toast.success('Copied')
toast.success(t`toasts.copied`)
},
},
],

View File

@ -5,11 +5,14 @@ import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const ArtistContextMenu = () => {
const { t } = useTranslation()
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const likeAArtist = useMutationLikeAArtist()
@ -18,9 +21,9 @@ const ArtistContextMenu = () => {
const { data: likedArtists } = useUserArtists()
const followLabel = useMemo(() => {
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
? 'Follow'
: 'Unfollow'
}, [dataSourceID, likedArtists?.data])
? t`context-menu.unfollow`
: t`context-menu.follow`
}, [dataSourceID, likedArtists?.data, t])
return (
<AnimatePresence>
@ -41,7 +44,9 @@ const ArtistContextMenu = () => {
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
if (res?.code === 200) {
toast.success(
followLabel === 'Unfollow' ? 'Followed' : 'Unfollowed'
followLabel === t`context-menu.unfollow`
? t`context-menu.unfollowed`
: t`context-menu.followed`
)
}
})
@ -52,16 +57,16 @@ const ArtistContextMenu = () => {
},
{
type: 'submenu',
label: 'Share',
label: t`context-menu.share`,
items: [
{
type: 'item',
label: 'Copy Netease Link',
label: t`context-menu.copy-netease-link`,
onClick: () => {
copyToClipboard(
`https://music.163.com/#/artist?id=${dataSourceID}`
)
toast.success('Copied')
toast.success(t`toasts.copied`)
},
},
{
@ -71,7 +76,7 @@ const ArtistContextMenu = () => {
copyToClipboard(
`${window.location.origin}/artist/${dataSourceID}`
)
toast.success('Copied')
toast.success(t`toasts.copied`)
},
},
],

View File

@ -0,0 +1,85 @@
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 MenuPanel from './MenuPanel'
const BasicContextMenu = ({
onClose,
items,
target,
cursorPosition,
options,
classNames,
}: {
onClose: (e: MouseEvent) => void
items: ContextMenuItem[]
target: HTMLElement
cursorPosition: { x: number; y: number }
options?: {
useCursorPosition?: boolean
} | null
classNames?: string
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const [measureRef, menu] = useMeasure()
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
)
useClickAway(menuRef, onClose)
useLockMainScroll(!!position)
useLayoutEffect(() => {
if (options?.useCursorPosition) {
const leftX = cursorPosition.x
const rightX = cursorPosition.x - menu.width
const bottomY = cursorPosition.y
const topY = cursorPosition.y - menu.height
const position = {
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
}
setPosition(position)
} else {
const button = target.getBoundingClientRect()
const leftX = button.x
const rightX = button.x - menu.width + button.width
const bottomY = button.y + button.height + 8
const topY = button.y - menu.height - 8
const position = {
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
}
setPosition(position)
}
}, [target, menu, options?.useCursorPosition, cursorPosition])
return (
<>
<MenuPanel
position={{ x: 99999, y: 99999 }}
items={items}
ref={measureRef}
onClose={() => {
//
}}
forMeasure={true}
classNames={classNames}
/>
{position && (
<MenuPanel
position={position}
items={items}
ref={menuRef}
onClose={onClose}
classNames={classNames}
/>
)}
</>
)
}
export default BasicContextMenu

View File

@ -0,0 +1,112 @@
import { css, cx } from '@emotion/css'
import { ForwardedRef, forwardRef, useRef, useState } from 'react'
import Icon from '../Icon'
export interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider'
label?: string
onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[]
}
const MenuItem = ({
item,
index,
onClose,
onSubmenuOpen,
onSubmenuClose,
className,
}: {
item: ContextMenuItem
index: number
onClose: (e: MouseEvent) => void
onSubmenuOpen: (props: { itemRect: DOMRect; index: number }) => void
onSubmenuClose: () => void
className?: string
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const [isHover, setIsHover] = useState(false)
if (item.type === 'divider') {
return (
<div className='my-2 h-px w-full px-3'>
<div className='h-full w-full bg-white/20'></div>
</div>
)
}
return (
<div
ref={itemRef}
onClick={e => {
if (!item.onClick) {
return
}
const event = e as unknown as MouseEvent
item.onClick?.(event)
onClose(event)
}}
onMouseOver={() => {
if (item.type !== 'submenu') return
setIsHover(true)
onSubmenuOpen({
itemRect: itemRef.current!.getBoundingClientRect(),
index,
})
}}
onMouseLeave={e => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.classList?.contains('submenu')) {
return
}
setIsHover(false)
onSubmenuClose()
}}
className={cx(
'relative',
className,
css`
padding-right: 9px;
padding-left: 9px;
`
)}
>
<div
className={cx(
'relative flex w-full items-center justify-between whitespace-nowrap rounded-[5px] p-3 text-16 font-medium text-neutral-200 transition-colors duration-400 hover:bg-white/[.06]',
item.type !== 'submenu' && !isHover && 'active:bg-gray/50',
isHover && 'bg-white/[.06]'
)}
>
<div>{item.label}</div>
{item.type === 'submenu' && (
<>
<Icon
name='caret-right'
className={cx(
'ml-10 text-neutral-600',
css`
height: 8px;
width: 5px;
`
)}
/>
{/* 将item变宽一点避免移动鼠标时还没移动到submenu就关闭submenu了 */}
<div
className={cx(
'absolute h-full',
css`
left: -24px;
width: calc(100% + 48px);
`
)}
></div>
</>
)}
</div>
</div>
)
}
export default MenuItem

View File

@ -0,0 +1,173 @@
import { css, cx } from '@emotion/css'
import {
ForwardedRef,
forwardRef,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { motion } from 'framer-motion'
import MenuItem, { ContextMenuItem } from './MenuItem'
interface PanelProps {
position: {
x: number
y: number
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
}
items: ContextMenuItem[]
onClose: (e: MouseEvent) => void
forMeasure?: boolean
classNames?: string
isSubmenu?: boolean
}
interface SubmenuProps {
itemRect: DOMRect
index: number
}
const MenuPanel = forwardRef(
(
{ position, items, onClose, forMeasure, classNames, isSubmenu }: PanelProps,
ref: ForwardedRef<HTMLDivElement>
) => {
const [submenuProps, setSubmenuProps] = useState<SubmenuProps | null>(null)
return (
// Container (to add padding for submenus)
<motion.div
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.1,
},
}}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
ref={ref}
className={cx(
'fixed',
position.transformOrigin || 'origin-top-left',
isSubmenu ? 'submenu z-20 px-1' : 'z-10'
)}
style={{ left: position.x, top: position.y }}
>
{/* The real panel */}
<div
className={cx(
'rounded-12 border border-white/[.06] bg-gray-900/95 p-px py-2.5 shadow-xl outline outline-1 outline-black backdrop-blur-3xl',
css`
min-width: 200px;
`,
classNames
)}
>
{items.map((item, index) => (
<MenuItem
key={index}
index={index}
item={item}
onClose={onClose}
onSubmenuOpen={(props: SubmenuProps) => setSubmenuProps(props)}
onSubmenuClose={() => setSubmenuProps(null)}
className={isSubmenu ? 'submenu' : ''}
/>
))}
</div>
{/* Submenu */}
<SubMenu
items={
submenuProps?.index ? items[submenuProps?.index]?.items : undefined
}
itemRect={submenuProps?.itemRect}
onClose={onClose}
/>
</motion.div>
)
}
)
MenuPanel.displayName = 'Menu'
export default MenuPanel
const SubMenu = ({
items,
itemRect,
onClose,
}: {
items?: ContextMenuItem[]
itemRect?: DOMRect
onClose: (e: MouseEvent) => void
}) => {
const submenuRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
x: number
y: number
transformOrigin: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
}>()
useLayoutEffect(() => {
if (!itemRect || !submenuRef.current) {
return
}
const item = itemRect
const submenu = submenuRef.current.getBoundingClientRect()
const isRightSide = item.x + item.width + submenu.width <= window.innerWidth
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 transformOriginTable = {
top: {
right: 'origin-top-left',
left: 'origin-top-right',
},
bottom: {
right: 'origin-bottom-left',
left: 'origin-bottom-right',
},
} as const
setPosition({
x,
y,
transformOrigin:
transformOriginTable[isTopSide ? 'top' : 'bottom'][
isRightSide ? 'right' : 'left'
],
})
}, [itemRect])
if (!items || !itemRect) {
return <></>
}
return (
<>
<MenuPanel
position={{ x: 99999, y: 99999 }}
items={items || []}
ref={submenuRef}
onClose={() => {
// Do nothing
}}
forMeasure={true}
isSubmenu={true}
/>
<MenuPanel
position={position || { x: 99999, y: 99999 }}
items={items || []}
onClose={onClose}
isSubmenu={true}
/>
</>
)
}

View File

@ -1,6 +1,9 @@
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
import { fetchTracks } from '@/web/api/track'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
@ -8,6 +11,8 @@ import BasicContextMenu from './BasicContextMenu'
const TrackContextMenu = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const [, copyToClipboard] = useCopyToClipboard()
const { type, dataSourceID, target, cursorPosition, options } =
@ -24,7 +29,7 @@ const TrackContextMenu = () => {
items={[
{
type: 'item',
label: 'Add to Queue',
label: t`context-menu.add-to-queue`,
onClick: () => {
toast('开发中')
},
@ -34,16 +39,24 @@ const TrackContextMenu = () => {
},
{
type: 'item',
label: 'Go to artist',
onClick: () => {
toast('开发中')
label: t`context-menu.go-to-artist`,
onClick: async () => {
const tracks = await fetchTracksWithReactQuery({
ids: [Number(dataSourceID)],
})
const track = tracks?.songs?.[0]
if (track) navigate(`/artist/${track.ar[0].id}`)
},
},
{
type: 'item',
label: 'Go to album',
onClick: () => {
toast('开发中')
label: t`context-menu.go-to-album`,
onClick: async () => {
const tracks = await fetchTracksWithReactQuery({
ids: [Number(dataSourceID)],
})
const track = tracks?.songs?.[0]
if (track) navigate(`/album/${track.al.id}`)
},
},
{
@ -51,30 +64,30 @@ const TrackContextMenu = () => {
},
{
type: 'item',
label: 'Add to Liked Tracks',
label: t`context-menu.add-to-liked-tracks`,
onClick: () => {
toast('开发中')
},
},
{
type: 'item',
label: 'Add to playlist',
label: t`context-menu.add-to-playlist`,
onClick: () => {
toast('开发中')
},
},
{
type: 'submenu',
label: 'Share',
label: t`context-menu.share`,
items: [
{
type: 'item',
label: 'Copy Netease Link',
label: t`context-menu.copy-netease-link`,
onClick: () => {
copyToClipboard(
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success('Copied')
toast.success(t`toasts.copied`)
},
},
{
@ -84,7 +97,7 @@ const TrackContextMenu = () => {
copyToClipboard(
`${window.location.origin}/album/${dataSourceID}`
)
toast.success('Copied')
toast.success(t`toasts.copied`)
},
},
],

View File

@ -1,66 +0,0 @@
import Icon from '@/web/components/Icon'
import { cx } from '@emotion/css'
import { useState } from 'react'
const Cover = ({
imageUrl,
onClick,
roundedClass = 'rounded-xl',
showPlayButton = false,
showHover = true,
alwaysShowShadow = false,
}: {
imageUrl: string
onClick?: () => void
roundedClass?: string
showPlayButton?: boolean
showHover?: boolean
alwaysShowShadow?: boolean
}) => {
const [isError, setIsError] = useState(imageUrl.includes('3132508627578625'))
return (
<div onClick={onClick} className='group relative z-0'>
{/* Neon shadow */}
{showHover && (
<div
className={cx(
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] bg-cover blur-lg filter transition duration-300 ',
roundedClass,
!alwaysShowShadow && 'opacity-0 group-hover:opacity-60'
)}
style={{
backgroundImage: `url("${imageUrl}")`,
}}
></div>
)}
{/* Cover */}
{isError ? (
<div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300 '>
<Icon name='music-note' className='h-1/2 w-1/2' />
</div>
) : (
<img
className={cx(
'box-content aspect-square h-full w-full border border-black border-opacity-5 dark:border-white dark:border-opacity-[.03]',
roundedClass
)}
src={imageUrl}
onError={() => imageUrl && setIsError(true)}
/>
)}
{/* Play button */}
{showPlayButton && (
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
<Icon className='ml-0.5 h-6 w-6' name='play-fill' />
</button>
</div>
)}
</div>
)
}
export default Cover

View File

@ -1,229 +1,136 @@
import Cover from '@/web/components/Cover'
import Skeleton from '@/web/components/Skeleton'
import Icon from '@/web/components/Icon'
import { resizeImage } from '@/web/utils/common'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
import { cx } from '@emotion/css'
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { memo, useCallback } from 'react'
import dayjs from 'dayjs'
import ArtistInline from './ArtistsInLine'
export enum Subtitle {
Copywriter = 'copywriter',
Creator = 'creator',
TypeReleaseYear = 'type+releaseYear',
Artist = 'artist',
}
type ItemTitle = undefined | 'name'
type ItemSubTitle = undefined | 'artist' | 'year'
const Title = ({
title,
seeMoreLink,
const Album = ({
album,
itemTitle,
itemSubtitle,
}: {
title: string
seeMoreLink: string
album: Album
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
const navigate = useNavigate()
const goTo = () => {
navigate(`/album/${album.id}`)
}
const prefetch = () => {
prefetchAlbum({ id: album.id })
}
const title =
itemTitle &&
{
name: album.name,
}[itemTitle]
const subtitle =
itemSubtitle &&
{
artist: (
<ArtistInline
artists={album.artists}
hoverClassName='hover:text-white/50 transition-colors duration-400'
/>
),
year: dayjs(album.publishTime || 0).year(),
}[itemSubtitle]
return (
<div className='flex items-baseline justify-between'>
<div className='my-4 text-[28px] font-bold text-black dark:text-white'>
{title}
</div>
{seeMoreLink && (
<div className='text-13px font-semibold text-gray-600 hover:underline'>
See More
<div>
<Image
onClick={goTo}
key={album.id}
src={resizeImage(album?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
{title && (
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{title}
</div>
)}
{subtitle && (
<div className='mt-1 text-14 font-medium text-neutral-700'>
{subtitle}
</div>
)}
</div>
)
}
const getSubtitleText = (
item: Album | Playlist | Artist,
subtitle: Subtitle
) => {
const nickname = 'creator' in item ? item.creator.nickname : 'someone'
const artist =
'artist' in item
? item.artist.name
: 'artists' in item
? item.artists?.[0]?.name
: 'unknown'
const copywriter = 'copywriter' in item ? item.copywriter : 'unknown'
const releaseYear =
('publishTime' in item &&
formatDate(item.publishTime ?? 0, 'en', 'YYYY')) ||
'unknown'
const Playlist = ({ playlist }: { playlist: Playlist }) => {
const navigate = useNavigate()
const goTo = useCallback(() => {
navigate(`/playlist/${playlist.id}`)
}, [navigate, playlist.id])
const prefetch = useCallback(() => {
prefetchPlaylist({ id: playlist.id })
}, [playlist.id])
const type = {
playlist: 'playlist',
album: 'Album',
: 'Album',
Single: 'Single',
'EP/Single': 'EP',
EP: 'EP',
unknown: 'unknown',
: 'Collection',
}[('type' in item && typeof item.type !== 'number' && item.type) || 'unknown']
const table = {
[Subtitle.Creator]: `by ${nickname}`,
[Subtitle.TypeReleaseYear]: `${type} · ${releaseYear}`,
[Subtitle.Artist]: artist,
[Subtitle.Copywriter]: copywriter,
}
return table[subtitle]
}
const getImageUrl = (item: Album | Playlist | Artist) => {
let cover: string | undefined = ''
if ('coverImgUrl' in item) cover = item.coverImgUrl
if ('picUrl' in item) cover = item.picUrl
if ('img1v1Url' in item) cover = item.img1v1Url
return resizeImage(cover || '', 'md')
return (
<Image
onClick={goTo}
key={playlist.id}
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
)
}
const CoverRow = ({
title,
albums,
artists,
playlists,
subtitle = Subtitle.Copywriter,
seeMoreLink,
isSkeleton,
title,
className,
rows = 2,
navigateCallback, // Callback function when click on the cover/title
itemTitle,
itemSubtitle,
}: {
title?: string
albums?: Album[]
artists?: Artist[]
playlists?: Playlist[]
subtitle?: Subtitle
seeMoreLink?: string
isSkeleton?: boolean
className?: string
rows?: number
navigateCallback?: () => void
albums?: Album[]
playlists?: Playlist[]
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
const renderItems = useMemo(() => {
if (isSkeleton) {
return new Array(rows * 5).fill({}) as Array<Album | Playlist | Artist>
}
return albums ?? playlists ?? artists ?? []
}, [albums, artists, isSkeleton, playlists, rows])
const navigate = useNavigate()
const goTo = (id: number) => {
if (isSkeleton) return
if (albums) navigate(`/album/${id}`)
if (playlists) navigate(`/playlist/${id}`)
if (artists) navigate(`/artist/${id}`)
if (navigateCallback) navigateCallback()
scrollToTop()
}
const prefetch = (id: number) => {
if (albums) prefetchAlbum({ id })
if (playlists) prefetchPlaylist({ id })
}
return (
<div>
{title && <Title title={title} seeMoreLink={seeMoreLink ?? ''} />}
<div className={className}>
{/* Title */}
{title && (
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
{title}
</h4>
)}
<div
className={cx(
'grid',
className,
!className &&
'grid-cols-3 gap-x-6 gap-y-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
)}
>
{renderItems.map((item, index) => (
<div
key={item.id ?? index}
onMouseOver={() => prefetch(item.id)}
className='grid gap-x-6 gap-y-7'
>
<div>
{/* Cover */}
{isSkeleton ? (
<Skeleton className='box-content aspect-square w-full rounded-xl border border-black border-opacity-0' />
) : (
<Cover
onClick={() => goTo(item.id)}
imageUrl={getImageUrl(item)}
showPlayButton={true}
roundedClass={artists ? 'rounded-full' : 'rounded-xl'}
/>
)}
{/* Info */}
<div className='mt-2'>
<div className='font-semibold'>
{/* Name */}
{isSkeleton ? (
<div className='flex w-full -translate-y-px flex-col'>
<Skeleton className='w-full leading-tight'>
PLACEHOLDER
</Skeleton>
<Skeleton className='w-1/3 translate-y-px leading-tight'>
PLACEHOLDER
</Skeleton>
</div>
) : (
<span
className={cx(
'line-clamp-2 leading-tight',
artists && 'mt-3 text-center'
)}
>
{/* Playlist private icon */}
{(item as Playlist).privacy === 10 && (
<Icon
name='lock'
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
/>
)}
{/* Explicit icon */}
{(item as Album)?.mark === 1056768 && (
<Icon
name='explicit'
className='float-right mt-[2px] h-4 w-4 text-gray-300'
/>
)}
{/* Name */}
<span
onClick={() => goTo(item.id)}
className='decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'
>
{item.name}
</span>
</span>
)}
</div>
{/* Subtitle */}
{isSkeleton ? (
<Skeleton className='w-3/5 translate-y-px text-[12px]'>
PLACEHOLDER
</Skeleton>
) : (
!artists && (
<div className='flex text-[12px] text-gray-500 dark:text-gray-400'>
<span>{getSubtitleText(item, subtitle)}</span>
</div>
)
)}
</div>
</div>
</div>
{/* Items */}
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
{albums?.map(album => (
<Album
key={album.id}
album={album}
itemTitle={itemTitle}
itemSubtitle={itemSubtitle}
/>
))}
{playlists?.map(playlist => (
<Playlist key={playlist.id} playlist={playlist} />
))}
</div>
</div>
)
}
export default CoverRow
const memoizedCoverRow = memo(CoverRow)
memoizedCoverRow.displayName = 'CoverRow'
export default memoizedCoverRow

View File

@ -1,45 +0,0 @@
import Icon from './Icon'
import { cx, css, keyframes } from '@emotion/css'
const move = keyframes`
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
`
const DailyTracksCard = () => {
return (
<div className='relative h-[198px] cursor-pointer overflow-hidden rounded-2xl'>
{/* Cover */}
<img
className={cx(
'absolute top-0 left-0 w-full will-change-transform',
css`
animation: ${move} 38s infinite;
animation-direction: alternate;
`
)}
src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
/>
{/* 每日推荐 */}
<div className='absolute flex h-full w-1/2 items-center bg-gradient-to-r from-[#0000004d] to-transparent pl-8'>
<div className='grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]'>
{Array.from('每日推荐').map(word => (
<div key={word}>{word}</div>
))}
</div>
</div>
{/* Play button */}
<button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
<Icon name='play-fill' className='ml-0.5 h-6 w-6' />
</button>
</div>
)
}
export default DailyTracksCard

View File

@ -1,135 +0,0 @@
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import Icon from './Icon'
import ArtistInline from './ArtistsInline'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import useCoverColor from '../hooks/useCoverColor'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
const MediaControls = () => {
const classes =
'btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-1.5 transition duration-200 after:bg-white/10'
const playerSnapshot = useSnapshot(player)
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const playOrPause = () => {
if (playerSnapshot.mode === PlayerMode.FM) {
player.playOrPause()
} else {
player.playFM()
}
}
return (
<div>
<button
key='dislike'
className={classes}
onClick={() => player.fmTrash()}
>
<Icon name='dislike' className='h-6 w-6' />
</button>
<button key='play' className={classes} onClick={playOrPause}>
<Icon
className='h-6 w-6'
name={
playerSnapshot.mode === PlayerMode.FM &&
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
/>
</button>
<button
key='next'
className={classes}
onClick={() => player.nextTrack(true)}
>
<Icon name='next' className='h-6 w-6' />
</button>
</div>
)
}
const FMCard = () => {
const navigate = useNavigate()
const { track } = useSnapshot(player)
const coverUrl = useMemo(
() => resizeImage(track?.al?.picUrl ?? '', 'md'),
[track?.al?.picUrl]
)
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
return (
<div
className='relative flex h-[198px] overflow-hidden rounded-2xl bg-gray-100 p-4 dark:bg-gray-800'
style={{
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
}}
>
{coverUrl ? (
<img
onClick={() => track?.al?.id && navigate(`/album/${track.al.id}`)}
className='rounded-lg shadow-2xl'
src={coverUrl}
/>
) : (
<div className='aspect-square h-full rounded-lg bg-gray-200 dark:bg-white/5'></div>
)}
<div className='ml-5 flex w-full flex-col justify-between text-white'>
{/* Track info */}
<div>
{track ? (
<div className='line-clamp-2 text-xl font-semibold'>
{track?.name}
</div>
) : (
<div className='flex'>
<div className='bg-gray-200 text-xl text-transparent dark:bg-white/5'>
PLACEHOLDER12345
</div>
</div>
)}
{track ? (
<ArtistInline
className='line-clamp-2 opacity-75'
artists={track?.ar ?? []}
/>
) : (
<div className='mt-1 flex'>
<div className='bg-gray-200 text-transparent dark:bg-white/5'>
PLACEHOLDER
</div>
</div>
)}
</div>
<div className='-mb-1 flex items-center justify-between'>
{track ? <MediaControls /> : <div className='h-9'></div>}
{/* FM logo */}
<div
className={cx(
'right-4 bottom-5 flex opacity-20',
track ? 'text-white ' : 'text-gray-700 dark:text-white'
)}
>
<Icon name='fm' className='mr-1 h-6 w-6' />
<span className='font-semibold'>FM</span>
</div>
</div>
</div>
</div>
)
}
export default FMCard

View File

@ -1 +1 @@
export type IconNames = 'back' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | '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' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'
export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | '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' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'

View File

@ -1,32 +0,0 @@
import { ReactNode } from 'react'
import { cx } from '@emotion/css'
const IconButton = ({
children,
onClick,
disabled,
className,
}: {
children: ReactNode
onClick: () => void
disabled?: boolean | undefined
className?: string
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={cx(
className,
'relative transform cursor-default p-1.5 transition duration-200',
!disabled &&
'btn-pressed-animation btn-hover-animation after:bg-black/[.06] dark:after:bg-white/10',
disabled && 'opacity-30'
)}
>
{children}
</button>
)
}
export default IconButton

View File

@ -1,7 +1,7 @@
import Main from '@/web/components/New/Main'
import Player from '@/web/components/New/Player'
import MenuBar from '@/web/components/New/MenuBar'
import Topbar from '@/web/components/New/Topbar/TopbarDesktop'
import Main from '@/web/components/Main'
import Player from '@/web/components/Player'
import MenuBar from '@/web/components/MenuBar'
import Topbar from '@/web/components/Topbar/TopbarDesktop'
import { css, cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
@ -22,8 +22,8 @@ const Layout = () => {
<div
id='layout'
className={cx(
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black'
// window.env?.isElectron && !fullscreen && 'rounded-24'
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
window.env?.isElectron && !fullscreen && 'rounded-24'
)}
>
<BlurBackground />

View File

@ -1,9 +1,9 @@
import Player from '@/web/components/New/PlayerMobile'
import Player from '@/web/components/PlayerMobile'
import { css, cx } from '@emotion/css'
import { useMemo } from 'react'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Router from '@/web/components/New/Router'
import Router from '@/web/components/Router'
import MenuBar from './MenuBar'
import Topbar from './Topbar/TopbarMobile'
import { isIOS, isIosPwa, isPWA, isSafari } from '@/web/utils/common'

View File

@ -9,6 +9,7 @@ import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import LoginWithQRCode from './LoginWithQRCode'
import persistedUiStates from '@/web/states/persistedUiStates'
import useUser from '@/web/api/hooks/useUser'
import { useTranslation } from 'react-i18next'
const OR = ({
children,
@ -17,11 +18,13 @@ const OR = ({
children: React.ReactNode
onClick: () => void
}) => {
const { t } = useTranslation()
return (
<>
<div className='mt-4 flex items-center'>
<div className='h-px flex-grow bg-white/20'></div>
<div className='mx-2 text-16 font-medium text-white'>or</div>
<div className='mx-2 text-16 font-medium text-white'>{t`auth.or`}</div>
<div className='h-px flex-grow bg-white/20'></div>
</div>
@ -38,6 +41,8 @@ const OR = ({
}
const Login = () => {
const { t } = useTranslation()
const { data: user, isLoading: isLoadingUser } = useUser()
const { loginType } = useSnapshot(persistedUiStates)
const { showLoginPanel } = useSnapshot(uiStates)
@ -132,8 +137,8 @@ const Login = () => {
<OR onClick={handleSwitchCard}>
{cardType === 'qrCode'
? 'Use Phone or Email'
: 'Scan QR Code'}
? t`auth.use-phone-or-email`
: t`auth.scan-qr-code`}
</OR>
</motion.div>
</AnimatePresence>

View File

@ -17,8 +17,12 @@ import uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { UserApiNames } from '@/shared/api/User'
import { useTranslation } from 'react-i18next'
const LoginWithPhoneOrEmail = () => {
const { t, i18n } = useTranslation()
const isZH = i18n.language.startsWith('zh')
const { loginPhoneCountryCode, loginType: persistedLoginType } =
useSnapshot(persistedUiStates)
const [email, setEmail] = useState<string>('')
@ -130,7 +134,7 @@ const LoginWithPhoneOrEmail = () => {
return (
<>
<div className='text-center text-18 font-medium text-white/20'>
Log in with{' '}
{!isZH && 'Login with '}
<span
className={cx(
'transition-colors duration-300',
@ -142,9 +146,10 @@ const LoginWithPhoneOrEmail = () => {
persistedUiStates.loginType = type
}}
>
Phone
</span>{' '}
/{' '}
{t`auth.phone`}
{isZH && '登录'}
</span>
{' / '}
<span
className={cx(
'transition-colors duration-300',
@ -154,7 +159,8 @@ const LoginWithPhoneOrEmail = () => {
if (loginType !== 'email') setLoginType('email')
}}
>
Email
{t`auth.email`}
{isZH && '登录'}
</span>
</div>
@ -167,7 +173,7 @@ const LoginWithPhoneOrEmail = () => {
initial='hidden'
animate='show'
exit='hidden'
className='flex items-center'
className='flex items-center '
>
<input
onChange={e => {
@ -175,7 +181,7 @@ const LoginWithPhoneOrEmail = () => {
persistedUiStates.loginPhoneCountryCode = e.target.value
}}
className={cx(
'my-3.5 flex-shrink-0 bg-transparent',
'my-3.5 flex-shrink-0 bg-transparent placeholder:text-white/30',
css`
width: 28px;
`
@ -186,8 +192,8 @@ const LoginWithPhoneOrEmail = () => {
<div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div>
<input
onChange={e => setPhone(e.target.value)}
className='my-3.5 flex-grow appearance-none bg-transparent'
placeholder='Phone'
className='my-3.5 flex-grow appearance-none bg-transparent placeholder:text-white/30'
placeholder={t`auth.phone`}
type='tel'
value={phone}
/>
@ -209,8 +215,8 @@ const LoginWithPhoneOrEmail = () => {
>
<input
onChange={e => setEmail(e.target.value)}
className='w-full flex-grow appearance-none bg-transparent'
placeholder='Email'
className='w-full flex-grow appearance-none bg-transparent placeholder:text-white/30'
placeholder={t`auth.email`}
type='email'
value={email}
/>
@ -223,8 +229,8 @@ const LoginWithPhoneOrEmail = () => {
<div className='mt-4 flex items-center rounded-12 bg-black/50 p-3 text-16 font-medium text-night-50'>
<input
onChange={e => setPassword(e.target.value)}
className='w-full bg-transparent'
placeholder='Password'
className='w-full bg-transparent placeholder:text-white/30'
placeholder={t`auth.password`}
type='password'
value={password}
/>
@ -237,7 +243,7 @@ const LoginWithPhoneOrEmail = () => {
}
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
>
LOG IN
{t`auth.login`}
</div>
</>
)

View File

@ -8,6 +8,7 @@ import { setCookies } from '@/web/utils/cookie'
import uiStates from '@/web/states/uiStates'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { UserApiNames } from '@/shared/api/User'
import { useTranslation } from 'react-i18next'
const QRCode = ({ className, text }: { className?: string; text: string }) => {
const [image, setImage] = useState<string>('')
@ -37,6 +38,8 @@ const QRCode = ({ className, text }: { className?: string; text: string }) => {
}
const LoginWithQRCode = () => {
const { t } = useTranslation()
const {
data: key,
status: keyStatus,
@ -101,7 +104,7 @@ const LoginWithQRCode = () => {
return (
<>
<div className='text-center text-18 font-medium text-white/20'>
Log in with NetEase QR
{t`auth.login-with-netease-qr`}
</div>
<div className='mt-4 rounded-24 bg-white p-2.5'>

View File

@ -1,24 +1,67 @@
import { css, cx } from '@emotion/css'
import Router from './Router'
import Topbar from './Topbar'
import { cx } from '@emotion/css'
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
import uiStates from '@/web/states/uiStates'
import { useEffect, useRef, useState } from 'react'
import { breakpoint as bp, ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import persistedUiStates from '@/web/states/persistedUiStates'
import { motion, useAnimation } from 'framer-motion'
import { sleep } from '@/web/utils/common'
import player from '@/web/states/player'
const Main = () => {
const playerSnapshot = useSnapshot(player)
// Show/hide topbar background
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen } = useIntersectionObserver(observePoint)
useEffect(() => {
uiStates.hideTopbarBackground = onScreen
return () => {
uiStates.hideTopbarBackground = false
}
}, [onScreen])
// Change width when player is minimized
const { minimizePlayer } = useSnapshot(persistedUiStates)
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
const controlsMain = useAnimation()
useEffect(() => {
const animate = async () => {
await controlsMain.start({ opacity: 0 })
await sleep(100)
setIsMaxWidth(minimizePlayer)
await controlsMain.start({ opacity: 1 })
}
if (minimizePlayer !== isMaxWidth) animate()
}, [controlsMain, isMaxWidth, minimizePlayer])
return (
<div
id='mainContainer'
className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
<motion.main
id='main'
animate={controlsMain}
transition={{ ease, duration: 0.4 }}
className={cx(
'no-scrollbar z-10 h-screen overflow-y-auto',
css`
${bp.lg} {
margin-left: 144px;
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
}
`
)}
>
<Topbar />
<main
id='main'
className={cx(
'mb-24 flex-grow px-8',
window.env?.isEnableTitlebar && 'mt-8'
)}
<div ref={observePoint}></div>
<div
className={css`
margin-top: 132px;
`}
>
<Router />
</main>
</div>
</div>
</motion.main>
)
}

View File

@ -1,14 +1,11 @@
import React, { useEffect, useState } from 'react'
import { css, cx } from '@emotion/css'
import Icon from '../Icon'
import Icon from './Icon'
import { useLocation, useNavigate } from 'react-router-dom'
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 uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
const tabs = [
{

View File

@ -1,238 +0,0 @@
import { css, cx } from '@emotion/css'
import {
ForwardedRef,
forwardRef,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { useClickAway } from 'react-use'
import Icon from '../../Icon'
import useLockMainScroll from '@/web/hooks/useLockMainScroll'
import { motion } from 'framer-motion'
import useMeasure from 'react-use-measure'
interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider'
label?: string
onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[]
}
const Divider = () => (
<div className='my-2 h-px w-full px-3'>
<div className='h-full w-full bg-white/5'></div>
</div>
)
const Item = ({
item,
onClose,
}: {
item: ContextMenuItem
onClose: (e: MouseEvent) => void
}) => {
const [isHover, setIsHover] = useState(false)
const itemRef = useRef<HTMLDivElement>(null)
const submenuRef = useRef<HTMLDivElement>(null)
const getSubmenuPosition = () => {
if (!itemRef.current || !submenuRef.current) {
return { x: 0, y: 0 }
}
const item = itemRef.current.getBoundingClientRect()
const submenu = submenuRef.current.getBoundingClientRect()
const isRightSide = item.x + item.width + submenu.width <= window.innerWidth
const x = isRightSide ? item.x + item.width : item.x - submenu.width
const isTopSide = item.y - 8 + submenu.height <= window.innerHeight
const y = isTopSide ? item.y - 8 : item.y + item.height + 8 - submenu.height
const transformOriginTable = {
top: {
right: 'origin-top-left',
left: 'origin-top-right',
},
bottom: {
right: 'origin-bottom-left',
left: 'origin-bottom-right',
},
} as const
return {
x,
y,
transformOrigin:
transformOriginTable[isTopSide ? 'top' : 'bottom'][
isRightSide ? 'right' : 'left'
],
}
}
if (item.type === 'divider') return <Divider />
return (
<div
ref={itemRef}
onClick={e => {
if (!item.onClick) {
return
}
const event = e as unknown as MouseEvent
item.onClick?.(event)
onClose(event)
}}
onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
className='relative px-2'
>
<div className='flex w-full items-center justify-between whitespace-nowrap rounded-md px-3 py-2 text-white/70 transition-colors duration-400 hover:bg-white/10 hover:text-white/80'>
<div>{item.label}</div>
{item.type === 'submenu' && (
<Icon name='more' className='ml-8 h-4 w-4' />
)}
{item.type === 'submenu' && item.items && (
<Menu
position={{ x: 99999, y: 99999 }}
items={item.items}
ref={submenuRef}
onClose={onClose}
forMeasure={true}
/>
)}
{item.type === 'submenu' && item.items && isHover && (
<Menu
position={getSubmenuPosition()}
items={item.items}
onClose={onClose}
/>
)}
</div>
</div>
)
}
const Menu = forwardRef(
(
{
position,
items,
onClose,
forMeasure,
}: {
position: {
x: number
y: number
transformOrigin?:
| 'origin-top-left'
| 'origin-top-right'
| 'origin-bottom-left'
| 'origin-bottom-right'
}
items: ContextMenuItem[]
onClose: (e: MouseEvent) => void
forMeasure?: boolean
},
ref: ForwardedRef<HTMLDivElement>
) => {
return (
<motion.div
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.1,
},
}}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
ref={ref}
className={cx(
'fixed z-10 rounded-12 border border-day-500 bg-day-600 py-2 font-medium',
position.transformOrigin || 'origin-top-left'
)}
style={{ left: position.x, top: position.y }}
>
{items.map((item, index) => (
<Item key={index} item={item} onClose={onClose} />
))}
</motion.div>
)
}
)
Menu.displayName = 'Menu'
const BasicContextMenu = ({
onClose,
items,
target,
cursorPosition,
options,
}: {
onClose: (e: MouseEvent) => void
items: ContextMenuItem[]
target: HTMLElement
cursorPosition: { x: number; y: number }
options?: {
useCursorPosition?: boolean
} | null
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const [measureRef, menu] = useMeasure()
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
)
useClickAway(menuRef, onClose)
useLockMainScroll(!!position)
useLayoutEffect(() => {
if (options?.useCursorPosition) {
const leftX = cursorPosition.x
const rightX = cursorPosition.x - menu.width
const bottomY = cursorPosition.y
const topY = cursorPosition.y - menu.height
const position = {
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
}
setPosition(position)
} else {
const button = target.getBoundingClientRect()
const leftX = button.x
const rightX = button.x - menu.width + button.width
const bottomY = button.y + button.height + 8
const topY = button.y - menu.height - 8
const position = {
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
}
setPosition(position)
}
}, [target, menu, options?.useCursorPosition, cursorPosition])
return (
<>
<Menu
position={{ x: 99999, y: 99999 }}
items={items}
ref={measureRef}
onClose={onClose}
forMeasure={true}
/>
{position && (
<Menu
position={position}
items={items}
ref={menuRef}
onClose={onClose}
/>
)}
</>
)
}
export default BasicContextMenu

View File

@ -1,136 +0,0 @@
import { resizeImage } from '@/web/utils/common'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { memo, useCallback } from 'react'
import dayjs from 'dayjs'
import ArtistInline from './ArtistsInLine'
type ItemTitle = undefined | 'name'
type ItemSubTitle = undefined | 'artist' | 'year'
const Album = ({
album,
itemTitle,
itemSubtitle,
}: {
album: Album
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
const navigate = useNavigate()
const goTo = () => {
navigate(`/album/${album.id}`)
}
const prefetch = () => {
prefetchAlbum({ id: album.id })
}
const title =
itemTitle &&
{
name: album.name,
}[itemTitle]
const subtitle =
itemSubtitle &&
{
artist: (
<ArtistInline
artists={album.artists}
hoverClassName='hover:text-white/50 transition-colors duration-400'
/>
),
year: dayjs(album.publishTime || 0).year(),
}[itemSubtitle]
return (
<div>
<Image
onClick={goTo}
key={album.id}
src={resizeImage(album?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
{title && (
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{title}
</div>
)}
{subtitle && (
<div className='mt-1 text-14 font-medium text-neutral-700'>
{subtitle}
</div>
)}
</div>
)
}
const Playlist = ({ playlist }: { playlist: Playlist }) => {
const navigate = useNavigate()
const goTo = useCallback(() => {
navigate(`/playlist/${playlist.id}`)
}, [navigate, playlist.id])
const prefetch = useCallback(() => {
prefetchPlaylist({ id: playlist.id })
}, [playlist.id])
return (
<Image
onClick={goTo}
key={playlist.id}
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
)
}
const CoverRow = ({
albums,
playlists,
title,
className,
itemTitle,
itemSubtitle,
}: {
title?: string
className?: string
albums?: Album[]
playlists?: Playlist[]
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
return (
<div className={className}>
{/* Title */}
{title && (
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
{title}
</h4>
)}
{/* Items */}
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
{albums?.map(album => (
<Album
key={album.id}
album={album}
itemTitle={itemTitle}
itemSubtitle={itemSubtitle}
/>
))}
{playlists?.map(playlist => (
<Playlist key={playlist.id} playlist={playlist} />
))}
</div>
</div>
)
}
const memoizedCoverRow = memo(CoverRow)
memoizedCoverRow.displayName = 'CoverRow'
export default memoizedCoverRow

View File

@ -1,68 +0,0 @@
import { css, cx } from '@emotion/css'
import Router from './Router'
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
import uiStates from '@/web/states/uiStates'
import { useEffect, useRef, useState } from 'react'
import { breakpoint as bp, ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import persistedUiStates from '@/web/states/persistedUiStates'
import { motion, useAnimation } from 'framer-motion'
import { sleep } from '@/web/utils/common'
import player from '@/web/states/player'
const Main = () => {
const playerSnapshot = useSnapshot(player)
// Show/hide topbar background
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen } = useIntersectionObserver(observePoint)
useEffect(() => {
uiStates.hideTopbarBackground = onScreen
return () => {
uiStates.hideTopbarBackground = false
}
}, [onScreen])
// Change width when player is minimized
const { minimizePlayer } = useSnapshot(persistedUiStates)
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
const controlsMain = useAnimation()
useEffect(() => {
const animate = async () => {
await controlsMain.start({ opacity: 0 })
await sleep(100)
setIsMaxWidth(minimizePlayer)
await controlsMain.start({ opacity: 1 })
}
if (minimizePlayer !== isMaxWidth) animate()
}, [controlsMain, isMaxWidth, minimizePlayer])
return (
<motion.main
id='main'
animate={controlsMain}
transition={{ ease, duration: 0.4 }}
className={cx(
'no-scrollbar z-10 h-screen overflow-y-auto',
css`
${bp.lg} {
margin-left: 144px;
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
}
`
)}
>
<div ref={observePoint}></div>
<div
className={css`
margin-top: 132px;
`}
>
<Router />
</div>
</motion.main>
)
}
export default Main

View File

@ -1,40 +0,0 @@
import { css, cx } from '@emotion/css'
import persistedUiStates from '@/web/states/persistedUiStates'
import { useSnapshot } from 'valtio'
import NowPlaying from './NowPlaying'
import PlayingNext from './PlayingNext'
import { AnimatePresence, motion, MotionConfig } from 'framer-motion'
import { ease } from '@/web/utils/const'
const Player = () => {
const { minimizePlayer } = useSnapshot(persistedUiStates)
return (
<MotionConfig transition={{ duration: 0.6 }}>
<div
className={cx(
'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden',
css`
width: 318px;
`
)}
>
<AnimatePresence>
{!minimizePlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<PlayingNext />
</motion.div>
)}
</AnimatePresence>
<NowPlaying />
</div>
</MotionConfig>
)
}
export default Player

View File

@ -1,43 +0,0 @@
import { Route, Routes, useLocation } from 'react-router-dom'
import Search from '@/web/pages/Search'
import Settings from '@/web/pages/Settings'
import { AnimatePresence } from 'framer-motion'
import React, { ReactNode, Suspense } from 'react'
const My = React.lazy(() => import('@/web/pages/New/My'))
const Discover = React.lazy(() => import('@/web/pages/New/Discover'))
const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
const Album = React.lazy(() => import('@/web/pages/New/Album'))
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
const MV = React.lazy(() => import('@/web/pages/New/MV'))
const Lyrics = React.lazy(() => import('@/web/pages/New/Lyrics'))
const lazy = (component: ReactNode) => {
return <Suspense>{component}</Suspense>
}
const Router = () => {
const location = useLocation()
return (
<AnimatePresence exitBeforeEnter>
<Routes location={location} key={location.pathname}>
<Route path='/' element={lazy(<My />)} />
<Route path='/discover' element={lazy(<Discover />)} />
<Route path='/browse' element={lazy(<Browse />)} />
<Route path='/album/:id' element={lazy(<Album />)} />
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
<Route path='/artist/:id' element={lazy(<Artist />)} />
<Route path='/mv/:id' element={lazy(<MV />)} />
<Route path='/settings' element={lazy(<Settings />)} />
<Route path='/lyrics' element={lazy(<Lyrics />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}>
<Route path=':type' element={lazy(<Search />)} />
</Route>
</Routes>
</AnimatePresence>
)
}
export default Router

View File

@ -1,176 +0,0 @@
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
import { cx } from '@emotion/css'
const Slider = ({
value,
min,
max,
onChange,
onlyCallOnChangeAfterDragEnded = false,
orientation = 'horizontal',
alwaysShowThumb = false,
}: {
value: number
min: number
max: number
onChange: (value: number) => void
onlyCallOnChangeAfterDragEnded?: boolean
orientation?: 'horizontal' | 'vertical'
alwaysShowTrack?: boolean
alwaysShowThumb?: boolean
}) => {
const sliderRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [draggingValue, setDraggingValue] = useState(value)
const memoedValue = useMemo(
() =>
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
)
/**
* Get the value of the slider based on the position of the pointer
*/
const getNewValue = useCallback(
(pointer: { x: number; y: number }) => {
if (!sliderRef?.current) return 0
const slider = sliderRef.current.getBoundingClientRect()
const newValue =
orientation === 'horizontal'
? ((pointer.x - slider.x) / slider.width) * max
: ((slider.height - (pointer.y - slider.y)) / slider.height) * max
if (newValue < min) return min
if (newValue > max) return max
return newValue
},
[sliderRef, max, min, orientation]
)
/**
* Handle slider click event
*/
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) =>
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
[getNewValue, onChange]
)
/**
* Handle pointer down event
*/
const handlePointerDown = () => {
setIsDragging(true)
}
/**
* Handle pointer move events
*/
useEffect(() => {
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
if (!isDragging) return
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
onlyCallOnChangeAfterDragEnded
? setDraggingValue(newValue)
: onChange(newValue)
}
document.addEventListener('pointermove', handlePointerMove)
return () => {
document.removeEventListener('pointermove', handlePointerMove)
}
}, [
isDragging,
onChange,
setDraggingValue,
onlyCallOnChangeAfterDragEnded,
getNewValue,
])
/**
* Handle pointer up events
*/
useEffect(() => {
const handlePointerUp = () => {
if (!isDragging) return
setIsDragging(false)
if (onlyCallOnChangeAfterDragEnded) {
onChange(draggingValue)
}
}
document.addEventListener('pointerup', handlePointerUp)
return () => {
document.removeEventListener('pointerup', handlePointerUp)
}
}, [
isDragging,
setIsDragging,
onlyCallOnChangeAfterDragEnded,
draggingValue,
onChange,
])
/**
* Track and thumb styles
*/
const usedTrackStyle = useMemo(() => {
const percentage = `${(memoedValue / max) * 100}%`
return orientation === 'horizontal'
? { width: percentage }
: { height: percentage }
}, [max, memoedValue, orientation])
const thumbStyle = useMemo(() => {
const percentage = `${(memoedValue / max) * 100}%`
return orientation === 'horizontal'
? { left: percentage }
: { bottom: percentage }
}, [max, memoedValue, orientation])
return (
<div
className={cx(
'group relative flex items-center',
orientation === 'horizontal' && 'h-2',
orientation === 'vertical' && 'h-full w-2 flex-col'
)}
ref={sliderRef}
onClick={handleClick}
>
{/* Track */}
<div
className={cx(
'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10',
orientation === 'horizontal' && 'h-[3px] w-full',
orientation === 'vertical' && 'h-full w-[3px]'
)}
>
{/* Passed track */}
<div
className={cx(
'bg-black dark:bg-white',
orientation === 'horizontal' && 'h-full rounded-r-full',
orientation === 'vertical' && 'bottom-0 w-full rounded-t-full'
)}
style={usedTrackStyle}
></div>
</div>
{/* Thumb */}
<div
className={cx(
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
isDragging || alwaysShowThumb
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100',
orientation === 'horizontal' && '-translate-x-1',
orientation === 'vertical' && 'translate-y-1'
)}
style={thumbStyle}
onClick={e => e.stopPropagation()}
onPointerDown={handlePointerDown}
></div>
</div>
)
}
export default Slider

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