diff --git a/package.json b/package.json index 1bbfee4..bf6b81e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c2df35..7fc8b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f02bb07..74d85f2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 = () => { + + {/* Devtool */} diff --git a/src/renderer/components/Cover.tsx b/src/renderer/components/Cover.tsx index dc92195..e366383 100644 --- a/src/renderer/components/Cover.tsx +++ b/src/renderer/components/Cover.tsx @@ -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 && (
+
) : ( diff --git a/src/renderer/components/FMCard.tsx b/src/renderer/components/FMCard.tsx index a10b8cf..88d9c3d 100644 --- a/src/renderer/components/FMCard.tsx +++ b/src/renderer/components/FMCard.tsx @@ -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 (
{ + // 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 ( +
+ {displayLines.map(({ content, time }, index) => { + return ( + + {content} + + ) + })} +
+ ) +} + +export default Lyric diff --git a/src/renderer/components/Lyric/Lyric2.tsx b/src/renderer/components/Lyric/Lyric2.tsx new file mode 100644 index 0000000..53aef50 --- /dev/null +++ b/src/renderer/components/Lyric/Lyric2.tsx @@ -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 ( +
+ {lyric?.lyric.map(({ content, time }, index) => { + return ( + 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} + + ) + })} +
+ ) +} + +export default Lyric diff --git a/src/renderer/components/Lyric/LyricPanel.tsx b/src/renderer/components/Lyric/LyricPanel.tsx new file mode 100644 index 0000000..14038bf --- /dev/null +++ b/src/renderer/components/Lyric/LyricPanel.tsx @@ -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 ( + + {stateSnapshot.uiStates.showLyricPanel && ( + + {/* Drag area */} +
+ + + {/* */} + + +
+ (state.uiStates.showLyricPanel = false)}> + + +
+
+ )} +
+ ) +} + +export default LyricPanel diff --git a/src/renderer/components/Lyric/Player.tsx b/src/renderer/components/Lyric/Player.tsx new file mode 100644 index 0000000..cb30a67 --- /dev/null +++ b/src/renderer/components/Lyric/Player.tsx @@ -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 ( +
+
+ {track?.name} +
+
+ + + {' '} + -{' '} + + {track?.al.name} + + +
+
+ ) +} + +const LikeButton = ({ track }: { track: Track | undefined | null }) => { + const { data: userLikedSongs } = useUserLikedTracksIDs() + const mutationLikeATrack = useMutationLikeATrack() + + return ( +
+ track?.id && mutationLikeATrack.mutate(track.id)} + > + + +
+ ) +} + +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 ( +
+ {mode === PlayerMode.PLAYLIST && ( + track && player.prevTrack()} + disabled={!track} + > + + + )} + {mode === PlayerMode.FM && ( + player.fmTrash()}> + + + )} + track && player.playOrPause()} + disabled={!track} + className='after:rounded-xl' + > + + + track && player.nextTrack()} disabled={!track}> + + +
+ ) +} + +const Player = ({ className }: { className?: string }) => { + const playerSnapshot = useSnapshot(player) + const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track]) + + return ( +
+
+ +
+
+ + +
+ + +
+
+
+ ) +} + +export default Player diff --git a/src/renderer/components/Lyric/index.ts b/src/renderer/components/Lyric/index.ts new file mode 100644 index 0000000..565b8db --- /dev/null +++ b/src/renderer/components/Lyric/index.ts @@ -0,0 +1,3 @@ +import LyricPanel from './LyricPanel' + +export default LyricPanel diff --git a/src/renderer/components/Player.tsx b/src/renderer/components/Player.tsx index f6f49e1..aed8f22 100644 --- a/src/renderer/components/Player.tsx +++ b/src/renderer/components/Player.tsx @@ -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 = () => { toast('施工中...')}> - toast('施工中...')}> + + {/* Lyric */} + (state.uiStates.showLyricPanel = true)}>
diff --git a/src/renderer/components/TitleBar.tsx b/src/renderer/components/TitleBar.tsx index 9bb5035..2ae80db 100644 --- a/src/renderer/components/TitleBar.tsx +++ b/src/renderer/components/TitleBar.tsx @@ -2,16 +2,16 @@ import SvgIcon from './SvgIcon' const TitleBar = () => { return ( -
+
YesPlayMusic
- - -
diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 04d8ea1..2864f31 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -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)) || {}) diff --git a/src/renderer/utils/lyric.ts b/src/renderer/utils/lyric.ts new file mode 100644 index 0000000..2952907 --- /dev/null +++ b/src/renderer/utils/lyric.ts @@ -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 = + /^(?(?:\[.+?\])+)(?!\[)(?.+)$/gm +const extractTimestampRegex = /\[(?\d+):(?\d+)(?:\.|:)*(?\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 +} diff --git a/src/renderer/utils/player.ts b/src/renderer/utils/player.ts index 81141d6..2801691 100644 --- a/src/renderer/utils/player.ts +++ b/src/renderer/utils/player.ts @@ -75,6 +75,10 @@ export class Player { this._initFM() } + get howler() { + return _howler + } + /** * Get prev track index */