refactor: version 2.0 (React)

This commit is contained in:
qier222 2022-03-13 14:40:38 +08:00
parent 7dad7d810a
commit 950f72d4e8
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
356 changed files with 7901 additions and 29547 deletions

136
packages/main/index.ts Normal file
View file

@ -0,0 +1,136 @@
import {
BrowserWindow,
BrowserWindowConstructorOptions,
app,
shell,
} from 'electron'
import installExtension, {
REACT_DEVELOPER_TOOLS,
REDUX_DEVTOOLS,
} from 'electron-devtools-installer'
import Store from 'electron-store'
import { release } from 'os'
import { join } from 'path'
import Realm from 'realm'
import logger from './logger'
import './server'
const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const isDev = !app.isPackaged
// Disable GPU Acceleration for Windows 7
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
// Set application name for Windows 10+ notifications
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
interface TypedElectronStore {
window: {
width: number
height: number
x?: number
y?: number
}
}
const store = new Store<TypedElectronStore>({
defaults: {
window: {
width: 1440,
height: 960,
},
},
})
let win: BrowserWindow | null = null
async function createWindow() {
// Create window
const options: BrowserWindowConstructorOptions = {
title: 'Main window',
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
},
width: store.get('window.width'),
height: store.get('window.height'),
minWidth: 1080,
minHeight: 720,
vibrancy: 'fullscreen-ui',
titleBarStyle: 'hiddenInset',
}
if (store.get('window')) {
options.x = store.get('window.x')
options.y = store.get('window.y')
}
win = new BrowserWindow(options)
// Web server
if (app.isPackaged || process.env['DEBUG']) {
win.loadFile(join(__dirname, '../renderer/index.html'))
} else {
const url = `http://${process.env['VITE_DEV_SERVER_HOST']}:${process.env['VITE_DEV_SERVER_PORT']}`
logger.info(`[index] Vite dev server running at: ${url}`)
win.loadURL(url)
win.webContents.openDevTools()
}
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) shell.openExternal(url)
return { action: 'deny' }
})
// Save window position
const saveBounds = () => {
const bounds = win?.getBounds()
if (bounds) {
store.set('window', bounds)
}
}
win.on('resized', saveBounds)
win.on('moved', saveBounds)
}
app.whenReady().then(async () => {
createWindow()
// Install devtool extension
if (isDev) {
installExtension(REACT_DEVELOPER_TOOLS.id).catch(err =>
console.log('An error occurred: ', err)
)
installExtension(REDUX_DEVTOOLS.id).catch(err =>
console.log('An error occurred: ', err)
)
}
})
app.on('window-all-closed', () => {
win = null
if (process.platform !== 'darwin') app.quit()
})
app.on('second-instance', () => {
if (win) {
// Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.on('activate', () => {
const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length) {
allWindows[0].focus()
} else {
createWindow()
}
})

29
packages/main/logger.ts Normal file
View file

@ -0,0 +1,29 @@
import styles from 'ansi-styles'
import { app } from 'electron'
import logger from 'electron-log'
const color = (hex: string, text: string) => {
return `${styles.color.ansi(styles.hexToAnsi(hex))}${text}${
styles.color.close
}`
}
logger.transports.console.format = `${color(
'38bdf8',
'[main]'
)} {h}:{i}:{s}.{ms}{scope} {text}`
logger.transports.file.level = app.isPackaged ? 'info' : 'debug'
logger.info(
color(
'335eea',
`\n\n██╗ ██╗███████╗███████╗██████╗ ██╗ █████╗ ██╗ ██╗███╗ ███╗██╗ ██╗███████╗██╗ ██████╗
\n`
)
)
export default logger

45
packages/main/server.ts Normal file
View file

@ -0,0 +1,45 @@
import { pathCase } from 'change-case'
import cookieParser from 'cookie-parser'
import express, { Request, Response } from 'express'
import logger from './logger'
const neteaseApi = require('NeteaseCloudMusicApi')
const app = express()
app.use(cookieParser())
const port = Number(process.env['ELECTRON_DEV_NETEASE_API_PORT'] ?? 3000)
Object.entries(neteaseApi).forEach(([name, handler]) => {
if (['serveNcmApi', 'getModulesDefinitions'].includes(name)) {
return
}
const wrappedHandler = async (req: Request, res: Response) => {
logger.info(`[server] Handling request: ${req.path}`)
try {
const result = await handler({
...req.query,
// cookie:
// 'MUSIC_U=1239b6c1217d8cd240df9c8fa15e99a62f9aaac86baa7a8aa3166acbad267cd8a237494327fc3ec043124f3fcebe94e446b14e3f0c3f8af9fe5c85647582a507',
// cookie: req.headers.cookie,
cookie: `MUSIC_U=${req.cookies['MUSIC_U']}`,
})
res.send(result.body)
} catch (error) {
res.status(500).send(error)
}
}
app.get(
`/netease/${pathCase(name)}`,
async (req: Request, res: Response) => await wrappedHandler(req, res)
)
app.post(
`/netease/${pathCase(name)}`,
async (req: Request, res: Response) => await wrappedHandler(req, res)
)
})
app.listen(port, () => {
logger.info(`[server] API server listening on port ${port}`)
})

View file

@ -0,0 +1,32 @@
import dotenv from 'dotenv'
import { builtinModules } from 'module'
import path from 'path'
import { defineConfig } from 'vite'
import pkg from '../../package.json'
import esm2cjs from '../../scripts/vite-plugin-esm2cjs'
dotenv.config({
path: path.resolve(process.cwd(), '.env'),
})
export default defineConfig({
root: __dirname,
build: {
outDir: '../../dist/main',
emptyOutDir: true,
lib: {
entry: 'index.ts',
formats: ['cjs'],
fileName: () => '[name].cjs',
},
minify: process.env./* from mode option */ NODE_ENV === 'production',
sourcemap: process.env./* from mode option */ NODE_ENV === 'debug',
rollupOptions: {
external: [
'electron',
...builtinModules,
...Object.keys(pkg.dependencies || {}),
],
},
},
})

36
packages/preload/index.ts Normal file
View file

@ -0,0 +1,36 @@
import { contextBridge, ipcRenderer } from 'electron'
import fs from 'fs'
import { useLoading } from './loading'
import { domReady } from './utils'
const { appendLoading, removeLoading } = useLoading()
;(async () => {
await domReady()
appendLoading()
})()
// --------- Expose some API to the Renderer process. ---------
contextBridge.exposeInMainWorld('fs', fs)
contextBridge.exposeInMainWorld('removeLoading', removeLoading)
contextBridge.exposeInMainWorld('ipcRenderer', withPrototype(ipcRenderer))
// `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it.
function withPrototype(obj: Record<string, any>) {
const protos = Object.getPrototypeOf(obj)
for (const [key, value] of Object.entries(protos)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) continue
if (typeof value === 'function') {
// Some native APIs, like `NodeJS.EventEmitter['on']`, don't work in the Renderer process. Wrapping them into a function.
obj[key] = function (...args: any) {
return value.call(obj, ...args)
}
} else {
obj[key] = value
}
}
return obj
}

View file

@ -0,0 +1,54 @@
/**
* https://tobiasahlin.com/spinkit
* https://connoratherton.com/loaders
* https://projects.lukehaas.me/css-loaders
* https://matejkustec.github.io/SpinThatShit
*/
export function useLoading() {
const className = `loaders-css__square-spin`
const styleContent = `
@keyframes square-spin {
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); }
75% { transform: perspective(100px) rotateX(0) rotateY(180deg); }
100% { transform: perspective(100px) rotateX(0) rotateY(0); }
}
.${className} > div {
animation-fill-mode: both;
width: 50px;
height: 50px;
background: #fff;
animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite;
}
.app-loading-wrap {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #282c34;
z-index: 9;
}
`
const oStyle = document.createElement('style')
const oDiv = document.createElement('div')
oStyle.id = 'app-loading-style'
oStyle.innerHTML = styleContent
oDiv.className = 'app-loading-wrap'
oDiv.innerHTML = `<div class="${className}"><div></div></div>`
return {
appendLoading() {
document.head.appendChild(oStyle)
document.body.appendChild(oDiv)
},
removeLoading() {
document.head.removeChild(oStyle)
document.body.removeChild(oDiv)
},
}
}

16
packages/preload/utils.ts Normal file
View file

@ -0,0 +1,16 @@
/** Document ready */
export const domReady = (
condition: DocumentReadyState[] = ['complete', 'interactive']
) => {
return new Promise(resolve => {
if (condition.includes(document.readyState)) {
resolve(true)
} else {
document.addEventListener('readystatechange', () => {
if (condition.includes(document.readyState)) {
resolve(true)
}
})
}
})
}

View file

