feat: 初步实现歌词界面

This commit is contained in:
qier222 2022-04-08 01:02:25 +08:00
parent 1eb38937fc
commit 530581ba82
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
15 changed files with 635 additions and 18 deletions

View File

@ -76,6 +76,7 @@
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"express-fileupload": "^1.3.1",
"framer-motion": "^6.2.8",
"howler": "^2.2.3",
"js-cookie": "^3.0.1",
"lodash-es": "^4.17.21",

58
pnpm-lock.yaml generated
View File

@ -45,6 +45,7 @@ specifiers:
express: ^4.17.3
express-fileupload: ^1.3.1
fast-folder-size: ^1.6.1
framer-motion: ^6.2.8
howler: ^2.2.3
js-cookie: ^3.0.1
lodash-es: ^4.17.21
@ -123,6 +124,7 @@ devDependencies:
eslint-plugin-react: 7.29.4_eslint@8.12.0
eslint-plugin-react-hooks: 4.4.0_eslint@8.12.0
express-fileupload: 1.3.1
framer-motion: 6.2.8_react-dom@18.0.0+react@18.0.0
howler: 2.2.3
js-cookie: 3.0.1
lodash-es: 4.17.21
@ -478,6 +480,19 @@ packages:
- supports-color
dev: true
/@emotion/is-prop-valid/0.8.8:
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
requiresBuild: true
dependencies:
'@emotion/memoize': 0.7.4
dev: true
optional: true
/@emotion/memoize/0.7.4:
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
dev: true
optional: true
/@eslint/eslintrc/1.2.1:
resolution: {integrity: sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -3653,6 +3668,29 @@ packages:
map-cache: 0.2.2
dev: true
/framer-motion/6.2.8_react-dom@18.0.0+react@18.0.0:
resolution: {integrity: sha512-4PtBWFJ6NqR350zYVt9AsFDtISTqsdqna79FvSYPfYDXuuqFmiKtZdkTnYPslnsOMedTW0pEvaQ7eqjD+sA+HA==}
peerDependencies:
react: '>=16.8 || ^17.0.0 || ^18.0.0'
react-dom: '>=16.8 || ^17.0.0 || ^18.0.0'
dependencies:
framesync: 6.0.1
hey-listen: 1.0.8
popmotion: 11.0.3
react: 18.0.0
react-dom: 18.0.0_react@18.0.0
style-value-types: 5.0.0
tslib: 2.3.1
optionalDependencies:
'@emotion/is-prop-valid': 0.8.8
dev: true
/framesync/6.0.1:
resolution: {integrity: sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==}
dependencies:
tslib: 2.3.1
dev: true
/fresh/0.5.2:
resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=}
engines: {node: '>= 0.6'}
@ -4056,6 +4094,10 @@ packages:
tslib: 2.3.1
dev: false
/hey-listen/1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
dev: true
/history/5.3.0:
resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==}
dependencies:
@ -5760,6 +5802,15 @@ packages:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
/popmotion/11.0.3:
resolution: {integrity: sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==}
dependencies:
framesync: 6.0.1
hey-listen: 1.0.8
style-value-types: 5.0.0
tslib: 2.3.1
dev: true
/posix-character-classes/0.1.1:
resolution: {integrity: sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=}
engines: {node: '>=0.10.0'}
@ -6934,6 +6985,13 @@ packages:
'@tokenizer/token': 0.3.0
peek-readable: 4.1.0
/style-value-types/5.0.0:
resolution: {integrity: sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==}
dependencies:
hey-listen: 1.0.8
tslib: 2.3.1
dev: true
/stylis/4.0.13:
resolution: {integrity: sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==}
dev: true

View File

@ -4,8 +4,9 @@ import { ReactQueryDevtools } from 'react-query/devtools'
import Player from '@/components/Player'
import Sidebar from '@/components/Sidebar'
import reactQueryClient from '@/utils/reactQueryClient'
import Main from './components/Main'
import TitleBar from './components/TitleBar'
import Main from '@/components/Main'
import TitleBar from '@/components/TitleBar'
import Lyric from '@/components/Lyric'
const App = () => {
return (
@ -18,6 +19,8 @@ const App = () => {
<Player />
</div>
<Lyric />
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
{/* Devtool */}

View File

@ -6,12 +6,14 @@ const Cover = ({
roundedClass = 'rounded-xl',
showPlayButton = false,
showHover = true,
alwaysShowShadow = false,
}: {
imageUrl: string
onClick?: () => void
roundedClass?: string
showPlayButton?: boolean
showHover?: boolean
alwaysShowShadow?: boolean
}) => {
const [isError, setIsError] = useState(false)
@ -21,8 +23,9 @@ const Cover = ({
{showHover && (
<div
className={classNames(
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] bg-cover opacity-0 blur-lg filter transition duration-300 group-hover:opacity-60',
roundedClass
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] bg-cover blur-lg filter transition duration-300 ',
roundedClass,
!alwaysShowShadow && 'opacity-0 group-hover:opacity-60'
)}
style={{
backgroundImage: `url("${imageUrl}")`,
@ -32,7 +35,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'>
<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' />
</div>
) : (

View File

@ -61,22 +61,24 @@ const FMCard = () => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.fmTrack, [playerSnapshot.fmTrack])
const coverUrl = useMemo(
() => resizeImage(playerSnapshot.fmTrack?.al?.picUrl ?? '', 'md'),
[playerSnapshot.fmTrack]
() => resizeImage(track?.al?.picUrl ?? '', 'md'),
[track?.al?.picUrl]
)
useEffect(() => {
const cover = resizeImage(playerSnapshot.fmTrack?.al?.picUrl ?? '', 'xs')
const cover = resizeImage(track?.al?.picUrl ?? '', 'xs')
if (cover) {
average(cover, { amount: 1, format: 'hex', sample: 1 }).then(color => {
let c = colord(color as string)
if (c.isLight()) c = c.darken(0.15)
else if (c.isDark()) c = c.lighten(0.1)
const hsl = c.toHsl()
if (hsl.s > 50) c = colord({ ...hsl, s: 50 })
if (hsl.l > 50) c = colord({ ...c.toHsl(), l: 50 })
if (hsl.l < 30) c = colord({ ...c.toHsl(), l: 30 })
const to = c.darken(0.15).rotate(-5).toHex()
setBackground(`linear-gradient(to bottom right, ${c.toHex()}, ${to})`)
setBackground(`linear-gradient(to bottom, ${c.toHex()}, ${to})`)
})
}
}, [playerSnapshot.fmTrack?.al?.picUrl])
}, [track?.al?.picUrl])
return (
<div

View File

@ -0,0 +1,122 @@
import useLyric from '@/hooks/useLyric'
import { player } from '@/store'
import {
motion,
useMotionValue,
AnimatePresence,
AnimationControls,
useAnimation,
LayoutGroup,
} from 'framer-motion'
import { lyricParser } from '@/utils/lyric'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
console.log('rendering')
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
const lyric = useMemo(() => {
return lyricRaw && lyricParser(lyricRaw)
}, [lyricRaw])
const progress = playerSnapshot.progress + 0.3
const currentLine = useMemo(() => {
const index =
(lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
return {
index: index < 1 ? 0 : index,
time: lyric?.lyric?.[index]?.time ?? 0,
}
}, [lyric?.lyric, progress])
const displayLines = useMemo(() => {
const index = currentLine.index
const lines =
lyric?.lyric.slice(index === 0 ? 0 : index - 1, currentLine.index + 7) ??
[]
if (index === 0) {
lines.unshift({
time: 0,
content: '',
rawTime: '[00:00:00]',
})
}
return lines
}, [currentLine.index, lyric?.lyric])
const variants = {
initial: { opacity: [0, 0.2], y: ['24%', 0] },
current: {
opacity: 1,
y: 0,
transition: {
ease: [0.5, 0.2, 0.2, 0.8],
duration: 0.7,
},
},
rest: (index: number) => ({
opacity: 0.2,
y: 0,
transition: {
delay: index * 0.04,
ease: [0.5, 0.2, 0.2, 0.8],
duration: 0.7,
},
}),
exit: {
opacity: 0,
y: -132,
height: 0,
paddingTop: 0,
paddingBottom: 0,
transition: {
duration: 0.7,
ease: [0.5, 0.2, 0.2, 0.8],
},
},
}
return (
<div
className={classNames(
'max-h-screen cursor-default overflow-hidden font-semibold',
className
)}
style={{
paddingTop: 'calc(100vh / 7 * 3)',
paddingBottom: 'calc(100vh / 7 * 3)',
fontSize: 'calc(100vw * 0.0264)',
lineHeight: 'calc(100vw * 0.032)',
}}
>
{displayLines.map(({ content, time }, index) => {
return (
<motion.div
key={time}
custom={index}
variants={variants}
initial={'initial'}
animate={
time === currentLine.time
? 'current'
: time < currentLine.time
? 'exit'
: 'rest'
}
layout
className={classNames(
'max-w-[78%] py-[calc(100vw_*_0.0111)] text-white'
)}
>
{content}
</motion.div>
)
})}
</div>
)
}
export default Lyric

View File

@ -0,0 +1,98 @@
import useLyric from '@/hooks/useLyric'
import { player } from '@/store'
import { motion, useMotionValue } from 'framer-motion'
import { lyricParser } from '@/utils/lyric'
import { useWindowSize } from 'react-use'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
const lyric = useMemo(() => {
return lyricRaw && lyricParser(lyricRaw)
}, [lyricRaw])
const [progress, setProgress] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setProgress(player.howler.seek() + 0.3)
}, 300)
return () => clearInterval(timer)
}, [])
const currentIndex = useMemo(() => {
return (lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
}, [lyric?.lyric, progress])
const y = useMotionValue(1000)
const { height: windowHight } = useWindowSize()
useEffect(() => {
const top = (
document.getElementById('lyrics')?.children?.[currentIndex] as any
)?.offsetTop
if (top) {
y.set((windowHight / 9) * 4 - top)
}
}, [currentIndex, windowHight, y])
useEffect(() => {
y.set(0)
}, [track, y])
return (
<div
className={classNames(
'max-h-screen cursor-default select-none overflow-hidden font-semibold',
className
)}
style={{
paddingTop: 'calc(100vh / 9 * 4)',
paddingBottom: 'calc(100vh / 9 * 4)',
fontSize: 'calc(100vw * 0.0264)',
lineHeight: 'calc(100vw * 0.032)',
}}
id='lyrics'
>
{lyric?.lyric.map(({ content, time }, index) => {
return (
<motion.div
id={String(time)}
key={time}
className={classNames(
'max-w-[78%] py-[calc(100vw_*_0.0111)] text-white duration-700'
)}
style={{
y,
opacity:
index === currentIndex
? 1
: index > currentIndex && index < currentIndex + 8
? 0.2
: 0,
transitionProperty:
index > currentIndex - 2 && index < currentIndex + 8
? 'transform, opacity'
: 'none',
transitionTimingFunction:
index > currentIndex - 2 && index < currentIndex + 8
? 'cubic-bezier(0.5, 0.2, 0.2, 0.8)'
: 'none',
transitionDelay: `${
index < currentIndex + 8 && index > currentIndex
? 0.04 * (index - currentIndex)
: 0
}s`,
}}
>
{content}
</motion.div>
)
})}
</div>
)
}
export default Lyric

View File

@ -0,0 +1,82 @@
import Player from './Player'
import { player, state } from '@/store'
import { resizeImage } from '@/utils/common'
import { average } from 'color.js'
import { colord } from 'colord'
import IconButton from '../IconButton'
import SvgIcon from '../SvgIcon'
import Lyric from './Lyric'
import { motion, AnimatePresence } from 'framer-motion'
import Lyric2 from './Lyric2'
const LyricPanel = () => {
const stateSnapshot = useSnapshot(state)
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const [bgColor, setBgColor] = useState({ from: '#222', to: '#222' })
useEffect(() => {
const cover = resizeImage(track?.al?.picUrl ?? '', 'xs')
if (cover) {
average(cover, { amount: 1, format: 'hex', sample: 1 }).then(color => {
let c = colord(color as string)
const hsl = c.toHsl()
if (hsl.s > 50) c = colord({ ...hsl, s: 50 })
if (hsl.l > 50) c = colord({ ...c.toHsl(), l: 50 })
if (hsl.l < 30) c = colord({ ...c.toHsl(), l: 30 })
const to = c.darken(0.15).rotate(-5).toHex()
setBgColor({
from: c.toHex(),
to,
})
})
}
}, [track?.al?.picUrl])
return (
<AnimatePresence>
{stateSnapshot.uiStates.showLyricPanel && (
<motion.div
initial={{
y: '100%',
}}
animate={{
y: 0,
transition: {
ease: 'easeIn',
duration: 0.4,
},
}}
exit={{
y: '100%',
transition: {
ease: 'easeIn',
duration: 0.4,
},
}}
className={classNames(
'fixed inset-0 z-40 grid grid-cols-[repeat(13,_minmax(0,_1fr))] gap-[8%] bg-gray-800'
)}
style={{
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
}}
>
{/* Drag area */}
<div className='app-region-drag absolute top-0 right-0 left-0 h-16'></div>
<Player className='col-span-6' />
{/* <Lyric className='col-span-7' /> */}
<Lyric2 className='col-span-7' />
<div className='absolute bottom-3.5 right-7 text-white'>
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
<SvgIcon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
export default LyricPanel

View File

@ -0,0 +1,149 @@
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/hooks/useUserLikedTracksIDs'
import { player, state } from '@/store'
import { resizeImage } from '@/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 '@/utils/player'
const PlayingTrack = () => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const navigate = useNavigate()
const toAlbum = () => {
const id = track?.al?.id
if (!id) return
navigate(`/album/${id}`)
state.uiStates.showLyricPanel = false
}
const trackListSource = useMemo(
() => playerSnapshot.trackListSource,
[playerSnapshot.trackListSource]
)
const toTrackListSource = () => {
if (!trackListSource?.type) return
navigate(`/${trackListSource.type}/${trackListSource.id}`)
state.uiStates.showLyricPanel = false
}
return (
<div>
<div
onClick={toTrackListSource}
className='line-clamp-1 text-[22px] font-semibold text-white hover:underline'
>
{track?.name}
</div>
<div className='line-clamp-1 -mt-0.5 inline-flex max-h-7 text-white opacity-60'>
<ArtistInline artists={track?.ar ?? []} />
<span>
{' '}
-{' '}
<span onClick={toAlbum} className='hover:underline'>
{track?.al.name}
</span>
</span>
</div>
</div>
)
}
const LikeButton = ({ track }: { track: Track | undefined | null }) => {
const { data: userLikedSongs } = useUserLikedTracksIDs()
const mutationLikeATrack = useMutationLikeATrack()
return (
<div className='mr-1 '>
<IconButton
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
>
<SvgIcon
className='h-6 w-6 text-white'
name={
track?.id && userLikedSongs?.ids?.includes(track.id)
? 'heart'
: 'heart-outline'
}
/>
</IconButton>
</div>
)
}
const Controls = () => {
const playerSnapshot = useSnapshot(player)
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
return (
<div className='flex items-center justify-center gap-2 text-white'>
{mode === PlayerMode.PLAYLIST && (
<IconButton
onClick={() => track && player.prevTrack()}
disabled={!track}
>
<SvgIcon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<SvgIcon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
onClick={() => track && player.playOrPause()}
disabled={!track}
className='after:rounded-xl'
>
<SvgIcon
className='h-7 w-7'
name={
[PlayerState.PLAYING, PlayerState.LOADING].includes(state)
? 'pause'
: 'play'
}
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon className='h-6 w-6' name='next' />
</IconButton>
</div>
)
}
const Player = ({ className }: { className?: string }) => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return (
<div
className={classNames('flex w-full items-center justify-end', className)}
>
<div className='relative w-[74%]'>
<Cover
imageUrl={resizeImage(track?.al.picUrl ?? '', 'lg')}
roundedClass='rounded-2xl'
alwaysShowShadow={true}
/>
<div className='absolute -bottom-32 right-0 left-0'>
<div className='mt-6 flex cursor-default justify-between'>
<PlayingTrack />
<LikeButton track={track} />
</div>
<Controls />
</div>
</div>
</div>
)
}
export default Player

View File

@ -0,0 +1,3 @@
import LyricPanel from './LyricPanel'
export default LyricPanel

View File

@ -5,7 +5,7 @@ import SvgIcon from '@/components/SvgIcon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/hooks/useUserLikedTracksIDs'
import { player } from '@/store'
import { player, state } from '@/store'
import { resizeImage } from '@/utils/common'
import { State as PlayerState, Mode as PlayerMode } from '@/utils/player'
@ -152,7 +152,9 @@ const Others = () => {
<IconButton onClick={() => toast('施工中...')}>
<SvgIcon className='h-6 w-6' name='volume' />
</IconButton>
<IconButton onClick={() => toast('施工中...')}>
{/* Lyric */}
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
<SvgIcon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>

View File

@ -2,16 +2,16 @@ import SvgIcon from './SvgIcon'
const TitleBar = () => {
return (
<div className='flex h-8 w-screen items-center justify-between bg-gray-50'>
<div className='app-region-drag flex h-8 w-screen items-center justify-between bg-gray-50'>
<div className='ml-2 text-sm text-gray-500'>YesPlayMusic</div>
<div className='flex h-full'>
<button className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'>
<button className='app-region-no-drag flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'>
<SvgIcon className='h-3 w-3' name='windows-minimize' />
</button>
<button className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'>
<button className='app-region-no-drag flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'>
<SvgIcon className='h-3 w-3' name='windows-maximize' />
</button>
<button className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'>
<button className='app-region-no-drag flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'>
<SvgIcon className='h-3 w-3' name='windows-close' />
</button>
</div>

View File

@ -5,6 +5,7 @@ import { Player } from '@/utils/player'
interface Store {
uiStates: {
loginPhoneCountryCode: string
showLyricPanel: boolean
}
settings: {
showSidebar: boolean
@ -14,6 +15,7 @@ interface Store {
const initialState: Store = {
uiStates: {
loginPhoneCountryCode: '+86',
showLyricPanel: false,
},
settings: {
showSidebar: true,
@ -28,6 +30,7 @@ subscribe(state, () => {
localStorage.setItem('state', JSON.stringify(state))
})
// player
const playerInLocalStorage = localStorage.getItem('player')
export const player = proxy(new Player())
player.init((playerInLocalStorage && JSON.parse(playerInLocalStorage)) || {})

View File

@ -0,0 +1,87 @@
import { FetchLyricResponse } from '@/api/track'
export function lyricParser(lrc: FetchLyricResponse) {
return {
lyric: parseLyric(lrc?.lrc?.lyric || ''),
tlyric: parseLyric(lrc?.tlyric?.lyric || ''),
lyricuser: lrc.lyricUser,
transuser: lrc.transUser,
}
}
/**
* @see https://regexr.com/6e52n
*/
const extractLrcRegex =
/^(?<lyricTimestamps>(?:\[.+?\])+)(?!\[)(?<content>.+)$/gm
const extractTimestampRegex = /\[(?<min>\d+):(?<sec>\d+)(?:\.|:)*(?<ms>\d+)*\]/g
interface ParsedLyric {
time: number
rawTime: string
content: string
}
function parseLyric(lrc: string): ParsedLyric[] {
// A sorted list of parsed lyric and its timestamp.
const parsedLyrics: ParsedLyric[] = []
// Find the appropriate index to push our parsed lyric.
const binarySearch = (lyric: ParsedLyric) => {
const time = lyric.time
let low = 0
let high = parsedLyrics.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const midTime = parsedLyrics[mid].time
if (midTime === time) {
return mid
} else if (midTime < time) {
low = mid + 1
} else {
high = mid - 1
}
}
return low
}
for (const line of lrc.trim().matchAll(extractLrcRegex)) {
const { lyricTimestamps, content } = line.groups as {
lyricTimestamps: string
content: string
}
if (content === '纯音乐,请欣赏') continue
if (
content.match(
/((\s|\S)*)(作曲|作词|编曲|制作|Producers|Producer|Produced|贝斯|工程师|吉他|合成器|助理|编程|制作|和声|母带|人声|鼓|混音|中提琴|编写|Talkbox|钢琴|出版|录音|发行|出品)((\s|\S)*)(:|:)/
)
) {
continue
}
for (const timestamp of lyricTimestamps.matchAll(extractTimestampRegex)) {
const { min, sec, ms } = timestamp.groups as {
min: string
sec: string
ms: string
}
const rawTime = timestamp[0]
const time = Number(min) * 60 + Number(sec) + Number(ms ?? 0) * 0.001
const parsedLyric = { rawTime, time, content: trimContent(content) }
parsedLyrics.splice(binarySearch(parsedLyric), 0, parsedLyric)
}
}
return parsedLyrics
}
function trimContent(content: string): string {
const t = content.trim()
return t.length < 1 ? content : t
}

View File

@ -75,6 +75,10 @@ export class Player {
this._initFM()
}
get howler() {
return _howler
}
/**
* Get prev track index
*/