mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 21:58:03 +00:00
feat: updates
This commit is contained in:
parent
a1b0bcf4d3
commit
884f3df41a
198 changed files with 4572 additions and 5336 deletions
114
packages/web/components/NowPlaying/Controls.tsx
Normal file
114
packages/web/components/NowPlaying/Controls.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import player from '@/web/states/player'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import { MotionConfig, motion } from 'framer-motion'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Icon from '../Icon'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
|
||||
const LikeButton = () => {
|
||||
const { track } = useSnapshot(player)
|
||||
const { data: likedIDs } = useUserLikedTracksIDs()
|
||||
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
|
||||
const likeATrack = useMutationLikeATrack()
|
||||
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
|
||||
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
|
||||
>
|
||||
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
const Controls = () => {
|
||||
const { state, track } = useSnapshot(player)
|
||||
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<MotionConfig transition={{ ease, duration: 0.6 }}>
|
||||
<motion.div
|
||||
className={cx(
|
||||
'fixed bottom-0 right-0 flex',
|
||||
mini
|
||||
? 'flex-col items-center justify-between'
|
||||
: 'items-center justify-between',
|
||||
mini
|
||||
? css`
|
||||
right: 24px;
|
||||
bottom: 18px;
|
||||
width: 44px;
|
||||
height: 254px;
|
||||
`
|
||||
: css`
|
||||
bottom: 56px;
|
||||
right: 56px;
|
||||
width: 254px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Minimize */}
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
|
||||
onClick={() => {
|
||||
persistedUiStates.minimizePlayer = !mini
|
||||
}}
|
||||
>
|
||||
<Icon name='hide-list' className='h-7 w-7 ' />
|
||||
</motion.button>
|
||||
|
||||
{/* Media controls */}
|
||||
<div className='flex flex-wrap gap-2 text-black/95 dark:text-white/80'>
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||
>
|
||||
<Icon name='previous' className='h-6 w-6' />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track && player.playOrPause()}
|
||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
className='h-6 w-6 '
|
||||
/>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track && player.nextTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||
>
|
||||
<Icon name='next' className='h-6 w-6 ' />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Like */}
|
||||
<LikeButton />
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
||||
export default Controls
|
||||
59
packages/web/components/NowPlaying/Cover.tsx
Normal file
59
packages/web/components/NowPlaying/Cover.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useAnimation, motion } from 'framer-motion'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Cover = () => {
|
||||
const { track } = useSnapshot(player)
|
||||
const [cover, setCover] = useState(track?.al.picUrl)
|
||||
const animationStartTime = useRef(0)
|
||||
const controls = useAnimation()
|
||||
const duration = 150 // ms
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const resizedCover = resizeImage(track?.al.picUrl || '', 'lg')
|
||||
const animate = async () => {
|
||||
animationStartTime.current = Date.now()
|
||||
await controls.start({ opacity: 0 })
|
||||
setCover(resizedCover)
|
||||
}
|
||||
animate()
|
||||
}, [controls, track?.al.picUrl])
|
||||
|
||||
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||
useEffect(() => {
|
||||
const realCover = resizeImage(track?.al.picUrl ?? '', 'lg')
|
||||
if (cover !== realCover) setCover(realCover)
|
||||
}, [cover, 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}
|
||||
onClick={() => {
|
||||
const id = track?.al.id
|
||||
if (id) navigate(`/album/${id}`)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
23
packages/web/components/NowPlaying/NowPlaying.stories.tsx
Normal file
23
packages/web/components/NowPlaying/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 '@/web/.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({})
|
||||
65
packages/web/components/NowPlaying/NowPlaying.tsx
Normal file
65
packages/web/components/NowPlaying/NowPlaying.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import Controls from './Controls'
|
||||
import Cover from './Cover'
|
||||
import Progress from './Progress'
|
||||
|
||||
const NowPlaying = () => {
|
||||
const { track } = useSnapshot(player)
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Now Playing */}
|
||||
<AnimatePresence>
|
||||
{!minimizePlayer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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>
|
||||
<ArtistInline
|
||||
artists={track?.ar || []}
|
||||
className='text-black/30 dark:text-white/30'
|
||||
hoverClassName='hover:text-black/50 dark:hover:text-white/70 transition-colors duration-400'
|
||||
/>
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mt-2 h-px w-2/5 bg-black/10 dark:bg-white/10'></div>
|
||||
|
||||
{/* Progress */}
|
||||
<Progress />
|
||||
|
||||
{/* Controls placeholder */}
|
||||
<div className='h-11'></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Controls */}
|
||||
<Controls />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NowPlaying
|
||||
29
packages/web/components/NowPlaying/Progress.tsx
Normal file
29
packages/web/components/NowPlaying/Progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import player from '@/web/states/player'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Slider from '../Slider'
|
||||
|
||||
const Progress = () => {
|
||||
const { track, progress } = useSnapshot(player)
|
||||
|
||||
return (
|
||||
<div className='mt-9 mb-4 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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Progress
|
||||
3
packages/web/components/NowPlaying/index.tsx
Normal file
3
packages/web/components/NowPlaying/index.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import NowPlaying from './NowPlaying'
|
||||
|
||||
export default NowPlaying
|
||||
Loading…
Add table
Add a link
Reference in a new issue