@ -0,0 +1,30 @@
import dotenv from 'dotenv'
import { builtinModules } from 'module'
import path from 'path'
import { defineConfig } from 'vite'
import pkg from '../../package.json'
dotenv.config({
path: path.resolve(process.cwd(), '.env'),
})
export default defineConfig({
root: __dirname,
build: {
outDir: '../../dist/preload',
emptyOutDir: true,
lib: {
entry: 'index.ts',
formats: ['cjs'],
fileName: () => '[name].cjs',
},
minify: process.env./* from mode option */ NODE_ENV === 'production',
rollupOptions: {
external: [
'electron',
...builtinModules,
...Object.keys(pkg.dependencies || {}),
],
},
},
})

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
<title>YesPlayMusic</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,36 @@
import { Toaster } from 'react-hot-toast'
import { QueryClientProvider } from 'react-query'
import { ReactQueryDevtools } from 'react-query/devtools'
import Player from '@/components/Player'
import Sidebar from '@/components/Sidebar'
import reactQueryClient from '@/utils/reactQueryClient'
import Main from './components/Main'
const App = () => {
return (
<QueryClientProvider client={reactQueryClient}>
<div id="layout" className="grid select-none grid-cols-[16rem_auto]">
<Sidebar />
<Main />
<Player />
</div>
<Toaster position="bottom-center" containerStyle={{ bottom: '5rem' }} />
{/* Devtool */}
<ReactQueryDevtools
initialIsOpen={false}
toggleButtonProps={{
style: {
position: 'fixed',
right: '0',
left: 'auto',
bottom: '4rem',
},
}}
/>
</QueryClientProvider>
)
}
export default App

View file

@ -0,0 +1,29 @@
import request from '@/utils/request'
export enum AlbumApiNames {
FETCH_ALBUM = 'fetchAlbum',
}
// 专辑详情
export interface FetchAlbumParams {
id: number
}
interface FetchAlbumResponse {
code: number
resourceState: boolean
album: Album
songs: Track[]
description: string
}
export function fetchAlbum(
params: FetchAlbumParams,
noCache: boolean
): Promise<FetchAlbumResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
return request({
url: '/album',
method: 'get',
params: { ...params, ...otherParams },
})
}

View file

@ -0,0 +1,51 @@
import request from '@/utils/request'
export enum ArtistApiNames {
FETCH_ARTIST = 'fetchArtist',
FETCH_ARTIST_ALBUMS = 'fetchArtistAlbums',
}
// 歌手详情
export interface FetchArtistParams {
id: number
}
interface FetchArtistResponse {
code: number
more: boolean
artist: Artist
hotSongs: Track[]
}
export function fetchArtist(
params: FetchArtistParams,
noCache: boolean
): Promise<FetchArtistResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
return request({
url: '/artists',
method: 'get',
params: { ...params, ...otherParams },
})
}
// 获取歌手的专辑列表
export interface FetchArtistAlbumsParams {
id: number
limit?: number // default: 50
offset?: number // default: 0
}
interface FetchArtistAlbumsResponse {
code: number
hotAlbums: Album[]
more: boolean
artist: Artist
}
export function fetchArtistAlbums(
params: FetchArtistAlbumsParams
): Promise<FetchArtistAlbumsResponse> {
return request({
url: 'artist/album',
method: 'get',
params,
})
}

View file

@ -0,0 +1,115 @@
import type { fetchUserAccountResponse } from '@/api/user'
import request from '@/utils/request'
// 手机号登录
interface LoginWithPhoneParams {
countrycode: number | string
phone: string
password?: string
md5_password?: string
captcha?: string | number
}
export interface LoginWithPhoneResponse {
loginType: number
code: number
cookie: string
}
export function loginWithPhone(
params: LoginWithPhoneParams
): Promise<LoginWithPhoneResponse> {
return request({
url: '/login/cellphone',
method: 'post',
params,
})
}
// 邮箱登录
export interface LoginWithEmailParams {
email: string
password?: string
md5_password?: string
}
export interface loginWithEmailResponse extends fetchUserAccountResponse {
code: number
cookie: string
loginType: number
token: string
binding: {
bindingTime: number
expired: boolean
expiresIn: number
id: number
refreshTime: number
tokenJsonStr: string
type: number
url: string
userId: number
}[]
}
export function loginWithEmail(
params: LoginWithEmailParams
): Promise<loginWithEmailResponse> {
return request({
url: '/login',
method: 'post',
params,
})
}
// 生成二维码key
export interface fetchLoginQrCodeKeyResponse {
code: number
data: {
code: number
unikey: string
}
}
export function fetchLoginQrCodeKey(): Promise<fetchLoginQrCodeKeyResponse> {
return request({
url: '/login/qr/key',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
})
}
// 二维码检测扫码状态接口
// 说明: 轮询此接口可获取二维码扫码状态,800为二维码过期,801为等待扫码,802为待确认,803为授权登录成功(803状态码下会返回cookies)
export interface CheckLoginQrCodeStatusParams {
key: string
}
export interface CheckLoginQrCodeStatusResponse {
code: number
message?: string
cookie?: string
}
export function checkLoginQrCodeStatus(
params: CheckLoginQrCodeStatusParams
): Promise<CheckLoginQrCodeStatusResponse> {
return request({
url: '/login/qr/check',
method: 'get',
params: {
key: params.key,
timestamp: new Date().getTime(),
},
})
}
// 刷新登录
export function refreshCookie() {
return request({
url: '/login/refresh',
method: 'post',
})
}
// 退出登录
export function logout() {
return request({
url: '/logout',
method: 'post',
})
}

View file

@ -0,0 +1,57 @@
import request from '@/utils/request'
export enum PlaylistApiNames {
FETCH_PLAYLIST = 'fetchPlaylist',
FETCH_RECOMMENDED_PLAYLISTS = 'fetchRecommendedPlaylists',
}
// 歌单详情
export interface FetchPlaylistParams {
id: number
s?: number // 歌单最近的 s 个收藏者
}
interface FetchPlaylistResponse {
code: number
playlist: Playlist
privileges: unknown // TODO: unknown type
relatedVideos: null
resEntrance: null
sharedPrivilege: null
urls: null
}
export function fetchPlaylist(
params: FetchPlaylistParams,
noCache: boolean
): Promise<FetchPlaylistResponse> {
const otherParams: { timestamp?: number } = {}
if (noCache) otherParams.timestamp = new Date().getTime()
if (!params.s) params.s = 0 // 网易云默认返回8个收藏者这里设置为0减少返回的JSON体积
return request({
url: '/playlist/detail',
method: 'get',
params: {
...params,
...otherParams,
},
})
}
// 推荐歌单
interface FetchRecommendedPlaylistsParams {
limit?: number
}
export interface FetchRecommendedPlaylistsResponse {
code: number
category: number
hasTaste: boolean
result: Playlist[]
}
export function fetchRecommendedPlaylists(
params: FetchRecommendedPlaylistsParams
): Promise<FetchRecommendedPlaylistsResponse> {
return request({
url: '/personalized',
method: 'get',
params,
})
}

View file

@ -0,0 +1,100 @@
import request from '@/utils/request'
export enum SearchApiNames {
SEARCH = 'search',
MULTI_MATCH_SEARCH = 'multiMatchSearch',
}
// 搜索
export enum SearchTypes {
SINGLE = 1,
ALBUM = 10,
ARTIST = 100,
PLAYLIST = 1000,
USER = 1002,
MV = 1004,
LYRICS = 1006,
RADIO = 1009,
VIDEO = 1014,
ALL = 1018,
}
export interface SearchParams {
keywords: string
limit?: number // 返回数量 , 默认为 30
offset?: number // 偏移数量,用于分页 , 如 : 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
type?: SearchTypes // type: 搜索类型
}
interface SearchResponse {
code: number
result: {
album: {
albums: Album[]
more: boolean
moreText: string
resourceIds: number[]
}
artist: {
artists: Artist[]
more: boolean
moreText: string
resourceIds: number[]
}
playList: {
playLists: Playlist[]
more: boolean
moreText: string
resourceIds: number[]
}
song: {
songs: Track[]
more: boolean
moreText: string
resourceIds: number[]
}
user: {
users: User[]
more: boolean
moreText: string
resourceIds: number[]
}
circle: unknown
new_mlog: unknown
order: string[]
rec_type: null
rec_query: null[]
sim_query: unknown
voice: unknown
voiceList: unknown
}
}
export function search(params: SearchParams): Promise<SearchResponse> {
return request({
url: '/search',
method: 'get',
params: params,
})
}
// 搜索多重匹配
export interface MultiMatchSearchParams {
keywords: string
}
interface MultiMatchSearchResponse {
code: number
result: {
album: Album[]
artist: Artist[]
playlist: Playlist[]
orpheus: unknown
orders: Array<'artist' | 'album'>
}
}
export function multiMatchSearch(
params: MultiMatchSearchParams
): Promise<MultiMatchSearchResponse> {
return request({
url: '/search/multimatch',
method: 'get',
params: params,
})
}

View file

