mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 05:38:04 +00:00
feat: updates
This commit is contained in:
parent
ffcc60b793
commit
dd5361b8c4
106 changed files with 11989 additions and 4143 deletions
42
packages/web/components/New/ArtistRow.tsx
Normal file
42
packages/web/components/New/ArtistRow.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Image from './Image'
|
||||
|
||||
const ArtistRow = ({
|
||||
artists,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
artists: Artist[] | undefined
|
||||
title?: string
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{/* Artists */}
|
||||
<div className='grid grid-cols-5 gap-10'>
|
||||
{artists?.map(artist => (
|
||||
<div key={artist.id} className='text-center'>
|
||||
<Image
|
||||
alt={artist.name}
|
||||
src={resizeImage(artist.img1v1Url, 'md')}
|
||||
className='aspect-square rounded-full'
|
||||
/>
|
||||
<div className='line-clamp-1 mt-2.5 text-14 font-bold text-neutral-700 dark:text-neutral-600'>
|
||||
{artist.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistRow
|
||||
58
packages/web/components/New/CoverRow.tsx
Normal file
58
packages/web/components/New/CoverRow.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Image from './Image'
|
||||
|
||||
const CoverRow = ({
|
||||
albums,
|
||||
playlists,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
title?: string
|
||||
className?: string
|
||||
albums?: Album[]
|
||||
playlists?: Playlist[]
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const goTo = (id: number) => {
|
||||
if (albums) navigate(`/album/${id}`)
|
||||
if (playlists) navigate(`/playlist/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
<div className='grid grid-cols-3 gap-10 xl:grid-cols-4 2xl:grid-cols-5'>
|
||||
{albums?.map(album => (
|
||||
<Image
|
||||
onClick={() => goTo(album.id)}
|
||||
key={album.id}
|
||||
alt={album.name}
|
||||
src={resizeImage(album?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
/>
|
||||
))}
|
||||
{playlists?.map(playlist => (
|
||||
<Image
|
||||
onClick={() => goTo(playlist.id)}
|
||||
key={playlist.id}
|
||||
alt={playlist.name}
|
||||
src={resizeImage(playlist?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverRow
|
||||
21
packages/web/components/New/CoverWall.stories.tsx
Normal file
21
packages/web/components/New/CoverWall.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import CoverWall from './CoverWall'
|
||||
import { shuffle } from 'lodash-es'
|
||||
import { covers } from '../../.storybook/mock/tracks'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
|
||||
export default {
|
||||
title: 'Components/CoverWall',
|
||||
component: CoverWall,
|
||||
} as ComponentMeta<typeof CoverWall>
|
||||
|
||||
const Template: ComponentStory<typeof CoverWall> = args => (
|
||||
<div className='rounded-3xl bg-[#F8F8F8] p-10 dark:bg-black'>
|
||||
<CoverWall
|
||||
covers={shuffle(covers.map(c => resizeImage(c, 'lg'))).slice(0, 31)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
63
packages/web/components/New/CoverWall.tsx
Normal file
63
packages/web/components/New/CoverWall.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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'
|
||||
|
||||
const CoverWall = () => {
|
||||
const bigCover = useMemo(
|
||||
() =>
|
||||
shuffle(
|
||||
sampleSize([...Array(covers.length).keys()], ~~(covers.length / 3))
|
||||
),
|
||||
[]
|
||||
)
|
||||
const breakpoint = useBreakpoint()
|
||||
const sizes = {
|
||||
small: {
|
||||
sm: 'xs',
|
||||
md: 'xs',
|
||||
lg: 'sm',
|
||||
xl: 'sm',
|
||||
'2xl': 'md',
|
||||
},
|
||||
big: {
|
||||
sm: 'xs',
|
||||
md: 'sm',
|
||||
lg: 'md',
|
||||
xl: 'md',
|
||||
'2xl': 'lg',
|
||||
},
|
||||
} as const
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'grid w-full grid-flow-row-dense grid-cols-8',
|
||||
css`
|
||||
gap: 13px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{covers.map((cover, index) => (
|
||||
<Image
|
||||
src={resizeImage(
|
||||
cover,
|
||||
sizes[bigCover.includes(index) ? 'big' : 'small'][breakpoint]
|
||||
)}
|
||||
key={cover}
|
||||
alt='Album Cover'
|
||||
placeholder={null}
|
||||
className={cx(
|
||||
'aspect-square h-full w-full rounded-24',
|
||||
bigCover.includes(index) && 'col-span-2 row-span-2'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverWall
|
||||
19
packages/web/components/New/Devtool.tsx
Normal file
19
packages/web/components/New/Devtool.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ReactQueryDevtools } from 'react-query/devtools'
|
||||
|
||||
const Devtool = () => {
|
||||
return (
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
toggleButtonProps={{
|
||||
style: {
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 'auto',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Devtool
|
||||
78
packages/web/components/New/Image.tsx
Normal file
78
packages/web/components/New/Image.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const Image = ({
|
||||
src,
|
||||
srcSet,
|
||||
className,
|
||||
alt,
|
||||
lazyLoad = true,
|
||||
sizes,
|
||||
placeholder = 'blank',
|
||||
onClick,
|
||||
}: {
|
||||
src?: string
|
||||
srcSet?: string
|
||||
sizes?: string
|
||||
className?: string
|
||||
alt: string
|
||||
lazyLoad?: boolean
|
||||
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | null
|
||||
onClick?: (e: React.MouseEvent<HTMLImageElement>) => void
|
||||
}) => {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const animate = useAnimation()
|
||||
const placeholderAnimate = useAnimation()
|
||||
const transition = { duration: 0.6, ease }
|
||||
|
||||
useEffect(() => setError(false), [src])
|
||||
|
||||
const onload = async () => {
|
||||
setLoaded(true)
|
||||
animate.start({ opacity: 1 })
|
||||
}
|
||||
const onError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const hidden = error || !loaded
|
||||
|
||||
return (
|
||||
<div className={cx('relative overflow-hidden', className)}>
|
||||
{/* Image */}
|
||||
<motion.img
|
||||
alt={alt}
|
||||
className={cx('absolute inset-0 h-full w-full')}
|
||||
src={src}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
decoding='async'
|
||||
loading={lazyLoad ? 'lazy' : undefined}
|
||||
onLoad={onload}
|
||||
onError={onError}
|
||||
animate={animate}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={transition}
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
||||
{/* Placeholder / Error fallback */}
|
||||
<AnimatePresence>
|
||||
{hidden && placeholder && (
|
||||
<motion.div
|
||||
animate={placeholderAnimate}
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={transition}
|
||||
className='absolute inset-0 h-full w-full bg-white dark:bg-neutral-800'
|
||||
></motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Image
|
||||
44
packages/web/components/New/Layout.tsx
Normal file
44
packages/web/components/New/Layout.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import Main from '@/web/components/New/Main'
|
||||
import Player from '@/web/components/New/Player'
|
||||
import Sidebar from '@/web/components/New/Sidebar'
|
||||
import Topbar from '@/web/components/New/Topbar'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { player } from '@/web/store'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Layout = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot])
|
||||
|
||||
return (
|
||||
<div
|
||||
id='layout'
|
||||
className={cx(
|
||||
'grid h-screen select-none overflow-hidden rounded-24 bg-white dark:bg-black',
|
||||
css`
|
||||
grid-template-columns: 6.5rem auto 358px;
|
||||
grid-template-rows: 132px auto;
|
||||
`,
|
||||
track
|
||||
? css`
|
||||
grid-template-areas:
|
||||
'sidebar main -'
|
||||
'sidebar main player';
|
||||
`
|
||||
: css`
|
||||
grid-template-areas:
|
||||
'sidebar main main'
|
||||
'sidebar main main';
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
<Topbar />
|
||||
<Main />
|
||||
{track && <Player />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
23
packages/web/components/New/Main.tsx
Normal file
23
packages/web/components/New/Main.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Router from './Router'
|
||||
|
||||
const Main = () => {
|
||||
return (
|
||||
<main
|
||||
className={cx(
|
||||
'overflow-y-auto pb-16 pr-6 pl-10',
|
||||
css`
|
||||
padding-top: 132px;
|
||||
grid-area: main;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Router />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Main
|
||||
23
packages/web/components/New/NowPlaying.stories.tsx
Normal file
23
packages/web/components/New/NowPlaying.stories.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import NowPlaying from './NowPlaying'
|
||||
import tracks from '../../.storybook/mock/tracks'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
export default {
|
||||
title: 'Components/NowPlaying',
|
||||
component: NowPlaying,
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: 'iphone8p',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof NowPlaying>
|
||||
|
||||
const Template: ComponentStory<typeof NowPlaying> = args => (
|
||||
<div className='fixed inset-0 bg-[#F8F8F8] p-4 dark:bg-black'>
|
||||
<NowPlaying track={sample(tracks)} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
169
packages/web/components/New/NowPlaying.tsx
Normal file
169
packages/web/components/New/NowPlaying.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import { player } from '@/web/store'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||
import Slider from './Slider'
|
||||
import { animate, motion, useAnimation } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const Progress = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const progress = useMemo(
|
||||
() => playerSnapshot.progress,
|
||||
[playerSnapshot.progress]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='mt-10 flex w-full flex-col'>
|
||||
<Slider
|
||||
min={0}
|
||||
max={(track?.dt ?? 100000) / 1000}
|
||||
value={progress}
|
||||
onChange={value => {
|
||||
player.progress = value
|
||||
}}
|
||||
onlyCallOnChangeAfterDragEnded={true}
|
||||
/>
|
||||
|
||||
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
|
||||
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
|
||||
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Cover = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const [cover, setCover] = useState('')
|
||||
const animationStartTime = useRef(0)
|
||||
const controls = useAnimation()
|
||||
const duration = 150 // ms
|
||||
|
||||
useEffect(() => {
|
||||
const cover = resizeImage(playerSnapshot.track?.al.picUrl || '', 'lg')
|
||||
const animate = async () => {
|
||||
animationStartTime.current = Date.now()
|
||||
await controls.start({ opacity: 0 })
|
||||
setCover(cover)
|
||||
}
|
||||
animate()
|
||||
}, [controls, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||
useEffect(() => {
|
||||
const realCover = playerSnapshot.track?.al.picUrl ?? ''
|
||||
if (cover !== realCover) setCover(realCover)
|
||||
}, [cover, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
const onLoad = () => {
|
||||
const passedTime = Date.now() - animationStartTime.current
|
||||
controls.start({
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.img
|
||||
animate={controls}
|
||||
transition={{ duration: duration / 1000, ease }}
|
||||
className={cx('absolute inset-0 w-full')}
|
||||
src={cover}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NowPlaying = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
|
||||
css`
|
||||
border-color: hsl(0, 100%, 100%, 0.08);
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Cover */}
|
||||
<Cover />
|
||||
|
||||
{/* Info & Controls */}
|
||||
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
|
||||
{/* Track Info */}
|
||||
<div className='line-clamp-1 text-lg text-black dark:text-white'>
|
||||
{track?.name}
|
||||
</div>
|
||||
<div className='line-clamp-1 text-base text-black/30 dark:text-white/30'>
|
||||
{track?.ar.map(a => a.name).join(', ')}
|
||||
</div>
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
|
||||
|
||||
{/* Progress */}
|
||||
<Progress />
|
||||
|
||||
{/* Controls */}
|
||||
<div className='mt-4 flex w-full items-center justify-between'>
|
||||
<button>
|
||||
<Icon
|
||||
name='shuffle'
|
||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className='text-black/95 dark:text-white/80'>
|
||||
<button
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||
>
|
||||
<Icon name='previous' className='h-6 w-6 ' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => track && player.playOrPause()}
|
||||
className='mx-2 rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
className='h-6 w-6 '
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => track && player.nextTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||
>
|
||||
<Icon name='next' className='h-6 w-6 ' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button>
|
||||
<Icon
|
||||
name='repeat-1'
|
||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NowPlaying
|
||||
23
packages/web/components/New/PageTransition.tsx
Normal file
23
packages/web/components/New/PageTransition.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const PageTransition = ({
|
||||
children,
|
||||
disableEnterAnimation,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disableEnterAnimation?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: disableEnterAnimation ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageTransition
|
||||
93
packages/web/components/New/PlayLikedSongsCard.tsx
Normal file
93
packages/web/components/New/PlayLikedSongsCard.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import useLyric from '@/web/api/hooks/useLyric'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import { player } from '@/web/store'
|
||||
import { sample, chunk } from 'lodash-es'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const PlayLikedSongsCard = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
|
||||
const { data: likedSongsPlaylist } = usePlaylist({
|
||||
id: playlists?.playlist?.[0].id ?? 0,
|
||||
})
|
||||
|
||||
// Lyric
|
||||
const [trackID, setTrackID] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (trackID === 0) {
|
||||
setTrackID(
|
||||
sample(likedSongsPlaylist?.playlist.trackIds?.map(t => t.id) ?? []) ?? 0
|
||||
)
|
||||
}
|
||||
}, [likedSongsPlaylist?.playlist.trackIds, trackID])
|
||||
|
||||
const { data: lyric } = useLyric({
|
||||
id: trackID,
|
||||
})
|
||||
|
||||
const lyricLines = useMemo(() => {
|
||||
return (
|
||||
sample(
|
||||
chunk(
|
||||
lyric?.lrc.lyric
|
||||
?.split('\n')
|
||||
?.map(l => l.split(']').pop()?.trim())
|
||||
?.filter(
|
||||
l =>
|
||||
l &&
|
||||
!l.includes('作词') &&
|
||||
!l.includes('作曲') &&
|
||||
!l.includes('纯音乐,请欣赏')
|
||||
),
|
||||
4
|
||||
)
|
||||
) ?? []
|
||||
)
|
||||
}, [lyric])
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!likedSongsPlaylist?.playlist.id) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playPlaylist(likedSongsPlaylist.playlist.id)
|
||||
},
|
||||
[likedSongsPlaylist?.playlist.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex flex-col justify-between rounded-24 p-8 dark:bg-night-600',
|
||||
css`
|
||||
height: 322px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div className='text-21 font-medium text-white/20'>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className='rounded-full bg-brand-700 py-5 px-6 text-16 font-medium text-white'
|
||||
>
|
||||
Play Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayLikedSongsCard
|
||||
23
packages/web/components/New/Player.tsx
Normal file
23
packages/web/components/New/Player.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import NowPlaying from './NowPlaying'
|
||||
import PlayingNext from './PlayingNext'
|
||||
|
||||
const Player = () => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex w-full flex-col justify-between overflow-hidden pr-6 pl-4',
|
||||
css`
|
||||
grid-area: player;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<PlayingNext className='mb-3 h-full' />
|
||||
<div className='pb-20'>
|
||||
<NowPlaying />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
||||
21
packages/web/components/New/PlayingNext.stories.tsx
Normal file
21
packages/web/components/New/PlayingNext.stories.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import PlayingNext from './PlayingNext'
|
||||
|
||||
export default {
|
||||
title: 'Components/PlayingNext',
|
||||
component: PlayingNext,
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: 'iphone6',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof PlayingNext>
|
||||
|
||||
const Template: ComponentStory<typeof PlayingNext> = args => (
|
||||
<div className='fixed inset-0 bg-[#F8F8F8] p-4 dark:bg-black'>
|
||||
<PlayingNext />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
88
packages/web/components/New/PlayingNext.tsx
Normal file
88
packages/web/components/New/PlayingNext.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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'
|
||||
|
||||
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 })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
'absolute top-0 z-10 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300'
|
||||
)}
|
||||
>
|
||||
PLAYING NEXT
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'relative z-10 overflow-scroll',
|
||||
className,
|
||||
css`
|
||||
&::-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);
|
||||
`
|
||||
)}
|
||||
>
|
||||
<motion.div className='grid gap-4'>
|
||||
<AnimatePresence>
|
||||
{tracks?.songs?.map((track, index) => (
|
||||
<motion.div
|
||||
className='flex items-center justify-between'
|
||||
key={track.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ x: '100%', opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.24,
|
||||
}}
|
||||
layout
|
||||
>
|
||||
<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'>
|
||||
<div className='line-clamp-1 text-18 font-medium text-neutral-700 dark:text-neutral-200'>
|
||||
{track.name}
|
||||
</div>
|
||||
<div className='line-clamp-1 mt-1 text-16 font-bold text-neutral-200 dark:text-neutral-700'>
|
||||
{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>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className='pointer-events-none sticky bottom-0 h-8 w-full bg-gradient-to-t from-black'></div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayingNext
|
||||
79
packages/web/components/New/Router.tsx
Normal file
79
packages/web/components/New/Router.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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'
|
||||
import Settings from '@/web/pages/Settings'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
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 routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <My />,
|
||||
},
|
||||
{
|
||||
path: '/discover',
|
||||
element: <Discover />,
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
element: <Library />,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/search/:keywords',
|
||||
element: <Search />,
|
||||
children: [
|
||||
{
|
||||
path: ':type',
|
||||
element: <Search />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/playlist/:id',
|
||||
element: <Playlist />,
|
||||
},
|
||||
{
|
||||
path: '/album/:id',
|
||||
element: <Album />,
|
||||
},
|
||||
{
|
||||
path: '/artist/:id',
|
||||
element: <Artist />,
|
||||
},
|
||||
]
|
||||
|
||||
const lazy = (components: ReactNode) => {
|
||||
return <Suspense>{components}</Suspense>
|
||||
}
|
||||
|
||||
const Router = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path='/' element={lazy(<My />)} />
|
||||
<Route path='/discover' element={lazy(<Discover />)} />
|
||||
<Route path='/login' element={lazy(<Login />)} />
|
||||
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default Router
|
||||
16
packages/web/components/New/Sidebar.stories.tsx
Normal file
16
packages/web/components/New/Sidebar.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
export default {
|
||||
title: 'Components/Sidebar',
|
||||
component: Sidebar,
|
||||
} as ComponentMeta<typeof Sidebar>
|
||||
|
||||
const Template: ComponentStory<typeof Sidebar> = args => (
|
||||
<div className='h-[calc(100vh_-_32px)] w-min rounded-l-3xl bg-[#F8F8F8] dark:bg-black'>
|
||||
<Sidebar />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
110
packages/web/components/New/Sidebar.tsx
Normal file
110
packages/web/components/New/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useAnimation, motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'MY MUSIC',
|
||||
path: '/',
|
||||
icon: 'my',
|
||||
},
|
||||
{
|
||||
name: 'DISCOVER',
|
||||
path: '/discover',
|
||||
icon: 'explore',
|
||||
},
|
||||
{
|
||||
name: 'BROWSE',
|
||||
path: '/browse',
|
||||
icon: 'discovery',
|
||||
},
|
||||
{
|
||||
name: 'LYRICS',
|
||||
path: '/lyrics',
|
||||
icon: 'lyrics',
|
||||
},
|
||||
] as const
|
||||
|
||||
const getNameByPath = (path: string): string => {
|
||||
return tabs.find(tab => tab.path === path)?.name || ''
|
||||
}
|
||||
const TabName = () => {
|
||||
const location = useLocation()
|
||||
const [name, setName] = useState(getNameByPath(location.pathname))
|
||||
const controls = useAnimation()
|
||||
|
||||
useEffect(() => {
|
||||
const newName = getNameByPath(location.pathname)
|
||||
const animate = async () => {
|
||||
await controls.start('out')
|
||||
setName(newName)
|
||||
await controls.start('in')
|
||||
}
|
||||
if (newName !== name) animate()
|
||||
}, [controls, location.pathname, name])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute bottom-8 right-0 left-0 z-10 flex rotate-180 select-none items-center font-bold text-brand-600 dark:text-brand-700',
|
||||
css`
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
letter-spacing: 0.02em;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<motion.span
|
||||
initial='in'
|
||||
animate={controls}
|
||||
variants={{
|
||||
in: { opacity: 1 },
|
||||
out: { opacity: 0 },
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.18,
|
||||
ease,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</motion.span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Sidebar = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-drag relative flex h-full w-full flex-col justify-center',
|
||||
css`
|
||||
grid-area: sidebar;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div className='grid grid-cols-1 justify-items-center gap-12 text-black/10 dark:text-white/20'>
|
||||
{tabs.map(tab => (
|
||||
<NavLink key={tab.name} to={tab.path}>
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className={cx(
|
||||
'app-region-no-drag h-10 w-10 transition duration-500 active:scale-75',
|
||||
location.pathname === tab.path
|
||||
? 'text-brand-600 dark:text-brand-700'
|
||||
: 'hover:text-black dark:hover:text-white'
|
||||
)}
|
||||
/>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<TabName />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
176
packages/web/components/New/Slider.tsx
Normal file
176
packages/web/components/New/Slider.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const Slider = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
onlyCallOnChangeAfterDragEnded = false,
|
||||
orientation = 'horizontal',
|
||||
alwaysShowThumb = false,
|
||||
}: {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
onChange: (value: number) => void
|
||||
onlyCallOnChangeAfterDragEnded?: boolean
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
alwaysShowTrack?: boolean
|
||||
alwaysShowThumb?: boolean
|
||||
}) => {
|
||||
const sliderRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [draggingValue, setDraggingValue] = useState(value)
|
||||
const memoedValue = useMemo(
|
||||
() =>
|
||||
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
|
||||
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the value of the slider based on the position of the pointer
|
||||
*/
|
||||
const getNewValue = useCallback(
|
||||
(pointer: { x: number; y: number }) => {
|
||||
if (!sliderRef?.current) return 0
|
||||
const slider = sliderRef.current.getBoundingClientRect()
|
||||
const newValue =
|
||||
orientation === 'horizontal'
|
||||
? ((pointer.x - slider.x) / slider.width) * max
|
||||
: ((slider.height - (pointer.y - slider.y)) / slider.height) * max
|
||||
if (newValue < min) return min
|
||||
if (newValue > max) return max
|
||||
return newValue
|
||||
},
|
||||
[sliderRef, max, min, orientation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle slider click event
|
||||
*/
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
|
||||
[getNewValue, onChange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle pointer down event
|
||||
*/
|
||||
const handlePointerDown = () => {
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pointer move events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
||||
if (!isDragging) return
|
||||
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
|
||||
onlyCallOnChangeAfterDragEnded
|
||||
? setDraggingValue(newValue)
|
||||
: onChange(newValue)
|
||||
}
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
onChange,
|
||||
setDraggingValue,
|
||||
onlyCallOnChangeAfterDragEnded,
|
||||
getNewValue,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handle pointer up events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
if (onlyCallOnChangeAfterDragEnded) {
|
||||
onChange(draggingValue)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
onlyCallOnChangeAfterDragEnded,
|
||||
draggingValue,
|
||||
onChange,
|
||||
])
|
||||
|
||||
/**
|
||||
* Track and thumb styles
|
||||
*/
|
||||
const usedTrackStyle = useMemo(() => {
|
||||
const percentage = `${(memoedValue / max) * 100}%`
|
||||
return orientation === 'horizontal'
|
||||
? { width: percentage }
|
||||
: { height: percentage }
|
||||
}, [max, memoedValue, orientation])
|
||||
const thumbStyle = useMemo(() => {
|
||||
const percentage = `${(memoedValue / max) * 100}%`
|
||||
return orientation === 'horizontal'
|
||||
? { left: percentage }
|
||||
: { bottom: percentage }
|
||||
}, [max, memoedValue, orientation])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'group relative flex items-center',
|
||||
orientation === 'horizontal' && 'h-2',
|
||||
orientation === 'vertical' && 'h-full w-2 flex-col'
|
||||
)}
|
||||
ref={sliderRef}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Track */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10',
|
||||
orientation === 'horizontal' && 'h-[3px] w-full',
|
||||
orientation === 'vertical' && 'h-full w-[3px]'
|
||||
)}
|
||||
>
|
||||
{/* Passed track */}
|
||||
<div
|
||||
className={cx(
|
||||
'bg-black dark:bg-white',
|
||||
orientation === 'horizontal' && 'h-full rounded-r-full',
|
||||
orientation === 'vertical' && 'bottom-0 w-full rounded-t-full'
|
||||
)}
|
||||
style={usedTrackStyle}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
|
||||
isDragging || alwaysShowThumb
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100',
|
||||
orientation === 'horizontal' && '-translate-x-1',
|
||||
orientation === 'vertical' && 'translate-y-1'
|
||||
)}
|
||||
style={thumbStyle}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPointerDown={handlePointerDown}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Slider
|
||||
34
packages/web/components/New/Tabs.tsx
Normal file
34
packages/web/components/New/Tabs.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { cx } from '@emotion/css'
|
||||
|
||||
const Tabs = ({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex'>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cx(
|
||||
'mr-2.5 rounded-12 py-3 px-6 text-16 font-medium ',
|
||||
value === tab.id
|
||||
? 'bg-brand-700 text-white'
|
||||
: 'dark:bg-white/10 dark:text-white/20'
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tabs
|
||||
16
packages/web/components/New/Topbar.stories.tsx
Normal file
16
packages/web/components/New/Topbar.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import Topbar from './Topbar'
|
||||
|
||||
export default {
|
||||
title: 'Components/Topbar',
|
||||
component: Topbar,
|
||||
} as ComponentMeta<typeof Topbar>
|
||||
|
||||
const Template: ComponentStory<typeof Topbar> = args => (
|
||||
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] px-11 dark:bg-black'>
|
||||
<Topbar />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
123
packages/web/components/New/Topbar.tsx
Normal file
123
packages/web/components/New/Topbar.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import Icon from '../Icon'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
|
||||
const NavigationButtons = () => {
|
||||
const navigate = useNavigate()
|
||||
const controlsBack = useAnimation()
|
||||
const controlsForward = useAnimation()
|
||||
const transition = { duration: 0.2, ease }
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
navigate(-1)
|
||||
await controlsBack.start({ x: -5 })
|
||||
await controlsBack.start({ x: 0 })
|
||||
}}
|
||||
className='app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600'
|
||||
>
|
||||
<motion.div animate={controlsBack} transition={transition}>
|
||||
<Icon name='back' className='h-7 w-7 text-neutral-500' />
|
||||
</motion.div>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
navigate(1)
|
||||
await controlsForward.start({ x: 5 })
|
||||
await controlsForward.start({ x: 0 })
|
||||
}}
|
||||
className='app-region-no-drag ml-2.5 rounded-full bg-day-600 p-2.5 dark:bg-night-600'
|
||||
>
|
||||
<motion.div animate={controlsForward} transition={transition}>
|
||||
<Icon name='forward' className='h-7 w-7 text-neutral-500' />
|
||||
</motion.div>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Avatar = ({ className }: { className?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
const { data: user } = useUser()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
onClick={() => navigate('/login')}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => navigate('/login')}
|
||||
className={cx(
|
||||
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
>
|
||||
<Icon name='user' className='h-7 w-7 text-neutral-500' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Topbar = () => {
|
||||
const location = useLocation()
|
||||
|
||||
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 ',
|
||||
css`
|
||||
left: 104px;
|
||||
`,
|
||||
!location.pathname.startsWith('/album/') &&
|
||||
!location.pathname.startsWith('/playlist/') &&
|
||||
'bg-gradient-to-b from-white dark:from-black'
|
||||
)}
|
||||
>
|
||||
{/* Left Part */}
|
||||
<div className='flex items-center'>
|
||||
<NavigationButtons />
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mx-6 h-4 w-px bg-black/20 dark:bg-white/20'></div>
|
||||
|
||||
{/* Search Box */}
|
||||
<div className='app-region-no-drag flex min-w-[284px] items-center rounded-full bg-day-600 p-2.5 text-neutral-500 dark:bg-night-600'>
|
||||
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||
<input
|
||||
placeholder='Artist, songs and more'
|
||||
className='bg-transparent font-medium placeholder:text-neutral-500 dark:text-neutral-200'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Part */}
|
||||
<div className='flex'>
|
||||
<button className='app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600'>
|
||||
<Icon name='placeholder' className='h-7 w-7 text-neutral-500' />
|
||||
</button>
|
||||
|
||||
<Avatar className='ml-3 h-12 w-12' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Topbar
|
||||
51
packages/web/components/New/TrackList.tsx
Normal file
51
packages/web/components/New/TrackList.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { formatDuration } from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { player } from '@/web/store'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const TrackList = ({
|
||||
tracks,
|
||||
onPlay,
|
||||
className,
|
||||
}: {
|
||||
tracks?: Track[]
|
||||
onPlay: (id: number) => void
|
||||
className?: string
|
||||
}) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const playingTrack = useMemo(
|
||||
() => playerSnapshot.track,
|
||||
[playerSnapshot.track]
|
||||
)
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onPlay?.(trackID)
|
||||
}
|
||||
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<div className='mr-6'>{String(track.no).padStart(2, '0')}</div>
|
||||
<div className='flex-grow'>{track.name}</div>
|
||||
<div className='h-10 w-10'></div>
|
||||
<div className='text-right'>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackList
|
||||
16
packages/web/components/New/TrackListHeader.stories.tsx
Normal file
16
packages/web/components/New/TrackListHeader.stories.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import TrackListHeader from './TrackListHeader'
|
||||
|
||||
export default {
|
||||
title: 'Components/TrackListHeader',
|
||||
component: TrackListHeader,
|
||||
} as ComponentMeta<typeof TrackListHeader>
|
||||
|
||||
const Template: ComponentStory<typeof TrackListHeader> = args => (
|
||||
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] p-10 dark:bg-black'>
|
||||
<TrackListHeader />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
83
packages/web/components/New/TrackListHeader.tsx
Normal file
83
packages/web/components/New/TrackListHeader.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import Image from './Image'
|
||||
|
||||
const TrackListHeader = ({
|
||||
album,
|
||||
onPlay,
|
||||
}: {
|
||||
album?: Album
|
||||
onPlay: () => void
|
||||
}) => {
|
||||
const albumDuration = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||
}, [album?.songs])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'grid grid-rows-1 gap-10',
|
||||
css`
|
||||
grid-template-columns: 318px auto;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
className='z-10 aspect-square w-full rounded-24'
|
||||
src={resizeImage(album?.picUrl || '', 'lg')}
|
||||
alt='Cover'
|
||||
/>
|
||||
|
||||
<img
|
||||
className={cx(
|
||||
'fixed 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')}
|
||||
/>
|
||||
|
||||
<div className=' flex flex-col justify-between'>
|
||||
<div>
|
||||
<div className='text-36 font-medium dark:text-neutral-100'>
|
||||
{album?.name}
|
||||
</div>
|
||||
<div className='mt-6 text-24 font-medium dark:text-neutral-600'>
|
||||
{album?.artist.name}
|
||||
</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}
|
||||
</div>
|
||||
<div className='line-clamp-3 mt-6 text-14 font-bold dark:text-neutral-600'>
|
||||
{album?.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='z-10 flex'>
|
||||
<button
|
||||
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>
|
||||
<button className='ml-6 h-[72px] w-[72px] rounded-full dark:bg-night-50'></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackListHeader
|
||||
Loading…
Add table
Add a link
Reference in a new issue