feat: updates

This commit is contained in:
qier222 2022-05-29 17:53:27 +08:00
parent ffcc60b793
commit dd5361b8c4
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
106 changed files with 11989 additions and 4143 deletions

View file

@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom'
import cx from 'classnames'
import {cx} from '@emotion/css'
const ArtistInline = ({
artists,

View file

@ -1,7 +1,7 @@
import { resizeImage } from '../utils/common'
import useUser from '../hooks/useUser'
import SvgIcon from './SvgIcon'
import cx from 'classnames'
import useUser from '@/web/api/hooks/useUser'
import Icon from './Icon'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
const Avatar = ({ size }: { size?: string }) => {
@ -25,7 +25,7 @@ const Avatar = ({ size }: { size?: string }) => {
/>
) : (
<div onClick={() => navigate('/login')}>
<SvgIcon
<Icon
name='user'
className={cx(
'rounded-full bg-black/[.06] p-1 text-gray-500 dark:bg-white/5',

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react'
import cx from 'classnames'
import { cx } from '@emotion/css'
export enum Color {
Primary = 'primary',
@ -28,7 +28,7 @@ const Button = ({
<button
onClick={onClick}
className={cx(
'btn-pressed-animation flex cursor-default items-center rounded-lg px-4 py-1.5 text-lg font-medium',
'btn-pressed-animation flex cursor-default items-center rounded-20 px-4 py-1.5 text-lg font-medium',
{
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
'text-brand-500 dark:text-white': iconColor === Color.Primary,

View file

@ -1,5 +1,5 @@
import SvgIcon from '@/web/components/SvgIcon'
import cx from 'classnames'
import Icon from '@/web/components/Icon'
import { cx } from '@emotion/css'
import { useState } from 'react'
const Cover = ({
@ -38,7 +38,7 @@ const Cover = ({
{/* Cover */}
{isError ? (
<div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300 '>
<SvgIcon name='music-note' className='h-1/2 w-1/2' />
<Icon name='music-note' className='h-1/2 w-1/2' />
</div>
) : (
<img
@ -55,7 +55,7 @@ const Cover = ({
{showPlayButton && (
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
<SvgIcon className='ml-0.5 h-6 w-6' name='play-fill' />
<Icon className='ml-0.5 h-6 w-6' name='play-fill' />
</button>
</div>
)}

View file

@ -1,10 +1,10 @@
import Cover from '@/web/components/Cover'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import { prefetchAlbum } from '@/web/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/hooks/usePlaylist'
import Icon from '@/web/components/Icon'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
import cx from 'classnames'
import { cx } from '@emotion/css'
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
@ -180,7 +180,7 @@ const CoverRow = ({
>
{/* Playlist private icon */}
{(item as Playlist).privacy === 10 && (
<SvgIcon
<Icon
name='lock'
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
/>
@ -188,7 +188,7 @@ const CoverRow = ({
{/* Explicit icon */}
{(item as Album)?.mark === 1056768 && (
<SvgIcon
<Icon
name='explicit'
className='float-right mt-[2px] h-4 w-4 text-gray-300'
/>

View file

@ -1,51 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import CoverWall from './CoverWall'
import { shuffle } from 'lodash-es'
const covers = [
'https://p1.music.126.net/MbjHjs0EebOFomva9oh6aQ==/109951164683206719.jpg?param=1024y1024',
'https://p1.music.126.net/T7qkRJsFDat6GxWDXP2cTA==/109951164486305073.jpg?param=1024y1024',
'https://p2.music.126.net/2jls9nqjYYlQEybpHPaccw==/109951164706184612.jpg?param=1024y1024',
'https://p1.music.126.net/lEzPSOjusKaRXKXT3987lQ==/109951166035876388.jpg?param=1024y1024',
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363831.jpg?param=1024y1024',
'https://p2.music.126.net/W-mYCTf6nPLUSaLxFlXDUA==/109951165806001138.jpg?param=1024y1024',
'https://p2.music.126.net/6CB6Jsmb7k7qiJqfMY5Row==/109951164260234943.jpg?param=1024y1024',
'https://p2.music.126.net/IeRnZyxClyoTwqZ76Qcyhw==/109951166161936990.jpg?param=1024y1024',
'https://p2.music.126.net/oYxxIkeXY5Qap7pW1aSzqQ==/109951165389077755.jpg?param=1024y1024',
'https://p2.music.126.net/AhYP9TET8l-VSGOpWAKZXw==/109951165134386387.jpg?param=1024y1024',
'https://p1.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024',
'https://p2.music.126.net/vCTNT88k1rnflXtDdmWT9g==/109951165359041202.jpg?param=1024y1024',
'https://p2.music.126.net/iBxAZvHMTKfO3Vf8tdRa7Q==/109951165985707287.jpg?param=1024y1024',
'https://p1.music.126.net/b36xosI5j0cpdN1y7ytZPg==/109951166021477556.jpg?param=1024y1024',
'https://p1.music.126.net/bYwl8c5jErgbfGhv1tLJJA==/109951165276142037.jpg?param=1024y1024',
'https://p2.music.126.net/ZR1nD3lHsAoDUatf3gl1nQ==/109951165061667554.jpg?param=1024y1024',
'https://p1.music.126.net/XCMOOyclkmstP7KYHnNwcA==/109951164764312194.jpg?param=1024y1024',
'https://p1.music.126.net/jE6ebqtlzw7S0nnO6Heq2A==/109951166270713524.jpg?param=1024y1024',
'https://p1.music.126.net/6EoK9Mk27y3Cww5d9FA6ng==/109951165862426529.jpg?param=1024y1024',
'https://p1.music.126.net/XPQs_6fT2Ioy5a9eFDPpQw==/109951165255101112.jpg?param=1024y1024',
'https://p1.music.126.net/ocpMw2ku61bwhi7V7DJo9g==/109951167225594912.jpg?param=1024y1024',
'https://p2.music.126.net/LFmG3XD07JH4OYMafO0txw==/109951167410278760.jpg?param=1024y1024',
'https://p1.music.126.net/iZRipUtb21xr2E9Hz8sjYw==/109951167409480781.jpg?param=1024y1024',
'https://p2.music.126.net/rvUDvsxa0LZu9o_Oww-0Iw==/109951167344103348.jpg?param=1024y1024',
'https://p1.music.126.net/VGN68yovUJZtC47A_pYISg==/109951166515892030.jpg?param=1024y1024',
'https://p2.music.126.net/xqluTLLrxqGWr8qiMZNlfw==/109951166327062990.jpg?param=1024y1024',
'https://p2.music.126.net/I-gC5w8ECkgwPojf4YybeQ==/109951166074865960.jpg?param=1024y1024',
'https://p1.music.126.net/MHIvytC5RXh5Lp2J_3tpaQ==/19017153114022258.jpg?param=1024y1024',
'https://p1.music.126.net/3JcFV7xICf5gLwfaNK6wQQ==/109951163618704084.jpg?param=1024y1024',
'https://p2.music.126.net/dUHTsm1kr_CdhmcQ3WVhVg==/109951163663181135.jpg?param=1024y1024',
'https://p1.music.126.net/d7MyyfAt_YE0e85oK7eFMg==/7697680906568884.jpg?param=1024y1024',
]
export default {
title: '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)} />
</div>
)
export const Primary = Template.bind({})

View file

@ -1,22 +0,0 @@
import cx from 'classnames'
const bigCover = [0, 2, 3, 8, 9, 11, 16, 18, 24, 26, 27]
const CoverWall = ({ covers }: { covers: string[] }) => {
return (
<div className='grid auto-rows-auto grid-cols-8 gap-[13px] '>
{covers.map((cover, index) => (
<img
src={cover}
key={cover}
className={cx(
'rounded-3xl',
bigCover.includes(index) && 'col-span-2 row-span-2'
)}
/>
))}
</div>
)
}
export default CoverWall

View file

@ -1,13 +0,0 @@
@keyframes move {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animation {
animation: move 38s infinite;
animation-direction: alternate;
}

View file

@ -1,6 +1,14 @@
import SvgIcon from './SvgIcon'
import style from './DailyTracksCard.module.scss'
import cx from 'classnames'
import Icon from './Icon'
import { cx, css, keyframes } from '@emotion/css'
const move = keyframes`
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
`
const DailyTracksCard = () => {
return (
@ -9,7 +17,10 @@ const DailyTracksCard = () => {
<img
className={cx(
'absolute top-0 left-0 w-full will-change-transform',
style.animation
css`
animation: ${move} 38s infinite;
animation-direction: alternate;
`
)}
src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
/>
@ -25,7 +36,7 @@ const DailyTracksCard = () => {
{/* Play button */}
<button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
<SvgIcon name='play-fill' className='ml-0.5 h-6 w-6' />
<Icon name='play-fill' className='ml-0.5 h-6 w-6' />
</button>
</div>
)

View file

@ -1,13 +1,10 @@
import { player } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import SvgIcon from './SvgIcon'
import Icon from './Icon'
import ArtistInline from './ArtistsInline'
import {
State as PlayerState,
Mode as PlayerMode,
} from '@/web/utils/player'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import useCoverColor from '../hooks/useCoverColor'
import cx from 'classnames'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
@ -34,11 +31,11 @@ const MediaControls = () => {
className={classes}
onClick={() => player.fmTrash()}
>
<SvgIcon name='dislike' className='h-6 w-6' />
<Icon name='dislike' className='h-6 w-6' />
</button>
<button key='play' className={classes} onClick={playOrPause}>
<SvgIcon
<Icon
className='h-6 w-6'
name={
playerSnapshot.mode === PlayerMode.FM &&
@ -54,7 +51,7 @@ const MediaControls = () => {
className={classes}
onClick={() => player.nextTrack(true)}
>
<SvgIcon name='next' className='h-6 w-6' />
<Icon name='next' className='h-6 w-6' />
</button>
</div>
)
@ -127,7 +124,7 @@ const FMCard = () => {
track ? 'text-white ' : 'text-gray-700 dark:text-white'
)}
>
<SvgIcon name='fm' className='mr-1 h-6 w-6' />
<Icon name='fm' className='mr-1 h-6 w-6' />
<span className='font-semibold'>FM</span>
</div>
</div>

View file

@ -40,13 +40,7 @@ export type SvgName =
| 'windows-un-maximize'
| 'x'
const SvgIcon = ({
name,
className,
}: {
name: SvgName
className?: string
}) => {
const Icon = ({ name, className }: { name: SvgName; className?: string }) => {
const symbolId = `#icon-${name}`
return (
<svg aria-hidden='true' className={className}>
@ -55,4 +49,4 @@ const SvgIcon = ({
)
}
export default SvgIcon
export default Icon

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react'
import cx from 'classnames'
import { cx } from '@emotion/css'
const IconButton = ({
children,

View file

@ -1,10 +1,10 @@
import useLyric from '@/web/hooks/useLyric'
import useLyric from '@/web/api/hooks/useLyric'
import { player } from '@/web/store'
import { motion } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
import { cx } from '@emotion/css'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]

View file

@ -1,11 +1,11 @@
import useLyric from '@/web/hooks/useLyric'
import useLyric from '@/web/api/hooks/useLyric'
import { player } from '@/web/store'
import { motion, useMotionValue } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useWindowSize } from 'react-use'
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
import { cx } from '@emotion/css'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]

View file

@ -3,12 +3,12 @@ import { player, state } from '@/web/store'
import { getCoverColor } from '@/web/utils/common'
import { colord } from 'colord'
import IconButton from '../IconButton'
import SvgIcon from '../SvgIcon'
import Icon from '../Icon'
import Lyric from './Lyric'
import { motion, AnimatePresence } from 'framer-motion'
import Lyric2 from './Lyric2'
import useCoverColor from '@/web/hooks/useCoverColor'
import cx from 'classnames'
import { cx } from '@emotion/css'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
@ -56,7 +56,7 @@ const LyricPanel = () => {
<div className='absolute bottom-3.5 right-7 text-white'>
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
<SvgIcon className='h-6 w-6' name='lyrics' />
<Icon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>
</motion.div>

View file

@ -1,21 +1,18 @@
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player, state } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import ArtistInline from '../ArtistsInline'
import Cover from '../Cover'
import IconButton from '../IconButton'
import SvgIcon from '../SvgIcon'
import {
State as PlayerState,
Mode as PlayerMode,
} from '@/web/utils/player'
import Icon from '../Icon'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
import { cx } from '@emotion/css'
const PlayingTrack = () => {
const playerSnapshot = useSnapshot(player)
@ -85,7 +82,7 @@ const LikeButton = ({ track }: { track: Track | undefined | null }) => {
<IconButton
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
>
<SvgIcon
<Icon
className='h-6 w-6 text-white'
name={
track?.id && userLikedSongs?.ids?.includes(track.id)
@ -111,12 +108,12 @@ const Controls = () => {
onClick={() => track && player.prevTrack()}
disabled={!track}
>
<SvgIcon className='h-6 w-6' name='previous' />
<Icon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<SvgIcon className='h-6 w-6' name='dislike' />
<Icon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
@ -124,7 +121,7 @@ const Controls = () => {
disabled={!track}
className='after:rounded-xl'
>
<SvgIcon
<Icon
className='h-7 w-7'
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
@ -134,7 +131,7 @@ const Controls = () => {
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon className='h-6 w-6' name='next' />
<Icon className='h-6 w-6' name='next' />
</IconButton>
</div>
)

View file

@ -1,6 +1,6 @@
import Router from './Router'
import Topbar from './Topbar'
import cx from 'classnames'
import { cx } from '@emotion/css'
const Main = () => {
return (

View 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

View 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

View 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({})

View 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

View 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

View 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

View 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

View 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

View 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({})

View 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

View 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

View 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

View 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

View 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({})

View 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

View 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

View file

@ -3,7 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'
import Sidebar from './Sidebar'
export default {
title: 'Sidebar',
title: 'Components/Sidebar',
component: Sidebar,
} as ComponentMeta<typeof Sidebar>
@ -13,4 +13,4 @@ const Template: ComponentStory<typeof Sidebar> = args => (
</div>
)
export const Primary = Template.bind({})
export const Default = Template.bind({})

View 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

View 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

View 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

View file

@ -3,14 +3,14 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'
import Topbar from './Topbar'
export default {
title: 'Topbar',
title: 'Components/Topbar',
component: Topbar,
} as ComponentMeta<typeof Topbar>
const Template: ComponentStory<typeof Topbar> = args => (
<div className='w-[calc(100vw_-_32px)] rounded-3xl bg-[#F8F8F8] px-11 dark:bg-black'>
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] px-11 dark:bg-black'>
<Topbar />
</div>
)
export const Primary = Template.bind({})
export const Default = Template.bind({})

View 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

View 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

View 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({})

View 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

View file

@ -1,17 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import NowPlaying from './NowPlaying'
export default {
title: 'NowPlaying',
component: NowPlaying,
parameters: {
viewport: {
defaultViewport: 'iphone8p',
},
},
} as ComponentMeta<typeof NowPlaying>
const Template: ComponentStory<typeof NowPlaying> = args => <NowPlaying />
export const Primary = Template.bind({})

View file

@ -1,72 +0,0 @@
import React from 'react'
import cx from 'classnames'
import SvgIcon from './SvgIcon'
const NowPlaying = () => {
return (
<div className='relative flex aspect-square w-full flex-col justify-end overflow-hidden rounded-3xl'>
{/* Cover */}
<img
className='insert-0 absolute w-full'
src='https://p2.music.126.net/8g2DIiWDpgZ2nSCoILc9kg==/109951165124745870.jpg?param=1024y1024'
/>
{/* Info & Controls */}
<div className='m-3 flex flex-col items-center rounded-[20px] bg-white/60 p-5 backdrop-blur-3xl dark:bg-black/70'>
{/* Track Info */}
<div className='text-lg text-black dark:text-white'>
Life In Technicolor II
</div>
<div className='text-base text-black/30 dark:text-white/30'>
Coldplay
</div>
{/* Dividing line */}
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
{/* Progress */}
<div className='mt-10 flex w-full flex-col'>
{/* Slider */}
<div className='relative h-[3px] rounded-full bg-black/10 dark:bg-white/10'>
<div className='absolute left-0 top-0 bottom-0 w-2/3 rounded-full bg-black dark:bg-white'></div>
</div>
<div className='mt-1 flex justify-between text-[14px] font-semibold text-black/20 dark:text-white/20'>
<span>00:54</span>
<span>02:53</span>
</div>
</div>
{/* Controls */}
<div className='mt-4 flex w-full items-center justify-between'>
<button>
<SvgIcon
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 className='rounded-full bg-black/10 p-[10px] dark:bg-white/10'>
<SvgIcon name='previous' className='h-6 w-6 ' />
</button>
<button className='mx-2 rounded-full bg-black/10 p-[10px] dark:bg-white/10'>
<SvgIcon name='play' className='h-6 w-6 ' />
</button>
<button className='rounded-full bg-black/10 p-[10px] dark:bg-white/10'>
<SvgIcon name='next' className='h-6 w-6 ' />
</button>
</div>
<button>
<SvgIcon
name='repeat-1'
className='h-7 w-7 text-black/90 dark:text-white/40'
/>{' '}
</button>
</div>
</div>
</div>
)
}
export default NowPlaying

View file

@ -1,18 +1,15 @@
import ArtistInline from './ArtistsInline'
import IconButton from './IconButton'
import Slider from './Slider'
import SvgIcon from './SvgIcon'
import Icon from './Icon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player, state } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import {
State as PlayerState,
Mode as PlayerMode,
} from '@/web/utils/player'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
import cx from 'classnames'
import { cx } from '@emotion/css'
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import toast from 'react-hot-toast'
@ -60,7 +57,7 @@ const PlayingTrack = () => {
onClick={toAlbum}
className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm'
>
<SvgIcon className='h-6 w-6 text-gray-300' name='music-note' />
<Icon className='h-6 w-6 text-gray-300' name='music-note' />
</div>
)}
@ -82,7 +79,7 @@ const PlayingTrack = () => {
<IconButton
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
>
<SvgIcon
<Icon
className='h-5 w-5 text-black dark:text-white'
name={
track?.id && userLikedSongs?.ids?.includes(track.id)
@ -111,12 +108,12 @@ const MediaControls = () => {
onClick={() => track && player.prevTrack()}
disabled={!track}
>
<SvgIcon className='h-6 w-6' name='previous' />
<Icon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<SvgIcon className='h-6 w-6' name='dislike' />
<Icon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
@ -124,7 +121,7 @@ const MediaControls = () => {
disabled={!track}
className='after:rounded-xl'
>
<SvgIcon
<Icon
className='h-7 w-7'
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
@ -134,7 +131,7 @@ const MediaControls = () => {
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon className='h-6 w-6' name='next' />
<Icon className='h-6 w-6' name='next' />
</IconButton>
</div>
)
@ -159,13 +156,13 @@ const Others = () => {
onClick={() => toast('Work in progress')}
disabled={playerSnapshot.mode === PlayerMode.FM}
>
<SvgIcon className='h-6 w-6' name='playlist' />
<Icon className='h-6 w-6' name='playlist' />
</IconButton>
<IconButton
onClick={switchRepeatMode}
disabled={playerSnapshot.mode === PlayerMode.FM}
>
<SvgIcon
<Icon
className={cx(
'h-6 w-6',
playerSnapshot.repeatMode !== PlayerRepeatMode.Off &&
@ -182,15 +179,15 @@ const Others = () => {
onClick={() => toast('施工中...')}
disabled={playerSnapshot.mode === PlayerMode.FM}
>
<SvgIcon className='h-6 w-6' name='shuffle' />
<Icon className='h-6 w-6' name='shuffle' />
</IconButton>
<IconButton onClick={() => toast('施工中...')}>
<SvgIcon className='h-6 w-6' name='volume' />
<Icon className='h-6 w-6' name='volume' />
</IconButton>
{/* Lyric */}
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
<SvgIcon className='h-6 w-6' name='lyrics' />
<Icon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>
)

View file

@ -1,29 +1,107 @@
import React from 'react'
import cx from 'classnames'
import SvgIcon from './SvgIcon'
import { NavLink } from 'react-router-dom'
import Icon from './Icon'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import { scrollToTop } from '@/web/utils/common'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { player } from '@/web/store'
import { Mode, TrackListSourceType } from '@/web/utils/player'
import { cx } from '@emotion/css'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
const primaryTabs = [
{
name: '主页',
icon: 'home',
route: '/',
},
{
name: '播客',
icon: 'podcast',
route: '/podcast',
},
{
name: '音乐库',
icon: 'music-library',
route: '/library',
},
] as const
const PrimaryTabs = () => {
return (
<div>
<div className={cx(window.env?.isMac && 'app-region-drag', 'h-14')}></div>
{primaryTabs.map(tab => (
<NavLink
onClick={() => scrollToTop()}
key={tab.route}
to={tab.route}
className={({ isActive }) =>
cx(
'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20',
!isActive && 'text-gray-700 dark:text-white',
isActive && 'text-brand-500 '
)
}
>
<Icon className='mr-3 h-6 w-6' name={tab.icon} />
<span className='font-semibold'>{tab.name}</span>
</NavLink>
))}
<div className='mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-10'></div>
</div>
)
}
const Playlists = () => {
const { data: playlists } = useUserPlaylists()
const playerSnapshot = useSnapshot(player)
const currentPlaylistID = useMemo(
() => playerSnapshot.trackListSource?.id,
[playerSnapshot.trackListSource]
)
const playlistMode = useMemo(
() => playerSnapshot.trackListSource?.type,
[playerSnapshot.trackListSource]
)
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
return (
<div className='mb-16 overflow-auto pb-2'>
{playlists?.playlist?.map(playlist => (
<NavLink
onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
key={playlist.id}
onClick={() => scrollToTop()}
to={`/playlist/${playlist.id}`}
className={({ isActive }: { isActive: boolean }) =>
cx(
'btn-hover-animation line-clamp-1 my-px mx-3 flex cursor-default items-center justify-between rounded-lg px-3 py-[0.38rem] text-sm text-black opacity-70 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/20',
isActive && 'after:scale-100 after:opacity-100'
)
}
>
<span className='line-clamp-1'>{playlist.name}</span>
{playlistMode === TrackListSourceType.Playlist &&
mode === Mode.TrackList &&
currentPlaylistID === playlist.id && (
<Icon className='h-5 w-5' name='volume-half' />
)}
</NavLink>
))}
</div>
)
}
const Sidebar = () => {
return (
<div className='relative flex h-full w-[104px] flex-col justify-center'>
<div className='grid grid-cols-1 justify-items-center gap-12 text-black/10 dark:text-white/20'>
<SvgIcon
name='my'
className='h-10 w-10 text-brand-600 dark:text-brand-700'
/>
<SvgIcon name='explore' className='h-10 w-10' />
<SvgIcon name='discovery' className='h-10 w-10' />
<SvgIcon name='lyrics' className='h-10 w-10' />
</div>
<div
className='absolute bottom-8 right-0 left-0 flex rotate-180 items-center font-medium text-brand-600 dark:text-brand-700'
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
letterSpacing: '0.5px',
}}
>
<span>USER PAGE</span>
</div>
<div
id='sidebar'
className='grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:border-gray-500/10 dark:bg-gray-900 dark:bg-opacity-80'
>
<PrimaryTabs />
<Playlists />
</div>
)
}

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react'
import cx from 'classnames'
import { cx } from '@emotion/css'
const Skeleton = ({
children,

View file

@ -0,0 +1,44 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import Slider from './Slider'
import { useArgs } from '@storybook/client-api'
import { cx } from '@emotion/css'
export default {
title: 'Basic/Slider',
component: Slider,
args: {
value: 50,
min: 0,
max: 100,
onlyCallOnChangeAfterDragEnded: false,
orientation: 'horizontal',
alwaysShowTrack: false,
alwaysShowThumb: false,
},
} as ComponentMeta<typeof Slider>
const Template: ComponentStory<typeof Slider> = args => {
const [, updateArgs] = useArgs()
return (
<div
className={cx(
'h-full rounded-24 bg-[#F8F8F8] dark:bg-black',
args.orientation === 'horizontal' && 'py-4 px-5',
args.orientation === 'vertical' && 'h-64 w-min py-5 px-4'
)}
>
<Slider {...args} onChange={value => updateArgs({ value })} />
</div>
)
}
export const Default = Template.bind({})
export const Vertical = Template.bind({})
Vertical.args = {
orientation: 'vertical',
alwaysShowTrack: true,
alwaysShowThumb: true,
}

View file

@ -1,5 +1,5 @@
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
import cx from 'classnames'
import { cx } from '@emotion/css'
const Slider = ({
value,
@ -8,6 +8,8 @@ const Slider = ({
onChange,
onlyCallOnChangeAfterDragEnded = false,
orientation = 'horizontal',
alwaysShowTrack = false,
alwaysShowThumb = false,
}: {
value: number
min: number
@ -15,6 +17,8 @@ const Slider = ({
onChange: (value: number) => void
onlyCallOnChangeAfterDragEnded?: boolean
orientation?: 'horizontal' | 'vertical'
alwaysShowTrack?: boolean
alwaysShowThumb?: boolean
}) => {
const sliderRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
@ -29,24 +33,26 @@ const Slider = ({
* Get the value of the slider based on the position of the pointer
*/
const getNewValue = useCallback(
(val: number) => {
(pointer: { x: number; y: number }) => {
if (!sliderRef?.current) return 0
const sliderWidth = sliderRef.current.getBoundingClientRect().width
const newValue = (val / sliderWidth) * max
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]
[sliderRef, max, min, orientation]
)
/**
* Handle slider click event
*/
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
onChange(getNewValue(e.clientX))
},
(e: React.MouseEvent<HTMLDivElement>) =>
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
[getNewValue, onChange]
)
@ -63,7 +69,7 @@ const Slider = ({
useEffect(() => {
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
if (!isDragging) return
const newValue = getNewValue(e.clientX)
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
onlyCallOnChangeAfterDragEnded
? setDraggingValue(newValue)
: onChange(newValue)
@ -109,32 +115,47 @@ const Slider = ({
/**
* Track and thumb styles
*/
const usedTrackStyle = useMemo(
() => ({ width: `${(memoedValue / max) * 100}%` }),
[max, memoedValue]
)
const thumbStyle = useMemo(
() => ({
left: `${(memoedValue / max) * 100}%`,
transform: `translateX(-10px)`,
}),
[max, memoedValue]
)
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='group flex h-2 -translate-y-[3px] items-center'
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='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
<div
className={cx(
'absolute bg-gray-500 bg-opacity-10',
orientation === 'horizontal' && 'h-[2px] w-full',
orientation === 'vertical' && 'h-full w-[2px]'
)}
></div>
{/* Passed track */}
<div
className={cx(
'absolute h-[2px] group-hover:bg-brand-500',
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-500'
'absolute group-hover:bg-brand-500',
isDragging || alwaysShowTrack
? 'bg-brand-500'
: 'bg-gray-300 dark:bg-gray-500',
orientation === 'horizontal' && 'h-[2px]',
orientation === 'vertical' && 'bottom-0 w-[2px]'
)}
style={usedTrackStyle}
></div>
@ -142,8 +163,12 @@ const Slider = ({
{/* Thumb */}
<div
className={cx(
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity ',
isDragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity',
isDragging || alwaysShowThumb
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100',
orientation === 'horizontal' && '-translate-x-2.5',
orientation === 'vertical' && 'translate-y-2.5'
)}
style={thumbStyle}
onClick={e => e.stopPropagation()}

View file

@ -0,0 +1,48 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import Slider from './SliderNative'
import { useArgs } from '@storybook/client-api'
import { cx } from '@emotion/css'
export default {
title: 'Basic/Slider (Native Input)',
component: Slider,
args: {
value: 50,
min: 0,
max: 100,
onlyCallOnChangeAfterDragEnded: false,
orientation: 'horizontal',
alwaysShowTrack: false,
alwaysShowThumb: false,
},
} as ComponentMeta<typeof Slider>
const Template: ComponentStory<typeof Slider> = args => {
const [, updateArgs] = useArgs()
return (
<div
className={cx(
'h-full rounded-24 bg-[#F8F8F8] dark:bg-black',
args.orientation === 'horizontal' && 'py-4 px-5',
args.orientation === 'vertical' && 'h-64 w-min py-5 px-4'
)}
>
<Slider {...args} onChange={value => updateArgs({ value })} />
</div>
)
}
export const Default = Template.bind({})
Default.args = {
alwaysShowTrack: true,
alwaysShowThumb: true,
}
export const Vertical = Template.bind({})
Vertical.args = {
orientation: 'vertical',
alwaysShowTrack: true,
alwaysShowThumb: true,
}

View file

@ -0,0 +1,78 @@
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
import { css, cx } from '@emotion/css'
const style = css`
-webkit-appearance: none;
background: transparent;
border-radius: 9999px;
width: 100%;
&::-webkit-slider-runnable-track {
border-radius: 9999px;
height: 2px;
width: 100%;
background-color: hsla(215 28% 17% / 0.1);
}
&::-moz-range-track {
border-radius: 9999px;
height: 8px;
width: 100%;
background-color: hsla(215 28% 17% / 0.1);
}
&::-webkit-slider-thumb {
background-color: hsl(var(--brand-color-500));
border-radius: 9999px;
height: 16px;
width: 16px;
border: none;
-webkit-appearance: none;
top: 50%;
color: hsl(215 28% 17%);
transform: translateY(-50%);
}
&::-moz-range-thumb {
background-color: hsl(0 0% 100%);
border-radius: 9999px;
height: 16px;
width: 16%;
border: none;
top: 50%;
color: hsl(215 28% 17%);
}
`
const Slider = ({
value,
min,
max,
onChange,
onlyCallOnChangeAfterDragEnded = false,
orientation = 'horizontal',
alwaysShowTrack = false,
alwaysShowThumb = false,
step = 0.0001,
}: {
value: number
min: number
max: number
onChange: (value: number) => void
onlyCallOnChangeAfterDragEnded?: boolean
orientation?: 'horizontal' | 'vertical'
alwaysShowTrack?: boolean
alwaysShowThumb?: boolean
step?: number
}) => {
return (
<input
type='range'
min={min}
max={max}
value={value}
step={step}
onChange={e => onChange(Number(e.target.value))}
className={style}
/>
)
}
export default Slider

View file

@ -1,10 +1,10 @@
import { player } from '@/web/store'
import SvgIcon from './SvgIcon'
import Icon from './Icon'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useState, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
import { cx } from '@emotion/css'
const Controls = () => {
const [isMaximized, setIsMaximized] = useState(false)
@ -31,13 +31,13 @@ const Controls = () => {
onClick={minimize}
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
>
<SvgIcon className='h-3 w-3' name='windows-minimize' />
<Icon className='h-3 w-3' name='windows-minimize' />
</button>
<button
onClick={maxRestore}
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
>
<SvgIcon
<Icon
className='h-3 w-3'
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
/>
@ -46,7 +46,7 @@ const Controls = () => {
onClick={close}
className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'
>
<SvgIcon className='h-3 w-3' name='windows-close' />
<Icon className='h-3 w-3' name='windows-close' />
</button>
</div>
)

View file

@ -1,44 +1,114 @@
import SvgIcon from './SvgIcon'
import Icon from '@/web/components/Icon'
import useScroll from '@/web/hooks/useScroll'
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import Avatar from './Avatar'
import { cx } from '@emotion/css'
const NavigationButtons = () => {
const navigate = useNavigate()
enum ACTION {
Back = 'back',
Forward = 'forward',
}
const handleNavigate = (action: ACTION) => {
if (action === ACTION.Back) navigate(-1)
if (action === ACTION.Forward) navigate(1)
}
return (
<div className='flex gap-1'>
{[ACTION.Back, ACTION.Forward].map(action => (
<div
onClick={() => handleNavigate(action)}
key={action}
className='app-region-no-drag btn-hover-animation rounded-lg p-2 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
>
<Icon className='h-5 w-5' name={action} />
</div>
))}
</div>
)
}
const SearchBox = () => {
const { type } = useParams()
const [keywords, setKeywords] = useState('')
const navigate = useNavigate()
const toSearch = (e: React.KeyboardEvent) => {
if (!keywords) return
if (e.key === 'Enter') {
navigate(`/search/${keywords}${type ? `/${type}` : ''}`)
}
}
return (
<div className='app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 pl-2.5 pr-2 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5'>
<Icon
className='mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200'
name='search'
/>
<input
value={keywords}
onChange={e => setKeywords(e.target.value)}
onKeyDown={toSearch}
type='text'
className='flex-grow bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
placeholder='搜索'
/>
<div
onClick={() => setKeywords('')}
className={cx(
'cursor-default rounded-full p-1 text-gray-600 transition hover:bg-gray-400/20 dark:text-white/50 dark:hover:bg-white/20',
!keywords && 'hidden'
)}
>
<Icon className='h-4 w-4' name='x' />
</div>
</div>
)
}
const Settings = () => {
const navigate = useNavigate()
return (
<div
onClick={() => navigate('/settings')}
className='app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
>
<Icon className='h-[1.125rem] w-[1.125rem]' name='settings' />
</div>
)
}
const Topbar = () => {
/**
* Show topbar background when scroll down
*/
const [mainContainer, setMainContainer] = useState<HTMLElement | null>(null)
const scroll = useScroll(mainContainer, { throttle: 100 })
useEffect(() => {
setMainContainer(document.getElementById('mainContainer'))
}, [setMainContainer])
return (
<div className='flex w-full items-center justify-between pt-11 pb-10'>
{/* Left Part */}
<div className='flex items-center'>
{/* Navigation Buttons */}
<button className='rounded-full bg-[#E9E9E9] p-[10px] dark:bg-[#0E0E0E]'>
<SvgIcon name='back' className='h-7 w-7 text-[#717171]' />
</button>
<button className='ml-[10px] rounded-full bg-[#E9E9E9] p-[10px] dark:bg-[#0E0E0E]'>
<SvgIcon name='forward' className='h-7 w-7 text-[#717171]' />
</button>
{/* Dividing line */}
<div className='mx-6 h-4 w-px bg-black/20 dark:bg-white/20'></div>
{/* Search Box */}
<div className='flex min-w-[284px] items-center rounded-full bg-[#E9E9E9] p-[10px] text-[#717171] dark:bg-[#0E0E0E]'>
<SvgIcon name='search' className='mr-[10px] h-7 w-7' />
<input
placeholder='Artist, songs and more'
className='bg-transparent placeholder:text-[#717171]'
/>
</div>
<div
className={cx(
'sticky z-30 flex h-16 min-h-[4rem] w-full cursor-default items-center justify-between px-8 transition duration-300',
window.env?.isMac && 'app-region-drag',
window.env?.isEnableTitlebar ? 'top-8' : 'top-0',
!scroll.arrivedState.top &&
'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'
)}
>
<div className='flex gap-2'>
<NavigationButtons />
<SearchBox />
</div>
{/* Right Part */}
<div className='flex'>
<button className='rounded-full bg-[#E9E9E9] p-[10px] dark:bg-[#0E0E0E]'>
<SvgIcon name='placeholder' className='h-7 w-7 text-[#717171]' />
</button>
{/* Avatar */}
<div>
<img
className='ml-3 h-12 w-12 rounded-full'
src='http://p1.music.126.net/AetIV1GOZiLKk1yy8PMPfw==/109951165378042240.jpg'
/>
</div>
<div className='flex items-center gap-3'>
<Settings />
<Avatar />
</div>
</div>
)

View file

@ -1,14 +1,14 @@
import { memo, useCallback, useMemo } from 'react'
import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import Icon from '@/web/components/Icon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player } from '@/web/store'
import { formatDuration } from '@/web/utils/common'
import { State as PlayerState } from '@/web/utils/player'
import cx from 'classnames'
import { cx } from '@emotion/css'
import { useSnapshot } from 'valtio'
const PlayOrPauseButtonInTrack = memo(
@ -31,7 +31,7 @@ const PlayOrPauseButtonInTrack = memo(
!isHighlight && 'hidden group-hover:block'
)}
>
<SvgIcon
<Icon
className='h-5 w-5 text-brand-500'
name={isPlaying && isHighlight ? 'pause' : 'play'}
/>
@ -118,7 +118,7 @@ const Track = memo(
<span className='flex items-center'>
{track.name}
{track.mark === 1318912 && (
<SvgIcon
<Icon
name='explicit'
className='ml-1.5 mt-[2px] h-4 w-4 text-gray-300 dark:text-gray-500'
/>
@ -169,7 +169,7 @@ const Track = memo(
!isSkeleton && 'group-hover:opacity-100'
)}
>
<SvgIcon
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-5 w-5'
/>

View file

@ -2,8 +2,8 @@ import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import { player } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import SvgIcon from './SvgIcon'
import cx from 'classnames'
import Icon from './Icon'
import { cx } from '@emotion/css'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
@ -65,7 +65,7 @@ const Track = ({
) : (
<span className='flex items-center'>
{track.mark === 1318912 && (
<SvgIcon
<Icon
name='explicit'
className={cx(
'mr-1 h-3 w-3',

View file

@ -2,13 +2,13 @@ import { memo, useMemo } from 'react'
import { NavLink } from 'react-router-dom'
import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import Icon from '@/web/components/Icon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { formatDuration, resizeImage } from '@/web/utils/common'
import { player } from '@/web/store'
import cx from 'classnames'
import { cx } from '@emotion/css'
import { useSnapshot } from 'valtio'
const Track = memo(
@ -96,7 +96,7 @@ const Track = memo(
) : (
<span className='inline-flex items-center'>
{track.mark === 1318912 && (
<SvgIcon
<Icon
name='explicit'
className='mr-1 h-3.5 w-3.5 text-gray-300 dark:text-gray-500'
/>
@ -141,7 +141,7 @@ const Track = memo(
!isSkeleton && 'group-hover:opacity-100'
)}
>
<SvgIcon
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-5 w-5'
/>