@ -0,0 +1,73 @@
import request from '@/utils/request'
export enum TrackApiNames {
FETCH_TRACKS = 'fetchTracks',
FETCH_AUDIO_SOURCE = 'fetchAudioSource',
}
// 获取歌曲详情
export interface FetchTracksParams {
ids: number[]
}
interface FetchTracksResponse {
code: number
songs: Track[]
privileges: {
[key: string]: unknown
}
}
export function fetchTracks(
params: FetchTracksParams
): Promise<FetchTracksResponse> {
return request({
url: '/song/detail',
method: 'get',
params: {
ids: params.ids.join(','),
},
})
}
// 获取音源URL
export interface FetchAudioSourceParams {
id: number
br?: number // bitrate, default 999000320000 = 320kbps
}
interface FetchAudioSourceResponse {
code: number
data: {
br: number
canExtend: boolean
code: number
encodeType: 'mp3' | null
expi: number
fee: number
flag: number
freeTimeTrialPrivilege: {
[key: string]: unknown
}
freeTrialPrivilege: {
[key: string]: unknown
}
freeTrialInfo: null
gain: number
id: number
level: 'standard' | 'null'
md5: string | null
payed: number
size: number
type: 'mp3' | null
uf: null
url: string | null
urlSource: number
}[]
}
export function fetchAudioSource(
params: FetchAudioSourceParams
): Promise<FetchAudioSourceResponse> {
return request({
url: '/song/url',
method: 'get',
params,
})
}

View file

@ -0,0 +1,271 @@
import request from '@/utils/request'
export enum UserApiNames {
FETCH_USER_ACCOUNT = 'fetchUserAccount',
FETCH_USER_LIKED_SONGS_IDS = 'fetchUserLikedSongsIDs',
FETCH_USER_PLAYLISTS = 'fetchUserPlaylists',
}
/**
*
* 说明 : 登录后调用此接口 , id,
* - uid : 用户 id
* @param {number} uid
*/
export function userDetail(uid) {
return request({
url: '/user/detail',
method: 'get',
params: {
uid,
timestamp: new Date().getTime(),
},
})
}
// 获取账号详情
export interface fetchUserAccountResponse {
code: number
account: {
anonimousUser: boolean
ban: number
baoyueVersion: number
createTime: number
donateVersion: number
id: number
paidFee: boolean
status: number
tokenVersion: number
type: number
userName: string
vipType: number
whitelistAuthority: number
} | null
profile: {
userId: number
userType: number
nickname: string
avatarImgId: number
avatarUrl: string
backgroundImgId: number
backgroundUrl: string
signature: string
createTime: number
userName: string
accountType: number
shortUserName: string
birthday: number
authority: number
gender: number
accountStatus: number
province: number
city: number
authStatus: number
description: string | null
detailDescription: string | null
defaultAvatar: boolean
expertTags: [] | null
experts: [] | null
djStatus: number
locationStatus: number
vipType: number
followed: boolean
mutual: boolean
authenticated: boolean
lastLoginTime: number
lastLoginIP: string
remarkName: string | null
viptypeVersion: number
authenticationTypes: number
avatarDetail: string | null
anchor: boolean
} | null
}
export function fetchUserAccount(): Promise<fetchUserAccountResponse> {
return request({
url: '/user/account',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
})
}
// 获取用户歌单
export interface FetchUserPlaylistsParams {
uid: number
offset: number
limit?: number // default 30
}
interface FetchUserPlaylistsResponse {
code: number
more: false
version: string
playlist: Playlist[]
}
export function fetchUserPlaylists(
params: FetchUserPlaylistsParams
): Promise<FetchUserPlaylistsResponse> {
return request({
url: '/user/playlist',
method: 'get',
params,
})
}
export interface FetchUserLikedSongsIDsParams {
uid: number
}
interface FetchUserLikedSongsIDsResponse {
code: number
checkPoint: number
ids: number[]
}
export function fetchUserLikedSongsIDs(
params: FetchUserLikedSongsIDsParams
): Promise<FetchUserLikedSongsIDsResponse> {
return request({
url: '/likelist',
method: 'get',
params: {
uid: params.uid,
timestamp: new Date().getTime(),
},
})
}
/**
*
* 说明 : 调用此接口可签到获取积分
* - type: , 0, 0 ,1 web/PC
* @param {number} type
*/
export function dailySignin(type = 0) {
return request({
url: '/daily_signin',
method: 'post',
params: {
type,
timestamp: new Date().getTime(),
},
})
}
/**
*
* 说明 : 调用此接口可获取到用户收藏的专辑
* - limit : 返回数量 , 25
* - offset : 偏移数量 , :( -1)*25, 25 limit , 0
* @param {Object} params
* @param {number} params.limit
* @param {number=} params.offset
*/
export function likedAlbums(params) {
return request({
url: '/album/sublist',
method: 'get',
params: {
limit: params.limit,
timestamp: new Date().getTime(),
},
})
}
/**
*
* 说明 : 调用此接口可获取到用户收藏的歌手
*/
export function likedArtists(params) {
return request({
url: '/artist/sublist',
method: 'get',
params: {
limit: params.limit,
timestamp: new Date().getTime(),
},
})
}
/**
* MV
* 说明 : 调用此接口可获取到用户收藏的MV
*/
export function likedMVs(params) {
return request({
url: '/mv/sublist',
method: 'get',
params: {
limit: params.limit,
timestamp: new Date().getTime(),
},
})
}
/**
*
*/
export function uploadSong(file) {
let formData = new FormData()
formData.append('songFile', file)
return request({
url: '/cloud',
method: 'post',
params: {
timestamp: new Date().getTime(),
},
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 200000,
}).catch(error => {
alert(`上传失败Error: ${error}`)
})
}
/**
*
* 说明 : 登录后调用此接口 , , url, /song/url url
* - limit : 返回数量 , 200
* - offset : 偏移数量 , :( -1)*200, 200 limit , 0
* @param {Object} params
* @param {number} params.limit
* @param {number=} params.offset
*/
export function cloudDisk(params = {}) {
params.timestamp = new Date().getTime()
return request({
url: '/user/cloud',
method: 'get',
params,
})
}
/**
*
*/
export function cloudDiskTrackDetail(id) {
return request({
url: '/user/cloud/detail',
method: 'get',
params: {
timestamp: new Date().getTime(),
id,
},
})
}
/**
*
* @param {Array} id
*/
export function cloudDiskTrackDelete(id) {
return request({
url: '/user/cloud/del',
method: 'get',
params: {
timestamp: new Date().getTime(),
id,
},
})
}

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chevron-left" class="svg-inline--fa fa-chevron-left" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M224 480c-8.188 0-16.38-3.125-22.62-9.375l-192-192c-12.5-12.5-12.5-32.75 0-45.25l192-192c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L77.25 256l169.4 169.4c12.5 12.5 12.5 32.75 0 45.25C240.4 476.9 232.2 480 224 480z"></path></svg>

After

Width:  |  Height:  |  Size: 455 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chevron-up" class="svg-inline--fa fa-chevron-up fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M240.971 130.524l194.343 194.343c9.373 9.373 9.373 24.569 0 33.941l-22.667 22.667c-9.357 9.357-24.522 9.375-33.901.04L224 227.495 69.255 381.516c-9.379 9.335-24.544 9.317-33.901-.04l-22.667-22.667c-9.373-9.373-9.373-24.569 0-33.941L207.03 130.525c9.372-9.373 24.568-9.373 33.941-.001z"></path></svg>

After

Width:  |  Height:  |  Size: 525 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48px" height="48px"><circle cx="12" cy="12" r="10" opacity=".35"/><path d="M15.839,6.559l-5.7,1.9c-0.793,0.264-1.416,0.887-1.68,1.68l-1.9,5.7c-0.33,0.99,0.612,1.933,1.603,1.603l5.7-1.9 c0.793-0.264,1.416-0.887,1.68-1.68l1.9-5.7C17.771,7.171,16.829,6.228,15.839,6.559z M12,14c-1.105,0-2-0.895-2-2 c0-1.105,0.895-2,2-2s2,0.895,2,2C14,13.105,13.105,14,12,14z"/></svg>

After

Width:  |  Height:  |  Size: 433 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="thumbs-down" class="svg-inline--fa fa-thumbs-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M96 32.04H32c-17.67 0-32 14.32-32 31.1v223.1c0 17.67 14.33 31.1 32 31.1h64c17.67 0 32-14.33 32-31.1V64.03C128 46.36 113.7 32.04 96 32.04zM467.3 240.2C475.1 231.7 480 220.4 480 207.9c0-23.47-16.87-42.92-39.14-47.09C445.3 153.6 448 145.1 448 135.1c0-21.32-14-39.18-33.25-45.43C415.5 87.12 416 83.61 416 79.98C416 53.47 394.5 32 368 32h-58.69c-34.61 0-68.28 11.22-95.97 31.98L179.2 89.57C167.1 98.63 160 112.9 160 127.1l.1074 160c0 0-.0234-.0234 0 0c.0703 13.99 6.123 27.94 17.91 37.36l16.3 13.03C276.2 403.9 239.4 480 302.5 480c30.96 0 49.47-24.52 49.47-48.11c0-15.15-11.76-58.12-34.52-96.02H464c26.52 0 48-21.47 48-47.98C512 262.5 492.2 241.9 467.3 240.2z"></path></svg>

