feat: updates

This commit is contained in:
qier222 2022-08-22 16:51:23 +08:00
parent ebebf2a733
commit a1b0bcf4d3
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
68 changed files with 4776 additions and 5559 deletions

View file

@ -17,6 +17,7 @@
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
"build:web": "turbo run build:web",
"pack": "turbo run build pack",
"pack:test": "turbo run pack:test",
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
@ -25,9 +26,9 @@
},
"devDependencies": {
"cross-env": "^7.0.3",
"eslint": "^8.17.0",
"prettier": "^2.6.2",
"turbo": "^1.2.16",
"typescript": "^4.7.3"
"eslint": "^8.21.0",
"prettier": "^2.7.1",
"turbo": "^1.4.2",
"typescript": "^4.7.4"
}
}

View file

@ -3,20 +3,21 @@
* @see https://www.electron.build/configuration/configuration
*/
const pkg = require('../../package.json')
const pkg = require('./package.json')
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
module.exports = {
appId: 'com.qier222.yesplaymusic.alpha',
productName: pkg.productName,
copyright: 'Copyright © 2022 qier222',
asar: false,
asar: true,
directories: {
output: 'release',
buildResources: 'build',
},
npmRebuild: false,
buildDependenciesFromSource: true,
electronVersion: '19.0.3',
electronVersion,
publish: [
{
provider: 'github',
@ -32,14 +33,14 @@ module.exports = {
target: 'nsis',
arch: ['x64'],
},
{
target: 'nsis',
arch: ['arm64'],
},
{
target: 'portable',
arch: ['x64'],
},
// {
// target: 'nsis',
// arch: ['arm64'],
// },
// {
// target: 'portable',
// arch: ['x64'],
// },
],
publisherName: 'qier222',
icon: 'build/icons/icon.ico',
@ -58,7 +59,11 @@ module.exports = {
target: [
{
target: 'dmg',
arch: ['x64', 'arm64', 'universal'],
arch: [
'x64',
'arm64',
// 'universal'
],
},
],
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
@ -72,28 +77,32 @@ module.exports = {
target: [
{
target: 'deb',
arch: ['x64', 'arm64', 'armv7l'],
arch: [
'x64',
// 'arm64',
// 'armv7l'
],
},
{
target: 'AppImage',
arch: ['x64'],
},
{
target: 'snap',
arch: ['x64'],
},
{
target: 'pacman',
arch: ['x64'],
},
{
target: 'rpm',
arch: ['x64'],
},
{
target: 'tar.gz',
arch: ['x64'],
},
// {
// target: 'snap',
// arch: ['x64'],
// },
// {
// target: 'pacman',
// arch: ['x64'],
// },
// {
// target: 'rpm',
// arch: ['x64'],
// },
// {
// target: 'tar.gz',
// arch: ['x64'],
// },
],
artifactName: '${productName}-${version}-${os}.${ext}',
category: 'Music',
@ -101,7 +110,7 @@ module.exports = {
},
files: [
'!**/*.ts',
// '!**/node_modules/better-sqlite3/{bin,build,deps}/**',
'!**/node_modules/better-sqlite3/{bin,build,deps}/**',
'!**/node_modules/*/{*.MD,*.md,README,readme}',
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
'!**/node_modules/*.d.ts',

View file

@ -95,15 +95,19 @@ class DB {
this.initTables()
this.migrate()
log.info('[db] Database initialized')
log.info('[db] Database initialized.')
}
initTables() {
log.info('[db] Initializing database tables...')
const init = readSqlFile('init.sql')
this.sqlite.exec(init)
log.info('[db] Database tables initialized.')
}
migrate() {
log.info('[db] Migrating database..')
const key = 'appVersion'
const appVersion = this.find(Tables.AppData, key)
const updateAppVersionInDB = () => {
@ -129,6 +133,8 @@ class DB {
})
updateAppVersionInDB()
log.info('[db] Database migrated.')
}
find<T extends TableNames>(

View file

@ -17,7 +17,7 @@ import { createTaskbar, Thumbar } from './windowsTaskbar'
import { createMenu } from './menu'
import { isDev, isWindows, isLinux, isMac } from './utils'
import store from './store'
import Airplay from './airplay'
// import Airplay from './airplay'
class Main {
win: BrowserWindow | null = null
@ -26,7 +26,6 @@ class Main {
constructor() {
log.info('[index] Main process start')
// Disable GPU Acceleration for Windows 7
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
@ -49,7 +48,7 @@ class Main {
this.createThumbar()
initIpcMain(this.win, this.tray, this.thumbar, store)
this.initDevTools()
new Airplay(this.win)
// new Airplay(this.win)
})
}
@ -92,7 +91,8 @@ class Main {
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
trafficLightPosition: { x: 24, y: 24 },
frame: false,
transparent: true,
backgroundColor: '#000',
show: false,
}
if (store.get('window')) {
options.x = store.get('window.x')
@ -110,6 +110,11 @@ class Main {
return { action: 'deny' }
})
// 减少显示空白窗口的时间
this.win.once('ready-to-show', () => {
this.win && this.win.show()
})
this.disableCORS()
}

View file

@ -16,4 +16,4 @@ if (process.env.PORTABLE_EXECUTABLE_DIR) {
app.setPath('appData', portableUserDataPath)
}
log.info(`[index] userData path: ${app.getPath('userData')}`)
log.info(`[preload] userData path: ${app.getPath('userData')}`)

View file

@ -1,13 +1,15 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { IpcChannels } from '@/shared/IpcChannels'
import { isLinux, isMac, isWindows } from './utils'
import { isLinux, isMac, isProd, isWindows } from './utils'
const { contextBridge, ipcRenderer } = require('electron')
const log = require('electron-log')
if (isProd) {
const log = require('electron-log')
log.transports.file.level = 'info'
log.transports.ipc.level = false
log.variables.process = 'renderer'
contextBridge.exposeInMainWorld('log', log)
}
contextBridge.exposeInMainWorld('ipcRenderer', {
sendSync: ipcRenderer.sendSync,

View file

@ -333,7 +333,7 @@ class Server {
}
listen() {
this.app.listen(this.port, '0.0.0.0', () => {
this.app.listen(this.port, '127.0.0.1', () => {
log.info(`[server] API server listening on port ${this.port}`)
})
}

View file

@ -10,6 +10,7 @@
"dev": "node scripts/build.main.mjs --watch",
"build": "node scripts/build.main.mjs",
"pack": "electron-builder build -c .electron-builder.config.js",
"pack:test": "electron-builder build -c .electron-builder.config.js --publish never --mac --dir --arm64",
"test:types": "tsc --noEmit --project ./tsconfig.json",
"lint": "eslint --ext .ts,.js ./",
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'",
@ -24,49 +25,48 @@
"@sentry/electron": "^3.0.7",
"@unblockneteasemusic/rust-napi": "^0.3.0",
"NeteaseCloudMusicApi": "^4.6.7",
"better-sqlite3": "7.5.1",
"better-sqlite3": "7.6.2",
"change-case": "^4.1.2",
"chromecast-api": "^0.4.0",
"compare-versions": "^4.1.3",
"connect-history-api-fallback": "^2.0.0",
"cookie-parser": "^1.4.6",
"electron-log": "^4.4.8",
"electron-store": "^8.0.2",
"electron-store": "^8.1.0",
"express": "^4.18.1",
"fast-folder-size": "^1.7.0",
"m3u8-parser": "^4.7.1",
"pretty-bytes": "^6.0.0",
"zx": "^7.0.7"
"zx": "^7.0.8"
},
"devDependencies": {
"@electron/universal": "1.2.1",
"@types/better-sqlite3": "^7.5.0",
"@electron/universal": "1.3.0",
"@types/better-sqlite3": "^7.6.0",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13",
"@types/express-fileupload": "^1.2.2",
"@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7",
"@vitejs/plugin-react": "^1.3.1",
"@vitest/ui": "^0.12.10",
"@types/express-fileupload": "^1.2.3",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"@vitejs/plugin-react": "^2.0.0",
"@vitest/ui": "^0.20.3",
"axios": "^0.27.2",
"cross-env": "^7.0.3",
"dotenv": "^16.0.0",
"electron": "^19.0.8",
"electron-builder": "23.3.1",
"electron": "^18.3.6",
"electron-builder": "23.3.3",
"electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.8",
"electron-releases": "^3.1072.0",
"esbuild": "^0.14.49",
"electron-rebuild": "^3.2.9",
"electron-releases": "^3.1091.0",
"esbuild": "^0.14.53",
"eslint": "*",
"express-fileupload": "^1.4.0",
"minimist": "^1.2.6",
"music-metadata": "^7.12.4",
"music-metadata": "^7.12.5",
"open-cli": "^7.0.1",
"ora": "^6.1.2",
"picocolors": "^1.0.0",
"prettier": "*",
"typescript": "*",
"vitest": "^0.12.10",
"vitest": "^0.20.3",
"wait-on": "^6.0.1"
},
"resolutions": {

View file

@ -21,12 +21,22 @@ const betterSqlite3Version = pkg.dependencies['better-sqlite3'].replaceAll(
const electronModuleVersion = releases.find(r =>
r.version.includes(electronVersion)
)?.deps?.modules
if (!electronModuleVersion) {
console.error(
pc.red('Can not find electron module version in electron-releases')
)
process.exit(1)
}
const argv = minimist(process.argv.slice(2))
const projectDir = path.resolve(process.cwd(), '../../')
const distDir = `${projectDir}/packages/desktop/dist/binary`
console.log(pc.cyan(`projectDir=${projectDir}`))
console.log(pc.cyan(`distDir=${distDir}`))
if (!fs.existsSync(`${projectDir}/packages/desktop/dist/binary`)) {
fs.mkdirSync(`${projectDir}/packages/desktop/dist/binary`, {
if (!fs.existsSync(distDir)) {
console.log(pc.cyan(`Creating dist/binary directory: ${distDir}`))
fs.mkdirSync(distDir, {
recursive: true,
})
}
@ -37,12 +47,12 @@ const download = async arch => {
console.log(pc.red('No electron module version found! Skip download.'))
return false
}
const dir = `${projectDir}/tmp/better-sqlite3`
const tmpDir = `${projectDir}/tmp/better-sqlite3`
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
const zipFileName = `${fileName}.tar.gz`
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, {
recursive: true,
})
}
@ -53,7 +63,7 @@ const download = async arch => {
url,
responseType: 'stream',
}).then(response => {
response.data.pipe(fs.createWriteStream(`${dir}/${zipFileName}`))
response.data.pipe(fs.createWriteStream(`${tmpDir}/${zipFileName}`))
return true
})
} catch (e) {
@ -62,7 +72,7 @@ const download = async arch => {
}
try {
execSync(`tar -xvzf ${dir}/${zipFileName} -C ${dir}`)
execSync(`tar -xvzf ${tmpDir}/${zipFileName} -C ${tmpDir}`)
} catch (e) {
console.log(pc.red('Extract failed! Skip extract.'))
return false
@ -70,8 +80,8 @@ const download = async arch => {
try {
fs.copyFileSync(
`${dir}/build/Release/better_sqlite3.node`,
`${projectDir}/packages/desktop/dist/binary/better_sqlite3_${arch}.node`
`${tmpDir}/build/Release/better_sqlite3.node`,
`${distDir}/better_sqlite3_${arch}.node`
)
} catch (e) {
console.log(pc.red('Copy failed! Skip copy.', e))
@ -79,7 +89,7 @@ const download = async arch => {
}
try {
fs.rmSync(`${dir}/build`, { recursive: true, force: true })
fs.rmSync(`${tmpDir}/build`, { recursive: true, force: true })
} catch (e) {
console.log(pc.red('Delete failed! Skip delete.'))
return false
@ -103,10 +113,10 @@ const build = async arch => {
})
.then(() => {
console.info('Build succeeded')
fs.copyFileSync(
`${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`,
`${projectDir}/packages/desktop/dist/binary/better_sqlite3_${arch}.node`
)
const from = `${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`
const to = `${distDir}/better_sqlite3_${arch}.node`
console.info(`copy ${from} to ${to}`)
fs.copyFileSync(from, to)
})
.catch(e => {
console.error(pc.red('Build failed!'))

View file

@ -1,6 +1,7 @@
export enum SearchApiNames {
Search = 'search',
MultiMatchSearch = 'multiMatchSearch',
FetchSearchSuggestions = 'fetchSearchSuggestions',
}
// 搜索
@ -80,3 +81,19 @@ export interface MultiMatchSearchResponse {
orders: Array<'artist' | 'album'>
}
}
// 搜索建议
export interface FetchSearchSuggestionsParams {
keywords: string
type?: 'mobile'
}
export interface FetchSearchSuggestionsResponse {
code: number
result: {
albums?: Album[]
artists?: Artist[]
playlists?: Playlist[]
songs?: Track[]
order: Array<'songs' | 'artists' | 'albums' | 'playlists'>
}
}

View file

@ -112,6 +112,7 @@ declare interface Track {
v: number
version: number
tns: (string | null)[]
duration?: number
}
declare interface Artist {
alias: unknown[]

View file

@ -1,4 +1,3 @@
import { Toaster } from 'react-hot-toast'
import TitleBar from '@/web/components/TitleBar'
import IpcRendererReact from '@/web/IpcRendererReact'
import Layout from '@/web/components/New/Layout'
@ -7,20 +6,18 @@ import ErrorBoundary from '@/web/components/New/ErrorBoundary'
import useIsMobile from '@/web/hooks/useIsMobile'
import LayoutMobile from '@/web/components/New/LayoutMobile'
import ScrollRestoration from '@/web/components/New/ScrollRestoration'
import Toaster from './components/New/Toaster'
const App = () => {
const isMobile = useIsMobile()
return (
<ErrorBoundary>
<div>
{window.env?.isEnableTitlebar && <TitleBar />}
{isMobile ? <LayoutMobile /> : <Layout />}
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
<Toaster />
<ScrollRestoration />
<IpcRendererReact />
<Devtool />
</div>
</ErrorBoundary>
)
}

View file

@ -7,7 +7,7 @@ import {
AlbumApiNames,
FetchAlbumResponse,
} from '@/shared/api/Album'
import { useQuery } from '@tanstack/react-query'
import { QueryOptions, useQuery } from '@tanstack/react-query'
const fetch = async (params: FetchAlbumParams) => {
const album = await fetchAlbum(params)
@ -23,11 +23,15 @@ const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse =>
query: params,
})
export default function useAlbum(params: FetchAlbumParams) {
export default function useAlbum(
params: FetchAlbumParams
// queryOptions?: QueryOptions
) {
return useQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
enabled: !!params.id,
staleTime: 24 * 60 * 60 * 1000, // 24 hours
placeholderData: () => fetchFromCache(params),
// ...queryOptions,
})
}

View file

@ -11,7 +11,7 @@ import { useQuery } from '@tanstack/react-query'
export default function useArtists(ids: number[]) {
return useQuery(
['fetchArtists', ids],
() => Promise.all(ids.map(id => fetchArtist({ id }, false))),
() => Promise.all(ids.map(id => fetchArtist({ id }))),
{
enabled: !!ids && ids.length > 0,
staleTime: 5 * 60 * 1000, // 5 mins

View file

@ -5,6 +5,8 @@ import {
SearchTypes,
MultiMatchSearchParams,
MultiMatchSearchResponse,
FetchSearchSuggestionsParams,
FetchSearchSuggestionsResponse,
} from '@/shared/api/Search'
// 搜索
@ -29,3 +31,14 @@ export function multiMatchSearch(
params: params,
})
}
// 搜索建议
export function fetchSearchSuggestions(
params: FetchSearchSuggestionsParams
): Promise<FetchSearchSuggestionsResponse> {
return request({
url: '/search/suggest',
method: 'get',
params,
})
}

View file

@ -1,11 +1,7 @@
import axios, { AxiosInstance } from 'axios'
const baseURL = String(
import.meta.env.DEV ? '/yesplaymusic' : `http://127.0.0.1:42710/yesplaymusic`
)
const request: AxiosInstance = axios.create({
baseURL,
baseURL: '/yesplaymusic',
withCredentials: true,
timeout: 15000,
})

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 304 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 1,012 B

Before After
Before After

View file

@ -1,52 +0,0 @@
export type SvgName =
| 'back'
| 'dislike'
| 'dj'
| 'email'
| 'explicit'
| 'eye-off'
| 'eye'
| 'fm'
| 'forward'
| 'heart-outline'
| 'heart'
| 'home'
| 'lock'
| 'lyrics'
| 'more'
| 'music-library'
| 'music-note'
| 'next'
| 'pause'
| 'phone'
| 'play-fill'
| 'play'
| 'playlist'
| 'podcast'
| 'previous'
| 'qrcode'
| 'repeat'
| 'repeat-1'
| 'search'
| 'settings'
| 'shuffle'
| 'user'
| 'volume-half'
| 'volume-mute'
| 'volume'
| 'windows-close'
| 'windows-minimize'
| 'windows-maximize'
| 'windows-un-maximize'
| 'x'
const Icon = ({ name, className }: { name: SvgName; className?: string }) => {
const symbolId = `#icon-${name}`
return (
<svg aria-hidden='true' className={className}>
<use href={symbolId} fill='currentColor' />
</svg>
)
}
export default Icon

View file

@ -0,0 +1,12 @@
import { IconNames } from './iconNamesType'
const Icon = ({ name, className }: { name: IconNames; className?: string }) => {
const symbolId = `#icon-${name}`
return (
<svg aria-hidden='true' className={className}>
<use href={symbolId} fill='currentColor' />
</svg>
)
}
export default Icon

View file

@ -0,0 +1 @@
export type IconNames = 'back' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'

View file

@ -0,0 +1,2 @@
import Icon from './Icon'
export default Icon

View file

@ -1,5 +1,6 @@
import { useNavigate } from 'react-router-dom'
import { cx } from '@emotion/css'
import { openContextMenu } from '@/web/states/contextMenus'
const ArtistInline = ({
artists,
@ -37,6 +38,16 @@ const ArtistInline = ({
<span key={`${artist.id}-${artist.name}`}>
<span
onClick={() => handleClick(artist.id)}
onContextMenu={event => {
openContextMenu({
event,
type: 'artist',
dataSourceID: artist.id,
options: {
useCursorPosition: true,
},
})
}}
className={cx(!!artist.id && !disableLink && hoverClassName)}
>
{artist.name}

View file

@ -0,0 +1,107 @@
import useUserAlbums, {
useMutationLikeAAlbum,
} from '@/web/api/hooks/useUserAlbums'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import player from '@/web/states/player'
import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const AlbumContextMenu = () => {
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const likeAAlbum = useMutationLikeAAlbum()
const [, copyToClipboard] = useCopyToClipboard()
const { data: likedAlbums } = useUserAlbums()
const addToLibraryLabel = useMemo(() => {
return likedAlbums?.data?.find(a => a.id === Number(dataSourceID))
? 'Remove from Library'
: 'Add to Library'
}, [dataSourceID, likedAlbums?.data])
return (
<AnimatePresence>
{cursorPosition && type === 'album' && dataSourceID && target && (
<BasicContextMenu
target={target}
cursorPosition={cursorPosition}
onClose={closeContextMenu}
options={options}
items={[
{
type: 'item',
label: 'Add to Queue',
onClick: () => {
toast('开发中')
// toast.success('Added to Queue', { duration: 100000000 })
// toast.error('Not implemented yet', { duration: 100000000 })
// toast.loading('Loading...')
// toast('ADADADAD', { duration: 100000000 })
},
},
{
type: 'divider',
},
{
type: 'item',
label: addToLibraryLabel,
onClick: () => {
if (type !== 'album' || !dataSourceID) {
return
}
likeAAlbum.mutateAsync(Number(dataSourceID)).then(res => {
if (res?.code === 200) {
toast.success('Added to Library')
}
})
},
},
{
type: 'item',
label: 'Add to playlist',
onClick: () => {
toast('开发中')
},
},
{
type: 'divider',
},
{
type: 'submenu',
label: 'Share',
items: [
{
type: 'item',
label: 'Copy Netease Link',
onClick: () => {
copyToClipboard(
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success('Copied')
},
},
{
type: 'item',
label: 'Copy YPM Link',
onClick: () => {
copyToClipboard(
`${window.location.origin}/album/${dataSourceID}`
)
toast.success('Copied')
},
},
],
},
]}
/>
)}
</AnimatePresence>
)
}
export default AlbumContextMenu

View file

@ -0,0 +1,86 @@
import useUserArtists, {
useMutationLikeAArtist,
} from '@/web/api/hooks/useUserArtists'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const ArtistContextMenu = () => {
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const likeAArtist = useMutationLikeAArtist()
const [, copyToClipboard] = useCopyToClipboard()
const { data: likedArtists } = useUserArtists()
const followLabel = useMemo(() => {
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
? 'Follow'
: 'Unfollow'
}, [dataSourceID, likedArtists?.data])
return (
<AnimatePresence>
{cursorPosition && type === 'artist' && dataSourceID && target && (
<BasicContextMenu
target={target}
cursorPosition={cursorPosition}
onClose={closeContextMenu}
options={options}
items={[
{
type: 'item',
label: followLabel,
onClick: () => {
if (type !== 'artist' || !dataSourceID) {
return
}
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
if (res?.code === 200) {
toast.success(
followLabel === 'Unfollow' ? 'Followed' : 'Unfollowed'
)
}
})
},
},
{
type: 'divider',
},
{
type: 'submenu',
label: 'Share',
items: [
{
type: 'item',
label: 'Copy Netease Link',
onClick: () => {
copyToClipboard(
`https://music.163.com/#/artist?id=${dataSourceID}`
)
toast.success('Copied')
},
},
{
type: 'item',
label: 'Copy YPM Link',
onClick: () => {
copyToClipboard(
`${window.location.origin}/artist/${dataSourceID}`
)
toast.success('Copied')
},
},
],
},
]}
/>
)}
</AnimatePresence>
)
}
export default ArtistContextMenu

View file

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

View file

@ -0,0 +1,15 @@
import AlbumContextMenu from './AlbumContextMenu'
import ArtistContextMenu from './ArtistContextMenu'
import TrackContextMenu from './TrackContextMenu'
const ContextMenus = () => {
return (
<>
<TrackContextMenu />
<AlbumContextMenu />
<ArtistContextMenu />
</>
)
}
export default ContextMenus

View file

@ -0,0 +1,99 @@
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const TrackContextMenu = () => {
const navigate = useNavigate()
const [, copyToClipboard] = useCopyToClipboard()
const { type, dataSourceID, target, cursorPosition, options } =
useSnapshot(contextMenus)
return (
<AnimatePresence>
{type === 'track' && dataSourceID && target && cursorPosition && (
<BasicContextMenu
target={target}
cursorPosition={cursorPosition}
onClose={closeContextMenu}
options={options}
items={[
{
type: 'item',
label: 'Add to Queue',
onClick: () => {
toast('开发中')
},
},
{
type: 'divider',
},
{
type: 'item',
label: 'Go to artist',
onClick: () => {
toast('开发中')
},
},
{
type: 'item',
label: 'Go to album',
onClick: () => {
toast('开发中')
},
},
{
type: 'divider',
},
{
type: 'item',
label: 'Add to Liked Tracks',
onClick: () => {
toast('开发中')
},
},
{
type: 'item',
label: 'Add to playlist',
onClick: () => {
toast('开发中')
},
},
{
type: 'submenu',
label: 'Share',
items: [
{
type: 'item',
label: 'Copy Netease Link',
onClick: () => {
copyToClipboard(
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success('Copied')
},
},
{
type: 'item',
label: 'Copy YPM Link',
onClick: () => {
copyToClipboard(
`${window.location.origin}/album/${dataSourceID}`
)
toast.success('Copied')
},
},
],
},
]}
/>
)}
</AnimatePresence>
)
}
export default TrackContextMenu

View file

@ -6,8 +6,20 @@ import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { memo, useCallback } from 'react'
import dayjs from 'dayjs'
import ArtistInline from './ArtistsInLine'
const Album = ({ album }: { album: Album }) => {
type ItemTitle = undefined | 'name'
type ItemSubTitle = undefined | 'artist' | 'year'
const Album = ({
album,
itemTitle,
itemSubtitle,
}: {
album: Album
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
const navigate = useNavigate()
const goTo = () => {
navigate(`/album/${album.id}`)
@ -16,6 +28,24 @@ const Album = ({ album }: { album: Album }) => {
prefetchAlbum({ id: album.id })
}
const title =
itemTitle &&
{
name: album.name,
}[itemTitle]
const subtitle =
itemSubtitle &&
{
artist: (
<ArtistInline
artists={album.artists}
hoverClassName='hover:text-white/50 transition-colors duration-400'
/>
),
year: dayjs(album.publishTime || 0).year(),
}[itemSubtitle]
return (
<div>
<Image
@ -25,12 +55,16 @@ const Album = ({ album }: { album: Album }) => {
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
{title && (
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{album.name}
{title}
</div>
)}
{subtitle && (
<div className='mt-1 text-14 font-medium text-neutral-700'>
{dayjs(album.publishTime || 0).year()}
{subtitle}
</div>
)}
</div>
)
}
@ -60,11 +94,15 @@ const CoverRow = ({
playlists,
title,
className,
itemTitle,
itemSubtitle,
}: {
title?: string
className?: string
albums?: Album[]
playlists?: Playlist[]
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
return (
<div className={className}>
@ -78,7 +116,12 @@ const CoverRow = ({
{/* Items */}
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
{albums?.map(album => (
<Album key={album.id} album={album} />
<Album
key={album.id}
album={album}
itemTitle={itemTitle}
itemSubtitle={itemSubtitle}
/>
))}
{playlists?.map(playlist => (
<Playlist key={playlist.id} playlist={playlist} />

View file

@ -82,7 +82,7 @@ const CoverRow = ({
'',
'md'
)}
className='rounded-24'
className='aspect-square w-full rounded-24'
onMouseOver={() => prefetch(item.id)}
/>
))}

View file

@ -11,6 +11,7 @@ import BlurBackground from './BlurBackground'
import Airplay from './Airplay'
import TitleBar from './TitleBar'
import uiStates from '@/web/states/uiStates'
import ContextMenus from './ContextMenus/ContextMenus'
const Layout = () => {
const playerSnapshot = useSnapshot(player)
@ -21,8 +22,8 @@ const Layout = () => {
<div
id='layout'
className={cx(
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
window.env?.isElectron && !fullscreen && 'rounded-24'
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black'
// window.env?.isElectron && !fullscreen && 'rounded-24'
)}
>
<BlurBackground />
@ -38,7 +39,9 @@ const Layout = () => {
</div>
)}
{window.env?.isWindows && <TitleBar />}
{(window.env?.isWindows || window.env?.isLinux) && <TitleBar />}
<ContextMenus />
{/* {window.env?.isElectron && <Airplay />} */}
</div>

View file

@ -56,7 +56,7 @@ const NowPlaying = () => {
)}
</AnimatePresence>
{/* Controls (for Animation) */}
{/* Controls */}
<Controls />
</>
)

View file

@ -10,6 +10,8 @@ import { useWindowSize } from 'react-use'
import { playerWidth, topbarHeight } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile'
import { Virtuoso } from 'react-virtuoso'
import toast from 'react-hot-toast'
import { openContextMenu } from '@/web/states/contextMenus'
const Header = () => {
return (
@ -23,10 +25,10 @@ const Header = () => {
PLAYING NEXT
</div>
<div className='flex'>
<div className='mr-2'>
<div onClick={() => toast('开发中')} className='mr-2'>
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
</div>
<div>
<div onClick={() => toast('开发中')}>
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
</div>
</div>
@ -51,6 +53,17 @@ const Track = ({
onClick={e => {
if (e.detail === 2 && track?.id) player.playTrack(track.id)
}}
onContextMenu={event => {
track?.id &&
openContextMenu({
event,
type: 'track',
dataSourceID: track.id,
options: {
useCursorPosition: true,
},
})
}}
>
{/* Cover */}
<img
@ -71,7 +84,7 @@ const Track = ({
>
{track?.name}
</div>
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'>
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-white/25'>
{track?.ar.map(a => a.name).join(', ')}
</div>
</div>

View file

@ -11,6 +11,7 @@ const Album = React.lazy(() => import('@/web/pages/New/Album'))
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
const MV = React.lazy(() => import('@/web/pages/New/MV'))
const Lyrics = React.lazy(() => import('@/web/pages/New/Lyrics'))
const lazy = (component: ReactNode) => {
return <Suspense>{component}</Suspense>
@ -30,6 +31,7 @@ const Router = () => {
<Route path='/artist/:id' element={lazy(<Artist />)} />
<Route path='/mv/:id' element={lazy(<MV />)} />
<Route path='/settings' element={lazy(<Settings />)} />
<Route path='/lyrics' element={lazy(<Lyrics />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}>
<Route path=':type' element={lazy(<Search />)} />
</Route>

View file

@ -51,7 +51,6 @@ const Controls = () => {
classNames,
css`
margin-right: 5px;
border-radius: 4px 20px 4px 4px;
`
)}
>

View file

@ -0,0 +1,37 @@
import { css, cx } from '@emotion/css'
import { Toaster as ReactHotToaster } from 'react-hot-toast'
const Toaster = () => {
return (
<ReactHotToaster
position='top-center'
containerStyle={{ top: '80px' }}
toastOptions={{
className: cx(
css`
border-radius: 99999px !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #000 !important;
box-shadow: none !important;
line-height: unset !important;
user-select: none !important;
font-size: 12px !important;
padding: 10px 16px !important;
font-weight: 500 !important;
& div[role='status'] {
margin: 0 8px !important;
}
`
),
success: {
iconTheme: {
primary: 'green',
secondary: '#fff',
},
},
}}
/>
)
}
export default Toaster

View file

@ -2,13 +2,126 @@ import { css, cx } from '@emotion/css'
import Icon from '../../Icon'
import { breakpoint as bp } from '@/web/utils/const'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useMemo, useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchSearchSuggestions } from '@/web/api/search'
import { SearchApiNames } from '@/shared/api/Search'
import { useClickAway, useDebounce } from 'react-use'
import { AnimatePresence, motion } from 'framer-motion'
const SearchSuggestions = ({ searchText }: { searchText: string }) => {
const navigate = useNavigate()
const [debouncedSearchText, setDebouncedSearchText] = useState('')
useDebounce(() => setDebouncedSearchText(searchText), 500, [searchText])
const { data: suggestions } = useQuery(
[SearchApiNames.FetchSearchSuggestions, debouncedSearchText],
() => fetchSearchSuggestions({ keywords: debouncedSearchText }),
{
enabled: debouncedSearchText.length > 0,
keepPreviousData: true,
}
)
const suggestionsArray = useMemo(() => {
if (suggestions?.code !== 200) {
return []
}
const suggestionsArray: {
name: string
type: 'album' | 'artist' | 'track'
id: number
}[] = []
const rawItems = [
...(suggestions.result.artists || []),
...(suggestions.result.albums || []),
...(suggestions.result.songs || []),
]
rawItems.forEach(item => {
const type = (item as Artist).albumSize
? 'artist'
: (item as Track).duration
? 'track'
: 'album'
suggestionsArray.push({
name: item.name,
type,
id: item.id,
})
})
return suggestionsArray
}, [suggestions])
const [clickedSearchText, setClickedSearchText] = useState('')
useEffect(() => {
if (clickedSearchText !== searchText) {
setClickedSearchText('')
}
}, [clickedSearchText, searchText])
const panelRef = useRef<HTMLDivElement>(null)
useClickAway(panelRef, () => setClickedSearchText(searchText))
return (
<AnimatePresence>
{searchText.length > 0 &&
suggestionsArray.length > 0 &&
!clickedSearchText &&
searchText === debouncedSearchText && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, scaleY: 0.96 }}
animate={{
opacity: 1,
scaleY: 1,
transition: {
duration: 0.1,
},
}}
exit={{
opacity: 0,
scaleY: 0.96,
transition: {
duration: 0.2,
},
}}
className={cx(
'absolute mt-2 origin-top rounded-24 border border-white/10 bg-white/10 p-2 backdrop-blur-3xl',
css`
width: 286px;
`
)}
>
{suggestionsArray?.map(suggestion => (
<div
key={`${suggestion.type}-${suggestion.id}`}
className='line-clamp-1 rounded-12 p-2 text-white hover:bg-white/10'
onClick={() => {
setClickedSearchText(searchText)
if (['album', 'artist'].includes(suggestion.type)) {
navigate(`${suggestion.type}/${suggestion.id}`)
}
if (suggestion.type === 'track') {
// TODO: play song
}
}}
>
{suggestion.type} -{suggestion.name}
</div>
))}
</motion.div>
)}
</AnimatePresence>
)
}
const SearchBox = () => {
const navigate = useNavigate()
const [searchText, setSearchText] = useState('')
return (
<div className='relative'>
{/* Input */}
<div
className={cx(
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
@ -39,6 +152,9 @@ const SearchBox = () => {
}}
/>
</div>
<SearchSuggestions searchText={searchText} />
</div>
)
}

View file

@ -1,15 +1,17 @@
import Icon from '@/web/components/Icon'
import { cx } from '@emotion/css'
import toast from 'react-hot-toast'
const SettingsButton = ({ className }: { className?: string }) => {
return (
<button
onClick={() => toast('开发中')}
className={cx(
'app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600',
'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300',
className
)}
>
<Icon name='placeholder' className='h-7 w-7 text-neutral-500' />
<Icon name='settings' className='h-5 w-5 ' />
</button>
)
}

View file

@ -45,7 +45,7 @@ const TopbarDesktop = () => {
return (
<div
className={cx(
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between overflow-hidden bg-contain pt-11 pb-10 pr-6',
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between bg-contain pt-11 pb-10 pr-6',
css`
padding-left: 144px;
`,

View file

@ -1,25 +1,127 @@
import { formatDuration } from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import { useMemo } from 'react'
import { cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Wave from './Wave'
import Icon from '@/web/components/Icon'
import useIsMobile from '@/web/hooks/useIsMobile'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import toast from 'react-hot-toast'
import { memo, useEffect, useState } from 'react'
import contextMenus, { openContextMenu } from '@/web/states/contextMenus'
const Actions = ({ track }: { track: Track }) => {
const { data: likedTracksIDs } = useUserLikedTracksIDs()
const likeATrack = useMutationLikeATrack()
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const menu = useSnapshot(contextMenus)
useEffect(() => {
if (menu.type !== 'track' || !menu.dataSourceID) {
setIsContextMenuOpen(false)
}
}, [menu.dataSourceID, menu.type])
return (
<div className='mr-5 lg:flex' onClick={e => e.stopPropagation()}>
{/* Context menu */}
<div
className={cx(
'transition-opacity group-hover:opacity-100',
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
)}
>
<button
onClick={event => {
setIsContextMenuOpen(true)
openContextMenu({
event,
type: 'track',
dataSourceID: track.id,
})
}}
className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/40 transition-colors duration-400 hover:bg-white/30 hover:text-white/70'
>
<Icon name='more' className='pointer-events-none h-5 w-5' />
</button>
</div>
{/* Add to playlist */}
<button
className={cx(
'opacity-0 transition-opacity group-hover:opacity-100',
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
)}
>
<div
onClick={() => toast('开发中...')}
className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/40 transition-colors duration-400 hover:bg-white/30 hover:text-white/70'
>
<Icon name='plus' className='h-5 w-5' />
</div>
</button>
{/* Like */}
<button
className={cx(
'rounded-full ',
likedTracksIDs?.ids.includes(track.id)
? 'group-hover:bg-white/10'
: cx(
'bg-white/10 transition-opacity group-hover:opacity-100',
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
)
)}
>
<div
onClick={() => likeATrack.mutateAsync(track.id)}
className='flex h-10 w-10 items-center justify-center rounded-full text-white/40 transition duration-400 hover:bg-white/20 hover:text-white/70'
>
<Icon
name={
likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'
}
className='h-5 w-5'
/>
</div>
</button>
</div>
)
}
const TrackList = ({
tracks,
onPlay,
className,
isLoading,
placeholderRows = 12,
}: {
tracks?: Track[]
onPlay: (id: number) => void
className?: string
isLoading?: boolean
placeholderRows?: number
}) => {
const { track: playingTrack, state } = useSnapshot(player)
const isMobile = useIsMobile()
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
if (isLoading) return
if (e.type === 'contextmenu') {
e.preventDefault()
openContextMenu({
event: e,
type: 'track',
dataSourceID: trackID,
options: {
useCursorPosition: true,
},
})
return
}
if (isMobile) {
onPlay?.(trackID)
} else {
@ -27,15 +129,14 @@ const TrackList = ({
}
}
const playing = state === 'playing'
return (
<div className={className}>
{tracks?.map(track => (
{(isLoading ? [] : tracks)?.map(track => (
<div
key={track.id}
onClick={e => handleClick(e, track.id)}
className='group relative flex items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
onContextMenu={e => handleClick(e, track.id)}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
>
{/* Track no */}
<div className='mr-3 lg:mr-6'>
@ -47,23 +148,13 @@ const TrackList = ({
<span className='line-clamp-1 mr-4'>{track.name}</span>
{playingTrack?.id === track.id && (
<span className='mr-4 inline-block'>
<Wave playing={playing} />
<Wave playing={state === 'playing'} />
</span>
)}
</div>
{/* Desktop context menu */}
<div className='mr-12 hidden opacity-0 transition-opacity group-hover:opacity-100 lg:flex'>
<div className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-brand-600 text-white/80'>
{/* <Icon name='play' className='h-7 w-7' /> */}
</div>
<div className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-night-900 text-white/80'>
{/* <Icon name='play' className='h-7 w-7' /> */}
</div>
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-night-900 text-white/80'>
{/* <Icon name='play' className='h-7 w-7' /> */}
</div>
</div>
{/* Desktop menu */}
<Actions track={track} />
{/* Mobile menu */}
<div className='lg:hidden'>
@ -76,8 +167,35 @@ const TrackList = ({
</div>
</div>
))}
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(
index => (
<div
key={index}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
>
{/* Track no */}
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>
00
</div>
{/* Track name */}
<div className='flex flex-grow items-center text-transparent'>
<span className='mr-4 rounded-full bg-white/10'>
PLACEHOLDER1234567
</span>
</div>
{/* Track duration */}
<div className='hidden text-right text-transparent lg:block'>
<span className='rounded-full bg-white/10'>00:00</span>
</div>
</div>
)
)}
</div>
)
}
export default TrackList
const memorizedTrackList = memo(TrackList)
memorizedTrackList.displayName = 'TrackList'
export default memorizedTrackList

View file

@ -1,26 +1,52 @@
import { openContextMenu } from '@/web/states/contextMenus'
import { cx } from '@emotion/css'
import { useParams } from 'react-router-dom'
import Icon from '../../Icon'
const Actions = ({
onPlay,
onLike,
isLiked,
isLoading,
}: {
isLiked?: boolean
isLoading?: boolean
onPlay: () => void
onLike?: () => void
}) => {
const params = useParams()
return (
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
<div className='flex items-end'>
{/* Menu */}
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
<Icon name='more' className='h-7 w-7' />
<button
onClick={event => {
params?.id &&
openContextMenu({
event,
type: 'album',
dataSourceID: params.id,
})
}}
className={cx(
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
</button>
{/* Like */}
{onLike && (
<button
onClick={() => onLike()}
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30 lg:mr-2.5'
className={cx(
'flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400 lg:mr-2.5',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
@ -31,7 +57,10 @@ const Actions = ({
</div>
<button
onClick={() => onPlay()}
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
className={cx(
'h-14 rounded-full px-10 text-18 font-medium',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
Play
</button>

View file

@ -1,10 +1,6 @@
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
import { resizeImage } from '@/web/utils/common'
import Image from '@/web/components/New/Image'
import { memo, useEffect } from 'react'
import useVideoCover from '@/web/hooks/useVideoCover'
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
import uiStates from '@/web/states/uiStates'
import VideoCover from './VideoCover'

View file

@ -4,6 +4,7 @@ import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom'
import useIsMobile from '@/web/hooks/useIsMobile'
import { ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
const Info = ({
title,
@ -11,12 +12,14 @@ const Info = ({
creatorLink,
description,
extraInfo,
isLoading,
}: {
title?: string
creatorName?: string
creatorLink?: string
description?: string
extraInfo?: string | ReactNode
isLoading?: boolean
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
@ -24,11 +27,24 @@ const Info = ({
return (
<div>
{/* Title */}
<div className='mt-2.5 text-28 font-semibold dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
{isLoading ? (
<div className='mt-2.5 text-28 font-semibold text-transparent lg:mt-0 lg:text-36 lg:font-medium'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</div>
) : (
<div className='mt-2.5 text-28 font-semibold transition-colors duration-300 dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
{title}
</div>
)}
{/* Creator */}
{isLoading ? (
<div className='mt-2.5 lg:mt-6'>
<span className='text-24 font-medium text-transparent'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</span>
</div>
) : (
<div className='mt-2.5 lg:mt-6'>
<span
onClick={() => creatorLink && navigate(creatorLink)}
@ -37,20 +53,30 @@ const Info = ({
{creatorName}
</span>
</div>
)}
{/* Extra info */}
<div className='mt-1 flex items-center text-12 font-medium dark:text-white/40 lg:text-14 lg:font-bold'>
{isLoading ? (
<div className='mt-1 flex items-center text-12 font-medium text-transparent lg:text-14 lg:font-bold'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</div>
) : (
<div className='mt-1 flex items-center text-12 font-medium transition-colors duration-300 dark:text-white/40 lg:text-14 lg:font-bold'>
{extraInfo}
</div>
)}
{/* Description */}
{!isMobile && (
<div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
dangerouslySetInnerHTML={{
__html: description || '',
}}
></div>
></motion.div>
)}
</div>
)

View file

@ -6,6 +6,7 @@ import React from 'react'
interface Props {
className?: string
isLoading?: boolean
title?: string
creatorName?: string
creatorLink?: string
@ -20,6 +21,7 @@ interface Props {
const TrackListHeader = ({
className,
isLoading,
title,
creatorName,
creatorLink,
@ -46,9 +48,16 @@ const TrackListHeader = ({
<div className='flex flex-col justify-between'>
<Info
{...{ title, creatorName, creatorLink, description, extraInfo }}
{...{
title,
creatorName,
creatorLink,
description,
extraInfo,
isLoading,
}}
/>
<Actions {...{ onPlay, onLike, isLiked }} />
<Actions {...{ onPlay, onLike, isLiked, isLoading }} />
</div>
</div>
)

View file

@ -0,0 +1,21 @@
import { useEffect } from 'react'
const useLockMainScroll = (lock: boolean) => {
useEffect(() => {
const main = document.querySelector('#main') as HTMLElement | null
if (!main) {
throw new Error('Main element not found')
}
if (lock) {
main.style.overflow = 'hidden'
} else {
main.style.overflow = 'auto'
}
return () => {
main.style.overflow = 'auto'
}
}, [lock])
}
export default useLockMainScroll

View file

@ -3,7 +3,6 @@ import './utils/theme'
import { StrictMode } from 'react'
import * as ReactDOMClient from 'react-dom/client'
import {
Routes,
BrowserRouter,
useLocation,
useNavigationType,

View file

@ -23,32 +23,33 @@
"node": "^14.13.1 || >=16.0.0"
},
"dependencies": {
"@emotion/css": "^11.9.0",
"@sentry/react": "^7.8.0",
"@sentry/tracing": "^7.8.0",
"@emotion/css": "^11.10.0",
"@sentry/react": "^7.8.1",
"@sentry/tracing": "^7.8.1",
"@tanstack/react-query": "^4.0.10",
"@tanstack/react-query-devtools": "^4.0.10",
"ahooks": "^3.4.1",
"ahooks": "^3.6.2",
"axios": "^0.27.2",
"color.js": "^1.2.0",
"colord": "^2.9.2",
"dayjs": "^1.11.1",
"framer-motion": "^6.3.4",
"hls.js": "^1.1.5",
"dayjs": "^1.11.4",
"framer-motion": "^6.5.1",
"hls.js": "^1.2.0",
"howler": "^2.2.3",
"js-cookie": "^3.0.1",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"plyr-react": "^5.0.2",
"qrcode": "^1.5.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"plyr-react": "^5.1.0",
"qrcode": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^1.4.1",
"react-hot-toast": "^2.2.0",
"react-hot-toast": "^2.3.0",
"react-router-dom": "^6.3.0",
"react-use": "^17.4.0",
"react-virtuoso": "^2.16.5",
"valtio": "^1.6.1"
"react-use-measure": "^2.1.1",
"react-virtuoso": "^2.16.6",
"valtio": "^1.6.3"
},
"devDependencies": {
"@storybook/addon-actions": "^6.5.5",
@ -66,30 +67,30 @@
"@types/lodash-es": "^4.17.6",
"@types/md5": "^2.3.2",
"@types/qrcode": "^1.4.2",
"@types/react": "^18.0.11",
"@types/react-dom": "^18.0.5",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"@vitejs/plugin-react": "^1.3.1",
"@vitest/ui": "^0.12.10",
"autoprefixer": "^10.4.5",
"c8": "^7.11.3",
"dotenv": "^16.0.0",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"@vitejs/plugin-react": "^2.0.0",
"@vitest/ui": "^0.20.3",
"autoprefixer": "^10.4.8",
"c8": "^7.12.0",
"dotenv": "^16.0.1",
"eslint": "*",
"eslint-plugin-react": "^7.30.0",
"eslint-plugin-react-hooks": "^4.5.0",
"jsdom": "^19.0.0",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^20.0.0",
"open-cli": "^7.0.1",
"postcss": "^8.4.14",
"prettier": "*",
"prettier-plugin-tailwindcss": "^0.1.11",
"rollup-plugin-visualizer": "^5.6.0",
"storybook-tailwind-dark-mode": "^1.0.12",
"tailwindcss": "^3.0.24",
"tailwindcss": "^3.1.7",
"typescript": "*",
"vite": "^2.9.6",
"vite-plugin-pwa": "^0.12.0",
"vite": "^3.0.4",
"vite-plugin-pwa": "^0.12.3",
"vite-plugin-svg-icons": "^2.0.1",
"vitest": "^0.12.10"
"vitest": "^0.20.3"
}
}

View file

@ -1,22 +1,17 @@
import TrackListHeader from '@/web/components/New/TrackListHeader'
import useAlbum from '@/web/api/hooks/useAlbum'
import useTracks from '@/web/api/hooks/useTracks'
import { NavLink, useParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import PageTransition from '@/web/components/New/PageTransition'
import TrackList from '@/web/components/New/TrackList'
import player from '@/web/states/player'
import toast from 'react-hot-toast'
import { useSnapshot } from 'valtio'
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
import { css, cx } from '@emotion/css'
import CoverRow from '@/web/components/New/CoverRow'
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import MoreByArtist from './MoreByArtist'
import Header from './Header'
const Album = () => {
const params = useParams()
const { data: album } = useAlbum({
const { data: album, isLoading } = useAlbum({
id: Number(params.id),
})
@ -39,9 +34,18 @@ const Album = () => {
<PageTransition>
<Header />
<TrackList
tracks={tracks?.songs || album?.album.songs || album?.songs}
tracks={
tracks?.songs?.length
? tracks?.songs
: album?.album?.songs?.length
? album?.album.songs
: album?.songs?.length
? album.songs
: undefined
}
className='z-10 mx-2.5 mt-3 lg:mx-0 lg:mt-10'
onPlay={onPlay}
isLoading={isLoading}
/>
<MoreByArtist album={album?.album} />

View file

@ -17,7 +17,7 @@ const Header = () => {
const params = useParams()
const { data: userLikedAlbums } = useUserAlbums()
const { data: albumRaw } = useAlbum({
const { data: albumRaw, isLoading: isLoadingAlbum } = useAlbum({
id: Number(params.id),
})
const album = useMemo(() => albumRaw?.album, [albumRaw])
@ -89,6 +89,7 @@ const Header = () => {
return (
<TrackListHeader
{...{
isLoading: isLoadingAlbum,
title,
creatorName,
creatorLink,

View file

@ -78,7 +78,12 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
</NavLink>
</div>
<CoverRow albums={filteredAlbums} className='mx-2.5 lg:mx-0' />
<CoverRow
albums={filteredAlbums}
itemTitle='name'
itemSubtitle='year'
className='mx-2.5 lg:mx-0'
/>
</div>
)
}

View file

@ -1,6 +1,3 @@
import useArtist from '@/web/api/hooks/useArtist'
import { cx, css } from '@emotion/css'
import { useParams } from 'react-router-dom'
import Header from './Header'
import Popular from './Popular'
import ArtistAlbum from './ArtistAlbums'
@ -8,20 +5,12 @@ import FansAlsoLike from './FansAlsoLike'
import ArtistMVs from './ArtistMVs'
const Artist = () => {
const params = useParams()
const { data: artist, isLoading: isLoadingArtist } = useArtist({
id: Number(params.id) || 0,
})
return (
<div>
<Header artist={artist?.artist} />
<Header />
{/* Dividing line */}
<div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div>
<Popular tracks={artist?.hotSongs} />
<Popular />
<ArtistAlbum />
<ArtistMVs />
<FansAlsoLike />

View file

@ -36,6 +36,8 @@ const ArtistAlbum = () => {
<CoverRow
key={index}
albums={page}
itemTitle='name'
itemSubtitle='year'
className='h-full w-full flex-shrink-0'
/>
))}

View file

@ -2,10 +2,13 @@ import useUserArtists, {
useMutationLikeAArtist,
} from '@/web/api/hooks/useUserArtists'
import Icon from '@/web/components/Icon'
import { openContextMenu } from '@/web/states/contextMenus'
import player from '@/web/states/player'
import { cx } from '@emotion/css'
import toast from 'react-hot-toast'
import { useParams } from 'react-router-dom'
const Actions = () => {
const Actions = ({ isLoading }: { isLoading: boolean }) => {
const { data: likedArtists } = useUserArtists()
const params = useParams()
const id = Number(params.id) || 0
@ -16,14 +19,33 @@ const Actions = () => {
<div className='mt-11 flex items-end justify-between lg:z-10 lg:mt-6'>
<div className='flex items-end'>
{/* Menu */}
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
<Icon name='more' className='h-7 w-7' />
<button
onClick={event => {
openContextMenu({
event,
type: 'artist',
dataSourceID: id,
})
}}
className={cx(
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full transition duration-400 dark:bg-white/10 ',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
</button>
{/* Like */}
<button
onClick={() => likeArtist.mutateAsync(id)}
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'
className={cx(
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full transition duration-400 dark:bg-white/10 ',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
)}
>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
@ -35,7 +57,10 @@ const Actions = () => {
{/* Listen */}
<button
onClick={() => player.playArtistPopularTracks(id)}
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
className={cx(
'h-14 rounded-full px-10 text-18 font-medium',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
Listen
</button>

View file

@ -2,7 +2,13 @@ import useIsMobile from '@/web/hooks/useIsMobile'
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
import { cx, css } from '@emotion/css'
const ArtistInfo = ({ artist }: { artist?: Artist }) => {
const ArtistInfo = ({
artist,
isLoading,
}: {
artist?: Artist
isLoading: boolean
}) => {
const isMobile = useIsMobile()
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
useAppleMusicArtist({
@ -12,19 +18,56 @@ const ArtistInfo = ({ artist }: { artist?: Artist }) => {
return (
<div>
{/* Name */}
{isLoading ? (
<div className=' text-28 font-semibold text-transparent lg:text-32'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</div>
) : (
<div className='text-28 font-semibold text-white/70 lg:text-32'>
{artist?.name}
</div>
)}
{/* Type */}
{isLoading ? (
<div className='mt-2.5 text-24 font-medium text-transparent lg:mt-6'>
<span className='rounded-full bg-white/10'>Artist</span>
</div>
) : (
<div className='mt-2.5 text-24 font-medium text-white/40 lg:mt-6'>
Artist
</div>
)}
{/* Counts */}
{isLoading ? (
<div className='mt-1 text-12 font-medium text-transparent'>
<span className='rounded-full bg-white/10'>PLACEHOLDER12345</span>
</div>
) : (
<div className='mt-1 text-12 font-medium text-white/40'>
{artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '}
{artist?.mvSize} Videos
</div>
)}
{/* Description */}
{!isMobile && !isLoadingArtistFromApple && (
{!isMobile &&
(isLoading || isLoadingArtistFromApple ? (
<div
className={cx(
'line-clamp-5 mt-6 text-14 font-bold text-transparent',
css`
height: 86px;
`
)}
>
<span className='rounded-full bg-white/10'>
PLACEHOLDER1234567890
</span>
</div>
) : (
<div
className={cx(
'line-clamp-5 mt-6 text-14 font-bold text-white/40',
@ -35,7 +78,7 @@ const ArtistInfo = ({ artist }: { artist?: Artist }) => {
>
{artistFromApple?.attributes?.artistBio || artist?.briefDesc}
</div>
)}
))}
</div>
)
}

View file

@ -3,8 +3,16 @@ import { breakpoint as bp } from '@/web/utils/const'
import ArtistInfo from './ArtistInfo'
import Actions from './Actions'
import LatestRelease from './LatestRelease'
import Cover from "./Cover"
const Header = ({ artist }: { artist?: Artist }) => {
import Cover from './Cover'
import useArtist from '@/web/api/hooks/useArtist'
import { useParams } from 'react-router-dom'
const Header = () => {
const params = useParams()
const { data: artistRaw, isLoading } = useArtist({
id: Number(params.id) || 0,
})
const artist = artistRaw?.artist
return (
<div
@ -39,8 +47,8 @@ const Header = ({ artist }: { artist?: Artist }) => {
`
)}
>
<ArtistInfo artist={artist} />
<Actions />
<ArtistInfo isLoading={isLoading} artist={artist} />
<Actions isLoading={isLoading} />
</div>
<LatestRelease />

View file

@ -6,18 +6,11 @@ import Image from '@/web/components/New/Image'
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
import { useMemo } from 'react'
import useArtistMV from '@/web/api/hooks/useArtistMV'
import { motion } from 'framer-motion'
const Album = () => {
const params = useParams()
const Album = ({ album }: { album?: Album }) => {
const navigate = useNavigate()
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
id: Number(params.id) || 0,
limit: 1000,
})
const album = useMemo(() => albumsRaw?.hotAlbums?.[0], [albumsRaw?.hotAlbums])
if (!album) {
return <></>
}
@ -25,7 +18,7 @@ const Album = () => {
return (
<div
onClick={() => navigate(`/album/${album.id}`)}
className='flex rounded-24 bg-white/10 p-2.5'
className='group flex rounded-24 bg-white/10 p-2.5 transition-colors duration-400 hover:bg-white/20'
>
<Image
src={resizeImage(album.picUrl, 'sm')}
@ -39,14 +32,14 @@ const Album = () => {
)}
/>
<div className='flex-shrink-1 ml-2'>
<div className='line-clamp-1 text-16 font-medium text-night-100'>
<div className='line-clamp-1 text-16 font-medium text-night-100 transition-colors duration-400 group-hover:text-night-50'>
{album.name}
</div>
<div className='mt-1 text-14 font-bold text-night-500'>
<div className='mt-1 text-14 font-bold text-night-500 transition-colors duration-400 group-hover:text-night-200'>
{album.type}
{album.size > 1 ? `· ${album.size} Tracks` : ''}
</div>
<div className='mt-1.5 text-12 font-medium text-night-500'>
<div className='mt-1.5 text-12 font-medium text-night-500 transition-colors duration-400 group-hover:text-night-200'>
{dayjs(album?.publishTime || 0).format('MMM DD, YYYY')}
</div>
</div>
@ -54,17 +47,14 @@ const Album = () => {
)
}
const Video = () => {
const params = useParams()
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
const video = videos?.mvs?.[0]
const Video = ({ video }: { video?: any }) => {
const navigate = useNavigate()
return (
<>
{video && (
<div
className='mt-4 flex rounded-24 bg-white/10 p-2.5'
className='group mt-4 flex rounded-24 bg-white/10 p-2.5 transition-colors duration-400 hover:bg-white/20'
onClick={() => navigate(`/mv/${video.id}`)}
>
<img
@ -78,11 +68,13 @@ const Video = () => {
)}
/>
<div className='flex-shrink-1 ml-2'>
<div className='line-clamp-1 text-16 font-medium text-night-100'>
<div className='line-clamp-1 text-16 font-medium text-night-100 transition-colors duration-400 group-hover:text-night-50'>
{video.name}
</div>
<div className='mt-1 text-14 font-bold text-night-500'>MV</div>
<div className='mt-1.5 text-12 font-medium text-night-500'>
<div className='mt-1 text-14 font-bold text-night-500 transition-colors duration-400 group-hover:text-night-200'>
MV
</div>
<div className='mt-1.5 text-12 font-medium text-night-500 transition-colors duration-400 group-hover:text-night-200'>
{dayjs(video.publishTime).format('MMM DD, YYYY')}
</div>
</div>
@ -93,15 +85,37 @@ const Video = () => {
}
const LatestRelease = () => {
const params = useParams()
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
id: Number(params.id) || 0,
limit: 1000,
})
const album = useMemo(() => albumsRaw?.hotAlbums?.[0], [albumsRaw?.hotAlbums])
const { data: videos, isLoading: isLoadingVideos } = useArtistMV({
id: Number(params.id) || 0,
})
const video = videos?.mvs?.[0]
return (
<div className='mx-2.5 lg:mx-0'>
<>
{!isLoadingVideos && !isLoadingAlbums && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='mx-2.5 lg:mx-0'
>
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
Latest Releases
</div>
<Album />
<Video />
</div>
<Album album={album} />
<Video video={video} />
</motion.div>
)}
</>
)
}

View file

@ -4,6 +4,8 @@ import { State as PlayerState } from '@/web/utils/player'
import useTracks from '@/web/api/hooks/useTracks'
import { css, cx } from '@emotion/css'
import Image from '@/web/components/New/Image'
import useArtist from '@/web/api/hooks/useArtist'
import { useParams } from 'react-router-dom'
const Track = ({
track,
@ -49,7 +51,13 @@ const Track = ({
)
}
const Popular = ({ tracks }: { tracks?: Track[] }) => {
const Popular = () => {
const params = useParams()
const { data: artist, isLoading: isLoadingArtist } = useArtist({
id: Number(params.id) || 0,
})
const tracks = artist?.hotSongs || []
const onPlay = (id: number) => {
if (!tracks) return
player.playAList(

View file

@ -0,0 +1,5 @@
const Lyrics = () => {
return <div className='text-white'></div>
}
export default Lyrics

View file

@ -1,13 +1,19 @@
import { css, cx } from '@emotion/css'
import useUserArtists from '@/web/api/hooks/useUserArtists'
import Tabs from '@/web/components/New/Tabs'
import { useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import CoverRow from '@/web/components/New/CoverRow'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import ArtistRow from '@/web/components/New/ArtistRow'
import { playerWidth, topbarHeight } from '@/web/utils/const'
import topbarBackground from '@/web/assets/images/topbar-background.png'
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
import { AnimatePresence, motion } from 'framer-motion'
import { scrollToBottom } from '@/web/utils/common'
import { throttle } from 'lodash-es'
const tabs = [
{
@ -31,7 +37,9 @@ const tabs = [
const Albums = () => {
const { data: albums } = useUserAlbums()
return <CoverRow albums={albums?.data} />
return (
<CoverRow albums={albums?.data} itemTitle='name' itemSubtitle='artist' />
)
}
const Playlists = () => {
@ -45,7 +53,7 @@ const Artists = () => {
return <ArtistRow artists={artists?.data || []} />
}
const Collections = () => {
const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
const setSelectedTab = (
id: 'playlists' | 'albums' | 'artists' | 'videos'
@ -54,18 +62,73 @@ const Collections = () => {
}
return (
<div>
<>
{/* Topbar background */}
<AnimatePresence>
{showBg && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cx(
'pointer-events-none fixed top-0 left-10 z-10 hidden lg:block',
css`
height: 230px;
right: ${playerWidth + 32}px;
background-image: url(${topbarBackground});
`
)}
></motion.div>
)}
</AnimatePresence>
<Tabs
tabs={tabs}
value={selectedTab}
onChange={(id: string) => setSelectedTab(id)}
className='px-2.5 lg:px-0'
onChange={(id: string) => {
setSelectedTab(id)
scrollToBottom(true)
}}
className={cx(
'sticky z-10 -mb-10 px-2.5 lg:px-0',
css`
top: ${topbarHeight}px;
`
)}
/>
<div className='mt-6 px-2.5 lg:px-0'>
</>
)
}
const Collections = () => {
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen: isScrollReachBottom } =
useIntersectionObserver(observePoint)
const onScroll = throttle(() => {
if (isScrollReachBottom) return
scrollToBottom(true)
}, 500)
return (
<div>
<CollectionTabs showBg={isScrollReachBottom} />
<div
className={cx(
'no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0',
css`
height: calc(100vh - ${topbarHeight}px);
`
)}
onScroll={onScroll}
>
{selectedTab === 'albums' && <Albums />}
{selectedTab === 'playlists' && <Playlists />}
{selectedTab === 'artists' && <Artists />}
</div>
<div ref={observePoint}></div>
</div>
)
}

View file

@ -0,0 +1,56 @@
import { assign } from 'lodash-es'
import { proxy, ref } from 'valtio'
interface ContextMenu {
target: HTMLElement | null
cursorPosition: {
x: number
y: number
} | null
type: 'album' | 'track' | 'playlist' | 'artist' | null
dataSourceID: string | number | null
options: {
useCursorPosition?: boolean
} | null
}
const initContextMenu: ContextMenu = {
target: null,
cursorPosition: null,
type: null,
dataSourceID: null,
options: null,
}
const contextMenus = proxy<ContextMenu>(initContextMenu)
export default contextMenus
export const openContextMenu = ({
event,
type,
dataSourceID,
options = null,
}: {
event: React.MouseEvent<HTMLElement, MouseEvent>
type: ContextMenu['type']
dataSourceID: ContextMenu['dataSourceID']
options?: ContextMenu['options']
}) => {
const target = event.target as HTMLElement
contextMenus.target = ref(target)
contextMenus.type = type
contextMenus.dataSourceID = dataSourceID
contextMenus.options = options
contextMenus.cursorPosition = {
x: event.clientX,
y: event.clientY,
}
}
export const closeContextMenu = (event: MouseEvent) => {
if (event.target === contextMenus.target) {
return
}
assign(contextMenus, initContextMenu)
}

View file

@ -1,5 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;200;300;400;500;600;700;800;900&display=swap');
@tailwind base;
@tailwind components;

View file

@ -93,6 +93,11 @@ module.exports = {
transitionDuration: {
400: '400ms',
},
opacity: {
5: '0.05',
15: '.15',
25: '.25',
},
},
},
variants: {},

View file

@ -18,7 +18,8 @@
"baseUrl": "../",
"paths": {
"@/*": ["./*"]
}
},
"types": ["vite-plugin-svg-icons/client"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../shared/**/*.ts"]
}

View file

@ -110,9 +110,15 @@ export function formatDuration(
}
export function scrollToTop(smooth = false) {
const main = document.getElementById('mainContainer')
if (!main) return
main.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
document
.querySelector('#main')
?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
}
export function scrollToBottom(smooth = false) {
document
.querySelector('#main')
?.scrollTo({ top: 100000, behavior: smooth ? 'smooth' : 'auto' })
}
export async function getCoverColor(coverUrl: string) {

View file

@ -6,6 +6,7 @@ import { defineConfig } from 'vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import { visualizer } from 'rollup-plugin-visualizer'
import { VitePWA } from 'vite-plugin-pwa'
import filenamesToType from './vitePluginFilenamesToType'
dotenv.config({ path: path.resolve(process.cwd(), '../../.env') })
const IS_ELECTRON = process.env.IS_ELECTRON
@ -25,6 +26,12 @@ export default defineConfig({
},
plugins: [
react(),
filenamesToType([
{
dictionary: './assets/icons',
typeFile: './components/Icon/iconNamesType.ts',
},
]),
/**
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html
@ -85,8 +92,8 @@ export default defineConfig({
strictPort: IS_ELECTRON ? true : false,
proxy: {
'/netease/': {
target: `http://192.168.50.111:${
// target: `http://127.0.0.1:${
// target: `http://192.168.50.111:${
target: `http://127.0.0.1:${
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
}`,
changeOrigin: true,

View file

@ -0,0 +1,49 @@
import { promises as fs } from 'fs'
import { resolve } from 'path'
import { Plugin } from 'vite'
export type Conversion = {
dictionary: string
typeFile: string
}
const filenamesToType = (conversions: Conversion[]): Plugin => {
const generateTypes = async (conversion: Conversion) => {
const filenames = await fs.readdir(conversion.dictionary).catch(reason => {
console.error(
'vite-plugin-filenames-to-type: unable to read directory. ',
reason
)
return []
})
if (!filenames.length) return
const iconNames = filenames.map(
fileName => `'${fileName.replace(/\.[^.]+$/, '')}'`
)
await fs.writeFile(
conversion.typeFile,
`export type IconNames = ${iconNames.join(' | ')}`
)
}
const findConversion = (filePath: string) => {
return conversions.find(conversion => {
const path = resolve(__dirname, conversion.dictionary)
return filePath.startsWith(path)
})
}
return {
name: 'vite-plugin-filenames-to-type',
buildStart: async () => {
conversions.forEach(conversion => generateTypes(conversion))
},
handleHotUpdate: context => {
const conversion = findConversion(context.file)
if (conversion) generateTypes(conversion)
},
}
}
export default filenamesToType

8296
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,11 @@
"outputs": ["release/**"],
"cache": false
},
"pack:test": {
"dependsOn": ["build"],
"outputs": ["release/**"],
"cache": false
},
"test": {
"dependsOn": ["build"],
"outputs": []