mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2024-11-24 21:09:23 +08:00
feat: updates
This commit is contained in:
parent
a1b0bcf4d3
commit
884f3df41a
25
.vscode/i18n-ally-custom-framework.yml
vendored
Normal file
25
.vscode/i18n-ally-custom-framework.yml
vendored
Normal 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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
342
packages/desktop/main/cacheWithSQLite.ts
Normal file
342
packages/desktop/main/cacheWithSQLite.ts
Normal 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()
|
344
packages/desktop/main/cacheWithSurreal.ts
Normal file
344
packages/desktop/main/cacheWithSurreal.ts
Normal 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()
|
|
@ -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() {
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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!')
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
// })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ if (isProd) {
|
|||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
sendSync: ipcRenderer.sendSync,
|
||||
invoke: ipcRenderer.invoke,
|
||||
send: ipcRenderer.send,
|
||||
on: (
|
||||
|
|
|
@ -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 = ''
|
||||
|
|
301
packages/desktop/main/surrealdb.ts
Normal file
301
packages/desktop/main/surrealdb.ts
Normal 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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ const options = {
|
|||
'electron',
|
||||
'NeteaseCloudMusicApi',
|
||||
'better-sqlite3',
|
||||
'@unblockneteasemusic/rust-napi',
|
||||
// '@unblockneteasemusic/rust-napi',
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
63
packages/desktop/scripts/copySQLite3.js
Normal file
63
packages/desktop/scripts/copySQLite3.js
Normal 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
65
packages/server/.gitignore
vendored
Normal 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
4
packages/server/.taprc
Normal file
|
@ -0,0 +1,4 @@
|
|||
test-env: [
|
||||
TS_NODE_FILES=true,
|
||||
TS_NODE_PROJECT=./test/tsconfig.json
|
||||
]
|
23
packages/server/README.md
Normal file
23
packages/server/README.md
Normal 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
41
packages/server/fly.toml
Normal 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"
|
37
packages/server/package.json
Normal file
37
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
35
packages/server/src/app.ts
Normal file
35
packages/server/src/app.ts
Normal 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 }
|
16
packages/server/src/plugins/README.md
Normal file
16
packages/server/src/plugins/README.md
Normal 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/).
|
13
packages/server/src/plugins/sensible.ts
Normal file
13
packages/server/src/plugins/sensible.ts
Normal 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
|
||||
})
|
||||
})
|
20
packages/server/src/plugins/support.ts
Normal file
20
packages/server/src/plugins/support.ts
Normal 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;
|
||||
}
|
||||
}
|
38
packages/server/src/routes/apple-music/album.ts
Normal file
38
packages/server/src/routes/apple-music/album.ts
Normal 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
|
46
packages/server/src/routes/apple-music/artist.ts
Normal file
46
packages/server/src/routes/apple-music/artist.ts
Normal 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
|
9
packages/server/src/routes/root.ts
Normal file
9
packages/server/src/routes/root.ts
Normal 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;
|
57
packages/server/src/utils/appleMusicRequest.ts
Normal file
57
packages/server/src/utils/appleMusicRequest.ts
Normal 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
|
35
packages/server/test/helper.ts
Normal file
35
packages/server/test/helper.ts
Normal 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
|
||||
}
|
11
packages/server/test/plugins/support.test.ts
Normal file
11
packages/server/test/plugins/support.test.ts
Normal 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')
|
||||
})
|
12
packages/server/test/routes/example.test.ts
Normal file
12
packages/server/test/routes/example.test.ts
Normal 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')
|
||||
})
|
11
packages/server/test/routes/root.test.ts
Normal file
11
packages/server/test/routes/root.test.ts
Normal 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 })
|
||||
})
|
8
packages/server/test/tsconfig.json
Normal file
8
packages/server/test/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["../src/**/*.ts", "**/*.ts"]
|
||||
}
|
8
packages/server/tsconfig.json
Normal file
8
packages/server/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "fastify-tsconfig",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
15
packages/shared/db/appleMusic.ts
Normal file
15
packages/shared/db/appleMusic.ts
Normal 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
|
||||
}
|
||||
}
|
44
packages/shared/db/netease.ts
Normal file
44
packages/shared/db/netease.ts
Normal 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
|
||||
}
|
20
packages/shared/db/replay.ts
Normal file
20
packages/shared/db/replay.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
// },
|
||||
// }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
() => {
|
||||
|
|
|
@ -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,
|
||||
// },
|
||||
// }),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
5
packages/web/assets/icons/caret-right.svg
Normal file
5
packages/web/assets/icons/caret-right.svg
Normal 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 |
|
@ -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 |
|
@ -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 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistInline
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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`)
|
||||
},
|
||||
},
|
||||
],
|
|
@ -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`)
|
||||
},
|
||||
},
|
||||
],
|
85
packages/web/components/ContextMenus/BasicContextMenu.tsx
Normal file
85
packages/web/components/ContextMenus/BasicContextMenu.tsx
Normal 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
|
112
packages/web/components/ContextMenus/MenuItem.tsx
Normal file
112
packages/web/components/ContextMenus/MenuItem.tsx
Normal 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
|
173
packages/web/components/ContextMenus/MenuPanel.tsx
Normal file
173
packages/web/components/ContextMenus/MenuPanel.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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`)
|
||||
},
|
||||
},
|
||||
],
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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'
|
|
@ -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
|
|
@ -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 />
|
|
@ -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'
|
|
@ -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>
|
|
@ -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>
|
||||
</>
|
||||
)
|
|
@ -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'>
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = [
|
||||
{
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue
Block a user