After

Width:  |  Height:  |  Size: 889 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="24" height="24"
viewBox="0 0 24 24"
style=" fill:#000000;"><path d="M19.971,14.583C19.985,14.39,20,14.197,20,14c0-0.513-0.053-1.014-0.145-1.5C19.947,12.014,20,11.513,20,11 c0-4.418-3.582-8-8-8s-8,3.582-8,8c0,0.513,0.053,1.014,0.145,1.5C4.053,12.986,4,13.487,4,14c0,0.197,0.015,0.39,0.029,0.583 C3.433,14.781,3,15.337,3,16c0,0.828,0.672,1.5,1.5,1.5c0.103,0,0.203-0.01,0.3-0.03C6.093,20.148,8.827,22,12,22 s5.907-1.852,7.2-4.53c0.097,0.02,0.197,0.03,0.3,0.03c0.828,0,1.5-0.672,1.5-1.5C21,15.337,20.567,14.781,19.971,14.583z" opacity=".35"></path><path d="M21,18h-2v-6h2c1.105,0,2,0.895,2,2v2C23,17.105,22.105,18,21,18z"></path><path d="M3,12h2v6H3c-1.105,0-2-0.895-2-2v-2C1,12.895,1.895,12,3,12z"></path><path d="M5,13c0-0.843,0-1.638,0-2c0-3.866,3.134-7,7-7s7,3.134,7,7c0,0.362,0,1.157,0,2h2c0-0.859,0-1.617,0-2c0-4.971-4.029-9-9-9 s-9,4.029-9,9c0,0.383,0,1.141,0,2H5z"></path></svg>

After

Width:  |  Height:  |  Size: 946 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>

After

Width:  |  Height:  |  Size: 494 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>

After

Width:  |  Height:  |  Size: 429 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="radio-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-radio-alt fa-w-16 fa-7x"><path fill="currentColor" d="M209 368h-64a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16zm144 56a72 72 0 1 0-72-72 72.09 72.09 0 0 0 72 72zm96-296H212.5l288.83-81.21a16 16 0 0 0 11.07-19.74l-4.33-15.38A16 16 0 0 0 488.33.6L47.68 124.5A64 64 0 0 0 1 186.11V448a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64V192a64 64 0 0 0-64-64zm16 320a16 16 0 0 1-16 16H65a16 16 0 0 1-16-16V256h416zM113 336h128a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16H113a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 733 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chevron-right" class="svg-inline--fa fa-chevron-right" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z"></path></svg>

After

Width:  |  Height:  |  Size: 456 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M244 84L255.1 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 0 232.4 0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84C243.1 84 244 84.01 244 84L244 84zM255.1 163.9L210.1 117.1C188.4 96.28 157.6 86.4 127.3 91.44C81.55 99.07 48 138.7 48 185.1V190.9C48 219.1 59.71 246.1 80.34 265.3L256 429.3L431.7 265.3C452.3 246.1 464 219.1 464 190.9V185.1C464 138.7 430.4 99.07 384.7 91.44C354.4 86.4 323.6 96.28 301.9 117.1L255.1 163.9z"/></svg>

After

Width:  |  Height:  |  Size: 702 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z"/></svg>

After

Width:  |  Height:  |  Size: 417 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48px" height="48px"><path d="M18,21H6c-1.657,0-3-1.343-3-3V8.765c0-1.09,0.591-2.093,1.543-2.622l6-3.333 c0.906-0.503,2.008-0.503,2.914,0l6,3.333C20.409,6.672,21,7.676,21,8.765V18C21,19.657,19.657,21,18,21z" opacity=".35"/><path d="M15,21H9v-6c0-1.105,0.895-2,2-2h2c1.105,0,2,0.895,2,2V21z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="lock-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-lock-alt fa-w-14 fa-7x"><path fill="currentColor" d="M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zM264 392c0 22.1-17.9 40-40 40s-40-17.9-40-40v-48c0-22.1 17.9-40 40-40s40 17.9 40 40v48zm32-168H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 550 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="ellipsis-h" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-ellipsis-h fa-w-16 fa-9x"><path fill="currentColor" d="M304 256c0 26.5-21.5 48-48 48s-48-21.5-48-48 21.5-48 48-48 48 21.5 48 48zm120-48c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zm-336 0c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 472 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48px" height="48px"><path d="M5,6h14c0.353,0,0.686,0.072,1,0.184V6c0-1.657-1.343-3-3-3H7C5.343,3,4,4.343,4,6v0.184C4.314,6.072,4.647,6,5,6z"/><path d="M19,20H5c-1.657,0-3-1.343-3-3V9c0-1.657,1.343-3,3-3h14c1.657,0,3,1.343,3,3v8 C22,18.657,20.657,20,19,20z" opacity=".35"/><circle cx="10.5" cy="15.5" r="2.5"/><path d="M13,8c-1.105,0-2,0.895-2,2v6l2-0.5V11h1c1.105,0,2-0.895,2-2V8.5C16,8.224,15.776,8,15.5,8H13z"/></svg>

After

Width:  |  Height:  |  Size: 488 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="30" height="30"
viewBox="0 0 30 30"
style=" fill:#000000;"> <path d="M 23 2 A 1 1 0 0 0 22.708984 2.0449219 L 13.726562 4.0351562 L 13.722656 4.0410156 A 1 1 0 0 0 13 5 L 13 16 C 13 17.105 12.105 18 11 18 L 10.503906 18 A 4.5 4.5 0 0 0 10.5 18 A 4.5 4.5 0 0 0 6 22.5 A 4.5 4.5 0 0 0 10.5 27 A 4.5 4.5 0 0 0 15 22.5 L 15 9.8007812 L 23.154297 7.9882812 A 1 1 0 0 0 24 7 L 24 3 A 1 1 0 0 0 23 2 z"></path></svg>

After

Width:  |  Height:  |  Size: 476 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="step-forward" class="svg-inline--fa fa-step-forward fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M384 44v424c0 6.6-5.4 12-12 12h-48c-6.6 0-12-5.4-12-12V291.6l-195.5 181C95.9 489.7 64 475.4 64 448V64c0-27.4 31.9-41.7 52.5-24.6L312 219.3V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12z"></path></svg>

After

Width:  |  Height:  |  Size: 427 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="pause" class="svg-inline--fa fa-pause fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"></path></svg>

After

Width:  |  Height:  |  Size: 444 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 2a2 2 0 00-2 2v12a2 2 0 002 2h6a2 2 0 002-2V4a2 2 0 00-2-2H7zm3 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="play" class="svg-inline--fa fa-play fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"></path></svg>

After

Width:  |  Height:  |  Size: 340 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="list-music" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-list-music fa-w-16 fa-9x"><path fill="currentColor" d="M16 256h256a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16zm0-128h256a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16H16A16 16 0 0 0 0 80v32a16 16 0 0 0 16 16zm128 192H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM470.94 1.33l-96.53 28.51A32 32 0 0 0 352 60.34V360a148.76 148.76 0 0 0-48-8c-61.86 0-112 35.82-112 80s50.14 80 112 80 112-35.82 112-80V148.15l73-21.39a32 32 0 0 0 23-30.71V32a32 32 0 0 0-41.06-30.67z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 735 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="step-backward" class="svg-inline--fa fa-step-backward fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M64 468V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12v176.4l195.5-181C352.1 22.3 384 36.6 384 64v384c0 27.4-31.9 41.7-52.5 24.6L136 292.7V468c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12z"></path></svg>

After

Width:  |  Height:  |  Size: 428 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2V5h1v1H5zM3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm2 2v-1h1v1H5zM13 3a1 1 0 00-1 1v3a1 1 0 001 1h3a1 1 0 001-1V4a1 1 0 00-1-1h-3zm1 2v1h1V5h-1z" clip-rule="evenodd" />
<path d="M11 4a1 1 0 10-2 0v1a1 1 0 002 0V4zM10 7a1 1 0 011 1v1h2a1 1 0 110 2h-3a1 1 0 01-1-1V8a1 1 0 011-1zM16 9a1 1 0 100 2 1 1 0 000-2zM9 13a1 1 0 011-1h1a1 1 0 110 2v2a1 1 0 11-2 0v-3zM7 11a1 1 0 100-2H4a1 1 0 100 2h3zM17 13a1 1 0 01-1 1h-2a1 1 0 110-2h2a1 1 0 011 1zM16 17a1 1 0 100-2h-3a1 1 0 100 2h3z" />
</svg>

After

Width:  |  Height:  |  Size: 708 B

View file

