mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 21:28:06 +00:00
feat: updates
This commit is contained in:
parent
9a52681687
commit
840a5b8e9b
104 changed files with 1645 additions and 13494 deletions
|
|
@ -62,7 +62,7 @@ const ArtistRow = ({
|
|||
placeholderRow,
|
||||
}: {
|
||||
artists: Artist[] | undefined
|
||||
title?: string
|
||||
title?: string | null
|
||||
className?: string
|
||||
placeholderRow?: number
|
||||
}) => {
|
||||
|
|
|
|||
82
packages/web/components/ArtworkViewer.tsx
Normal file
82
packages/web/components/ArtworkViewer.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import uiStates from '../states/uiStates'
|
||||
import { resizeImage } from '../utils/common'
|
||||
import { ease } from '../utils/const'
|
||||
import Icon from './Icon'
|
||||
|
||||
function ArtworkViewer({
|
||||
type,
|
||||
artwork,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
type: 'album' | 'playlist'
|
||||
artwork: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
uiStates.isPauseVideos = isOpen
|
||||
}, [isOpen])
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Blur bg */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className='fixed inset-0 z-30 bg-black/70 backdrop-blur-3xl lg:rounded-24'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3, delay: 0.3 } }}
|
||||
transition={{ ease }}
|
||||
></motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Content */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.3, delay: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||
transition={{ ease }}
|
||||
className={cx('fixed inset-0 z-30 flex flex-col items-center justify-center')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className='relative'>
|
||||
<img
|
||||
src={resizeImage(artwork, 'lg')}
|
||||
className={cx(
|
||||
'rounded-24',
|
||||
css`
|
||||
height: 65vh;
|
||||
width: 65vh;
|
||||
`
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<div className='absolute -bottom-24 flex w-full justify-center'>
|
||||
<div
|
||||
onClick={onClose}
|
||||
className='flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
|
||||
>
|
||||
<Icon name='x' className='h-6 w-6' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtworkViewer
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import useUserArtists, {
|
||||
useMutationLikeAArtist,
|
||||
} from '@/web/api/hooks/useUserArtists'
|
||||
import useUserArtists, { useMutationLikeAArtist } from '@/web/api/hooks/useUserArtists'
|
||||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -13,8 +11,7 @@ import BasicContextMenu from './BasicContextMenu'
|
|||
const ArtistContextMenu = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus)
|
||||
const likeAArtist = useMutationLikeAArtist()
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
|
|
@ -63,19 +60,15 @@ const ArtistContextMenu = () => {
|
|||
type: 'item',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/artist?id=${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`https://music.163.com/#/artist?id=${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
label: t`context-menu.copy-r3play-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/artist/${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`${window.location.origin}/artist/${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from 'react'
|
|||
import { useClickAway } from 'react-use'
|
||||
import useLockMainScroll from '@/web/hooks/useLockMainScroll'
|
||||
import useMeasure from 'react-use-measure'
|
||||
import { ContextMenuItem } from './MenuItem'
|
||||
import { ContextMenuItem } from './types'
|
||||
import MenuPanel from './MenuPanel'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ContextMenuPosition } from './types'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ForwardedRef, forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import MenuItem from './MenuItem'
|
||||
import { ContextMenuItem, ContextMenuPosition } from './types'
|
||||
|
|
@ -36,7 +30,7 @@ const MenuPanel = forwardRef(
|
|||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'fixed select-none',
|
||||
'app-region-no-drag fixed select-none',
|
||||
isSubmenu ? 'submenu z-30 px-1' : 'z-20'
|
||||
)}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
|
|
@ -77,9 +71,7 @@ const MenuPanel = forwardRef(
|
|||
|
||||
{/* Submenu */}
|
||||
<SubMenu
|
||||
items={
|
||||
submenuProps?.index ? items[submenuProps?.index]?.items : undefined
|
||||
}
|
||||
items={submenuProps?.index ? items[submenuProps?.index]?.items : undefined}
|
||||
itemRect={submenuProps?.itemRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
|
@ -118,9 +110,7 @@ const SubMenu = ({
|
|||
const x = isRightSide ? item.x + item.width : item.x - submenu.width
|
||||
|
||||
const isTopSide = item.y - 10 + submenu.height <= window.innerHeight
|
||||
const y = isTopSide
|
||||
? item.y - 10
|
||||
: item.y + item.height + 10 - submenu.height
|
||||
const y = isTopSide ? item.y - 10 : item.y + item.height + 10 - submenu.height
|
||||
|
||||
const transformOriginTable = {
|
||||
top: {
|
||||
|
|
@ -137,9 +127,7 @@ const SubMenu = ({
|
|||
x,
|
||||
y,
|
||||
transformOrigin:
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][
|
||||
isRightSide ? 'right' : 'left'
|
||||
],
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][isRightSide ? 'right' : 'left'],
|
||||
})
|
||||
}, [itemRect])
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ const TrackContextMenu = () => {
|
|||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { type, dataSourceID, target, cursorPosition, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const { type, dataSourceID, target, cursorPosition, options } = useSnapshot(contextMenus)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
@ -84,19 +83,15 @@ const TrackContextMenu = () => {
|
|||
type: 'item',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`https://music.163.com/#/album?id=${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
label: t`context-menu.copy-r3play-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export interface ContextMenuPosition {
|
|||
|
||||
export interface ContextMenuItem {
|
||||
type: 'item' | 'submenu' | 'divider'
|
||||
label?: string
|
||||
label?: string | null
|
||||
onClick?: (e: MouseEvent) => void
|
||||
items?: ContextMenuItem[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
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({})
|
||||
|
|
@ -53,7 +53,6 @@ function DescriptionViewer({
|
|||
</div>
|
||||
|
||||
{/* Description */}
|
||||
|
||||
<div
|
||||
className={css`
|
||||
mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩
|
||||
|
|
@ -73,7 +72,7 @@ function DescriptionViewer({
|
|||
)}
|
||||
>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: description + description }}
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
className='mt-8 whitespace-pre-wrap pb-8 text-16 font-bold leading-6 text-neutral-200'
|
||||
></p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { IconNames } from './iconNamesType'
|
||||
|
||||
const Icon = ({ name, className }: { name: IconNames; className?: string }) => {
|
||||
const Icon = ({
|
||||
name,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
name: IconNames
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) => {
|
||||
const symbolId = `#icon-${name}`
|
||||
return (
|
||||
<svg aria-hidden='true' className={className}>
|
||||
<svg aria-hidden='true' className={className} style={style}>
|
||||
<use href={symbolId} fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'fullscreen-enter' | 'fullscreen-exit' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'video-settings' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'
|
||||
export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'dropdown-triangle' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'fullscreen-enter' | 'fullscreen-exit' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'video-settings' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'
|
||||
|
|
@ -8,7 +8,7 @@ import Icon from '@/web/components/Icon'
|
|||
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
|
||||
import LoginWithQRCode from './LoginWithQRCode'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import useUser, { useIsLoggedIn } from '@/web/api/hooks/useUser'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const OR = ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => {
|
||||
|
|
@ -38,6 +38,7 @@ const Login = () => {
|
|||
const { t } = useTranslation()
|
||||
|
||||
const { data: user, isLoading: isLoadingUser } = useUser()
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
const { loginType } = useSnapshot(persistedUiStates)
|
||||
const { showLoginPanel } = useSnapshot(uiStates)
|
||||
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
|
||||
|
|
@ -46,10 +47,10 @@ const Login = () => {
|
|||
|
||||
// Show login panel when user first loads the page and not logged in
|
||||
useEffect(() => {
|
||||
if (!user?.account && !isLoadingUser) {
|
||||
if (!isLoggedIn) {
|
||||
uiStates.showLoginPanel = true
|
||||
}
|
||||
}, [user?.account, isLoadingUser])
|
||||
}, [isLoggedIn])
|
||||
|
||||
const animateCard = useAnimation()
|
||||
const handleSwitchCard = async () => {
|
||||
|
|
|
|||
|
|
@ -23,21 +23,16 @@ const LoginWithPhoneOrEmail = () => {
|
|||
const { t, i18n } = useTranslation()
|
||||
const isZH = i18n.language.startsWith('zh')
|
||||
|
||||
const { loginPhoneCountryCode, loginType: persistedLoginType } =
|
||||
useSnapshot(persistedUiStates)
|
||||
const { loginPhoneCountryCode, loginType: persistedLoginType } = useSnapshot(persistedUiStates)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [countryCode, setCountryCode] = useState<string>(
|
||||
loginPhoneCountryCode || '+86'
|
||||
)
|
||||
const [countryCode, setCountryCode] = useState<string>(loginPhoneCountryCode || '+86')
|
||||
const [phone, setPhone] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [loginType, setLoginType] = useState<'phone' | 'email'>(
|
||||
persistedLoginType === 'email' ? 'email' : 'phone'
|
||||
)
|
||||
|
||||
const handleAfterLogin = (
|
||||
result: LoginWithEmailResponse | LoginWithPhoneResponse
|
||||
) => {
|
||||
const handleAfterLogin = (result: LoginWithEmailResponse | LoginWithPhoneResponse) => {
|
||||
if (result?.code !== 200) return
|
||||
setCookies(result.cookie)
|
||||
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
|
||||
|
|
@ -76,11 +71,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
toast.error('Please enter password')
|
||||
return
|
||||
}
|
||||
if (
|
||||
email.match(
|
||||
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
|
||||
) == null
|
||||
) {
|
||||
if (email.match(/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/) == null) {
|
||||
toast.error('Please use netease email')
|
||||
return
|
||||
}
|
||||
|
|
@ -238,9 +229,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
|
||||
{/* Login button */}
|
||||
<div
|
||||
onClick={() =>
|
||||
loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin()
|
||||
}
|
||||
onClick={() => (loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin())}
|
||||
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
|
||||
>
|
||||
{t`auth.login`}
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import useLyric from '@/web/api/hooks/useLyric'
|
||||
import player from '@/web/states/player'
|
||||
import { motion } from 'framer-motion'
|
||||
import { lyricParser } from '@/web/utils/lyric'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
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 = 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={cx(
|
||||
'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={`${String(index)}-${String(time)}`}
|
||||
custom={index}
|
||||
variants={variants}
|
||||
initial={'initial'}
|
||||
animate={
|
||||
time === currentLine.time
|
||||
? 'current'
|
||||
: time < currentLine.time
|
||||
? 'exit'
|
||||
: 'rest'
|
||||
}
|
||||
layout
|
||||
className={cx('max-w-[78%] py-[calc(100vw_*_0.0111)] text-white')}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Lyric
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import useLyric from '@/web/api/hooks/useLyric'
|
||||
import player from '@/web/states/player'
|
||||
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 '@emotion/css'
|
||||
|
||||
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()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
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={cx(
|
||||
'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={`${String(index)}-${String(time)}`}
|
||||
className={cx(
|
||||
'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
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import Player from './Player'
|
||||
import player from '@/web/states/player'
|
||||
import { getCoverColor } from '@/web/utils/common'
|
||||
import { colord } from 'colord'
|
||||
import IconButton from '../IconButton'
|
||||
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 '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const LyricPanel = () => {
|
||||
const stateSnapshot = useSnapshot(player)
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
const bgColor = useCoverColor(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={cx(
|
||||
'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={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
<Icon className='h-6 w-6' name='lyrics' />
|
||||
</IconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricPanel
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
|
||||
import ArtistInline from '../ArtistsInline'
|
||||
import Cover from '../Cover'
|
||||
import IconButton from '../IconButton'
|
||||
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 '@emotion/css'
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
const trackListSource = useMemo(
|
||||
() => playerSnapshot.trackListSource,
|
||||
[playerSnapshot.trackListSource]
|
||||
)
|
||||
|
||||
const hasListSource = playerSnapshot.mode !== PlayerMode.FM && trackListSource?.type
|
||||
|
||||
const toTrackListSource = () => {
|
||||
if (!hasListSource) return
|
||||
|
||||
navigate(`/${trackListSource.type}/${trackListSource.id}`)
|
||||
}
|
||||
|
||||
const toArtist = (id: number) => {
|
||||
navigate(`/artist/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={toTrackListSource}
|
||||
className={cx(
|
||||
'line-clamp-1 text-[22px] font-semibold text-white',
|
||||
hasListSource && '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 ?? []} onClick={toArtist} />
|
||||
{!!track?.al?.id && (
|
||||
<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)}>
|
||||
<Icon
|
||||
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.TrackList && (
|
||||
<IconButton onClick={() => track && player.prevTrack()} disabled={!track}>
|
||||
<Icon className='h-6 w-6' name='previous' />
|
||||
</IconButton>
|
||||
)}
|
||||
{mode === PlayerMode.FM && (
|
||||
<IconButton onClick={() => player.fmTrash()}>
|
||||
<Icon className='h-6 w-6' name='dislike' />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => track && player.playOrPause()}
|
||||
disabled={!track}
|
||||
className='after:rounded-xl'
|
||||
>
|
||||
<Icon
|
||||
className='h-7 w-7'
|
||||
name={[PlayerState.Playing, PlayerState.Loading].includes(state) ? 'pause' : 'play'}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||
<Icon 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={cx('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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import LyricPanel from './LyricPanel'
|
||||
|
||||
export default LyricPanel
|
||||
|
|
@ -6,6 +6,8 @@ import { useAnimation, motion } from 'framer-motion'
|
|||
import { ease } from '@/web/utils/const'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import settings from '../states/settings'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
|
@ -81,9 +83,8 @@ const Tabs = () => {
|
|||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const controls = useAnimation()
|
||||
const [active, setActive] = useState<string>(
|
||||
location.pathname || tabs[0].path
|
||||
)
|
||||
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
||||
const [active, setActive] = useState<string>(location.pathname || tabs[0].path)
|
||||
|
||||
const animate = async (path: string) => {
|
||||
await controls.start((p: string) =>
|
||||
|
|
@ -94,40 +95,45 @@ const Tabs = () => {
|
|||
|
||||
return (
|
||||
<div className='grid grid-cols-4 justify-items-center text-black/10 dark:text-white/20 lg:grid-cols-1 lg:gap-12'>
|
||||
{tabs.map(tab => (
|
||||
<motion.div
|
||||
key={tab.name}
|
||||
animate={controls}
|
||||
transition={{ ease, duration: 0.18 }}
|
||||
onMouseDown={() => {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(20)
|
||||
}
|
||||
animate(tab.path)
|
||||
}}
|
||||
onClick={() => {
|
||||
setActive(tab.path)
|
||||
navigate(tab.path)
|
||||
}}
|
||||
custom={tab.path}
|
||||
variants={{
|
||||
scale: { scale: 0.8 },
|
||||
reset: { scale: 1 },
|
||||
}}
|
||||
className={cx(
|
||||
active === tab.path
|
||||
? 'text-brand-600 dark:text-brand-700'
|
||||
: 'lg:hover:text-black lg:dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
{tabs
|
||||
.filter(tab => {
|
||||
if (!displayPlaylistsFromNeteaseMusic && tab.name === 'BROWSE') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(tab => (
|
||||
<motion.div
|
||||
key={tab.name}
|
||||
animate={controls}
|
||||
transition={{ ease, duration: 0.18 }}
|
||||
onMouseDown={() => {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(20)
|
||||
}
|
||||
animate(tab.path)
|
||||
}}
|
||||
onClick={() => {
|
||||
setActive(tab.path)
|
||||
navigate(tab.path)
|
||||
}}
|
||||
custom={tab.path}
|
||||
variants={{
|
||||
scale: { scale: 0.8 },
|
||||
reset: { scale: 1 },
|
||||
}}
|
||||
className={cx(
|
||||
'app-region-no-drag h-10 w-10 transition-colors duration-500'
|
||||
active === tab.path
|
||||
? 'text-brand-600 dark:text-brand-700'
|
||||
: 'lg:hover:text-black lg:dark:hover:text-white'
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className={cx('app-region-no-drag h-10 w-10 transition-colors duration-500')}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
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({})
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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({})
|
||||
|
|
@ -13,26 +13,74 @@ import { Virtuoso } from 'react-virtuoso'
|
|||
import toast from 'react-hot-toast'
|
||||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useHoverLightSpot from '../hooks/useHoverLightSpot'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
|
||||
const RepeatButton = () => {
|
||||
const { buttonRef, buttonStyle } = useHoverLightSpot()
|
||||
const [repeat, setRepeat] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
setRepeat(!repeat)
|
||||
toast('开发中')
|
||||
}}
|
||||
className={cx(
|
||||
'group relative transition duration-300 ease-linear',
|
||||
repeat
|
||||
? 'text-brand-700 hover:text-brand-400'
|
||||
: 'text-neutral-300 opacity-40 hover:opacity-100'
|
||||
)}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<div className='absolute top-1/2 left-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 blur group-hover:opacity-100'></div>
|
||||
<Icon name='repeat-1' className='h-7 w-7' />
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
const ShuffleButton = () => {
|
||||
const { buttonRef, buttonStyle } = useHoverLightSpot()
|
||||
const [shuffle, setShuffle] = useState(false)
|
||||
return (
|
||||
<motion.button
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
setShuffle(!shuffle)
|
||||
toast('开发中')
|
||||
}}
|
||||
className={cx(
|
||||
'group relative transition duration-300 ease-linear',
|
||||
shuffle
|
||||
? 'text-brand-700 hover:text-brand-400'
|
||||
: 'text-neutral-300 opacity-40 hover:opacity-100'
|
||||
)}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Icon name='shuffle' className='h-7 w-7' />
|
||||
<div className='absolute top-1/2 left-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 blur group-hover:opacity-100'></div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300 lg:px-0'
|
||||
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold lg:px-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex'>
|
||||
<div className='flex text-neutral-300'>
|
||||
<div className='mr-2 h-4 w-1 rounded-full bg-brand-700'></div>
|
||||
{t`player.queue`}
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div onClick={() => toast('开发中')} className='mr-2'>
|
||||
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
<div onClick={() => toast('开发中')}>
|
||||
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<RepeatButton />
|
||||
<ShuffleButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const Playlist = React.lazy(() => import('@/web/pages/Playlist'))
|
|||
const Artist = React.lazy(() => import('@/web/pages/Artist'))
|
||||
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics'))
|
||||
const Search = React.lazy(() => import('@/web/pages/Search'))
|
||||
const Settings = React.lazy(() => import('@/web/pages/Settings'))
|
||||
|
||||
const lazy = (component: ReactNode) => {
|
||||
return <Suspense>{component}</Suspense>
|
||||
|
|
@ -29,7 +30,7 @@ const Router = () => {
|
|||
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
||||
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
||||
{/* <Route path='/settings' element={lazy(<Settings />)} /> */}
|
||||
<Route path='/settings' element={lazy(<Settings />)} />
|
||||
<Route path='/lyrics' element={lazy(<Lyrics />)} />
|
||||
<Route path='/search/:keywords' element={lazy(<Search />)}>
|
||||
<Route path=':type' element={lazy(<Search />)} />
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import Sidebar from './MenuBar'
|
||||
|
||||
export default {
|
||||
title: 'Components/Sidebar',
|
||||
component: Sidebar,
|
||||
} as ComponentMeta<typeof Sidebar>
|
||||
|
||||
const Template: ComponentStory<typeof Sidebar> = args => (
|
||||
<div className='h-[calc(100vh_-_32px)] w-min rounded-l-3xl bg-[#F8F8F8] dark:bg-black'>
|
||||
<Sidebar />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { cx } from '@emotion/css'
|
||||
|
||||
const Tabs = ({
|
||||
function Tabs<T>({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -8,19 +8,19 @@ const Tabs = ({
|
|||
style,
|
||||
}: {
|
||||
tabs: {
|
||||
id: string
|
||||
id: T
|
||||
name: string
|
||||
}[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
onChange: (id: T) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) => {
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('no-scrollbar flex overflow-y-auto', className)} style={style}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
key={tab.id as string}
|
||||
className={cx(
|
||||
'mr-2.5 rounded-12 py-3 px-6 text-16 font-medium backdrop-blur transition duration-500',
|
||||
value === tab.id
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import Topbar from './Topbar/TopbarDesktop'
|
||||
|
||||
export default {
|
||||
title: 'Components/Topbar',
|
||||
component: Topbar,
|
||||
} as ComponentMeta<typeof Topbar>
|
||||
|
||||
const Template: ComponentStory<typeof Topbar> = args => (
|
||||
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] px-11 dark:bg-black'>
|
||||
<Topbar />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
|
|
@ -8,10 +8,12 @@ import BasicContextMenu from '../ContextMenus/BasicContextMenu'
|
|||
import { AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const Avatar = ({ className }: { className?: string }) => {
|
||||
const { data: user } = useUser()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
|
|
@ -36,10 +38,7 @@ const Avatar = ({ className }: { className?: string }) => {
|
|||
}
|
||||
setShowMenu(true)
|
||||
}}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
className={cx('app-region-no-drag rounded-full', className || 'h-12 w-12')}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{avatarRef.current && showMenu && (
|
||||
|
|
@ -63,7 +62,7 @@ const Avatar = ({ className }: { className?: string }) => {
|
|||
type: 'item',
|
||||
label: t`settings.settings`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
navigate('/settings')
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { css, cx, keyframes } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
|
@ -10,6 +10,20 @@ import { useClickAway, useDebounce } from 'react-use'
|
|||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const bounce = keyframes`
|
||||
from { transform: rotate(0deg) translateX(1px) rotate(0deg) }
|
||||
to { transform: rotate(360deg) translateX(1px) rotate(-360deg) }
|
||||
`
|
||||
function SearchIcon({ isSearching }: { isSearching: boolean }) {
|
||||
return (
|
||||
<div
|
||||
// style={{ animation: `${bounce} 1.2s linear infinite` }}
|
||||
>
|
||||
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchSuggestions = ({
|
||||
searchText,
|
||||
isInputFocused,
|
||||
|
|
@ -144,7 +158,7 @@ const SearchBox = () => {
|
|||
`
|
||||
)}
|
||||
>
|
||||
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||
<SearchIcon />
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder={t`search.search`}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const SettingsButton = ({ className }: { className?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<button
|
||||
onClick={() => toast('开发中')}
|
||||
onClick={() => navigate('/settings')}
|
||||
className={cx(
|
||||
'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300',
|
||||
className
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { formatDuration } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Wave from './Wave'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import toast from 'react-hot-toast'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import contextMenus, { openContextMenu } from '@/web/states/contextMenus'
|
||||
import regexifyString from 'regexify-string'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
const Actions = ({ track }: { track: Track }) => {
|
||||
const { data: likedTracksIDs } = useUserLikedTracksIDs()
|
||||
|
|
@ -81,9 +81,7 @@ const Actions = ({ track }: { track: Track }) => {
|
|||
className='flex h-10 w-10 items-center justify-center rounded-full text-white/40 transition duration-400 hover:bg-white/20 hover:text-white/70'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'
|
||||
}
|
||||
name={likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -92,6 +90,75 @@ const Actions = ({ track }: { track: Track }) => {
|
|||
)
|
||||
}
|
||||
|
||||
function Track({
|
||||
track,
|
||||
handleClick,
|
||||
}: {
|
||||
track: Track
|
||||
handleClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) {
|
||||
const { track: playingTrack, state } = useSnapshot(player)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={e => handleClick(e, track.id)}
|
||||
onContextMenu={e => handleClick(e, track.id)}
|
||||
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 lg:mr-6'>
|
||||
{playingTrack?.id === track.id ? (
|
||||
<span className='inline-block'>
|
||||
<Wave playing={state === 'playing'} />
|
||||
</span>
|
||||
) : (
|
||||
String(track.no).padStart(2, '0')
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex flex-grow items-center'>
|
||||
<span className='line-clamp-1'>{track?.name}</span>
|
||||
{/* Explicit symbol */}
|
||||
{[1318912, 1310848].includes(track.mark) && (
|
||||
<Icon name='explicit' className='ml-2 mr-1 mt-px h-3.5 w-3.5 text-white/20' />
|
||||
)}
|
||||
{/* Other artists */}
|
||||
{track?.ar?.length > 1 && (
|
||||
<div className='text-white/20'>
|
||||
<span className='px-1'>-</span>
|
||||
{track.ar.slice(1).map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<NavLink
|
||||
to={`/artist/${artist.id}`}
|
||||
className='text-white/20 transition duration-300 hover:text-white/40'
|
||||
>
|
||||
{artist.name}
|
||||
</NavLink>
|
||||
{index !== track.ar.length - 2 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop menu */}
|
||||
<Actions track={track} />
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className='lg:hidden'>
|
||||
<div className='h-10 w-10 rounded-full bg-night-900'></div>
|
||||
</div>
|
||||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right lg:block'>
|
||||
{formatDuration(track.dt, 'en-US', 'hh:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TrackList = ({
|
||||
tracks,
|
||||
onPlay,
|
||||
|
|
@ -105,7 +172,6 @@ const TrackList = ({
|
|||
isLoading?: boolean
|
||||
placeholderRows?: number
|
||||
}) => {
|
||||
const { track: playingTrack, state } = useSnapshot(player)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
|
|
@ -133,66 +199,27 @@ const TrackList = ({
|
|||
return (
|
||||
<div className={className}>
|
||||
{(isLoading ? [] : tracks)?.map(track => (
|
||||
<Track key={track.id} track={track} handleClick={handleClick} />
|
||||
))}
|
||||
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(index => (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={e => handleClick(e, track.id)}
|
||||
onContextMenu={e => handleClick(e, track.id)}
|
||||
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
|
||||
key={index}
|
||||
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 lg:mr-6'>
|
||||
{String(track.no).padStart(2, '0')}
|
||||
</div>
|
||||
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>00</div>
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex flex-grow items-center'>
|
||||
<span className='line-clamp-1 mr-4'>{track.name}</span>
|
||||
{playingTrack?.id === track.id && (
|
||||
<span className='mr-4 inline-block'>
|
||||
<Wave playing={state === 'playing'} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop menu */}
|
||||
<Actions track={track} />
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className='lg:hidden'>
|
||||
<div className='h-10 w-10 rounded-full bg-night-900'></div>
|
||||
<div className='flex flex-grow items-center text-transparent'>
|
||||
<span className='mr-4 rounded-full bg-white/10'>PLACEHOLDER1234567</span>
|
||||
</div>
|
||||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right lg:block'>
|
||||
{formatDuration(track.dt, 'en-US', 'hh:mm:ss')}
|
||||
<div className='hidden text-right text-transparent lg:block'>
|
||||
<span className='rounded-full bg-white/10'>00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(
|
||||
index => (
|
||||
<div
|
||||
key={index}
|
||||
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>
|
||||
00
|
||||
</div>
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex flex-grow items-center text-transparent'>
|
||||
<span className='mr-4 rounded-full bg-white/10'>
|
||||
PLACEHOLDER1234567
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right text-transparent lg:block'>
|
||||
<span className='rounded-full bg-white/10'>00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,40 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/Image'
|
||||
import { memo, useEffect } from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from '@/web/components/VideoCover'
|
||||
import ArtworkViewer from '../ArtworkViewer'
|
||||
import useSettings from '@/web/hooks/useSettings'
|
||||
|
||||
const Cover = memo(
|
||||
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
const Cover = memo(({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative aspect-square w-full overflow-hidden rounded-24 '>
|
||||
<Image
|
||||
className='absolute inset-0'
|
||||
src={resizeImage(cover || '', 'lg')}
|
||||
/>
|
||||
const [isOpenArtworkViewer, setIsOpenArtworkViewer] = useState(false)
|
||||
|
||||
{videoCover && <VideoCover source={videoCover} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (cover) setIsOpenArtworkViewer(true)
|
||||
}}
|
||||
className='relative aspect-square w-full overflow-hidden rounded-24'
|
||||
>
|
||||
<Image className='absolute inset-0' src={resizeImage(cover || '', 'lg')} />
|
||||
|
||||
{videoCover && <VideoCover source={videoCover} />}
|
||||
</div>
|
||||
|
||||
<ArtworkViewer
|
||||
type='album'
|
||||
artwork={cover || ''}
|
||||
isOpen={isOpenArtworkViewer}
|
||||
onClose={() => setIsOpenArtworkViewer(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
Cover.displayName = 'Cover'
|
||||
|
||||
export default Cover
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
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({})
|
||||
|
|
@ -1,17 +1,20 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { injectGlobal } from '@emotion/css'
|
||||
import { isIOS, isSafari } from '@/web/utils/common'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '../states/uiStates'
|
||||
import useWindowFocus from '../hooks/useWindowFocus'
|
||||
import useSettings from '../hooks/useSettings'
|
||||
|
||||
const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>()
|
||||
const windowFocus = useWindowFocus()
|
||||
const { playAnimatedArtworkFromApple } = useSettings()
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported() && videoRef.current) {
|
||||
if (source && Hls.isSupported() && videoRef.current && playAnimatedArtworkFromApple) {
|
||||
if (hls.current) hls.current.destroy()
|
||||
hls.current = new Hls()
|
||||
hls.current.loadSource(source)
|
||||
|
|
@ -24,12 +27,12 @@ const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }
|
|||
// Pause video cover when playing another video
|
||||
const { playingVideoID, isPauseVideos } = useSnapshot(uiStates)
|
||||
useEffect(() => {
|
||||
if (playingVideoID || isPauseVideos) {
|
||||
if (playingVideoID || isPauseVideos || !windowFocus) {
|
||||
videoRef?.current?.pause()
|
||||
} else {
|
||||
videoRef?.current?.play()
|
||||
}
|
||||
}, [playingVideoID, isPauseVideos])
|
||||
}, [playingVideoID, isPauseVideos, windowFocus])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue