mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2025-03-27 17:58:56 +08:00
feat: 初步实现歌词界面
This commit is contained in:
parent
1eb38937fc
commit
530581ba82
@ -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
58
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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 */}
|
||||
|
@ -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>
|
||||
) : (
|
||||
|
@ -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
|
||||
|
122
src/renderer/components/Lyric/Lyric.tsx
Normal file
122
src/renderer/components/Lyric/Lyric.tsx
Normal 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
|
98
src/renderer/components/Lyric/Lyric2.tsx
Normal file
98
src/renderer/components/Lyric/Lyric2.tsx
Normal 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
|
82
src/renderer/components/Lyric/LyricPanel.tsx
Normal file
82
src/renderer/components/Lyric/LyricPanel.tsx
Normal 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
|
149
src/renderer/components/Lyric/Player.tsx
Normal file
149
src/renderer/components/Lyric/Player.tsx
Normal 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
|
3
src/renderer/components/Lyric/index.ts
Normal file
3
src/renderer/components/Lyric/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import LyricPanel from './LyricPanel'
|
||||
|
||||
export default LyricPanel
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)) || {})
|
||||
|
87
src/renderer/utils/lyric.ts
Normal file
87
src/renderer/utils/lyric.ts
Normal 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
|
||||
}
|
@ -75,6 +75,10 @@ export class Player {
|
||||
this._initFM()
|
||||
}
|
||||
|
||||
get howler() {
|
||||
return _howler
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prev track index
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user