@ -0,0 +1 @@
<span class="dn color-inherit link hover-indigo"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="repeat-1" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-repeat-1 fa-w-16 fa-1x"><path fill="currentColor" d="M512 256c0 88.224-71.775 160-160 160H170.067l34.512 32.419c9.875 9.276 10.119 24.883.539 34.464l-10.775 10.775c-9.373 9.372-24.568 9.372-33.941 0l-92.686-92.686c-9.373-9.373-9.373-24.568 0-33.941l80.269-80.27c9.373-9.373 24.568-9.373 33.941 0l10.775 10.775c9.581 9.581 9.337 25.187-.539 34.464l-22.095 20H352c52.935 0 96-43.065 96-96 0-13.958-2.996-27.228-8.376-39.204-4.061-9.039-2.284-19.626 4.723-26.633l12.183-12.183c11.499-11.499 30.965-8.526 38.312 5.982C505.814 205.624 512 230.103 512 256zM72.376 295.204C66.996 283.228 64 269.958 64 256c0-52.935 43.065-96 96-96h181.933l-22.095 20.002c-9.875 9.276-10.119 24.883-.539 34.464l10.775 10.775c9.373 9.372 24.568 9.372 33.941 0l80.269-80.27c9.373-9.373 9.373-24.568 0-33.941l-92.686-92.686c-9.373-9.373-24.568-9.373-33.941 0l-10.775 10.775c-9.581 9.581-9.337 25.187.539 34.464L341.933 96H160C71.775 96 0 167.776 0 256c0 25.897 6.186 50.376 17.157 72.039 7.347 14.508 26.813 17.481 38.312 5.982l12.183-12.183c7.008-7.008 8.786-17.595 4.724-26.634zm154.887 4.323c0-7.477 3.917-11.572 11.573-11.572h15.131v-39.878c0-5.163.534-10.503.534-10.503h-.356s-1.779 2.67-2.848 3.738c-4.451 4.273-10.504 4.451-15.666-1.068l-5.518-6.231c-5.342-5.341-4.984-11.216.534-16.379l21.72-19.939c4.449-4.095 8.366-5.697 14.42-5.697h12.105c7.656 0 11.749 3.916 11.749 11.572v84.384h15.488c7.655 0 11.572 4.094 11.572 11.572v8.901c0 7.477-3.917 11.572-11.572 11.572h-67.293c-7.656 0-11.573-4.095-11.573-11.572v-8.9z" class=""></path></svg></span>

View file

@ -0,0 +1 @@
<span class="dn color-inherit link hover-pink"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="repeat" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-repeat fa-w-16 fa-1x"><path fill="currentColor" d="M512 256c0 88.224-71.775 160-160 160H170.067l34.512 32.419c9.875 9.276 10.119 24.883.539 34.464l-10.775 10.775c-9.373 9.372-24.568 9.372-33.941 0l-92.686-92.686c-9.373-9.373-9.373-24.568 0-33.941l92.686-92.686c9.373-9.373 24.568-9.373 33.941 0l10.775 10.775c9.581 9.581 9.337 25.187-.539 34.464L170.067 352H352c52.935 0 96-43.065 96-96 0-13.958-2.996-27.228-8.376-39.204-4.061-9.039-2.284-19.626 4.723-26.633l12.183-12.183c11.499-11.499 30.965-8.526 38.312 5.982C505.814 205.624 512 230.103 512 256zM72.376 295.204C66.996 283.228 64 269.958 64 256c0-52.935 43.065-96 96-96h181.933l-34.512 32.419c-9.875 9.276-10.119 24.883-.539 34.464l10.775 10.775c9.373 9.372 24.568 9.372 33.941 0l92.686-92.686c9.373-9.373 9.373-24.568 0-33.941l-92.686-92.686c-9.373-9.373-24.568-9.373-33.941 0L306.882 29.12c-9.581 9.581-9.337 25.187.539 34.464L341.933 96H160C71.775 96 0 167.776 0 256c0 25.897 6.186 50.376 17.157 72.039 7.347 14.508 26.813 17.481 38.312 5.982l12.183-12.183c7.008-7.008 8.786-17.595 4.724-26.634z" class=""></path></svg></span>

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="magnifying-glass" class="svg-inline--fa fa-magnifying-glass" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z"></path></svg>

After

Width:  |  Height:  |  Size: 628 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="random" class="svg-inline--fa fa-random fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M504.971 359.029c9.373 9.373 9.373 24.569 0 33.941l-80 79.984c-15.01 15.01-40.971 4.49-40.971-16.971V416h-58.785a12.004 12.004 0 0 1-8.773-3.812l-70.556-75.596 53.333-57.143L352 336h32v-39.981c0-21.438 25.943-31.998 40.971-16.971l80 79.981zM12 176h84l52.781 56.551 53.333-57.143-70.556-75.596A11.999 11.999 0 0 0 122.785 96H12c-6.627 0-12 5.373-12 12v56c0 6.627 5.373 12 12 12zm372 0v39.984c0 21.46 25.961 31.98 40.971 16.971l80-79.984c9.373-9.373 9.373-24.569 0-33.941l-80-79.981C409.943 24.021 384 34.582 384 56.019V96h-58.785a12.004 12.004 0 0 0-8.773 3.812L96 336H12c-6.627 0-12 5.373-12 12v56c0 6.627 5.373 12 12 12h110.785c3.326 0 6.503-1.381 8.773-3.812L352 176h32z"></path></svg>

After

Width:  |  Height:  |  Size: 904 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume-down" class="svg-inline--fa fa-volume-down fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M215.03 72.04L126.06 161H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V89.02c0-21.47-25.96-31.98-40.97-16.98zm123.2 108.08c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 229.28 336 242.62 336 257c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.87z"></path></svg>

After

Width:  |  Height:  |  Size: 679 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume-mute" class="svg-inline--fa fa-volume-mute fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM461.64 256l45.64-45.64c6.3-6.3 6.3-16.52 0-22.82l-22.82-22.82c-6.3-6.3-16.52-6.3-22.82 0L416 210.36l-45.64-45.64c-6.3-6.3-16.52-6.3-22.82 0l-22.82 22.82c-6.3 6.3-6.3 16.52 0 22.82L370.36 256l-45.63 45.63c-6.3 6.3-6.3 16.52 0 22.82l22.82 22.82c6.3 6.3 16.52 6.3 22.82 0L416 301.64l45.64 45.64c6.3 6.3 16.52 6.3 22.82 0l22.82-22.82c6.3-6.3 6.3-16.52 0-22.82L461.64 256z"></path></svg>

After

Width:  |  Height:  |  Size: 781 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="svg-inline--fa fa-volume fa-w-15 fa-2x"><path fill="currentColor" d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM480 256c0-63.53-32.06-121.94-85.77-156.24-11.19-7.14-26.03-3.82-33.12 7.46s-3.78 26.21 7.41 33.36C408.27 165.97 432 209.11 432 256s-23.73 90.03-63.48 115.42c-11.19 7.14-14.5 22.07-7.41 33.36 6.51 10.36 21.12 15.14 33.12 7.46C447.94 377.94 480 319.53 480 256zm-141.77-76.87c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 228.28 336 241.63 336 256c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.86z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 944 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

20
packages/renderer/src/auto-imports.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
// Generated by 'unplugin-auto-import'
// We suggest you to commit this file into source control
declare global {
const classNames: typeof import('classnames')['default']
const toast: typeof import('react-hot-toast')['toast']
const useCallback: typeof import('react')['useCallback']
const useContext: typeof import('react')['useContext']
const useEffect: typeof import('react')['useEffect']
const useInfiniteQuery: typeof import('react-query')['useInfiniteQuery']
const useMemo: typeof import('react')['useMemo']
const useMutation: typeof import('react-query')['useMutation']
const useNavigate: typeof import('react-router-dom')['useNavigate']
const useParams: typeof import('react-router-dom')['useParams']
const useQuery: typeof import('react-query')['useQuery']
const useReducer: typeof import('react')['useReducer']
const useRef: typeof import('react')['useRef']
const useSnapshot: typeof import('valtio')['useSnapshot']
const useState: typeof import('react')['useState']
}
export {}

View file

@ -0,0 +1,22 @@
const ArtistInline = ({
artists,
className,
}: {
artists: Artist[]
className?: string
}) => {
if (!artists) return <div></div>
return (
<div className={classNames('flex truncate', className)}>
{artists.map((artist, index) => (
<span key={artist.id}>
<span className="hover:underline">{artist.name}</span>
{index < artists.length - 1 ? ', ' : ''}&nbsp;
</span>
))}
</div>
)
}
export default ArtistInline

View file

@ -0,0 +1,53 @@
import { ReactNode } from 'react'
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
}) => {
const shape =
Array.isArray(children) && children.length === 1
? Shape.Square
: Shape.Default
return (
<button
onClick={onClick}
className={classNames(
'btn-pressed-animation flex cursor-default items-center rounded-lg text-lg font-semibold',
{
'px-4 py-1.5': shape === Shape.Default,
'px-3 py-1.5': shape === Shape.Square,
'bg-brand-100 dark:bg-brand-700': color === Color.Primary,
'text-brand-500 dark:text-white': iconColor === Color.Primary,
'bg-gray-100 dark:bg-gray-700': color === Color.Gray,
'text-gray-900 dark:text-gray-400': iconColor === Color.Gray,
'animate-pulse bg-gray-100 text-transparent dark:bg-gray-800':
isSkelton,
}
)}
>
{children}
</button>
)
}
export default Button

