refactor: version 2.0 (React)
136
packages/main/index.ts
Normal 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
|
|
@ -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
|
|
@ -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}`)
|
||||
})
|
||||
32
packages/main/vite.config.ts
Normal 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
|
|
@ -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
|
||||
}
|
||||
54
packages/preload/loading.ts
Normal 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
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
30
packages/preload/vite.config.ts
Normal 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 || {}),
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
17
packages/renderer/index.html
Normal 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>
|
||||
BIN
packages/renderer/public/fonts/Barlow-Black.ttf
Executable file
BIN
packages/renderer/public/fonts/Barlow-Black.woff2
Normal file
BIN
packages/renderer/public/fonts/Barlow-Bold.ttf
Executable file
BIN
packages/renderer/public/fonts/Barlow-Bold.woff2
Normal file
BIN
packages/renderer/public/fonts/Barlow-ExtraBold.ttf
Executable file
BIN
packages/renderer/public/fonts/Barlow-ExtraBold.woff2
Normal file
BIN
packages/renderer/public/fonts/Barlow-Medium.ttf
Executable file
BIN
packages/renderer/public/fonts/Barlow-Medium.woff2
Normal file
BIN
packages/renderer/public/fonts/Barlow-Regular.ttf
Executable file
BIN
packages/renderer/public/fonts/Barlow-Regular.woff2
Normal file
BIN
packages/renderer/public/fonts/Barlow-SemiBold.ttf
Executable file
BIN
packages/renderer/public/fonts/Barlow-SemiBold.woff2
Normal file
BIN
packages/renderer/public/fonts/Manrope-Bold.ttf
Executable file
BIN
packages/renderer/public/fonts/Manrope-ExtraBold.ttf
Executable file
BIN
packages/renderer/public/fonts/Manrope-ExtraLight.ttf
Executable file
BIN
packages/renderer/public/fonts/Manrope-Light.ttf
Executable file
BIN
packages/renderer/public/fonts/Manrope-Medium.ttf
Executable file
BIN
packages/renderer/public/fonts/Manrope-Regular.ttf
Executable file
BIN
packages/renderer/public/fonts/Manrope-SemiBold.ttf
Executable file
BIN
packages/renderer/public/fonts/PlusJakartaSans-Bold.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-BoldItalic.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-ExtraBold.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-ExtraLight.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-Italic.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-Light.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-LightItalic.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-Medium.woff2
Normal file
BIN
packages/renderer/public/fonts/PlusJakartaSans-Regular.woff2
Normal file
36
packages/renderer/src/App.tsx
Normal 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
|
||||
29
packages/renderer/src/api/album.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
51
packages/renderer/src/api/artist.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
115
packages/renderer/src/api/auth.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
57
packages/renderer/src/api/playlist.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
100
packages/renderer/src/api/search.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
73
packages/renderer/src/api/track.ts
Normal 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 999000,320000 = 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,
|
||||
})
|
||||
}
|
||||
271
packages/renderer/src/api/user.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
1
packages/renderer/src/assets/icons/back.svg
Normal 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 |
1
packages/renderer/src/assets/icons/chevron-up.svg
Normal 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 |
1
packages/renderer/src/assets/icons/compass.svg
Normal 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 |
1
packages/renderer/src/assets/icons/dislike.svg
Normal 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 |
4
packages/renderer/src/assets/icons/dj.svg
Normal 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 |
4
packages/renderer/src/assets/icons/email.svg
Normal 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 |
3
packages/renderer/src/assets/icons/eye-off.svg
Normal 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 |
4
packages/renderer/src/assets/icons/eye.svg
Normal 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 |
1
packages/renderer/src/assets/icons/fm.svg
Normal 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 |
1
packages/renderer/src/assets/icons/forward.svg
Normal 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 |
1
packages/renderer/src/assets/icons/heart-outline.svg
Normal 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 |
1
packages/renderer/src/assets/icons/heart.svg
Normal 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 |
1
packages/renderer/src/assets/icons/home.svg
Normal 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 |
1
packages/renderer/src/assets/icons/lock.svg
Normal 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 |
1
packages/renderer/src/assets/icons/more.svg
Normal 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 |
1
packages/renderer/src/assets/icons/music-library.svg
Normal 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 |
4
packages/renderer/src/assets/icons/music-note.svg
Normal 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 |
1
packages/renderer/src/assets/icons/next.svg
Normal 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 |
1
packages/renderer/src/assets/icons/pause.svg
Normal 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 |
3
packages/renderer/src/assets/icons/phone.svg
Normal 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 |
1
packages/renderer/src/assets/icons/play.svg
Normal 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 |
1
packages/renderer/src/assets/icons/playlist.svg
Normal 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 |
1
packages/renderer/src/assets/icons/previous.svg
Normal 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 |
4
packages/renderer/src/assets/icons/qrcode.svg
Normal 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 |
1
packages/renderer/src/assets/icons/repeat-1.svg
Normal 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>
|
||||
1
packages/renderer/src/assets/icons/repeat.svg
Normal 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>
|
||||
1
packages/renderer/src/assets/icons/search.svg
Normal 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 |
4
packages/renderer/src/assets/icons/settings.svg
Normal 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 |
1
packages/renderer/src/assets/icons/shuffle.svg
Normal 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 |
1
packages/renderer/src/assets/icons/volume-half.svg
Normal 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 |
1
packages/renderer/src/assets/icons/volume-mute.svg
Normal 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 |
1
packages/renderer/src/assets/icons/volume.svg
Normal 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 |
3
packages/renderer/src/assets/icons/x.svg
Normal 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
|
|
@ -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 {}
|
||||
22
packages/renderer/src/components/ArtistsInline.tsx
Normal 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 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistInline
|
||||
53
packages/renderer/src/components/Button.tsx
Normal 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
|
||||
55
packages/renderer/src/components/Cover.tsx
Normal 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
|
||||
192
packages/renderer/src/components/CoverRow.tsx
Normal 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
|
||||
13
packages/renderer/src/components/DailyTracksCard.module.scss
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@keyframes move {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animation {
|
||||
animation: move 38s infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
33
packages/renderer/src/components/DailyTracksCard.tsx
Normal 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
|
||||
65
packages/renderer/src/components/FMCard.tsx
Normal 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
|
||||
30
packages/renderer/src/components/IconButton.tsx
Normal 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
|
||||
18
packages/renderer/src/components/Main.tsx
Normal 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
|
||||
162
packages/renderer/src/components/Player.tsx
Normal 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
|
||||
33
packages/renderer/src/components/Router.tsx
Normal 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
|
||||
100
packages/renderer/src/components/Sidebar.tsx
Normal 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
|
||||
22
packages/renderer/src/components/Skeleton.tsx
Normal 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
|
||||
157
packages/renderer/src/components/Slider.tsx
Normal 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
|
||||
61
packages/renderer/src/components/Slider2.tsx
Normal 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
|
||||
10
packages/renderer/src/components/SvgIcon.tsx
Normal 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
|
||||
99
packages/renderer/src/components/Topbar.tsx
Normal 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
|
||||
247
packages/renderer/src/components/TracksAlbum.tsx
Normal 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
|
||||