feat: updates

This commit is contained in:
qier222 2023-03-03 03:12:27 +08:00
parent 9a52681687
commit 840a5b8e9b
No known key found for this signature in database
104 changed files with 1645 additions and 13494 deletions

View file

@ -62,7 +62,7 @@ const ArtistRow = ({
placeholderRow,
}: {
artists: Artist[] | undefined
title?: string
title?: string | null
className?: string
placeholderRow?: number
}) => {

View 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

View file

@ -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`)
},
},

View file

@ -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'

View file

@ -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])

View file

@ -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`)
},
},

View file

@ -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[]
}

View file

@ -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({})

View file

@ -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>

View file

@ -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>
)

View file

@ -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'

View file

@ -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 () => {

View file

@ -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`}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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>
)
}

View file

@ -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({})

View file

@ -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({})

View file

@ -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>
)

View file

@ -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 />)} />

View file

@ -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({})

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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

View file

@ -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({})

View file

@ -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')
},
},
{

View file

@ -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`}

View file

@ -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

View file

@ -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>
)
}

View file

@ -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

View file

@ -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({})

View file

@ -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