feat: updates
|
@ -14,8 +14,9 @@
|
|||
"packageManager": "pnpm@7.0.0",
|
||||
"scripts": {
|
||||
"install": "turbo run post-install --parallel --no-cache",
|
||||
"build": "ross-env-shell IS_ELECTRON=yes turbo run build",
|
||||
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
|
||||
"build:web": "turbo run build:web",
|
||||
"pack": "turbo run build pack",
|
||||
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
||||
|
@ -24,9 +25,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.16.0",
|
||||
"eslint": "^8.17.0",
|
||||
"prettier": "^2.6.2",
|
||||
"turbo": "^1.2.14",
|
||||
"typescript": "^4.7.2"
|
||||
"turbo": "^1.2.16",
|
||||
"typescript": "^4.7.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,15 @@
|
|||
module.exports = {
|
||||
appId: 'com.qier222.yesplaymusic',
|
||||
productName: 'YesPlayMusic',
|
||||
copyright: 'Copyright © 2022 ${author}',
|
||||
asar: true,
|
||||
copyright: 'Copyright © 2022 qier222',
|
||||
asar: false,
|
||||
directories: {
|
||||
output: 'release',
|
||||
buildResources: 'build',
|
||||
},
|
||||
npmRebuild: false,
|
||||
buildDependenciesFromSource: true,
|
||||
files: ['./dist'],
|
||||
electronVersion: '19.0.3',
|
||||
publish: [
|
||||
{
|
||||
provider: 'github',
|
||||
|
@ -98,16 +98,8 @@ module.exports = {
|
|||
icon: './build/icon.icns',
|
||||
},
|
||||
files: [
|
||||
'dist/main/**/*',
|
||||
'dist/renderer/**/*',
|
||||
{
|
||||
from: 'packages/electron/migrations',
|
||||
to: 'dist/main/migrations',
|
||||
},
|
||||
{
|
||||
from: 'src/main/assets',
|
||||
to: 'dist/main/assets',
|
||||
},
|
||||
'!**/*.ts',
|
||||
'!**/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',
|
||||
|
@ -120,5 +112,24 @@ module.exports = {
|
|||
'!**/{appveyor.yml,.travis.yml,circle.yml}',
|
||||
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
|
||||
'!**/*.{map,debug.min.js}',
|
||||
|
||||
'!**/dist/binary',
|
||||
{
|
||||
from: './dist',
|
||||
to: './main',
|
||||
},
|
||||
{
|
||||
from: '../web/dist',
|
||||
to: './web',
|
||||
},
|
||||
{
|
||||
from: './migrations',
|
||||
to: 'main/migrations',
|
||||
},
|
||||
{
|
||||
from: './assets',
|
||||
to: 'main/assets',
|
||||
},
|
||||
'./main',
|
||||
],
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 523 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 474 B After Width: | Height: | Size: 474 B |
Before Width: | Height: | Size: 750 B After Width: | Height: | Size: 750 B |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 965 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 276 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
@ -2,11 +2,13 @@
|
|||
"name": "electron",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"main": "./index.js",
|
||||
"main": "./main/index.js",
|
||||
"author": "*",
|
||||
"scripts": {
|
||||
"post-install": "node scripts/build.sqlite3.js",
|
||||
"dev": "node scripts/build.main.mjs --watch",
|
||||
"build": "node scripts/build.main.mjs",
|
||||
"pack": "electron-builder build -c .electron-builder.config.js",
|
||||
"test:types": "tsc --noEmit --project src/main/tsconfig.json",
|
||||
"lint": "eslint --ext .ts,.js ./",
|
||||
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'"
|
||||
|
@ -18,7 +20,7 @@
|
|||
"@sentry/node": "^6.19.7",
|
||||
"@sentry/tracing": "^6.19.7",
|
||||
"@unblockneteasemusic/rust-napi": "^0.3.0",
|
||||
"NeteaseCloudMusicApi": "^4.6.0",
|
||||
"NeteaseCloudMusicApi": "^4.6.2",
|
||||
"better-sqlite3": "7.5.1",
|
||||
"change-case": "^4.1.2",
|
||||
"compare-versions": "^4.1.3",
|
||||
|
@ -26,7 +28,7 @@
|
|||
"electron-log": "^4.4.6",
|
||||
"electron-store": "^8.0.1",
|
||||
"express": "^4.18.1",
|
||||
"fast-folder-size": "^1.6.1",
|
||||
"fast-folder-size": "^1.7.0",
|
||||
"pretty-bytes": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -35,18 +37,18 @@
|
|||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-fileupload": "^1.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.26.0",
|
||||
"@typescript-eslint/parser": "^5.26.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.27.0",
|
||||
"@typescript-eslint/parser": "^5.27.0",
|
||||
"@vitejs/plugin-react": "^1.3.1",
|
||||
"axios": "^0.27.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"electron": "^19.0.1",
|
||||
"electron": "^19.0.3",
|
||||
"electron-builder": "^23.0.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-rebuild": "^3.2.7",
|
||||
"electron-releases": "^3.1021.0",
|
||||
"esbuild": "^0.14.41",
|
||||
"electron-releases": "^3.1026.0",
|
||||
"esbuild": "^0.14.42",
|
||||
"eslint": "*",
|
||||
"express-fileupload": "^1.4.0",
|
||||
"minimist": "^1.2.6",
|
||||
|
@ -59,6 +61,6 @@
|
|||
"wait-on": "^6.0.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@electron/universal": "1.2.1"
|
||||
"@electron/universal": "1.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ export enum UserApiNames {
|
|||
FetchUserPlaylists = 'fetchUserPlaylists',
|
||||
FetchUserAlbums = 'fetchUserAlbums',
|
||||
FetchUserArtist = 'fetchUserArtists',
|
||||
FetchListenedRecords = 'fetchListenedRecords',
|
||||
}
|
||||
|
||||
// 获取账号详情
|
||||
|
|
|
@ -3,16 +3,19 @@ import TitleBar from '@/web/components/TitleBar'
|
|||
import IpcRendererReact from '@/web/IpcRendererReact'
|
||||
import Layout from '@/web/components/New/Layout'
|
||||
import Devtool from '@/web/components/New/Devtool'
|
||||
import ErrorBoundary from '@/web/components/New/ErrorBoundary'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<div className='dark '>
|
||||
{window.env?.isEnableTitlebar && <TitleBar />}
|
||||
<Layout />
|
||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||
<IpcRendererReact />
|
||||
<Devtool />
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<div className='dark'>
|
||||
{window.env?.isEnableTitlebar && <TitleBar />}
|
||||
<Layout />
|
||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||
<IpcRendererReact />
|
||||
<Devtool />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,12 @@ const fetch = async (params: FetchAlbumParams, noCache?: boolean) => {
|
|||
return album
|
||||
}
|
||||
|
||||
const fetchFromCache = (id: number): FetchAlbumResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Album,
|
||||
query: { id },
|
||||
})
|
||||
|
||||
export default function useAlbum(params: FetchAlbumParams, noCache?: boolean) {
|
||||
return useQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
|
@ -24,13 +30,7 @@ export default function useAlbum(params: FetchAlbumParams, noCache?: boolean) {
|
|||
{
|
||||
enabled: !!params.id,
|
||||
staleTime: 24 * 60 * 60 * 1000, // 24 hours
|
||||
placeholderData: (): FetchAlbumResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Album,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
placeholderData: () => fetchFromCache(params.id),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
|
|||
}
|
||||
|
||||
export async function prefetchAlbum(params: FetchAlbumParams) {
|
||||
if (fetchFromCache(params.id)) return
|
||||
await reactQueryClient.prefetchQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
() => fetch(params),
|
||||
|
|
27
packages/web/api/hooks/useArtists.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { fetchArtist } from '@/web/api/artist'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
FetchArtistParams,
|
||||
ArtistApiNames,
|
||||
FetchArtistResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function useArtists(ids: number[]) {
|
||||
return useQuery(
|
||||
['fetchArtists', ids],
|
||||
() => Promise.all(ids.map(id => fetchArtist({ id }, false))),
|
||||
{
|
||||
enabled: !!ids && ids.length > 0,
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
// placeholderData: (): FetchArtistResponse[] =>
|
||||
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
// api: APIs.Artist,
|
||||
// query: {
|
||||
// ids,
|
||||
// },
|
||||
// }),
|
||||
}
|
||||
)
|
||||
}
|
|
@ -13,6 +13,12 @@ const fetch = (params: FetchPlaylistParams, noCache?: boolean) => {
|
|||
return fetchPlaylist(params, !!noCache)
|
||||
}
|
||||
|
||||
export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Playlist,
|
||||
query: { id },
|
||||
})
|
||||
|
||||
export default function usePlaylist(
|
||||
params: FetchPlaylistParams,
|
||||
noCache?: boolean
|
||||
|
@ -23,13 +29,7 @@ export default function usePlaylist(
|
|||
{
|
||||
enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))),
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: (): FetchPlaylistResponse | undefined =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Playlist,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
placeholderData: () => fetchFromCache(params.id),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -45,6 +45,7 @@ export function fetchPlaylistWithReactQuery(params: FetchPlaylistParams) {
|
|||
}
|
||||
|
||||
export async function prefetchPlaylist(params: FetchPlaylistParams) {
|
||||
if (fetchFromCache(params.id)) return
|
||||
await reactQueryClient.prefetchQuery(
|
||||
[PlaylistApiNames.FetchPlaylist, params],
|
||||
() => fetch(params),
|
||||
|
|
33
packages/web/api/hooks/useUserListenedRecords.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
fetchListenedRecords,
|
||||
FetchListenedRecordsParams,
|
||||
} from '@/web/api/user'
|
||||
import { UserApiNames } from '@/shared/api/User'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import useUser from './useUser'
|
||||
|
||||
export default function useUserListenedRecords(params: {
|
||||
type: 'week' | 'all'
|
||||
}) {
|
||||
const { data: user } = useUser()
|
||||
const uid = user?.account?.id || 0
|
||||
|
||||
return useQuery(
|
||||
[UserApiNames.FetchListenedRecords],
|
||||
() =>
|
||||
fetchListenedRecords({
|
||||
uid,
|
||||
type: params.type === 'week' ? 1 : 0,
|
||||
}),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!uid,
|
||||
// placeholderData: (): FetchUserArtistsResponse =>
|
||||
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
// api: APIs.UserArtists,
|
||||
// }),
|
||||
}
|
||||
)
|
||||
}
|
|
@ -62,6 +62,47 @@ export function fetchUserLikedTracksIDs(
|
|||
})
|
||||
}
|
||||
|
||||
// 听歌打卡
|
||||
export function scrobble(params: {
|
||||
id: number // track id
|
||||
sourceid: number // 歌单或专辑id
|
||||
time?: number // 已听秒数
|
||||
}): Promise<null> {
|
||||
return request({
|
||||
url: '/scrobble',
|
||||
method: 'post',
|
||||
params: {
|
||||
...params,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface FetchListenedRecordsParams {
|
||||
uid: number // 用户id
|
||||
type: number // type=1 时只返回 weekData, type=0 时返回 allData
|
||||
}
|
||||
export interface FetchListenedRecordsResponse {
|
||||
code: number
|
||||
weekData: {
|
||||
playCount: number
|
||||
score: number
|
||||
song: Track
|
||||
}[]
|
||||
}
|
||||
export function fetchListenedRecords(
|
||||
params: FetchListenedRecordsParams
|
||||
): Promise<FetchListenedRecordsResponse> {
|
||||
return request({
|
||||
url: '/user/record',
|
||||
method: 'get',
|
||||
params: {
|
||||
...params,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日签到
|
||||
* 说明 : 调用此接口可签到获取积分
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 5.55297L12.4286 5.19799C15.0513 3.02586 18.9408 3.91027 20.4429 7.02028C21.4584 9.12294 21.0603 11.6624 19.4547 13.3247L13.7186 19.263C12.7694 20.2457 11.2305 20.2457 10.2814 19.263L4.54533 13.3247C2.93965 11.6624 2.54158 9.12294 3.55711 7.02028C5.05915 3.91027 8.9487 3.02586 11.5714 5.19799L12 5.55297Z" fill="currentColor"/>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9912 22.1621C14.2197 22.1621 14.5537 21.9863 14.8262 21.8105C19.7393 18.6465 22.8857 14.9375 22.8857 11.1846C22.8857 7.94141 20.6445 5.69141 17.8408 5.69141C16.0918 5.69141 14.7822 6.6582 13.9912 8.09961C13.2178 6.6582 11.8994 5.69141 10.1504 5.69141C7.33789 5.69141 5.09668 7.94141 5.09668 11.1846C5.09668 14.9375 8.25195 18.6465 13.1562 21.8105C13.4287 21.9863 13.7627 22.1621 13.9912 22.1621Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 535 B |
|
@ -2,6 +2,8 @@ import { resizeImage } from '@/web/utils/common'
|
|||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Image from './Image'
|
||||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
|
||||
const CoverRow = ({
|
||||
albums,
|
||||
|
@ -21,6 +23,11 @@ const CoverRow = ({
|
|||
if (playlists) navigate(`/playlist/${id}`)
|
||||
}
|
||||
|
||||
const prefetch = (id: number) => {
|
||||
if (albums) prefetchAlbum({ id })
|
||||
if (playlists) prefetchPlaylist({ id })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Title */}
|
||||
|
@ -39,6 +46,7 @@ const CoverRow = ({
|
|||
alt={album.name}
|
||||
src={resizeImage(album?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={() => prefetch(album.id)}
|
||||
/>
|
||||
))}
|
||||
{playlists?.map(playlist => (
|
||||
|
@ -46,8 +54,12 @@ const CoverRow = ({
|
|||
onClick={() => goTo(playlist.id)}
|
||||
key={playlist.id}
|
||||
alt={playlist.name}
|
||||
src={resizeImage(playlist?.picUrl || '', 'md')}
|
||||
src={resizeImage(
|
||||
playlist.coverImgUrl || playlist?.picUrl || '',
|
||||
'md'
|
||||
)}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={() => prefetch(playlist.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { sampleSize, shuffle } from 'lodash-es'
|
||||
import Image from './Image'
|
||||
import { covers } from '@/web/.storybook/mock/tracks'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import useBreakpoint from '@/web/hooks/useBreakpoint'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||
|
||||
const CoverWall = () => {
|
||||
const bigCover = useMemo(
|
||||
() =>
|
||||
shuffle(
|
||||
sampleSize([...Array(covers.length).keys()], ~~(covers.length / 3))
|
||||
),
|
||||
[]
|
||||
)
|
||||
const CoverWall = ({
|
||||
albums,
|
||||
}: {
|
||||
albums: { id: number; coverUrl: string; large: boolean }[]
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const breakpoint = useBreakpoint()
|
||||
const sizes = {
|
||||
small: {
|
||||
|
@ -23,7 +20,7 @@ const CoverWall = () => {
|
|||
xl: 'sm',
|
||||
'2xl': 'md',
|
||||
},
|
||||
big: {
|
||||
large: {
|
||||
sm: 'xs',
|
||||
md: 'sm',
|
||||
lg: 'md',
|
||||
|
@ -41,19 +38,21 @@ const CoverWall = () => {
|
|||
`
|
||||
)}
|
||||
>
|
||||
{covers.map((cover, index) => (
|
||||
{albums.map(album => (
|
||||
<Image
|
||||
src={resizeImage(
|
||||
cover,
|
||||
sizes[bigCover.includes(index) ? 'big' : 'small'][breakpoint]
|
||||
album.coverUrl,
|
||||
sizes[album.large ? 'large' : 'small'][breakpoint]
|
||||
)}
|
||||
key={cover}
|
||||
key={album.id}
|
||||
alt='Album Cover'
|
||||
placeholder={null}
|
||||
className={cx(
|
||||
'aspect-square h-full w-full rounded-24',
|
||||
bigCover.includes(index) && 'col-span-2 row-span-2'
|
||||
album.large && 'col-span-2 row-span-2'
|
||||
)}
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
onMouseOver={() => prefetchAlbum({ id: album.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
43
packages/web/components/New/ErrorBoundary.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { ErrorBoundary as ErrorBoundaryRaw } from 'react-error-boundary'
|
||||
|
||||
function ErrorFallback({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role='alert'
|
||||
className='app-region-drag flex h-screen w-screen items-center justify-center bg-white dark:bg-black'
|
||||
>
|
||||
<div className='app-region-no-drag'>
|
||||
<p>Something went wrong:</p>
|
||||
<pre>{error.message}</pre>
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorBoundary = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<ErrorBoundaryRaw
|
||||
FallbackComponent={ErrorFallback}
|
||||
onReset={() => {
|
||||
// reset the state of your app so the error doesn't happen again
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundaryRaw>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
|
@ -12,6 +12,7 @@ const Image = ({
|
|||
sizes,
|
||||
placeholder = 'blank',
|
||||
onClick,
|
||||
onMouseOver,
|
||||
}: {
|
||||
src?: string
|
||||
srcSet?: string
|
||||
|
@ -21,6 +22,7 @@ const Image = ({
|
|||
lazyLoad?: boolean
|
||||
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | null
|
||||
onClick?: (e: React.MouseEvent<HTMLImageElement>) => void
|
||||
onMouseOver?: (e: React.MouseEvent<HTMLImageElement>) => void
|
||||
}) => {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
@ -57,6 +59,7 @@ const Image = ({
|
|||
initial={{ opacity: 0 }}
|
||||
transition={transition}
|
||||
onClick={onClick}
|
||||
onMouseOver={onMouseOver}
|
||||
/>
|
||||
|
||||
{/* Placeholder / Error fallback */}
|
||||
|
|
|
@ -9,18 +9,19 @@ import { useSnapshot } from 'valtio'
|
|||
|
||||
const Layout = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot])
|
||||
const showPlayer = useMemo(() => !!playerSnapshot.track, [playerSnapshot])
|
||||
|
||||
return (
|
||||
<div
|
||||
id='layout'
|
||||
className={cx(
|
||||
'grid h-screen select-none overflow-hidden rounded-24 bg-white dark:bg-black',
|
||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
|
||||
window.ipcRenderer && 'rounded-24',
|
||||
css`
|
||||
grid-template-columns: 6.5rem auto 358px;
|
||||
grid-template-rows: 132px auto;
|
||||
`,
|
||||
track
|
||||
showPlayer
|
||||
? css`
|
||||
grid-template-areas:
|
||||
'sidebar main -'
|
||||
|
@ -36,7 +37,7 @@ const Layout = () => {
|
|||
<Sidebar />
|
||||
<Topbar />
|
||||
<Main />
|
||||
{track && <Player />}
|
||||
{showPlayer && <Player />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -45,18 +45,21 @@ const Cover = () => {
|
|||
const duration = 150 // ms
|
||||
|
||||
useEffect(() => {
|
||||
const cover = resizeImage(playerSnapshot.track?.al.picUrl || '', 'lg')
|
||||
const resizedCover = resizeImage(
|
||||
playerSnapshot.track?.al.picUrl || '',
|
||||
'lg'
|
||||
)
|
||||
const animate = async () => {
|
||||
animationStartTime.current = Date.now()
|
||||
await controls.start({ opacity: 0 })
|
||||
setCover(cover)
|
||||
setCover(resizedCover)
|
||||
}
|
||||
animate()
|
||||
}, [controls, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||
useEffect(() => {
|
||||
const realCover = playerSnapshot.track?.al.picUrl ?? ''
|
||||
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
|
||||
if (cover !== realCover) setCover(realCover)
|
||||
}, [cover, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
|
@ -156,7 +159,7 @@ const NowPlaying = () => {
|
|||
|
||||
<button>
|
||||
<Icon
|
||||
name='repeat-1'
|
||||
name='heart'
|
||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||
/>
|
||||
</button>
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import React, { useMemo } from 'react'
|
||||
import { player } from '@/web/store'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Image from './Image'
|
||||
import Wave from './Wave'
|
||||
|
||||
const PlayingNext = ({ className }: { className?: string }) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const list = useMemo(
|
||||
() => playerSnapshot.trackList.slice(playerSnapshot.trackIndex + 1, 100),
|
||||
[playerSnapshot.trackList, playerSnapshot.trackIndex]
|
||||
)
|
||||
const { data: tracks } = useTracks({ ids: list })
|
||||
const { data: tracks } = useTracks({ ids: playerSnapshot.trackList })
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -28,19 +24,13 @@ const PlayingNext = ({ className }: { className?: string }) => {
|
|||
className={cx(
|
||||
'relative z-10 overflow-scroll',
|
||||
className,
|
||||
css``,
|
||||
css`
|
||||
padding-top: 42px;
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 42px);
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
css`
|
||||
padding-top: 42px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
black 42px
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 42px);
|
||||
`
|
||||
)}
|
||||
>
|
||||
|
@ -57,13 +47,19 @@ const PlayingNext = ({ className }: { className?: string }) => {
|
|||
duration: 0.24,
|
||||
}}
|
||||
layout
|
||||
onClick={e => {
|
||||
if (e.detail === 2) player.playTrack(track.id)
|
||||
}}
|
||||
>
|
||||
{/* Cover */}
|
||||
<Image
|
||||
alt='Cover'
|
||||
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
|
||||
src={resizeImage(track.al.picUrl, 'sm')}
|
||||
/>
|
||||
<div className='flex-grow'>
|
||||
|
||||
{/* Track info */}
|
||||
<div className='mr-3 flex-grow'>
|
||||
<div className='line-clamp-1 text-18 font-medium text-neutral-700 dark:text-neutral-200'>
|
||||
{track.name}
|
||||
</div>
|
||||
|
@ -71,13 +67,21 @@ const PlayingNext = ({ className }: { className?: string }) => {
|
|||
{track.ar.map(a => a.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-18 font-medium text-neutral-700 dark:text-neutral-200'>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
{/* Wave icon */}
|
||||
{playerSnapshot.trackIndex === index ? (
|
||||
<Wave playing={playerSnapshot.state === 'playing'} />
|
||||
) : (
|
||||
<div className='text-18 font-medium text-neutral-700 dark:text-neutral-200'>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className='pointer-events-none sticky bottom-0 h-8 w-full bg-gradient-to-t from-black'></div>
|
||||
{(tracks?.songs.length || 0) >= 4 && (
|
||||
<div className='pointer-events-none sticky bottom-0 h-8 w-full bg-gradient-to-t from-black'></div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Route, RouteObject, Routes, useLocation } from 'react-router-dom'
|
||||
import Login from '@/web/pages/Login'
|
||||
import Playlist from '@/web/pages/Playlist'
|
||||
import Artist from '@/web/pages/Artist'
|
||||
import Search from '@/web/pages/Search'
|
||||
import Library from '@/web/pages/Library'
|
||||
|
@ -11,6 +10,7 @@ import React, { ReactNode, Suspense } from 'react'
|
|||
const My = React.lazy(() => import('@/web/pages/New/My'))
|
||||
const Discover = React.lazy(() => import('@/web/pages/New/Discover'))
|
||||
const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
||||
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
|
@ -71,6 +71,7 @@ const Router = () => {
|
|||
<Route path='/discover' element={lazy(<Discover />)} />
|
||||
<Route path='/login' element={lazy(<Login />)} />
|
||||
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
|
|
@ -82,7 +82,7 @@ const Topbar = () => {
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-drag fixed top-0 right-0 z-10 flex items-center justify-between pt-11 pb-10 pr-6 pl-10 ',
|
||||
'app-region-drag fixed top-0 right-0 z-20 flex items-center justify-between overflow-hidden rounded-tr-24 pt-11 pb-10 pr-6 pl-10 ',
|
||||
css`
|
||||
left: 104px;
|
||||
`,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css'
|
|||
import { useMemo } from 'react'
|
||||
import { player } from '@/web/store'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Wave from './Wave'
|
||||
|
||||
const TrackList = ({
|
||||
tracks,
|
||||
|
@ -23,18 +24,18 @@ const TrackList = ({
|
|||
if (e.detail === 2) onPlay?.(trackID)
|
||||
}
|
||||
|
||||
const playing = useMemo(
|
||||
() => playerSnapshot.state === 'playing',
|
||||
[playerSnapshot.state]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{tracks?.map(track => (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={e => handleClick(e, track.id)}
|
||||
className={cx(
|
||||
'flex py-2 text-18 font-medium transition duration-300 ease-in-out',
|
||||
playingTrack?.id === track.id
|
||||
? 'text-brand-700'
|
||||
: 'text-night-50 dark:hover:text-neutral-200'
|
||||
)}
|
||||
className='relative flex items-center py-2 text-18 font-medium text-night-50 transition duration-300 ease-in-out dark:hover:text-neutral-200'
|
||||
>
|
||||
<div className='mr-6'>{String(track.no).padStart(2, '0')}</div>
|
||||
<div className='flex-grow'>{track.name}</div>
|
||||
|
@ -42,6 +43,13 @@ const TrackList = ({
|
|||
<div className='text-right'>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
</div>
|
||||
|
||||
{/* The wave icon */}
|
||||
{playingTrack?.id === track.id && playing && (
|
||||
<div className='absolute -left-7'>
|
||||
<Wave playing={playing} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import { formatDate, formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import dayjs from 'dayjs'
|
||||
|
@ -7,9 +7,11 @@ import Image from './Image'
|
|||
|
||||
const TrackListHeader = ({
|
||||
album,
|
||||
playlist,
|
||||
onPlay,
|
||||
}: {
|
||||
album?: Album
|
||||
playlist?: Playlist
|
||||
onPlay: () => void
|
||||
}) => {
|
||||
const albumDuration = useMemo(() => {
|
||||
|
@ -17,6 +19,8 @@ const TrackListHeader = ({
|
|||
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||
}, [album?.songs])
|
||||
|
||||
const cover = album?.picUrl || playlist?.coverImgUrl || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
|
@ -28,48 +32,58 @@ const TrackListHeader = ({
|
|||
>
|
||||
<Image
|
||||
className='z-10 aspect-square w-full rounded-24'
|
||||
src={resizeImage(album?.picUrl || '', 'lg')}
|
||||
src={resizeImage(cover, 'lg')}
|
||||
alt='Cover'
|
||||
/>
|
||||
|
||||
{/* Blur bg */}
|
||||
<img
|
||||
className={cx(
|
||||
'fixed z-0 object-cover opacity-70',
|
||||
'absolute z-0 object-cover opacity-70',
|
||||
css`
|
||||
top: -400px;
|
||||
left: -370px;
|
||||
width: 1572px;
|
||||
height: 528px;
|
||||
filter: blur(256px) saturate(1.2);
|
||||
/* transform: scale(0.5); */
|
||||
`
|
||||
)}
|
||||
src={resizeImage(album?.picUrl || '', 'lg')}
|
||||
src={resizeImage(cover, 'sm')}
|
||||
/>
|
||||
|
||||
<div className=' flex flex-col justify-between'>
|
||||
<div className='flex flex-col justify-between'>
|
||||
<div>
|
||||
<div className='text-36 font-medium dark:text-neutral-100'>
|
||||
{album?.name}
|
||||
{album?.name || playlist?.name}
|
||||
</div>
|
||||
<div className='mt-6 text-24 font-medium dark:text-neutral-600'>
|
||||
{album?.artist.name}
|
||||
{album?.artist.name || playlist?.creator.nickname}
|
||||
</div>
|
||||
<div className='mt-1 flex items-center text-14 font-bold dark:text-neutral-600'>
|
||||
{album?.mark === 1056768 && (
|
||||
<Icon name='explicit' className='mb-px mr-1 h-3.5 w-3.5 ' />
|
||||
)}{' '}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length}{' '}
|
||||
Songs, {albumDuration}
|
||||
{!!album && (
|
||||
<>
|
||||
{album?.mark === 1056768 && (
|
||||
<Icon name='explicit' className='mb-px mr-1 h-3.5 w-3.5 ' />
|
||||
)}{' '}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length}{' '}
|
||||
Tracks, {albumDuration}
|
||||
</>
|
||||
)}
|
||||
{!!playlist && (
|
||||
<>
|
||||
Updated at {formatDate(playlist?.updateTime || 0, 'en')} ·{' '}
|
||||
{playlist.trackCount} Tracks
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='line-clamp-3 mt-6 text-14 font-bold dark:text-neutral-600'>
|
||||
{album?.description}
|
||||
<div className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-neutral-600'>
|
||||
{album?.description || playlist?.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='z-10 flex'>
|
||||
<button
|
||||
onClick={onPlay}
|
||||
onClick={() => onPlay()}
|
||||
className='h-[72px] w-[170px] rounded-full dark:bg-brand-700'
|
||||
></button>
|
||||
<button className='ml-6 h-[72px] w-[72px] rounded-full dark:bg-night-50'></button>
|
||||
|
|
32
packages/web/components/New/Wave.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { css, cx, keyframes } from '@emotion/css'
|
||||
|
||||
const Wave = ({ playing }: { playing: boolean }) => {
|
||||
const wave = keyframes`
|
||||
0% { transform: scaleY(1) }
|
||||
50% { transform: scaleY(0.2) }
|
||||
100% { transform: scaleY(1)}
|
||||
`
|
||||
const animation = css`
|
||||
transform-origin: bottom;
|
||||
animation: ${wave} 1s ease-in-out infinite;
|
||||
`
|
||||
|
||||
const delay = ['-100ms', '-500ms', '-1200ms', '-1000ms', '-700ms']
|
||||
|
||||
return (
|
||||
<div className='grid h-3 grid-cols-5 items-end gap-0.5'>
|
||||
{[...new Array(5).keys()].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={cx('h-full w-0.5 bg-brand-600', animation)}
|
||||
style={{
|
||||
animationDelay: delay[i],
|
||||
animationPlayState: playing ? 'running' : 'paused',
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wave
|
|
@ -36,6 +36,7 @@
|
|||
"qrcode": "^1.5.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-ga4": "^1.4.1",
|
||||
"react-hot-toast": "^2.2.0",
|
||||
"react-query": "^3.38.0",
|
||||
|
@ -59,12 +60,12 @@
|
|||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.2",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
"@types/react": "^18.0.8",
|
||||
"@types/react": "^18.0.11",
|
||||
"@types/react-dom": "^18.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.26.0",
|
||||
"@typescript-eslint/parser": "^5.26.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.27.0",
|
||||
"@typescript-eslint/parser": "^5.27.0",
|
||||
"@vitejs/plugin-react": "^1.3.1",
|
||||
"@vitest/ui": "^0.12.9",
|
||||
"@vitest/ui": "^0.12.10",
|
||||
"autoprefixer": "^10.4.5",
|
||||
"c8": "^7.11.3",
|
||||
"dotenv": "^16.0.0",
|
||||
|
@ -82,6 +83,6 @@
|
|||
"typescript": "*",
|
||||
"vite": "^2.9.6",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vitest": "^0.12.9"
|
||||
"vitest": "^0.12.10"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,97 @@
|
|||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import { player } from '@/web/store'
|
||||
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 { useMemo } from 'react'
|
||||
|
||||
const MoreByArtist = ({ album }: { album?: Album }) => {
|
||||
const { data: albums } = useArtistAlbums({
|
||||
id: album?.artist?.id || 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const filteredAlbums = useMemo((): Album[] => {
|
||||
if (!albums) return []
|
||||
const allReleases = albums?.hotAlbums || []
|
||||
const filteredAlbums = allReleases.filter(
|
||||
album =>
|
||||
['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
|
||||
)
|
||||
const singles = allReleases.filter(album => album.type === 'Single')
|
||||
|
||||
const qualifiedAlbums = [...filteredAlbums, ...singles]
|
||||
|
||||
const formatName = (name: string) =>
|
||||
name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
|
||||
|
||||
const uniqueAlbums: Album[] = []
|
||||
qualifiedAlbums.forEach(a => {
|
||||
// 去除当前页面的专辑
|
||||
if (formatName(a.name) === formatName(album?.name ?? '')) return
|
||||
|
||||
// 去除重复的专辑(包含 deluxe edition 的专辑会视为重复)
|
||||
if (
|
||||
uniqueAlbums.findIndex(aa => {
|
||||
return formatName(a.name) === formatName(aa.name)
|
||||
}) !== -1
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 去除 remix 专辑
|
||||
if (
|
||||
a.name.toLowerCase().includes('remix)') ||
|
||||
a.name.toLowerCase().includes('remixes)')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
uniqueAlbums.push(a)
|
||||
})
|
||||
|
||||
return uniqueAlbums.slice(0, 4)
|
||||
}, [album?.name, albums])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Dividing line */}
|
||||
<div
|
||||
className={cx(
|
||||
'h-px bg-white/20',
|
||||
css`
|
||||
margin: 30px 0;
|
||||
`
|
||||
)}
|
||||
></div>
|
||||
|
||||
{/* Title */}
|
||||
<div className='mb-5 text-14 font-bold text-neutral-300'>
|
||||
MORE BY{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.id}`}
|
||||
className='transition duration-300 ease-in-out hover:text-neutral-100'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<CoverRow albums={filteredAlbums} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const { data: album, isLoading } = useAlbum({
|
||||
id: Number(params.id) || 0,
|
||||
const { data: album } = useAlbum({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
const { data: tracks } = useTracks({
|
||||
|
@ -37,12 +117,13 @@ const Album = () => {
|
|||
|
||||
return (
|
||||
<PageTransition>
|
||||
<TrackListHeader album={album?.album} onPlay={() => onPlay()} />
|
||||
<TrackListHeader album={album?.album} onPlay={onPlay} />
|
||||
<TrackList
|
||||
tracks={tracks?.songs}
|
||||
className='z-10 mt-20'
|
||||
tracks={tracks?.songs || album?.songs || album?.album.songs}
|
||||
className='z-10 mt-10'
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
<MoreByArtist album={album?.album} />
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,108 @@
|
|||
import CoverWall from '@/web/components/New/CoverWall'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import {
|
||||
fetchPlaylistWithReactQuery,
|
||||
fetchFromCache,
|
||||
} from '@/web/api/hooks/usePlaylist'
|
||||
import useTracks, { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { sampleSize } from 'lodash-es'
|
||||
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
||||
|
||||
interface DiscoverAlbum {
|
||||
id: number
|
||||
coverUrl: string
|
||||
large: boolean
|
||||
}
|
||||
|
||||
const getAlbumsFromAPI = async () => {
|
||||
const playlistsIds = [
|
||||
2859214503, // 一周欧美上新
|
||||
2829816518, // 欧美私人订制
|
||||
5327906368, // 乐迷雷达
|
||||
5362359247, // 宝藏雷达
|
||||
3136952023, // 私人雷达
|
||||
60198, // Billboard 排行榜
|
||||
180106, // UK 排行榜
|
||||
5212729721, // 欧美点唱机
|
||||
2724708415, // 私藏推荐精选
|
||||
5300458264, // 新歌雷达
|
||||
7463185187, // 开发者夹带私货
|
||||
]
|
||||
|
||||
const playlists = (await Promise.all(
|
||||
sampleSize(playlistsIds, 5).map(
|
||||
id =>
|
||||
new Promise(resolve => {
|
||||
const cache = fetchFromCache(id)
|
||||
if (cache) {
|
||||
resolve(cache)
|
||||
return
|
||||
}
|
||||
return fetchPlaylistWithReactQuery({ id })
|
||||
})
|
||||
)
|
||||
)) as FetchPlaylistResponse[]
|
||||
|
||||
const ids: number[] = []
|
||||
playlists.forEach(playlist =>
|
||||
playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id))
|
||||
)
|
||||
if (!ids.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tracks = await fetchTracksWithReactQuery({ ids })
|
||||
if (!tracks.songs.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 从歌单中抽出歌曲
|
||||
const pickedIds: number[] = []
|
||||
let albums: DiscoverAlbum[] = []
|
||||
tracks.songs.forEach(t => {
|
||||
if (pickedIds.includes(t.al.id)) return
|
||||
pickedIds.push(t.al.id)
|
||||
albums.push({
|
||||
id: t.al.id,
|
||||
coverUrl: t.al.picUrl,
|
||||
large: false,
|
||||
})
|
||||
})
|
||||
|
||||
// 挑选出大图
|
||||
albums = sampleSize(albums, 100)
|
||||
const largeCover = sampleSize([...Array(100).keys()], ~~(100 / 3))
|
||||
albums.map((album, index) => (album.large = largeCover.includes(index)))
|
||||
|
||||
localStorage.setItem('discoverAlbums', JSON.stringify(albums))
|
||||
localStorage.setItem('discoverAlbumsTime', String(Date.now()))
|
||||
|
||||
return albums
|
||||
}
|
||||
|
||||
const Discover = () => {
|
||||
const [albums, setAlbums] = useState<DiscoverAlbum[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const get = async () => {
|
||||
const albumsInLocalStorageTime =
|
||||
localStorage.getItem('discoverAlbumsTime')
|
||||
if (
|
||||
!albumsInLocalStorageTime ||
|
||||
Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2
|
||||
) {
|
||||
setAlbums(await getAlbumsFromAPI())
|
||||
} else {
|
||||
setAlbums(JSON.parse(localStorage.getItem('discoverAlbums') || '[]'))
|
||||
}
|
||||
}
|
||||
get()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageTransition disableEnterAnimation={true}>
|
||||
<CoverWall />
|
||||
<CoverWall albums={albums} />
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,10 +4,12 @@ import PageTransition from '@/web/components/New/PageTransition'
|
|||
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||
import Tabs from '@/web/components/New/Tabs'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, 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 useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords'
|
||||
import useArtists from '@/web/api/hooks/useArtists'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
@ -34,12 +36,41 @@ const My = () => {
|
|||
const { data: albums } = useUserAlbums()
|
||||
const [selectedTab, setSelectedTab] = useState(tabs[0].id)
|
||||
|
||||
const { data: listenedRecords } = useUserListenedRecords({ type: 'week' })
|
||||
const recentListenedArtistsIDs = useMemo(() => {
|
||||
const artists: {
|
||||
id: number
|
||||
playCount: number
|
||||
}[] = []
|
||||
listenedRecords?.weekData?.forEach(record => {
|
||||
const artist = record.song.ar[0]
|
||||
const index = artists.findIndex(a => a.id === artist.id)
|
||||
if (index === -1) {
|
||||
artists.push({
|
||||
id: artist.id,
|
||||
playCount: record.playCount,
|
||||
})
|
||||
} else {
|
||||
artists[index].playCount += record.playCount
|
||||
}
|
||||
})
|
||||
|
||||
return artists
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.slice(0, 5)
|
||||
.map(artist => artist.id)
|
||||
}, [listenedRecords])
|
||||
const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs)
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className='grid grid-cols-1 gap-10'>
|
||||
<PlayLikedSongsCard />
|
||||
<div>
|
||||
<ArtistRow artists={artists?.data} title='ARTISTS' />
|
||||
<ArtistRow
|
||||
artists={recentListenedArtists?.map(a => a.artist)}
|
||||
title='RECENTLY LISTENED'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
@ -48,7 +79,7 @@ const My = () => {
|
|||
value={selectedTab}
|
||||
onChange={(id: string) => setSelectedTab(id)}
|
||||
/>
|
||||
<CoverRow albums={albums?.data} className='mt-6' />
|
||||
<CoverRow playlists={playlists?.playlist} className='mt-6' />
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
|
|
48
packages/web/pages/New/Playlist.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import { player } from '@/web/store'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import useTracksInfinite from '@/web/api/hooks/useTracksInfinite'
|
||||
import useScroll from '@/web/hooks/useScroll'
|
||||
const Playlist = () => {
|
||||
const params = useParams()
|
||||
const { data: playlist, isLoading } = usePlaylist({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
if (!playlist?.playlist.id) {
|
||||
toast('无法播放歌单,该歌单不存在')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
playerSnapshot.trackListSource?.type === 'playlist' &&
|
||||
playerSnapshot.trackListSource?.id === playlist.playlist.id &&
|
||||
playlist?.playlist?.trackIds?.[0].id
|
||||
) {
|
||||
await player.playTrack(trackID ?? playlist.playlist.trackIds[0].id)
|
||||
return
|
||||
}
|
||||
await player.playPlaylist(playlist.playlist.id, trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<TrackListHeader playlist={playlist?.playlist} onPlay={onPlay} />
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlist
|
|
@ -14,6 +14,7 @@ import { fetchAlbumWithReactQuery } from '@/web/api/hooks/useAlbum'
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||
import toast from 'react-hot-toast'
|
||||
import { scrobble } from '@/web/api/user'
|
||||
|
||||
type TrackID = number
|
||||
export enum TrackListSourceType {
|
||||
|
@ -173,7 +174,8 @@ export class Player {
|
|||
this._loadMoreFMTracks()
|
||||
}
|
||||
|
||||
private setStateToLoading() {
|
||||
private _setStateToLoading() {
|
||||
this._scrobble()
|
||||
this.state = State.Loading
|
||||
_howler.pause()
|
||||
}
|
||||
|
@ -184,6 +186,20 @@ export class Player {
|
|||
}, 1000)
|
||||
}
|
||||
|
||||
private async _scrobble() {
|
||||
if (!this.track?.id || !this.trackListSource?.id) {
|
||||
return
|
||||
}
|
||||
if (this.progress <= this.track.dt / 1000 / 3) {
|
||||
return
|
||||
}
|
||||
scrobble({
|
||||
id: this.track.id,
|
||||
sourceid: this.trackListSource.id,
|
||||
time: ~~this.progress,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch track details from Netease based on this.trackID
|
||||
*/
|
||||
|
@ -356,7 +372,7 @@ export class Player {
|
|||
* Play previous track
|
||||
*/
|
||||
prevTrack() {
|
||||
this.setStateToLoading()
|
||||
this._setStateToLoading()
|
||||
this._progress = 0
|
||||
if (this.mode === Mode.FM) {
|
||||
toast('Personal FM not support previous track')
|
||||
|
@ -374,7 +390,7 @@ export class Player {
|
|||
* Play next track
|
||||
*/
|
||||
nextTrack(forceFM: boolean = false) {
|
||||
this.setStateToLoading()
|
||||
this._setStateToLoading()
|
||||
this._progress = 0
|
||||
if (forceFM || this.mode === Mode.FM) {
|
||||
this.mode = Mode.FM
|
||||
|
@ -396,7 +412,7 @@ export class Player {
|
|||
* @param {null|number} autoPlayTrackID
|
||||
*/
|
||||
playAList(list: TrackID[], autoPlayTrackID?: null | number) {
|
||||
this.setStateToLoading()
|
||||
this._setStateToLoading()
|
||||
this.mode = Mode.TrackList
|
||||
this.trackList = list
|
||||
this._trackIndex = autoPlayTrackID
|
||||
|
@ -407,16 +423,16 @@ export class Player {
|
|||
|
||||
/**
|
||||
* Play a playlist
|
||||
* @param {number} playlistID
|
||||
* @param {number} id
|
||||
* @param {null|number=} autoPlayTrackID
|
||||
*/
|
||||
async playPlaylist(playlistID: number, autoPlayTrackID?: null | number) {
|
||||
this.setStateToLoading()
|
||||
const playlist = await fetchPlaylistWithReactQuery({ id: playlistID })
|
||||
async playPlaylist(id: number, autoPlayTrackID?: null | number) {
|
||||
this._setStateToLoading()
|
||||
const playlist = await fetchPlaylistWithReactQuery({ id })
|
||||
if (!playlist?.playlist?.trackIds?.length) return
|
||||
this.trackListSource = {
|
||||
type: TrackListSourceType.Playlist,
|
||||
id: playlistID,
|
||||
id,
|
||||
}
|
||||
this.playAList(
|
||||
playlist.playlist.trackIds.map(t => t.id),
|
||||
|
@ -426,18 +442,17 @@ export class Player {
|
|||
|
||||
/**
|
||||
* Play am album
|
||||
* @param {number} albumID
|
||||
* @param {number} id
|
||||
* @param {null|number=} autoPlayTrackID
|
||||
*/
|
||||
async playAlbum(albumID: number, autoPlayTrackID?: null | number) {
|
||||
this.setStateToLoading()
|
||||
const album = await fetchAlbumWithReactQuery({ id: albumID })
|
||||
async playAlbum(id: number, autoPlayTrackID?: null | number) {
|
||||
this._setStateToLoading()
|
||||
const album = await fetchAlbumWithReactQuery({ id })
|
||||
if (!album?.songs?.length) return
|
||||
this.trackListSource = {
|
||||
type: TrackListSourceType.Album,
|
||||
id: albumID,
|
||||
id,
|
||||
}
|
||||
this._playTrack()
|
||||
this.playAList(
|
||||
album.songs.map(t => t.id),
|
||||
autoPlayTrackID
|
||||
|
@ -448,7 +463,7 @@ export class Player {
|
|||
* Play personal fm
|
||||
*/
|
||||
async playFM() {
|
||||
this.setStateToLoading()
|
||||
this._setStateToLoading()
|
||||
this.mode = Mode.FM
|
||||
if (
|
||||
this.fmTrackList.length > 0 &&
|
||||
|
@ -475,7 +490,7 @@ export class Player {
|
|||
* Play track in trackList by id
|
||||
*/
|
||||
async playTrack(trackID: TrackID) {
|
||||
this.setStateToLoading()
|
||||
this._setStateToLoading()
|
||||
const index = this.trackList.findIndex(t => t === trackID)
|
||||
if (!index) toast('播放失败,歌曲不在列表内')
|
||||
this._trackIndex = index
|
||||
|
|
700
pnpm-lock.yaml
|
@ -12,8 +12,12 @@
|
|||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"pack": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": ["release/**"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["build"],
|
||||
"outputs": []
|
||||
},
|
||||
"lint": {
|
||||
|
|