mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 05:08:04 +00:00
feat: updates
This commit is contained in:
parent
a1b0bcf4d3
commit
884f3df41a
198 changed files with 4572 additions and 5336 deletions
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",
|
"install": "turbo run post-install --parallel --no-cache",
|
||||||
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
|
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
|
||||||
"build:web": "turbo run build:web",
|
"build:web": "turbo run build:web",
|
||||||
"pack": "turbo run build pack",
|
"pack": "turbo run build && turbo run pack",
|
||||||
"pack:test": "turbo run pack:test",
|
"pack:test": "turbo run build && turbo run pack:test",
|
||||||
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.21.0",
|
"eslint": "^8.21.0",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"turbo": "^1.4.2",
|
"turbo": "^1.6.1",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.7.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ module.exports = {
|
||||||
buildResources: 'build',
|
buildResources: 'build',
|
||||||
},
|
},
|
||||||
npmRebuild: false,
|
npmRebuild: false,
|
||||||
buildDependenciesFromSource: true,
|
buildDependenciesFromSource: false,
|
||||||
electronVersion,
|
electronVersion,
|
||||||
|
afterPack: './scripts/copySQLite3.js',
|
||||||
|
forceCodeSigning: false,
|
||||||
publish: [
|
publish: [
|
||||||
{
|
{
|
||||||
provider: 'github',
|
provider: 'github',
|
||||||
|
|
@ -34,10 +36,6 @@ module.exports = {
|
||||||
arch: ['x64'],
|
arch: ['x64'],
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// target: 'nsis',
|
|
||||||
// arch: ['arm64'],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// target: 'portable',
|
// target: 'portable',
|
||||||
// arch: ['x64'],
|
// arch: ['x64'],
|
||||||
// },
|
// },
|
||||||
|
|
@ -59,16 +57,13 @@ module.exports = {
|
||||||
target: [
|
target: [
|
||||||
{
|
{
|
||||||
target: 'dmg',
|
target: 'dmg',
|
||||||
arch: [
|
arch: ['x64', 'arm64', 'universal'],
|
||||||
'x64',
|
|
||||||
'arm64',
|
|
||||||
// 'universal'
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||||
darkModeSupport: true,
|
darkModeSupport: true,
|
||||||
category: 'public.app-category.music',
|
category: 'public.app-category.music',
|
||||||
|
identity: null,
|
||||||
},
|
},
|
||||||
dmg: {
|
dmg: {
|
||||||
icon: 'build/icons/icon.icns',
|
icon: 'build/icons/icon.icns',
|
||||||
|
|
@ -124,7 +119,6 @@ module.exports = {
|
||||||
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
|
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
|
||||||
'!**/*.{map,debug.min.js}',
|
'!**/*.{map,debug.min.js}',
|
||||||
|
|
||||||
'!**/dist/binary',
|
|
||||||
{
|
{
|
||||||
from: './dist',
|
from: './dist',
|
||||||
to: './main',
|
to: './main',
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const headers = {
|
||||||
Authority: 'amp-api.music.apple.com',
|
Authority: 'amp-api.music.apple.com',
|
||||||
Accept: '*/*',
|
Accept: '*/*',
|
||||||
Authorization:
|
Authorization:
|
||||||
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjQ2NjU1MDgwLCJleHAiOjE2NjIyMDcwODB9.pyOrt2FmP0cHkzYtO8KiEzQL2t1qpRszzxIYbLH7faXSddo6PQei771Ja3aGwGVU4hD99lZAw7bwat60iBcGiQ',
|
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjYxNDQwNDMyLCJleHAiOjE2NzY5OTI0MzIsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.z4BMv9_O4MpMK2iFhYkDqPsx53soPSnlXXK3jm99pHqGOrZADvTgEUw2U7_B1W0MAtFiWBYhYcGvWrzaOig6Bw',
|
||||||
Referer: 'https://music.apple.com/',
|
Referer: 'https://music.apple.com/',
|
||||||
'Sec-Fetch-Dest': 'empty',
|
'Sec-Fetch-Dest': 'empty',
|
||||||
'Sec-Fetch-Mode': 'cors',
|
'Sec-Fetch-Mode': 'cors',
|
||||||
|
|
@ -14,6 +14,7 @@ const headers = {
|
||||||
'User-Agent':
|
'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',
|
'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',
|
'Accept-Encoding': 'gzip',
|
||||||
|
Origin: 'https://music.apple.com',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAlbum = async ({
|
export const getAlbum = async ({
|
||||||
|
|
@ -43,6 +44,7 @@ export const getAlbum = async ({
|
||||||
|
|
||||||
const albums: AppleMusicAlbum[] | undefined =
|
const albums: AppleMusicAlbum[] | undefined =
|
||||||
searchResult?.data?.results?.albums?.data
|
searchResult?.data?.results?.albums?.data
|
||||||
|
|
||||||
const album =
|
const album =
|
||||||
albums?.find(
|
albums?.find(
|
||||||
a =>
|
a =>
|
||||||
|
|
@ -72,12 +74,13 @@ export const getArtist = async (
|
||||||
platform: 'web',
|
platform: 'web',
|
||||||
limit: '1',
|
limit: '1',
|
||||||
l: 'en-us', // TODO: get from settings
|
l: 'en-us', // TODO: get from settings
|
||||||
|
with: 'serverBubbles',
|
||||||
},
|
},
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
log.debug('[appleMusic] Search artist error', 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 (
|
if (
|
||||||
artist &&
|
artist &&
|
||||||
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()
|
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Request, Response } from 'express'
|
||||||
import log from './log'
|
import log from './log'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import * as musicMetadata from 'music-metadata'
|
import * as musicMetadata from 'music-metadata'
|
||||||
import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs'
|
import { APIs, APIsParams } from '@/shared/CacheAPIs'
|
||||||
import { TablesStructures } from './db'
|
import { TablesStructures } from './db'
|
||||||
|
|
||||||
class Cache {
|
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 fs from 'fs'
|
||||||
import SQLite3 from 'better-sqlite3'
|
import SQLite3 from 'better-sqlite3'
|
||||||
import log from './log'
|
import log from './log'
|
||||||
import { createFileIfNotExist, dirname } from './utils'
|
import { createFileIfNotExist, dirname, isProd } from './utils'
|
||||||
import pkg from '../../../package.json'
|
import pkg from '../../../package.json'
|
||||||
import { compare, validate } from 'compare-versions'
|
import { compare, validate } from 'compare-versions'
|
||||||
|
import os from 'os'
|
||||||
|
|
||||||
export const enum Tables {
|
export const enum Tables {
|
||||||
Track = 'Track',
|
Track = 'Track',
|
||||||
|
|
@ -83,19 +84,43 @@ class DB {
|
||||||
constructor() {
|
constructor() {
|
||||||
log.info('[db] Initializing database...')
|
log.info('[db] Initializing database...')
|
||||||
|
|
||||||
createFileIfNotExist(this.dbFilePath)
|
try {
|
||||||
|
createFileIfNotExist(this.dbFilePath)
|
||||||
|
|
||||||
this.sqlite = new SQLite3(this.dbFilePath, {
|
this.sqlite = new SQLite3(this.dbFilePath, {
|
||||||
nativeBinding: path.join(
|
nativeBinding: this.getBinPath(),
|
||||||
__dirname,
|
})
|
||||||
`./binary/better_sqlite3_${process.arch}.node`
|
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`
|
||||||
),
|
),
|
||||||
})
|
win32: path.resolve(
|
||||||
this.sqlite.pragma('auto_vacuum = FULL')
|
app.getPath('exe'),
|
||||||
this.initTables()
|
`../resources/bin/better_sqlite3.node`
|
||||||
this.migrate()
|
),
|
||||||
|
linux: '',
|
||||||
log.info('[db] Database initialized.')
|
}
|
||||||
|
return isProd
|
||||||
|
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']
|
||||||
|
: devBinPath
|
||||||
}
|
}
|
||||||
|
|
||||||
initTables() {
|
initTables() {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { createTaskbar, Thumbar } from './windowsTaskbar'
|
||||||
import { createMenu } from './menu'
|
import { createMenu } from './menu'
|
||||||
import { isDev, isWindows, isLinux, isMac } from './utils'
|
import { isDev, isWindows, isLinux, isMac } from './utils'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
|
// import './surrealdb'
|
||||||
// import Airplay from './airplay'
|
// import Airplay from './airplay'
|
||||||
|
|
||||||
class Main {
|
class Main {
|
||||||
|
|
@ -91,7 +92,7 @@ class Main {
|
||||||
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
|
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
|
||||||
trafficLightPosition: { x: 24, y: 24 },
|
trafficLightPosition: { x: 24, y: 24 },
|
||||||
frame: false,
|
frame: false,
|
||||||
backgroundColor: '#000',
|
transparent: true,
|
||||||
show: false,
|
show: false,
|
||||||
}
|
}
|
||||||
if (store.get('window')) {
|
if (store.get('window')) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { BrowserWindow, ipcMain, app } from 'electron'
|
import { BrowserWindow, ipcMain, app } from 'electron'
|
||||||
import { db, Tables } from './db'
|
// import { db, Tables } from './db'
|
||||||
import { IpcChannels, IpcChannelsParams } from '@/shared/IpcChannels'
|
import { IpcChannels, IpcChannelsParams } from '@/shared/IpcChannels'
|
||||||
import cache from './cache'
|
import cache from './cache'
|
||||||
import log from './log'
|
import log from './log'
|
||||||
|
|
@ -67,9 +67,9 @@ function initWindowIpcMain(win: BrowserWindow | null) {
|
||||||
win?.setSize(1440, 1024, true)
|
win?.setSize(1440, 1024, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
on(IpcChannels.IsMaximized, e => {
|
handle(IpcChannels.IsMaximized, () => {
|
||||||
if (!win) return
|
if (!win) return
|
||||||
e.returnValue = win.isMaximized()
|
return win.isMaximized()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,23 +118,36 @@ function initOtherIpcMain() {
|
||||||
* 清除API缓存
|
* 清除API缓存
|
||||||
*/
|
*/
|
||||||
on(IpcChannels.ClearAPICache, () => {
|
on(IpcChannels.ClearAPICache, () => {
|
||||||
db.truncate(Tables.Track)
|
// db.truncate(Tables.Track)
|
||||||
db.truncate(Tables.Album)
|
// db.truncate(Tables.Album)
|
||||||
db.truncate(Tables.Artist)
|
// db.truncate(Tables.Artist)
|
||||||
db.truncate(Tables.Playlist)
|
// db.truncate(Tables.Playlist)
|
||||||
db.truncate(Tables.ArtistAlbum)
|
// db.truncate(Tables.ArtistAlbum)
|
||||||
db.truncate(Tables.AccountData)
|
// db.truncate(Tables.AccountData)
|
||||||
db.truncate(Tables.Audio)
|
// db.truncate(Tables.Audio)
|
||||||
db.vacuum()
|
// db.vacuum()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get API cache
|
* 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 { api, query } = args
|
||||||
const data = cache.get(api, query)
|
if (api !== 'user/account') {
|
||||||
event.returnValue = data
|
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
|
event.returnValue = artist === 'no' ? undefined : artist
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登陆
|
||||||
|
*/
|
||||||
|
handle(IpcChannels.Logout, async () => {
|
||||||
|
// db.truncate(Tables.AccountData)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出tables到json文件,方便查看table大小(dev环境)
|
* 导出tables到json文件,方便查看table大小(dev环境)
|
||||||
*/
|
*/
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
on(IpcChannels.DevDbExportJson, () => {
|
// on(IpcChannels.DevDbExportJson, () => {
|
||||||
const tables = [
|
// const tables = [
|
||||||
Tables.ArtistAlbum,
|
// Tables.ArtistAlbum,
|
||||||
Tables.Playlist,
|
// Tables.Playlist,
|
||||||
Tables.Album,
|
// Tables.Album,
|
||||||
Tables.Track,
|
// Tables.Track,
|
||||||
Tables.Artist,
|
// Tables.Artist,
|
||||||
Tables.Audio,
|
// Tables.Audio,
|
||||||
Tables.AccountData,
|
// Tables.AccountData,
|
||||||
Tables.Lyric,
|
// Tables.Lyric,
|
||||||
]
|
// ]
|
||||||
tables.forEach(table => {
|
// tables.forEach(table => {
|
||||||
const data = db.findAll(table)
|
// const data = db.findAll(table)
|
||||||
|
// fs.writeFile(
|
||||||
fs.writeFile(
|
// `./tmp/${table}.json`,
|
||||||
`./tmp/${table}.json`,
|
// JSON.stringify(data),
|
||||||
JSON.stringify(data),
|
// function (err) {
|
||||||
function (err) {
|
// if (err) {
|
||||||
if (err) {
|
// return console.log(err)
|
||||||
return console.log(err)
|
// }
|
||||||
}
|
// console.log('The file was saved!')
|
||||||
console.log('The file was saved!')
|
// }
|
||||||
}
|
// )
|
||||||
)
|
// })
|
||||||
})
|
// })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ if (isProd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||||
sendSync: ipcRenderer.sendSync,
|
|
||||||
invoke: ipcRenderer.invoke,
|
invoke: ipcRenderer.invoke,
|
||||||
send: ipcRenderer.send,
|
send: ipcRenderer.send,
|
||||||
on: (
|
on: (
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,20 @@ import cache from './cache'
|
||||||
import fileUpload from 'express-fileupload'
|
import fileUpload from 'express-fileupload'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { db, Tables } from './db'
|
// import { db, Tables } from './db'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
|
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
|
||||||
import UNM from '@unblockneteasemusic/rust-napi'
|
|
||||||
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
|
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
|
||||||
import { isProd } from './utils'
|
import { isProd } from './utils'
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import history from 'connect-history-api-fallback'
|
import history from 'connect-history-api-fallback'
|
||||||
|
import { db, Tables } from './db'
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
port = Number(
|
port = Number(
|
||||||
isProd
|
isProd
|
||||||
? process.env.ELECTRON_WEB_SERVER_PORT ?? 42710
|
? process.env.ELECTRON_WEB_SERVER_PORT || 42710
|
||||||
: process.env.ELECTRON_DEV_NETEASE_API_PORT ?? 3000
|
: process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
|
||||||
)
|
)
|
||||||
app = express()
|
app = express()
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// 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) => {
|
const getFromUNM = async (id: number, req: Request) => {
|
||||||
log.debug('[server] Fetching audio url from UNM')
|
log.debug('[server] Fetching audio url from UNM')
|
||||||
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })
|
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })
|
||||||
|
|
@ -269,15 +269,15 @@ class Server {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
const fromUNM = await getFromUNM(id, req)
|
// const fromUNM = await getFromUNM(id, req)
|
||||||
if (fromUNM) {
|
// if (fromUNM) {
|
||||||
res.status(200).send(fromUNM)
|
// res.status(200).send(fromUNM)
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
log.error(`[server] getFromUNM failed: ${String(error)}`)
|
// log.error(`[server] getFromUNM failed: ${String(error)}`)
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (fromNetease?.data?.[0].freeTrialInfo) {
|
if (fromNetease?.data?.[0].freeTrialInfo) {
|
||||||
fromNetease.data[0].url = ''
|
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": {
|
"dependencies": {
|
||||||
"@sentry/electron": "^3.0.7",
|
"@sentry/electron": "^3.0.7",
|
||||||
"@unblockneteasemusic/rust-napi": "^0.3.0",
|
|
||||||
"NeteaseCloudMusicApi": "^4.6.7",
|
"NeteaseCloudMusicApi": "^4.6.7",
|
||||||
"better-sqlite3": "7.6.2",
|
"better-sqlite3": "7.6.2",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
|
|
@ -36,17 +35,16 @@
|
||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
"fast-folder-size": "^1.7.0",
|
"fast-folder-size": "^1.7.0",
|
||||||
"pretty-bytes": "^6.0.0",
|
"pretty-bytes": "^6.0.0",
|
||||||
|
"type-fest": "^3.0.0",
|
||||||
"zx": "^7.0.8"
|
"zx": "^7.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/universal": "1.3.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/express-fileupload": "^1.2.3",
|
"@types/express-fileupload": "^1.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||||
"@typescript-eslint/parser": "^5.32.0",
|
"@typescript-eslint/parser": "^5.32.0",
|
||||||
"@vitejs/plugin-react": "^2.0.0",
|
|
||||||
"@vitest/ui": "^0.20.3",
|
"@vitest/ui": "^0.20.3",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
|
@ -68,8 +66,5 @@
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"vitest": "^0.20.3",
|
"vitest": "^0.20.3",
|
||||||
"wait-on": "^6.0.1"
|
"wait-on": "^6.0.1"
|
||||||
},
|
|
||||||
"resolutions": {
|
|
||||||
"@electron/universal": "1.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ const options = {
|
||||||
'electron',
|
'electron',
|
||||||
'NeteaseCloudMusicApi',
|
'NeteaseCloudMusicApi',
|
||||||
'better-sqlite3',
|
'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 pkg = require(`${process.cwd()}/package.json`)
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const { execSync } = require('child_process')
|
const { execSync } = require('child_process')
|
||||||
const path = require('path')
|
const { resolve } = require('path')
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
|
|
@ -29,14 +29,15 @@ if (!electronModuleVersion) {
|
||||||
}
|
}
|
||||||
const argv = minimist(process.argv.slice(2))
|
const argv = minimist(process.argv.slice(2))
|
||||||
|
|
||||||
const projectDir = path.resolve(process.cwd(), '../../')
|
const projectDir = resolve(process.cwd(), '../../')
|
||||||
const distDir = `${projectDir}/packages/desktop/dist/binary`
|
const tmpDir = resolve(projectDir, `./tmp/better-sqlite3`)
|
||||||
|
const binDir = resolve(projectDir, `./tmp/bin`)
|
||||||
console.log(pc.cyan(`projectDir=${projectDir}`))
|
console.log(pc.cyan(`projectDir=${projectDir}`))
|
||||||
console.log(pc.cyan(`distDir=${distDir}`))
|
console.log(pc.cyan(`binDir=${binDir}`))
|
||||||
|
|
||||||
if (!fs.existsSync(distDir)) {
|
if (!fs.existsSync(binDir)) {
|
||||||
console.log(pc.cyan(`Creating dist/binary directory: ${distDir}`))
|
console.log(pc.cyan(`Creating dist/binary directory: ${binDir}`))
|
||||||
fs.mkdirSync(distDir, {
|
fs.mkdirSync(binDir, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +48,6 @@ const download = async arch => {
|
||||||
console.log(pc.red('No electron module version found! Skip download.'))
|
console.log(pc.red('No electron module version found! Skip download.'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const tmpDir = `${projectDir}/tmp/better-sqlite3`
|
|
||||||
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
|
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
|
||||||
const zipFileName = `${fileName}.tar.gz`
|
const zipFileName = `${fileName}.tar.gz`
|
||||||
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
|
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
|
||||||
|
|
@ -63,7 +63,9 @@ const download = async arch => {
|
||||||
url,
|
url,
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
response.data.pipe(fs.createWriteStream(`${tmpDir}/${zipFileName}`))
|
response.data.pipe(
|
||||||
|
fs.createWriteStream(resolve(tmpDir, `./${zipFileName}`))
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -80,8 +82,8 @@ const download = async arch => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.copyFileSync(
|
fs.copyFileSync(
|
||||||
`${tmpDir}/build/Release/better_sqlite3.node`,
|
resolve(tmpDir, './build/Release/better_sqlite3.node'),
|
||||||
`${distDir}/better_sqlite3_${arch}.node`
|
resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`)
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(pc.red('Copy failed! Skip copy.', e))
|
console.log(pc.red('Copy failed! Skip copy.', e))
|
||||||
|
|
@ -89,7 +91,7 @@ const download = async arch => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmSync(`${tmpDir}/build`, { recursive: true, force: true })
|
fs.rmSync(resolve(tmpDir, `./build`), { recursive: true, force: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(pc.red('Delete failed! Skip delete.'))
|
console.log(pc.red('Delete failed! Skip delete.'))
|
||||||
return false
|
return false
|
||||||
|
|
@ -113,8 +115,15 @@ const build = async arch => {
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.info('Build succeeded')
|
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}`)
|
console.info(`copy ${from} to ${to}`)
|
||||||
fs.copyFileSync(from, to)
|
fs.copyFileSync(from, to)
|
||||||
})
|
})
|
||||||
|
|
@ -130,7 +139,9 @@ const main = async () => {
|
||||||
if (argv.arm64) await build('arm64')
|
if (argv.arm64) await build('arm64')
|
||||||
if (argv.arm) await build('arm')
|
if (argv.arm) await build('arm')
|
||||||
} else {
|
} else {
|
||||||
if (isWindows || isMac) {
|
if (isWindows) {
|
||||||
|
await build('x64')
|
||||||
|
} else if (isMac) {
|
||||||
await build('x64')
|
await build('x64')
|
||||||
await build('arm64')
|
await build('arm64')
|
||||||
} else if (isLinux) {
|
} 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',
|
Close = 'Close',
|
||||||
IsMaximized = 'IsMaximized',
|
IsMaximized = 'IsMaximized',
|
||||||
FullscreenStateChange = 'FullscreenStateChange',
|
FullscreenStateChange = 'FullscreenStateChange',
|
||||||
GetApiCacheSync = 'GetApiCacheSync',
|
GetApiCache = 'GetApiCache',
|
||||||
DevDbExportJson = 'DevDbExportJson',
|
DevDbExportJson = 'DevDbExportJson',
|
||||||
CacheCoverColor = 'CacheCoverColor',
|
CacheCoverColor = 'CacheCoverColor',
|
||||||
SetTrayTooltip = 'SetTrayTooltip',
|
SetTrayTooltip = 'SetTrayTooltip',
|
||||||
|
|
@ -26,6 +26,7 @@ export const enum IpcChannels {
|
||||||
ResetWindowSize = 'ResetWindowSize',
|
ResetWindowSize = 'ResetWindowSize',
|
||||||
GetAlbumFromAppleMusic = 'GetAlbumFromAppleMusic',
|
GetAlbumFromAppleMusic = 'GetAlbumFromAppleMusic',
|
||||||
GetArtistFromAppleMusic = 'GetArtistFromAppleMusic',
|
GetArtistFromAppleMusic = 'GetArtistFromAppleMusic',
|
||||||
|
Logout = 'Logout',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipcMain.on params
|
// ipcMain.on params
|
||||||
|
|
@ -36,7 +37,7 @@ export interface IpcChannelsParams {
|
||||||
[IpcChannels.Close]: void
|
[IpcChannels.Close]: void
|
||||||
[IpcChannels.IsMaximized]: void
|
[IpcChannels.IsMaximized]: void
|
||||||
[IpcChannels.FullscreenStateChange]: void
|
[IpcChannels.FullscreenStateChange]: void
|
||||||
[IpcChannels.GetApiCacheSync]: {
|
[IpcChannels.GetApiCache]: {
|
||||||
api: APIs
|
api: APIs
|
||||||
query?: any
|
query?: any
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +69,7 @@ export interface IpcChannelsParams {
|
||||||
artist: string
|
artist: string
|
||||||
}
|
}
|
||||||
[IpcChannels.GetArtistFromAppleMusic]: { id: number; name: string }
|
[IpcChannels.GetArtistFromAppleMusic]: { id: number; name: string }
|
||||||
|
[IpcChannels.Logout]: void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipcRenderer.on params
|
// ipcRenderer.on params
|
||||||
|
|
@ -78,7 +80,7 @@ export interface IpcChannelsReturns {
|
||||||
[IpcChannels.Close]: void
|
[IpcChannels.Close]: void
|
||||||
[IpcChannels.IsMaximized]: boolean
|
[IpcChannels.IsMaximized]: boolean
|
||||||
[IpcChannels.FullscreenStateChange]: boolean
|
[IpcChannels.FullscreenStateChange]: boolean
|
||||||
[IpcChannels.GetApiCacheSync]: any
|
[IpcChannels.GetApiCache]: any
|
||||||
[IpcChannels.DevDbExportJson]: void
|
[IpcChannels.DevDbExportJson]: void
|
||||||
[IpcChannels.CacheCoverColor]: void
|
[IpcChannels.CacheCoverColor]: void
|
||||||
[IpcChannels.SetTrayTooltip]: void
|
[IpcChannels.SetTrayTooltip]: void
|
||||||
|
|
@ -92,4 +94,5 @@ export interface IpcChannelsReturns {
|
||||||
[IpcChannels.GetAudioCacheSize]: void
|
[IpcChannels.GetAudioCacheSize]: void
|
||||||
[IpcChannels.GetAlbumFromAppleMusic]: AppleMusicAlbum | undefined
|
[IpcChannels.GetAlbumFromAppleMusic]: AppleMusicAlbum | undefined
|
||||||
[IpcChannels.GetArtistFromAppleMusic]: AppleMusicArtist | 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 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 App = () => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={reactQueryClient}>
|
<ErrorBoundary>
|
||||||
{window.env?.isEnableTitlebar && <TitleBar />}
|
{isMobile ? <LayoutMobile /> : <Layout />}
|
||||||
|
<Toaster />
|
||||||
<div id='layout' className='grid select-none grid-cols-[16rem_auto]'>
|
<ScrollRestoration />
|
||||||
<Sidebar />
|
|
||||||
<Main />
|
|
||||||
<Player />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Lyric />
|
|
||||||
|
|
||||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
|
||||||
|
|
||||||
<IpcRendererReact />
|
<IpcRendererReact />
|
||||||
|
<Devtool />
|
||||||
{/* Devtool */}
|
</ErrorBoundary>
|
||||||
<ReactQueryDevtools
|
|
||||||
initialIsOpen={false}
|
|
||||||
toggleButtonProps={{
|
|
||||||
style: {
|
|
||||||
position: 'fixed',
|
|
||||||
right: '0',
|
|
||||||
left: 'auto',
|
|
||||||
bottom: '4rem',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
AlbumApiNames,
|
||||||
FetchAlbumResponse,
|
FetchAlbumResponse,
|
||||||
} from '@/shared/api/Album'
|
} from '@/shared/api/Album'
|
||||||
import { QueryOptions, useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
const fetch = async (params: FetchAlbumParams) => {
|
const fetch = async (params: FetchAlbumParams) => {
|
||||||
const album = await fetchAlbum(params)
|
const album = await fetchAlbum(params)
|
||||||
|
|
@ -17,22 +17,34 @@ const fetch = async (params: FetchAlbumParams) => {
|
||||||
return album
|
return album
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse =>
|
const fetchFromCache = async (
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
params: FetchAlbumParams
|
||||||
|
): Promise<FetchAlbumResponse | undefined> =>
|
||||||
|
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
|
||||||
api: APIs.Album,
|
api: APIs.Album,
|
||||||
query: params,
|
query: params,
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function useAlbum(
|
export default function useAlbum(params: FetchAlbumParams) {
|
||||||
params: FetchAlbumParams
|
const key = [AlbumApiNames.FetchAlbum, params]
|
||||||
// queryOptions?: QueryOptions
|
return useQuery(
|
||||||
) {
|
key,
|
||||||
return useQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
|
() => {
|
||||||
enabled: !!params.id,
|
// fetch from cache as placeholder
|
||||||
staleTime: 24 * 60 * 60 * 1000, // 24 hours
|
fetchFromCache(params).then(cache => {
|
||||||
placeholderData: () => fetchFromCache(params),
|
const existsQueryData = reactQueryClient.getQueryData(key)
|
||||||
// ...queryOptions,
|
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) {
|
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
|
||||||
|
|
@ -46,7 +58,7 @@ export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prefetchAlbum(params: FetchAlbumParams) {
|
export async function prefetchAlbum(params: FetchAlbumParams) {
|
||||||
if (fetchFromCache(params)) return
|
if (await fetchFromCache(params)) return
|
||||||
await reactQueryClient.prefetchQuery(
|
await reactQueryClient.prefetchQuery(
|
||||||
[AlbumApiNames.FetchAlbum, params],
|
[AlbumApiNames.FetchAlbum, params],
|
||||||
() => fetch(params),
|
() => fetch(params),
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,32 @@ import {
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
const fetchFromCache = (id: number): FetchArtistResponse =>
|
const fetchFromCache = async (
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
params: FetchArtistParams
|
||||||
|
): Promise<FetchArtistResponse | undefined> =>
|
||||||
|
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
|
||||||
api: APIs.Artist,
|
api: APIs.Artist,
|
||||||
query: {
|
query: params,
|
||||||
id,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function useArtist(params: FetchArtistParams) {
|
export default function useArtist(params: FetchArtistParams) {
|
||||||
|
const key = [ArtistApiNames.FetchArtist, params]
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[ArtistApiNames.FetchArtist, params],
|
key,
|
||||||
() => fetchArtist(params),
|
() => {
|
||||||
|
// 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)),
|
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
||||||
staleTime: 5 * 60 * 1000, // 5 mins
|
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) {
|
export async function prefetchArtist(params: FetchArtistParams) {
|
||||||
if (fetchFromCache(params.id)) return
|
if (await fetchFromCache(params)) return
|
||||||
await reactQueryClient.prefetchQuery(
|
await reactQueryClient.prefetchQuery(
|
||||||
[ArtistApiNames.FetchArtist, params],
|
[ArtistApiNames.FetchArtist, params],
|
||||||
() => fetchArtist(params),
|
() => fetchArtist(params),
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,35 @@
|
||||||
import { fetchArtistAlbums } from '@/web/api/artist'
|
import { fetchArtistAlbums } from '@/web/api/artist'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import {
|
import { FetchArtistAlbumsParams, ArtistApiNames } from '@/shared/api/Artist'
|
||||||
FetchArtistAlbumsParams,
|
|
||||||
ArtistApiNames,
|
|
||||||
FetchArtistAlbumsResponse,
|
|
||||||
} from '@/shared/api/Artist'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
|
export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
|
||||||
|
const key = [ArtistApiNames.FetchArtistAlbums, params]
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[ArtistApiNames.FetchArtistAlbums, params],
|
key,
|
||||||
async () => {
|
async () => {
|
||||||
const data = await fetchArtistAlbums(params)
|
// fetch from cache as placeholder
|
||||||
return data
|
window.ipcRenderer
|
||||||
},
|
?.invoke(IpcChannels.GetApiCache, {
|
||||||
{
|
|
||||||
enabled: !!params.id && params.id !== 0,
|
|
||||||
staleTime: 3600000,
|
|
||||||
placeholderData: (): FetchArtistAlbumsResponse =>
|
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
|
||||||
api: APIs.ArtistAlbum,
|
api: APIs.ArtistAlbum,
|
||||||
query: {
|
query: {
|
||||||
id: params.id,
|
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,
|
enabled: !!params.id && params.id !== 0,
|
||||||
staleTime: 3600000,
|
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 { fetchArtist } from '@/web/api/artist'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import {
|
import { ArtistApiNames } from '@/shared/api/Artist'
|
||||||
FetchArtistParams,
|
|
||||||
ArtistApiNames,
|
|
||||||
FetchArtistResponse,
|
|
||||||
} from '@/shared/api/Artist'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
export default function useArtists(ids: number[]) {
|
export default function useArtists(ids: number[]) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
['fetchArtists', ids],
|
['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,
|
enabled: !!ids && ids.length > 0,
|
||||||
staleTime: 5 * 60 * 1000, // 5 mins
|
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 { fetchLyric } from '@/web/api/track'
|
||||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
import {
|
import { FetchLyricParams, TrackApiNames } from '@/shared/api/Track'
|
||||||
FetchLyricParams,
|
|
||||||
FetchLyricResponse,
|
|
||||||
TrackApiNames,
|
|
||||||
} from '@/shared/api/Track'
|
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
export default function useLyric(params: FetchLyricParams) {
|
export default function useLyric(params: FetchLyricParams) {
|
||||||
|
const key = [TrackApiNames.FetchLyric, params]
|
||||||
return useQuery(
|
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)
|
return fetchLyric(params)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -20,18 +27,11 @@ export default function useLyric(params: FetchLyricParams) {
|
||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: Infinity,
|
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(
|
return reactQueryClient.fetchQuery(
|
||||||
[TrackApiNames.FetchLyric, params],
|
[TrackApiNames.FetchLyric, params],
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,6 @@ export default function useMV(params: FetchMVParams) {
|
||||||
return useQuery([MVApiNames.FetchMV, params], () => fetchMV(params), {
|
return useQuery([MVApiNames.FetchMV, params], () => fetchMV(params), {
|
||||||
enabled: !!params.mvid && params.mvid > 0 && !isNaN(Number(params.mvid)),
|
enabled: !!params.mvid && params.mvid > 0 && !isNaN(Number(params.mvid)),
|
||||||
staleTime: 5 * 60 * 1000, // 5 mins
|
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)
|
return fetchPlaylist(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
|
export const fetchFromCache = async (
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
params: FetchPlaylistParams
|
||||||
|
): Promise<FetchPlaylistResponse | undefined> =>
|
||||||
|
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
|
||||||
api: APIs.Playlist,
|
api: APIs.Playlist,
|
||||||
query: { id },
|
query: params,
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function usePlaylist(params: FetchPlaylistParams) {
|
export default function usePlaylist(params: FetchPlaylistParams) {
|
||||||
|
const key = [PlaylistApiNames.FetchPlaylist, params]
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[PlaylistApiNames.FetchPlaylist, params],
|
key,
|
||||||
() => fetch(params),
|
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))),
|
enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))),
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
placeholderData: () => fetchFromCache(params.id),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +54,7 @@ export function fetchPlaylistWithReactQuery(params: FetchPlaylistParams) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prefetchPlaylist(params: FetchPlaylistParams) {
|
export async function prefetchPlaylist(params: FetchPlaylistParams) {
|
||||||
if (fetchFromCache(params.id)) return
|
if (await fetchFromCache(params)) return
|
||||||
await reactQueryClient.prefetchQuery(
|
await reactQueryClient.prefetchQuery(
|
||||||
[PlaylistApiNames.FetchPlaylist, params],
|
[PlaylistApiNames.FetchPlaylist, params],
|
||||||
() => fetch(params),
|
() => fetch(params),
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
import { fetchSimilarArtists } from '@/web/api/artist'
|
import { fetchSimilarArtists } from '@/web/api/artist'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import {
|
import { FetchSimilarArtistsParams, ArtistApiNames } from '@/shared/api/Artist'
|
||||||
FetchSimilarArtistsParams,
|
|
||||||
ArtistApiNames,
|
|
||||||
FetchSimilarArtistsResponse,
|
|
||||||
} from '@/shared/api/Artist'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
|
export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
|
||||||
|
const key = [ArtistApiNames.FetchSimilarArtists, params]
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[ArtistApiNames.FetchSimilarArtists, params],
|
key,
|
||||||
() => fetchSimilarArtists(params),
|
() => {
|
||||||
{
|
window.ipcRenderer
|
||||||
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
?.invoke(IpcChannels.GetApiCache, {
|
||||||
staleTime: 5 * 60 * 1000, // 5 mins
|
|
||||||
retry: 0,
|
|
||||||
placeholderData: (): FetchSimilarArtistsResponse =>
|
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
|
||||||
api: APIs.SimilarArtist,
|
api: APIs.SimilarArtist,
|
||||||
query: {
|
query: {
|
||||||
id: params.id,
|
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) {
|
export default function useTracks(params: FetchTracksParams) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[TrackApiNames.FetchTracks, params],
|
[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)
|
return fetchTracks(params)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: params.ids.length !== 0,
|
enabled: params.ids.length !== 0,
|
||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
staleTime: Infinity,
|
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) {
|
export function fetchTracksWithReactQuery(params: FetchTracksParams) {
|
||||||
return reactQueryClient.fetchQuery(
|
return reactQueryClient.fetchQuery(
|
||||||
[TrackApiNames.FetchTracks, params],
|
[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)
|
return fetchTracks(params)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,41 @@ import { fetchUserAccount } from '@/web/api/user'
|
||||||
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
|
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
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() {
|
export default function useUser() {
|
||||||
return useQuery([UserApiNames.FetchUserAccount], fetchUserAccount, {
|
const key = [UserApiNames.FetchUserAccount]
|
||||||
refetchOnWindowFocus: true,
|
return useQuery(
|
||||||
placeholderData: (): FetchUserAccountResponse | undefined =>
|
key,
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
async () => {
|
||||||
api: APIs.UserAccount,
|
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 = {}) {
|
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
||||||
const { data: user } = useUser()
|
const { data: user } = useUser()
|
||||||
const uid = user?.profile?.userId ?? 0
|
const uid = user?.profile?.userId ?? 0
|
||||||
|
const key = [UserApiNames.FetchUserAlbums, uid]
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[UserApiNames.FetchUserAlbums, uid],
|
key,
|
||||||
() => fetchUserAlbums(params),
|
() => {
|
||||||
|
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,
|
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 reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
import { cloneDeep } from 'lodash-es'
|
import { cloneDeep } from 'lodash-es'
|
||||||
|
|
||||||
const KEYS = {
|
|
||||||
useUserArtists: [UserApiNames.FetchUserArtists],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useUserArtists() {
|
export default function useUserArtists() {
|
||||||
return useQuery(KEYS.useUserArtists, fetchUserArtists, {
|
const key = [UserApiNames.FetchUserArtists]
|
||||||
refetchOnWindowFocus: true,
|
return useQuery(
|
||||||
placeholderData: (): FetchUserArtistsResponse =>
|
key,
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
() => {
|
||||||
api: APIs.UserArtists,
|
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 = () => {
|
export const useMutationLikeAArtist = () => {
|
||||||
const { data: userLikedArtists } = useUserArtists()
|
const { data: userLikedArtists } = useUserArtists()
|
||||||
|
const key = [UserApiNames.FetchUserArtists]
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
async (artistID: number) => {
|
async (artistID: number) => {
|
||||||
|
|
@ -41,16 +51,16 @@ export const useMutationLikeAArtist = () => {
|
||||||
{
|
{
|
||||||
onMutate: async artistID => {
|
onMutate: async artistID => {
|
||||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
// 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)) {
|
if (!reactQueryClient.getQueryData(key)) {
|
||||||
await reactQueryClient.fetchQuery(KEYS.useUserArtists)
|
await reactQueryClient.fetchQuery(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const previousData = reactQueryClient.getQueryData(
|
const previousData = reactQueryClient.getQueryData(
|
||||||
KEYS.useUserArtists
|
key
|
||||||
) as FetchUserArtistsResponse
|
) as FetchUserArtistsResponse
|
||||||
|
|
||||||
const isLiked = !!previousData?.data.find(a => a.id === artistID)
|
const isLiked = !!previousData?.data.find(a => a.id === artistID)
|
||||||
|
|
@ -83,10 +93,10 @@ export const useMutationLikeAArtist = () => {
|
||||||
newLikedArtists.data.unshift(artist.artist)
|
newLikedArtists.data.unshift(artist.artist)
|
||||||
|
|
||||||
// Optimistically update to the new value
|
// 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 a context object with the snapshotted value
|
||||||
return { previousData }
|
return { previousData }
|
||||||
|
|
@ -94,10 +104,7 @@ export const useMutationLikeAArtist = () => {
|
||||||
// If the mutation fails, use the context returned from onMutate to roll back
|
// If the mutation fails, use the context returned from onMutate to roll back
|
||||||
onSettled: (data, error, artistID, context) => {
|
onSettled: (data, error, artistID, context) => {
|
||||||
if (data?.code !== 200) {
|
if (data?.code !== 200) {
|
||||||
reactQueryClient.setQueryData(
|
reactQueryClient.setQueryData(key, (context as any).previousData)
|
||||||
KEYS.useUserArtists,
|
|
||||||
(context as any).previousData
|
|
||||||
)
|
|
||||||
toast((error as any).toString())
|
toast((error as any).toString())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,30 @@ import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
export default function useUserLikedTracksIDs() {
|
export default function useUserLikedTracksIDs() {
|
||||||
const { data: user } = useUser()
|
const { data: user } = useUser()
|
||||||
const uid = user?.account?.id ?? 0
|
const uid = user?.account?.id ?? 0
|
||||||
|
const key = [UserApiNames.FetchUserLikedTracksIds, uid]
|
||||||
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[UserApiNames.FetchUserLikedTracksIds, uid],
|
key,
|
||||||
() => fetchUserLikedTracksIDs({ uid }),
|
() => {
|
||||||
|
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),
|
enabled: !!(uid && uid !== 0),
|
||||||
refetchOnWindowFocus: true,
|
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 { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import useUser from './useUser'
|
import useUser from './useUser'
|
||||||
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
export default function useUserListenedRecords(params: {
|
export default function useUserListenedRecords(params: {
|
||||||
type: 'week' | 'all'
|
type: 'week' | 'all'
|
||||||
}) {
|
}) {
|
||||||
const { data: user } = useUser()
|
const { data: user } = useUser()
|
||||||
const uid = user?.account?.id || 0
|
const uid = user?.account?.id || 0
|
||||||
|
const key = [UserApiNames.FetchListenedRecords]
|
||||||
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[UserApiNames.FetchListenedRecords],
|
key,
|
||||||
() =>
|
() => {
|
||||||
fetchListenedRecords({
|
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,
|
uid,
|
||||||
type: params.type === 'week' ? 1 : 0,
|
type: params.type === 'week' ? 1 : 0,
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
enabled: !!uid,
|
enabled: !!uid,
|
||||||
placeholderData: (): FetchListenedRecordsResponse =>
|
|
||||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
|
||||||
api: APIs.ListenedRecords,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,30 @@ export default function useUserPlaylists() {
|
||||||
limit: 2000,
|
limit: 2000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = [UserApiNames.FetchUserPlaylists, uid]
|
||||||
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[UserApiNames.FetchUserPlaylists, uid],
|
key,
|
||||||
async () => {
|
async () => {
|
||||||
if (!params.uid) {
|
if (!params.uid) {
|
||||||
throw new Error('请登录后再请求用户收藏的歌单')
|
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: !!(
|
enabled: !!(
|
||||||
|
|
@ -36,13 +52,6 @@ export default function useUserPlaylists() {
|
||||||
params.offset !== undefined
|
params.offset !== undefined
|
||||||
),
|
),
|
||||||
refetchOnWindowFocus: true,
|
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">
|
<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>
|
||||||
<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>
|
|
||||||
|
|
|
||||||
|
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 { AnimatePresence, motion, useAnimation } from 'framer-motion'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
const BlurBackground = () => {
|
const BlurBackground = () => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
@ -18,19 +18,29 @@ const BlurBackground = () => {
|
||||||
uiStates.blurBackgroundImage = null
|
uiStates.blurBackgroundImage = null
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
|
|
||||||
const onLoad = async () => {
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
animate.start({ opacity: 1 })
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{!isMobile && blurBackgroundImage && hideTopbarBackground && (
|
<motion.div
|
||||||
<motion.img
|
initial={{ opacity: 0 }}
|
||||||
initial={{ opacity: 0 }}
|
animate={animate}
|
||||||
animate={animate}
|
exit={{ opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
transition={{ ease }}
|
||||||
transition={{ ease }}
|
>
|
||||||
onLoad={onLoad}
|
<img
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute z-0 object-cover opacity-70',
|
'absolute z-0 object-cover opacity-70',
|
||||||
css`
|
css`
|
||||||
|
|
@ -41,9 +51,9 @@ const BlurBackground = () => {
|
||||||
filter: blur(256px) saturate(1.2);
|
filter: blur(256px) saturate(1.2);
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
src={resizeImage(blurBackgroundImage, 'sm')}
|
src={resizeImage(blurBackgroundImage || '', 'sm')}
|
||||||
/>
|
/>
|
||||||
)}
|
</motion.div>
|
||||||
</AnimatePresence>
|
</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 { AnimatePresence } from 'framer-motion'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useCopyToClipboard } from 'react-use'
|
import { useCopyToClipboard } from 'react-use'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import BasicContextMenu from './BasicContextMenu'
|
import BasicContextMenu from './BasicContextMenu'
|
||||||
|
|
||||||
const AlbumContextMenu = () => {
|
const AlbumContextMenu = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { cursorPosition, type, dataSourceID, target, options } =
|
const { cursorPosition, type, dataSourceID, target, options } =
|
||||||
useSnapshot(contextMenus)
|
useSnapshot(contextMenus)
|
||||||
const likeAAlbum = useMutationLikeAAlbum()
|
const likeAAlbum = useMutationLikeAAlbum()
|
||||||
|
|
@ -34,7 +37,7 @@ const AlbumContextMenu = () => {
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Add to Queue',
|
label: t`context-menu.add-to-queue`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
toast('开发中')
|
toast('开发中')
|
||||||
|
|
||||||
|
|
@ -63,7 +66,7 @@ const AlbumContextMenu = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Add to playlist',
|
label: t`context-menu.add-to-playlist`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
toast('开发中')
|
toast('开发中')
|
||||||
},
|
},
|
||||||
|
|
@ -73,16 +76,16 @@ const AlbumContextMenu = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'submenu',
|
type: 'submenu',
|
||||||
label: 'Share',
|
label: t`context-menu.share`,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Copy Netease Link',
|
label: t`context-menu.copy-netease-link`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||||
)
|
)
|
||||||
toast.success('Copied')
|
toast.success(t`toasts.copied`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -92,7 +95,7 @@ const AlbumContextMenu = () => {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`${window.location.origin}/album/${dataSourceID}`
|
`${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 { AnimatePresence } from 'framer-motion'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useCopyToClipboard } from 'react-use'
|
import { useCopyToClipboard } from 'react-use'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import BasicContextMenu from './BasicContextMenu'
|
import BasicContextMenu from './BasicContextMenu'
|
||||||
|
|
||||||
const ArtistContextMenu = () => {
|
const ArtistContextMenu = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { cursorPosition, type, dataSourceID, target, options } =
|
const { cursorPosition, type, dataSourceID, target, options } =
|
||||||
useSnapshot(contextMenus)
|
useSnapshot(contextMenus)
|
||||||
const likeAArtist = useMutationLikeAArtist()
|
const likeAArtist = useMutationLikeAArtist()
|
||||||
|
|
@ -18,9 +21,9 @@ const ArtistContextMenu = () => {
|
||||||
const { data: likedArtists } = useUserArtists()
|
const { data: likedArtists } = useUserArtists()
|
||||||
const followLabel = useMemo(() => {
|
const followLabel = useMemo(() => {
|
||||||
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
|
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
|
||||||
? 'Follow'
|
? t`context-menu.unfollow`
|
||||||
: 'Unfollow'
|
: t`context-menu.follow`
|
||||||
}, [dataSourceID, likedArtists?.data])
|
}, [dataSourceID, likedArtists?.data, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -41,7 +44,9 @@ const ArtistContextMenu = () => {
|
||||||
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
|
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
|
||||||
if (res?.code === 200) {
|
if (res?.code === 200) {
|
||||||
toast.success(
|
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',
|
type: 'submenu',
|
||||||
label: 'Share',
|
label: t`context-menu.share`,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Copy Netease Link',
|
label: t`context-menu.copy-netease-link`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`https://music.163.com/#/artist?id=${dataSourceID}`
|
`https://music.163.com/#/artist?id=${dataSourceID}`
|
||||||
)
|
)
|
||||||
toast.success('Copied')
|
toast.success(t`toasts.copied`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -71,7 +76,7 @@ const ArtistContextMenu = () => {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`${window.location.origin}/artist/${dataSourceID}`
|
`${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 contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
import { AnimatePresence } from 'framer-motion'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useCopyToClipboard } from 'react-use'
|
import { useCopyToClipboard } from 'react-use'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
@ -8,6 +11,8 @@ import BasicContextMenu from './BasicContextMenu'
|
||||||
|
|
||||||
const TrackContextMenu = () => {
|
const TrackContextMenu = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [, copyToClipboard] = useCopyToClipboard()
|
const [, copyToClipboard] = useCopyToClipboard()
|
||||||
|
|
||||||
const { type, dataSourceID, target, cursorPosition, options } =
|
const { type, dataSourceID, target, cursorPosition, options } =
|
||||||
|
|
@ -24,7 +29,7 @@ const TrackContextMenu = () => {
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Add to Queue',
|
label: t`context-menu.add-to-queue`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
toast('开发中')
|
toast('开发中')
|
||||||
},
|
},
|
||||||
|
|
@ -34,16 +39,24 @@ const TrackContextMenu = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Go to artist',
|
label: t`context-menu.go-to-artist`,
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
toast('开发中')
|
const tracks = await fetchTracksWithReactQuery({
|
||||||
|
ids: [Number(dataSourceID)],
|
||||||
|
})
|
||||||
|
const track = tracks?.songs?.[0]
|
||||||
|
if (track) navigate(`/artist/${track.ar[0].id}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Go to album',
|
label: t`context-menu.go-to-album`,
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
toast('开发中')
|
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',
|
type: 'item',
|
||||||
label: 'Add to Liked Tracks',
|
label: t`context-menu.add-to-liked-tracks`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
toast('开发中')
|
toast('开发中')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Add to playlist',
|
label: t`context-menu.add-to-playlist`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
toast('开发中')
|
toast('开发中')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'submenu',
|
type: 'submenu',
|
||||||
label: 'Share',
|
label: t`context-menu.share`,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Copy Netease Link',
|
label: t`context-menu.copy-netease-link`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||||
)
|
)
|
||||||
toast.success('Copied')
|
toast.success(t`toasts.copied`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -84,7 +97,7 @@ const TrackContextMenu = () => {
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`${window.location.origin}/album/${dataSourceID}`
|
`${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 { resizeImage } from '@/web/utils/common'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import { cx } from '@emotion/css'
|
||||||
import Icon from '@/web/components/Icon'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Image from './Image'
|
||||||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||||
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
|
import { memo, useCallback } from 'react'
|
||||||
import { cx } from '@emotion/css'
|
import dayjs from 'dayjs'
|
||||||
import { useMemo } from 'react'
|
import ArtistInline from './ArtistsInLine'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
|
|
||||||
export enum Subtitle {
|
type ItemTitle = undefined | 'name'
|
||||||
Copywriter = 'copywriter',
|
type ItemSubTitle = undefined | 'artist' | 'year'
|
||||||
Creator = 'creator',
|
|
||||||
TypeReleaseYear = 'type+releaseYear',
|
|
||||||
Artist = 'artist',
|
|
||||||
}
|
|
||||||
|
|
||||||
const Title = ({
|
const Album = ({
|
||||||
title,
|
album,
|
||||||
seeMoreLink,
|
itemTitle,
|
||||||
|
itemSubtitle,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
album: Album
|
||||||
seeMoreLink: string
|
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 (
|
return (
|
||||||
<div className='flex items-baseline justify-between'>
|
<div>
|
||||||
<div className='my-4 text-[28px] font-bold text-black dark:text-white'>
|
<Image
|
||||||
{title}
|
onClick={goTo}
|
||||||
</div>
|
key={album.id}
|
||||||
{seeMoreLink && (
|
src={resizeImage(album?.picUrl || '', 'md')}
|
||||||
<div className='text-13px font-semibold text-gray-600 hover:underline'>
|
className='aspect-square rounded-24'
|
||||||
See More
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSubtitleText = (
|
const Playlist = ({ playlist }: { playlist: Playlist }) => {
|
||||||
item: Album | Playlist | Artist,
|
const navigate = useNavigate()
|
||||||
subtitle: Subtitle
|
const goTo = useCallback(() => {
|
||||||
) => {
|
navigate(`/playlist/${playlist.id}`)
|
||||||
const nickname = 'creator' in item ? item.creator.nickname : 'someone'
|
}, [navigate, playlist.id])
|
||||||
const artist =
|
const prefetch = useCallback(() => {
|
||||||
'artist' in item
|
prefetchPlaylist({ id: playlist.id })
|
||||||
? item.artist.name
|
}, [playlist.id])
|
||||||
: '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 type = {
|
return (
|
||||||
playlist: 'playlist',
|
<Image
|
||||||
album: 'Album',
|
onClick={goTo}
|
||||||
专辑: 'Album',
|
key={playlist.id}
|
||||||
Single: 'Single',
|
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
|
||||||
'EP/Single': 'EP',
|
className='aspect-square rounded-24'
|
||||||
EP: 'EP',
|
onMouseOver={prefetch}
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CoverRow = ({
|
const CoverRow = ({
|
||||||
title,
|
|
||||||
albums,
|
albums,
|
||||||
artists,
|
|
||||||
playlists,
|
playlists,
|
||||||
subtitle = Subtitle.Copywriter,
|
title,
|
||||||
seeMoreLink,
|
|
||||||
isSkeleton,
|
|
||||||
className,
|
className,
|
||||||
rows = 2,
|
itemTitle,
|
||||||
navigateCallback, // Callback function when click on the cover/title
|
itemSubtitle,
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string
|
||||||
albums?: Album[]
|
|
||||||
artists?: Artist[]
|
|
||||||
playlists?: Playlist[]
|
|
||||||
subtitle?: Subtitle
|
|
||||||
seeMoreLink?: string
|
|
||||||
isSkeleton?: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
rows?: number
|
albums?: Album[]
|
||||||
navigateCallback?: () => void
|
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 (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
{title && <Title title={title} seeMoreLink={seeMoreLink ?? ''} />}
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
{/* Items */}
|
||||||
className={cx(
|
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
|
||||||
'grid',
|
{albums?.map(album => (
|
||||||
className,
|
<Album
|
||||||
!className &&
|
key={album.id}
|
||||||
'grid-cols-3 gap-x-6 gap-y-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
album={album}
|
||||||
)}
|
itemTitle={itemTitle}
|
||||||
>
|
itemSubtitle={itemSubtitle}
|
||||||
{renderItems.map((item, index) => (
|
/>
|
||||||
<div
|
))}
|
||||||
key={item.id ?? index}
|
{playlists?.map(playlist => (
|
||||||
onMouseOver={() => prefetch(item.id)}
|
<Playlist key={playlist.id} playlist={playlist} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 Main from '@/web/components/Main'
|
||||||
import Player from '@/web/components/New/Player'
|
import Player from '@/web/components/Player'
|
||||||
import MenuBar from '@/web/components/New/MenuBar'
|
import MenuBar from '@/web/components/MenuBar'
|
||||||
import Topbar from '@/web/components/New/Topbar/TopbarDesktop'
|
import Topbar from '@/web/components/Topbar/TopbarDesktop'
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
@ -22,8 +22,8 @@ const Layout = () => {
|
||||||
<div
|
<div
|
||||||
id='layout'
|
id='layout'
|
||||||
className={cx(
|
className={cx(
|
||||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black'
|
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
|
||||||
// window.env?.isElectron && !fullscreen && 'rounded-24'
|
window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BlurBackground />
|
<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 { css, cx } from '@emotion/css'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import player from '@/web/states/player'
|
import player from '@/web/states/player'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import Router from '@/web/components/New/Router'
|
import Router from '@/web/components/Router'
|
||||||
import MenuBar from './MenuBar'
|
import MenuBar from './MenuBar'
|
||||||
import Topbar from './Topbar/TopbarMobile'
|
import Topbar from './Topbar/TopbarMobile'
|
||||||
import { isIOS, isIosPwa, isPWA, isSafari } from '@/web/utils/common'
|
import { isIOS, isIosPwa, isPWA, isSafari } from '@/web/utils/common'
|
||||||
|
|
@ -9,6 +9,7 @@ import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
|
||||||
import LoginWithQRCode from './LoginWithQRCode'
|
import LoginWithQRCode from './LoginWithQRCode'
|
||||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||||
import useUser from '@/web/api/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const OR = ({
|
const OR = ({
|
||||||
children,
|
children,
|
||||||
|
|
@ -17,11 +18,13 @@ const OR = ({
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='mt-4 flex items-center'>
|
<div className='mt-4 flex items-center'>
|
||||||
<div className='h-px flex-grow bg-white/20'></div>
|
<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 className='h-px flex-grow bg-white/20'></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -38,6 +41,8 @@ const OR = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { data: user, isLoading: isLoadingUser } = useUser()
|
const { data: user, isLoading: isLoadingUser } = useUser()
|
||||||
const { loginType } = useSnapshot(persistedUiStates)
|
const { loginType } = useSnapshot(persistedUiStates)
|
||||||
const { showLoginPanel } = useSnapshot(uiStates)
|
const { showLoginPanel } = useSnapshot(uiStates)
|
||||||
|
|
@ -132,8 +137,8 @@ const Login = () => {
|
||||||
|
|
||||||
<OR onClick={handleSwitchCard}>
|
<OR onClick={handleSwitchCard}>
|
||||||
{cardType === 'qrCode'
|
{cardType === 'qrCode'
|
||||||
? 'Use Phone or Email'
|
? t`auth.use-phone-or-email`
|
||||||
: 'Scan QR Code'}
|
: t`auth.scan-qr-code`}
|
||||||
</OR>
|
</OR>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
@ -17,8 +17,12 @@ import uiStates from '@/web/states/uiStates'
|
||||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
import { UserApiNames } from '@/shared/api/User'
|
import { UserApiNames } from '@/shared/api/User'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const LoginWithPhoneOrEmail = () => {
|
const LoginWithPhoneOrEmail = () => {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const isZH = i18n.language.startsWith('zh')
|
||||||
|
|
||||||
const { loginPhoneCountryCode, loginType: persistedLoginType } =
|
const { loginPhoneCountryCode, loginType: persistedLoginType } =
|
||||||
useSnapshot(persistedUiStates)
|
useSnapshot(persistedUiStates)
|
||||||
const [email, setEmail] = useState<string>('')
|
const [email, setEmail] = useState<string>('')
|
||||||
|
|
@ -130,7 +134,7 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='text-center text-18 font-medium text-white/20'>
|
<div className='text-center text-18 font-medium text-white/20'>
|
||||||
Log in with{' '}
|
{!isZH && 'Login with '}
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
'transition-colors duration-300',
|
'transition-colors duration-300',
|
||||||
|
|
@ -142,9 +146,10 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
persistedUiStates.loginType = type
|
persistedUiStates.loginType = type
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Phone
|
{t`auth.phone`}
|
||||||
</span>{' '}
|
{isZH && '登录'}
|
||||||
/{' '}
|
</span>
|
||||||
|
{' / '}
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
'transition-colors duration-300',
|
'transition-colors duration-300',
|
||||||
|
|
@ -154,7 +159,8 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
if (loginType !== 'email') setLoginType('email')
|
if (loginType !== 'email') setLoginType('email')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Email
|
{t`auth.email`}
|
||||||
|
{isZH && '登录'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -167,7 +173,7 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
animate='show'
|
animate='show'
|
||||||
exit='hidden'
|
exit='hidden'
|
||||||
className='flex items-center'
|
className='flex items-center '
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
|
|
@ -175,7 +181,7 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
persistedUiStates.loginPhoneCountryCode = e.target.value
|
persistedUiStates.loginPhoneCountryCode = e.target.value
|
||||||
}}
|
}}
|
||||||
className={cx(
|
className={cx(
|
||||||
'my-3.5 flex-shrink-0 bg-transparent',
|
'my-3.5 flex-shrink-0 bg-transparent placeholder:text-white/30',
|
||||||
css`
|
css`
|
||||||
width: 28px;
|
width: 28px;
|
||||||
`
|
`
|
||||||
|
|
@ -186,8 +192,8 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
<div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div>
|
<div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div>
|
||||||
<input
|
<input
|
||||||
onChange={e => setPhone(e.target.value)}
|
onChange={e => setPhone(e.target.value)}
|
||||||
className='my-3.5 flex-grow appearance-none bg-transparent'
|
className='my-3.5 flex-grow appearance-none bg-transparent placeholder:text-white/30'
|
||||||
placeholder='Phone'
|
placeholder={t`auth.phone`}
|
||||||
type='tel'
|
type='tel'
|
||||||
value={phone}
|
value={phone}
|
||||||
/>
|
/>
|
||||||
|
|
@ -209,8 +215,8 @@ const LoginWithPhoneOrEmail = () => {
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
className='w-full flex-grow appearance-none bg-transparent'
|
className='w-full flex-grow appearance-none bg-transparent placeholder:text-white/30'
|
||||||
placeholder='Email'
|
placeholder={t`auth.email`}
|
||||||
type='email'
|
type='email'
|
||||||
value={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'>
|
<div className='mt-4 flex items-center rounded-12 bg-black/50 p-3 text-16 font-medium text-night-50'>
|
||||||
<input
|
<input
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
className='w-full bg-transparent'
|
className='w-full bg-transparent placeholder:text-white/30'
|
||||||
placeholder='Password'
|
placeholder={t`auth.password`}
|
||||||
type='password'
|
type='password'
|
||||||
value={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'
|
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
|
||||||
>
|
>
|
||||||
LOG IN
|
{t`auth.login`}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -8,6 +8,7 @@ import { setCookies } from '@/web/utils/cookie'
|
||||||
import uiStates from '@/web/states/uiStates'
|
import uiStates from '@/web/states/uiStates'
|
||||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
import { UserApiNames } from '@/shared/api/User'
|
import { UserApiNames } from '@/shared/api/User'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
||||||
const [image, setImage] = useState<string>('')
|
const [image, setImage] = useState<string>('')
|
||||||
|
|
@ -37,6 +38,8 @@ const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginWithQRCode = () => {
|
const LoginWithQRCode = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: key,
|
data: key,
|
||||||
status: keyStatus,
|
status: keyStatus,
|
||||||
|
|
@ -101,7 +104,7 @@ const LoginWithQRCode = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='text-center text-18 font-medium text-white/20'>
|
<div className='text-center text-18 font-medium text-white/20'>
|
||||||
Log in with NetEase QR
|
{t`auth.login-with-netease-qr`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-4 rounded-24 bg-white p-2.5'>
|
<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 Router from './Router'
|
||||||
import Topbar from './Topbar'
|
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
|
||||||
import { cx } from '@emotion/css'
|
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 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 (
|
return (
|
||||||
<div
|
<motion.main
|
||||||
id='mainContainer'
|
id='main'
|
||||||
className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
|
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 />
|
<div ref={observePoint}></div>
|
||||||
<main
|
<div
|
||||||
id='main'
|
className={css`
|
||||||
className={cx(
|
margin-top: 132px;
|
||||||
'mb-24 flex-grow px-8',
|
`}
|
||||||
window.env?.isEnableTitlebar && 'mt-8'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Router />
|
<Router />
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</motion.main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import Icon from '../Icon'
|
import Icon from './Icon'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAnimation, motion } from 'framer-motion'
|
import { useAnimation, motion } from 'framer-motion'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||||
import { breakpoint as bp } from '@/web/utils/const'
|
import { breakpoint as bp } from '@/web/utils/const'
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import uiStates from '@/web/states/uiStates'
|
|
||||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
|
||||||
|
|
||||||
const tabs = [
|
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…
Add table
Add a link
Reference in a new issue