feat: updates

This commit is contained in:
qier222 2022-06-06 01:00:25 +08:00
parent cf7a4528dd
commit 0e58bb6e80
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
44 changed files with 1027 additions and 496 deletions

View File

@ -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"
}
}

View File

@ -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',
],
}

View File

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 523 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 474 B

After

Width:  |  Height:  |  Size: 474 B

View File

Before

Width:  |  Height:  |  Size: 750 B

After

Width:  |  Height:  |  Size: 750 B

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 965 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 276 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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"
}
}

View File

@ -4,6 +4,7 @@ export enum UserApiNames {
FetchUserPlaylists = 'fetchUserPlaylists',
FetchUserAlbums = 'fetchUserAlbums',
FetchUserArtist = 'fetchUserArtists',
FetchListenedRecords = 'fetchListenedRecords',
}
// 获取账号详情

View File

@ -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>
)
}

View File

@ -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),

View 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,
// },
// }),
}
)
}

View File

@ -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),

View 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,
// }),
}
)
}

View File

@ -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(),
},
})
}
/**
*
* 说明 : 调用此接口可签到获取积分

View File

@ -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

View File

@ -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>

View File

@ -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>

View 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

View File

@ -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 */}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
)

View File

@ -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;
`,

View File

@ -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>

View File

@ -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>

View 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

View File

@ -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"
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,12 @@
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"pack": {
"dependsOn": ["build"],
"outputs": ["release/**"]
},
"test": {
"dependsOn": ["^build"],
"dependsOn": ["build"],
"outputs": []
},
"lint": {