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

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