View file

@ -0,0 +1,55 @@
import SvgIcon from '@/components/SvgIcon'
const Cover = ({
isRounded,
imageUrl,
onClick,
}: {
imageUrl: string
isRounded?: boolean
onClick?: () => void
}) => {
const [isError, setIsError] = useState(false)
return (
<div onClick={onClick} className="group relative z-0">
{/* Neon shadow */}
<div
className={classNames(
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-xl bg-cover opacity-0 blur-lg filter transition duration-300 group-hover:opacity-60',
isRounded && 'rounded-full',
!isRounded && 'rounded-xl'
)}
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">
<SvgIcon name="music-note" className="h-1/2 w-1/2" />
</div>
) : (
<img
className={classNames(
'box-content aspect-square h-full w-full border border-black border-opacity-5 dark:border-white dark:border-opacity-[.03]',
isRounded && 'rounded-full',
!isRounded && 'rounded-xl'
)}
src={imageUrl}
onError={() => setIsError(true)}
/>
)}
{/* Play button */}
<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]">
<SvgIcon className="ml-1 h-4 w-4" name="play" />
</button>
</div>
</div>
)
}
export default Cover

View file

@ -0,0 +1,192 @@
import Cover from '@/components/Cover'
import Skeleton from '@/components/Skeleton'
import SvgIcon from '@/components/SvgIcon'
import { prefetchAlbum } from '@/hooks/useAlbum'
import { prefetchPlaylist } from '@/hooks/usePlaylist'
import { formatDate, resizeImage } from '@/utils/common'
export enum Subtitle {
COPYWRITER = 'copywriter',
CREATOR = 'creator',
TYPE_RELEASE_YEAR = 'type+releaseYear',
ARTIST = 'artist',
}
const Title = ({
title,
seeMoreLink,
}: {
title: string
seeMoreLink: string
}) => {
return (
<div className="flex items-baseline justify-between">
<div className="my-4 text-[28px] font-bold text-black dark:text-white">
{title}
</div>
{seeMoreLink && (
<div className="text-13px font-semibold text-gray-600 hover:underline">
See More
</div>
)}
</div>
)
}
const getSubtitleText = (
item: Album | Playlist | Artist,
subtitle: Subtitle
) => {
const nickname = 'creator' in item ? item.creator.nickname : 'someone'
const releaseYear =
'publishTime' in item
? formatDate(item.publishTime ?? 0, 'en', 'YYYY')
: 'unknown'
const types = {
playlist: 'playlist',
album: 'Album',
: 'Album',
Single: 'Single',
'EP/Single': 'EP',
EP: 'EP',
unknown: 'unknown',
}
const type = 'type' in item ? item.type || 'unknown' : 'unknown'
const artist = 'artist' in item ? item.artist.name : 'unknown'
const table = {
[Subtitle.CREATOR]: `by ${nickname}`,
[Subtitle.TYPE_RELEASE_YEAR]: `${types[type]} · ${releaseYear}`,
[Subtitle.ARTIST]: artist,
}
return table[subtitle] ?? item[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 = ({
title,
albums,
artists,
playlists,
subtitle = Subtitle.COPYWRITER,
seeMoreLink,
isSkeleton,
className,
}: {
title?: string
albums?: Album[]
artists?: Artist[]
playlists?: Playlist[]
subtitle?: Subtitle
seeMoreLink?: string
isSkeleton?: boolean
className?: string
}) => {
const renderItems = useMemo(() => {
if (isSkeleton) {
return new Array(10).fill({}) as Array<Album | Playlist | Artist>
}
return albums ?? playlists ?? artists ?? []
}, [albums, artists, isSkeleton, playlists])
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}`)
}
const prefetch = (id: number) => {
if (albums) prefetchAlbum({ id })
if (playlists) prefetchPlaylist({ id })
}
return (
<div>
{title && <Title title={title} seeMoreLink={seeMoreLink ?? ''} />}
<div
className={classNames(
'grid gap-x-[24px] gap-y-7',
className,
!className &&
'grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
)}
>
{renderItems.map((item, index) => (
<div
key={item.id ?? index}
onMouseOver={() => prefetch(item.id)}
className="grid gap-x-[24px] 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)}
/>
)}
{/* 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="line-clamp-2 leading-tight ">
{/* Playlist private icon */}
{(item as Playlist).privacy && (
<SvgIcon
name="lock"
className="mr-1 mb-1 inline-block h-3 w-3 text-gray-300"
/>
)}
<span
onClick={() => goTo(item.id)}
className="decoration-gray-600 decoration-2 hover:underline dark:text-white"
>
{item.name}
</span>
</span>
)}
</div>
{/* Subtitle */}
{isSkeleton ? (
<Skeleton className="w-3/5 translate-y-px text-[12px]">
PLACEHOLDER
</Skeleton>
) : (
<div className="flex text-[12px] text-gray-500 dark:text-gray-400">
<span>{getSubtitleText(item, subtitle)}</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}
export default CoverRow

View file

@ -0,0 +1,13 @@
@keyframes move {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animation {
animation: move 38s infinite;
animation-direction: alternate;
}

View file

@ -0,0 +1,33 @@
import SvgIcon from '@/components/SvgIcon'
import style from './DailyTracksCard.module.scss'
const DailyTracksCard = () => {
return (
<div className="relative h-[198px] cursor-pointer overflow-hidden rounded-2xl">
{/* Cover */}
<img
className={classNames(
'absolute top-0 left-0 w-full will-change-transform',
style.animation
)}
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]">
<SvgIcon name="play" className="ml-1 h-4 w-4" />
</button>
</div>
)
}
export default DailyTracksCard

View file

@ -0,0 +1,65 @@
import { average } from 'color.js'
import { colord } from 'colord'
import SvgIcon from '@/components/SvgIcon'
enum ACTION {
DISLIKE = 'dislike',
PLAY = 'play',
NEXT = 'next',
}
const FMCard = () => {
const coverUrl =
'https://p1.music.126.net/lEzPSOjusKaRXKXT3987lQ==/109951166035876388.jpg?param=512y512'
const [background, setBackground] = useState('')
useEffect(() => {
average(coverUrl, { amount: 1, format: 'hex', sample: 1 }).then(color => {
const to = colord(color as string)
.darken(0.15)
.rotate(-5)
.toHex()
setBackground(`linear-gradient(to bottom right, ${color}, ${to})`)
})
}, [coverUrl])
return (
<div
className="relative flex h-[198px] overflow-hidden rounded-2xl p-4"
style={{ background }}
>
<img className="rounded-lg shadow-2xl" src={coverUrl} />
<div className="ml-5 flex w-full flex-col justify-between text-white">
{/* Track info */}
<div>
<div className="text-xl font-semibold">How Can I Make It OK?</div>
<div className="opacity-75">Wolf Alice</div>
</div>
<div className="flex items-center justify-between">
{/* Actions */}
<div>
{Object.values(ACTION).map(action => (
<button
key={action}
className="btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-2 transition duration-200 after:bg-white/10"
>
<SvgIcon name={action} className="h-5 w-5" />
</button>
))}
</div>
{/* FM logo */}
<div className="right-4 bottom-5 flex text-white opacity-20">
<SvgIcon name="fm" className="mr-2 h-5 w-5" />
<span className="font-semibold">FM</span>
</div>
</div>
</div>
</div>
)
}
export default FMCard

View file

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

View file

@ -0,0 +1,18 @@
import Router from '@/components/Router'
import Topbar from '@/components/Topbar'
const Main = () => {
return (
<div
id="mainContainer"
className="relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]"
>
<Topbar />
<main id="main" className="mb-24 flex-grow px-8">
<Router />
</main>
</div>
)
}
export default Main

View file

@ -0,0 +1,162 @@
import { Fragment } from 'react'
import ArtistInline from '@/components/ArtistsInline'
import IconButton from '@/components/IconButton'
import Slider from '@/components/Slider'
import SvgIcon from '@/components/SvgIcon'
import { player } from '@/store'
import { resizeImage } from '@/utils/common'
import { State as PlayerState } from '@/utils/player'
const PlayingTrack = () => {
const navigate = useNavigate()
const snappedPlayer = useSnapshot(player)
const track = useMemo(() => snappedPlayer.track, [snappedPlayer.track])
const trackListSource = useMemo(
() => snappedPlayer.trackListSource,
[snappedPlayer.trackListSource]
)
const toAlbum = () => {
const id = track?.al?.id
if (id) navigate(`/album/${id}`)
}
const toTrackListSource = () => {
if (trackListSource?.type)
navigate(`/${trackListSource.type}/${trackListSource.id}`)
}
return (
<Fragment>
{track && (
<div className="flex items-center gap-3">
{track?.al?.picUrl && (
<img
onClick={toAlbum}
className="aspect-square h-full rounded-md shadow-md"
src={resizeImage(track?.al?.picUrl ?? '', 'sm')}
/>
)}
{!track?.al?.picUrl && (
<div
onClick={toAlbum}
className="flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm"
>
<SvgIcon className="h-6 w-6 text-gray-300" name="music-note" />
</div>
)}
<div className="flex flex-col justify-center leading-tight">
<div
onClick={toTrackListSource}
className="line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 hover:underline dark:text-white"
>
{track?.name}
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<ArtistInline artists={track?.ar ?? []} />
</div>
</div>
<IconButton>
<SvgIcon
className="h-4 w-4 text-black dark:text-white"
name="heart-outline"
/>
</IconButton>
</div>
)}
{!track && <div></div>}
</Fragment>
)
}
const MediaControls = () => {
const playerSnapshot = useSnapshot(player)
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return (
<div className="flex items-center justify-center gap-2 text-black dark:text-white">
<IconButton onClick={() => track && player.prevTrack()} disabled={!track}>
<SvgIcon className="h-4 w-4" name="previous" />
</IconButton>
<IconButton
onClick={() => track && player.playOrPause()}
disabled={!track}
className="rounded-2xl"
>
<SvgIcon
className="h-[1.5rem] w-[1.5rem] "
name={state === PlayerState.PLAYING ? 'pause' : 'play'}
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon className="h-4 w-4" name="next" />
</IconButton>
</div>
)
}
const Others = () => {
return (
<div className="flex items-center justify-end gap-2 pr-2 text-black dark:text-white">
<IconButton>
<SvgIcon className="h-4 w-4" name="playlist" />
</IconButton>
<IconButton>
<SvgIcon className="h-4 w-4" name="repeat" />
</IconButton>
<IconButton>
<SvgIcon className="h-4 w-4" name="shuffle" />
</IconButton>
<IconButton>
<SvgIcon className="h-4 w-4" name="volume" />
</IconButton>
<IconButton>
<SvgIcon className="h-4 w-4" name="chevron-up" />
</IconButton>
</div>
)
}
const Progress = () => {
const playerSnapshot = useSnapshot(player)
const progress = useMemo(
() => playerSnapshot.progress,
[playerSnapshot.progress]
)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return (
<div className="absolute w-screen">
{track && (
<Slider
min={0}
max={(track.dt ?? 0) / 1000}
value={progress}
onChange={value => {
player.progress = value
}}
onlyCallOnChangeAfterDragEnded={true}
/>
)}
{!track && (
<div className="absolute h-[2px] w-full bg-gray-500 bg-opacity-10"></div>
)}
</div>
)
}
const Player = () => {
return (
<div className="fixed bottom-0 left-0 right-0 grid h-16 grid-cols-3 grid-rows-1 bg-white bg-opacity-[.86] py-2.5 px-5 backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222]">
<Progress />
<PlayingTrack />
<MediaControls />
<Others />
</div>
)
}
export default Player

View file

@ -0,0 +1,33 @@
import { Fragment } from 'react'
import type { RouteObject } from 'react-router-dom'
import { useRoutes } from 'react-router-dom'
import Album from '@/pages/Album'
import Home from '@/pages/Home'
import Login from '@/pages/Login'
import Playlist from '@/pages/Playlist'
const routes: RouteObject[] = [
{
path: '/',
element: <Home />,
},
{
path: '/login',
element: <Login />,
},
{
path: '/playlist/:id',
element: <Playlist />,
},
{
path: '/album/:id',
element: <Album />,
},
]
const router = () => {
const element = useRoutes(routes)
return <Fragment>{element}</Fragment>
}
export default router

View file

@ -0,0 +1,100 @@
import { NavLink } from 'react-router-dom'
import SvgIcon from '@/components/SvgIcon'
import { prefetchPlaylist } from '@/hooks/usePlaylist'
import useUser from '@/hooks/useUser'
import useUserPlaylists from '@/hooks/useUserPlaylists'
interface Tab {
name: string
icon?: string
route: string
}
interface PrimaryTab extends Tab {
icon: string
}
const primaryTabs: PrimaryTab[] = [
{
name: 'Home',
icon: 'home',
route: '/',
},
{
name: 'Explore',
icon: 'compass',
route: '/explore',
},
{
name: 'Library',
icon: 'music-library',
route: '/library',
},
]
const PrimaryTabs = () => {
return (
<div>
<div className="app-region-drag h-14"></div>
{primaryTabs.map(tab => (
<NavLink
key={tab.route}
to={tab.route}
className={({ isActive }: { isActive: boolean }) =>
classNames(
'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20',
!isActive && 'text-gray-700 dark:text-white',
isActive && 'text-brand-500 '
)
}
>
<SvgIcon className="mr-3 h-6 w-6" name={tab.icon} />
<span className="font-semibold">{tab.name}</span>
</NavLink>
))}
<div className="mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-20"></div>
</div>
)
}
const Playlists = () => {
const { data: user } = useUser()
const { data: playlists } = useUserPlaylists({
uid: user?.account?.id ?? 0,
offset: 0,
})
return (
<div className="overflow-auto pb-[4.6rem]">
{playlists?.playlist?.map(playlist => (
<NavLink
key={playlist.id}
to={`/playlist/${playlist.id}`}
onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
className={({ isActive }: { isActive: boolean }) =>
classNames(
'btn-hover-animation line-clamp-1 my-px mx-3 flex cursor-default items-center rounded-lg px-3 py-[0.38rem] text-sm text-black opacity-70 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/20',
isActive && 'after:scale-100 after:opacity-100'
)
}
>
<span className="line-clamp-1">{playlist.name}</span>
</NavLink>
))}
</div>
)
}
const Sidebar = () => {
return (
<div
id="sidebar"
className="grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:bg-black dark:bg-opacity-70"
>
<PrimaryTabs />
<Playlists />
</div>
)
}
export default Sidebar

View file

@ -0,0 +1,22 @@
import { ReactNode } from 'react'
const Skeleton = ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => {
return (
<div
className={classNames(
'relative animate-pulse bg-gray-100 text-transparent dark:bg-gray-800',
className
)}
>
{children}
</div>
)
}
export default Skeleton

View file

@ -0,0 +1,157 @@
const Slider = ({
value,
min,
max,
onChange,
onlyCallOnChangeAfterDragEnded = false,
orientation = 'horizontal',
}: {
value: number
min: number
max: number
onChange: (value: number) => void
onlyCallOnChangeAfterDragEnded?: boolean
orientation?: 'horizontal' | 'vertical'
}) => {
console.log('[Slider.tsx] rendering')
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(
(val: number) => {
if (!sliderRef?.current) return 0
const sliderWidth = sliderRef.current.getBoundingClientRect().width
const newValue = (val / sliderWidth) * max
if (newValue < min) return min
if (newValue > max) return max
return newValue
},
[sliderRef, max, min]
)
/**
* Handle slider click event
*/
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
onChange(getNewValue(e.clientX))
},
[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(e.clientX)
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) {
console.log('draggingValue', draggingValue)
onChange(draggingValue)
}
}
document.addEventListener('pointerup', handlePointerUp)
return () => {
document.removeEventListener('pointerup', handlePointerUp)
}
}, [
isDragging,
setIsDragging,
onlyCallOnChangeAfterDragEnded,
draggingValue,
onChange,
])
/**
* Track and thumb styles
*/
const usedTrackStyle = useMemo(
() => ({ width: `${(memoedValue / max) * 100}%` }),
[max, memoedValue]
)
const thumbStyle = useMemo(
() => ({
left: `${(memoedValue / max) * 100}%`,
transform: `translateX(-10px)`,
}),
[max, memoedValue]
)
return (
<div
className="group flex h-2 -translate-y-[3px] items-center"
ref={sliderRef}
onClick={handleClick}
>
{/* Track */}
<div className="absolute h-[2px] w-full bg-gray-500 bg-opacity-10"></div>
{/* Passed track */}
<div
className={classNames(
'absolute h-[2px] group-hover:bg-brand-500',
isDragging ? 'bg-brand-500' : 'bg-gray-500 dark:bg-gray-400'
)}
style={usedTrackStyle}
></div>
{/* Thumb */}
<div
className={classNames(
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity ',
isDragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
style={thumbStyle}
onClick={e => e.stopPropagation()}
onPointerDown={handlePointerDown}
>
<div className="absolute h-2 w-2 rounded-full bg-brand-500"></div>
</div>
</div>
)
}
export default Slider

View file

@ -0,0 +1,61 @@
import style from './Slider.module.scss'
const Slider = () => {
const [value, setValue] = useState(50)
const thumbStyle = useMemo(
() => ({
left: `${value}%`,
transform: `translate(-${value}%, -9px)`,
}),
[value]
)
const usedTrackStyle = useMemo(
() => ({
width: `${value}%`,
}),
[value]
)
const onDragging = false
const [isHover, setIsHover] = useState(false)
return (
<div
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<div className="absolute h-[2px] w-full bg-gray-500 bg-opacity-10"></div>
<div
className={classNames(
'absolute h-[2px]',
onDragging || isHover ? 'bg-brand-500' : 'bg-gray-500'
)}
style={usedTrackStyle}
></div>
<div
className={classNames(
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20',
!onDragging && !isHover && 'opacity-0'
)}
style={thumbStyle}
>
<div className="absolute h-2 w-2 rounded-full bg-brand-500"></div>
</div>
<input
type="range"
min="0"
max="100"
value={value}
onChange={e => setValue(Number(e.target.value))}
className="absolute h-[2px] w-full appearance-none opacity-0"
/>
</div>
)
}
export default Slider

View file

@ -0,0 +1,10 @@
const SvgIcon = ({ name, className }: { name: string; className?: string }) => {
const symbolId = `#icon-${name}`
return (
<svg aria-hidden="true" className={className}>
<use href={symbolId} fill="currentColor" />
</svg>
)
}
export default SvgIcon

View file

@ -0,0 +1,99 @@
import SvgIcon from '@/components/SvgIcon'
import useScroll from '@/hooks/useScroll'
import useUser from '@/hooks/useUser'
import { resizeImage } from '@/utils/common'
const NavigationButtons = () => {
const navigate = useNavigate()
enum ACTION {
BACK = 'back',
FORWARD = 'forward',
}
const handleNavigate = (action: ACTION) => {
if (action === ACTION.BACK) navigate(-1)
if (action === ACTION.FORWARD) navigate(1)
}
return (
<div className="flex gap-1">
{[ACTION.BACK, ACTION.FORWARD].map(action => (
<div
onClick={() => handleNavigate(action)}
key={action}
className="app-region-no-drag btn-hover-animation rounded-lg p-3 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200"
>
<SvgIcon className="h-4 w-4" name={action} />
</div>
))}
</div>
)
}
const SearchBox = () => {
return (
<div className="app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 px-3 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5">
<SvgIcon
className="mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200"
name="search"
/>
<input
type="text"
className="w-full bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400"
placeholder="Search"
/>
</div>
)
}
const Settings = () => {
return (
<div className="app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200">
<SvgIcon className="h-5 w-5" name="settings" />
</div>
)
}
const Avatar = () => {
const navigate = useNavigate()
const { data: user } = useUser()
return (
<img
src={user?.profile?.avatarUrl}
onClick={() => navigate('/login')}
className="app-region-no-drag h-9 w-9 rounded-full bg-gray-100 dark:bg-gray-700"
/>
)
}
const Topbar = () => {
/**
* Show topbar background when scroll down
*/
const [mainContainer, setMainContainer] = useState<HTMLElement | null>(null)
const scroll = useScroll(mainContainer, { throttle: 100 })
useEffect(() => {
setMainContainer(document.getElementById('mainContainer'))
}, [setMainContainer])
return (
<div
className={classNames(
'app-region-drag sticky top-0 z-30 flex h-16 min-h-[4rem] w-full cursor-default items-center justify-between px-8 transition duration-300',
!scroll.arrivedState.top &&
'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222]'
)}
>
<div className="flex gap-2">
<NavigationButtons />
<SearchBox />
</div>
<div className="flex items-center gap-3">
<Settings />
<Avatar />
</div>
</div>
)
}
export default Topbar

View file

@ -0,0 +1,247 @@
import { memo } from 'react'
import ArtistInline from '@/components/ArtistsInline'
import Skeleton from '@/components/Skeleton'
import SvgIcon from '@/components/SvgIcon'
import useUser from '@/hooks/useUser'
import useUserLikedSongsIDs from '@/hooks/useUserLikedSongsIDs'
import { player } from '@/store'
import { formatDuration } from '@/utils/common'
import { State as PlayerState } from '@/utils/player'
const enableRenderLog = true
const PlayOrPauseButtonInTrack = memo(
({ isHighlight, trackID }: { isHighlight: boolean; trackID: number }) => {
if (enableRenderLog)
console.debug(`Rendering TracksAlbum.tsx PlayOrPauseButtonInTrack`)
const playerSnapshot = useSnapshot(player)
const isPlaying = useMemo(
() => playerSnapshot.state === PlayerState.PLAYING,
[playerSnapshot.state]
)
const onClick = () => {
isHighlight ? player.playOrPause() : player.playTrack(trackID)
}
return (
<div
onClick={onClick}
className={classNames(
'self-center',
!isHighlight && 'hidden group-hover:block'
)}
>
<SvgIcon
className="h-3.5 w-3.5 text-brand-500"
name={isPlaying && isHighlight ? 'pause' : 'play'}
/>
</div>
)
}
)
PlayOrPauseButtonInTrack.displayName = 'PlayOrPauseButtonInTrack'
const Track = memo(
({
track,
isLiked = false,
isSkeleton = false,
isHighlight = false,
onClick,
}: {
track: Track
isLiked?: boolean
isSkeleton?: boolean
isHighlight?: boolean
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
}) => {
if (enableRenderLog)
console.debug(`Rendering TracksAlbum.tsx Track ${track.name}`)
return (
<div
onClick={e => onClick(e, track.id)}
className={classNames(
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl',
'grid-cols-12 py-2.5 px-4',
!isSkeleton && {
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/[.08]':
!isHighlight,
'bg-brand-100 dark:bg-gray-800': isHighlight,
}
)}
>
{/* Track name and number */}
<div className="col-span-6 grid grid-cols-[2rem_auto] pr-8">
{/* Track number */}
{isSkeleton ? (
<Skeleton className="h-6.5 w-6.5 -translate-x-1"></Skeleton>
) : (
!isHighlight && (
<div
className={classNames(
'self-center group-hover:hidden',
isHighlight && 'text-brand-500',
!isHighlight && 'text-gray-500 dark:text-gray-400'
)}
>
{track.no}
</div>
)
)}
{/* Play or pause button for playing track */}
{!isSkeleton && (
<PlayOrPauseButtonInTrack
isHighlight={isHighlight}
trackID={track.id}
/>
)}
{/* Track name */}
<div className="flex">
{isSkeleton ? (
<Skeleton className="text-lg">
PLACEHOLDER123456789012345
</Skeleton>
) : (
<div
className={classNames(
'line-clamp-1 break-all text-lg font-semibold',
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
)}
>
{track.name}
</div>
)}
</div>
</div>
{/* Artists */}
<div className="col-span-4 flex items-center">
{isSkeleton ? (
<Skeleton>PLACEHOLDER1234</Skeleton>
) : (
<ArtistInline
className={
isHighlight
? 'text-brand-500'
: 'text-gray-600 dark:text-gray-400'
}
artists={track.ar}
/>
)}
</div>
{/* Actions & Track duration */}
<div className="col-span-2 flex items-center justify-end">
{/* Like button */}
{!isSkeleton && (
<button
className={classNames(
'mr-5 cursor-default transition duration-300 hover:scale-[1.2]',
isLiked
? 'text-brand-500 opacity-100'
: 'text-gray-600 opacity-0 dark:text-gray-400',
!isSkeleton && 'group-hover:opacity-100'
)}
>
<SvgIcon
name={isLiked ? 'heart' : 'heart-outline'}
className="h-4 w-4 "
/>
</button>
)}
{/* Track duration */}
{isSkeleton ? (
<Skeleton>0:00</Skeleton>
) : (
<div
className={classNames(
'min-w-[2.5rem] text-right',
isHighlight
? 'text-brand-500'
: 'text-gray-600 dark:text-gray-400'
)}
>
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
</div>
)}
</div>
</div>
)
}
)
Track.displayName = 'Track'
const TracksAlbum = ({
tracks,
isSkeleton = false,
onTrackDoubleClick,
}: {
tracks: Track[]
isSkeleton?: boolean
onTrackDoubleClick?: (trackID: number) => void
}) => {
// Fake data when isSkeleton is true
const skeletonTracks: Track[] = new Array(1).fill({})
// Liked songs ids
const { data: user } = useUser()
const { data: userLikedSongs } = useUserLikedSongsIDs({
uid: user?.account?.id ?? 0,
})
const handleClick = useCallback(
(e: React.MouseEvent<HTMLElement>, trackID: number) => {
if (e.detail === 2) onTrackDoubleClick?.(trackID)
},
[onTrackDoubleClick]
)
const playerSnapshot = useSnapshot(player)
const playingTrack = useMemo(
() => playerSnapshot.track,
[playerSnapshot.track]
)
return (
<div className="grid w-full">
{/* Tracks table header */}
<div className="mx-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500">
<div className="col-span-6 grid grid-cols-[2rem_auto]">
<div>#</div>
<div>TITLE</div>
</div>
<div className="col-span-4">ARTIST</div>
<div className="col-span-2 justify-self-end">TIME</div>
</div>
{/* Tracks */}
{isSkeleton
? skeletonTracks.map((track, index) => (
<Track
key={index}
track={track}
onClick={() => null}
isSkeleton={true}
/>
))
: tracks.map(track => (
<Track
key={track.id}
track={track}
onClick={handleClick}
isLiked={userLikedSongs?.ids?.includes(track.id) ?? false}
isSkeleton={false}
isHighlight={track.id === playingTrack?.id}
/>
))}
</div>
)
}
export default TracksAlbum

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