feat: updates

This commit is contained in:
qier222 2023-01-28 11:54:57 +08:00
parent 7ce516877e
commit ccebe0a67a
No known key found for this signature in database
74 changed files with 56065 additions and 2810 deletions

View file

@ -1,67 +0,0 @@
import player from '@/web/states/player'
import { css, cx } from '@emotion/css'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useSnapshot } from 'valtio'
const useAirplayDevices = () => {
return useQuery(['useAirplayDevices'], () =>
window.ipcRenderer?.invoke('airplay-scan-devices')
)
}
const Airplay = () => {
const [showPanel, setShowPanel] = useState(false)
const { data: devices, isLoading } = useAirplayDevices()
const { remoteDevice } = useSnapshot(player)
const selectedAirplayDeviceID =
remoteDevice?.protocol === 'airplay' ? remoteDevice?.id : ''
return (
<div
className={cx(
'fixed z-20',
css`
top: 46px;
right: 256px;
`
)}
>
<div
onClick={() => setShowPanel(!showPanel)}
className='flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-24 text-white'
>
A
</div>
{showPanel && (
<div
className={cx(
'absolute rounded-24 border border-white/10 bg-black/60 p-2 backdrop-blur-xl',
css`
width: 256px;
height: 256px;
`
)}
>
{devices?.devices?.map(device => (
<div
key={device.identifier}
className={cx(
'rounded-12 p-2 hover:bg-white/10',
device.identifier === selectedAirplayDeviceID
? 'text-brand-500'
: 'text-white'
)}
onClick={() => player.switchToAirplayDevice(device.identifier)}
>
{device.name}
</div>
))}
</div>
)}
</div>
)
}
export default Airplay

View file

@ -87,7 +87,10 @@ const AlbumContextMenu = () => {
type: 'item',
label: t`context-menu.copy-r3play-link`,
onClick: () => {
copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
const baseUrl = window.env?.isElectron
? 'https://r3play.app'
: window.location.origin
copyToClipboard(`${baseUrl}/album/${dataSourceID}`)
toast.success(t`toasts.copied`)
},
},

View file

@ -0,0 +1,100 @@
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 { ease } from '../utils/const'
import Icon from './Icon'
function DescriptionViewer({
description,
title,
isOpen,
onClose,
}: {
description: string
title: 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')}
>
<div className='relative'>
{/* Title */}
<div className='line-clamp-1 absolute -top-8 mx-44 max-w-2xl select-none text-32 font-extrabold text-neutral-100'>
{title}
</div>
{/* Description */}
<div
className={css`
mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩
`}
>
<div
className={cx(
'no-scrollbar relative mx-44 max-w-2xl overflow-scroll',
css`
max-height: 60vh;
mask-image: linear-gradient(
to bottom,
transparent 12px,
black 32px
); // 顶部渐变遮罩
`
)}
>
<p
dangerouslySetInnerHTML={{ __html: description + description }}
className='mt-8 whitespace-pre-wrap pb-8 text-16 font-bold leading-6 text-neutral-200'
></p>
</div>
</div>
{/* 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 DescriptionViewer

View file

@ -6,48 +6,41 @@ import { useAnimation, motion } from 'framer-motion'
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import { subscribeKey } from 'valtio/utils'
const Cover = () => {
const { track } = useSnapshot(player)
const [cover, setCover] = useState(track?.al.picUrl)
const animationStartTime = useRef(0)
const controls = useAnimation()
const duration = 150 // ms
const navigate = useNavigate()
useEffect(() => {
const resizedCover = resizeImage(track?.al.picUrl || '', 'lg')
const animate = async () => {
animationStartTime.current = Date.now()
await controls.start({ opacity: 0 })
setCover(resizedCover)
}
animate()
}, [controls, track?.al.picUrl])
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
useEffect(() => {
const realCover = resizeImage(track?.al.picUrl ?? '', 'lg')
if (cover !== realCover) setCover(realCover)
}, [cover, track?.al.picUrl])
const onLoad = () => {
const passedTime = Date.now() - animationStartTime.current
controls.start({
opacity: 1,
transition: {
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
},
const unsubscribe = subscribeKey(player, 'track', async () => {
const coverUrl = player.track?.al.picUrl
await controls.start({
opacity: 0,
transition: { duration: 0.2 },
})
setCover(coverUrl)
if (!coverUrl) return
const img = new Image()
img.onload = () => {
controls.start({
opacity: 1,
transition: { duration: 0.2 },
})
}
img.src = coverUrl
})
}
return unsubscribe
}, [])
return (
<motion.img
animate={controls}
transition={{ duration: duration / 1000, ease }}
className={cx('absolute inset-0 w-full')}
transition={{ ease }}
className='absolute inset-0 w-full'
src={cover}
onLoad={onLoad}
onClick={() => {
const id = track?.al.id
if (id) navigate(`/album/${id}`)

View file

@ -23,7 +23,7 @@ const Header = () => {
)}
>
<div className='flex'>
<div className='mr-2 h-4 w-1 bg-brand-700'></div>
<div className='mr-2 h-4 w-1 rounded-full bg-brand-700'></div>
{t`player.queue`}
</div>
<div className='flex'>
@ -117,11 +117,7 @@ const TrackList = ({ className }: { className?: string }) => {
<>
<div
className={css`
mask-image: linear-gradient(
to bottom,
transparent 22px,
black 42px
); // 顶部渐变遮罩
mask-image: linear-gradient(to bottom, transparent 22px, black 42px); // 顶部渐变遮罩
`}
>
<Virtuoso
@ -133,11 +129,7 @@ const TrackList = ({ className }: { className?: string }) => {
'no-scrollbar relative z-10 w-full overflow-auto',
className,
css`
mask-image: linear-gradient(
to top,
transparent 8px,
black 42px
); // 底部渐变遮罩
mask-image: linear-gradient(to top, transparent 8px, black 42px); // 底部渐变遮罩
`
)}
fixedItemHeight={76}

View file

@ -0,0 +1,5 @@
function Tooltip() {
return <></>
}
export default Tooltip

View file

@ -128,13 +128,15 @@ const SearchBox = () => {
const [searchText, setSearchText] = useState('')
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className='relative'>
{/* Input */}
<div
onClick={() => inputRef.current?.focus()}
className={cx(
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
'app-region-no-drag flex cursor-text items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
css`
${bp.lg} {
min-width: 284px;
@ -144,6 +146,7 @@ const SearchBox = () => {
>
<Icon name='search' className='mr-2.5 h-7 w-7' />
<input
ref={inputRef}
placeholder={t`search.search`}
className={cx(
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',

View file

@ -3,8 +3,9 @@ import Icon from '@/web/components/Icon'
import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom'
import useIsMobile from '@/web/hooks/useIsMobile'
import { ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ReactNode, useState } from 'react'
import { motion } from 'framer-motion'
import DescriptionViewer from '../DescriptionViewer'
const Info = ({
title,
@ -23,6 +24,7 @@ const Info = ({
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const [isOpenDescription, setIsOpenDescription] = useState(false)
return (
<div>
@ -72,12 +74,20 @@ const Info = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold transition-colors duration-300 dark:text-white/40 dark:hover:text-white/60'
dangerouslySetInnerHTML={{
__html: description,
}}
onClick={() => setIsOpenDescription(true)}
></motion.div>
)}
<DescriptionViewer
description={description || ''}
isOpen={isOpenDescription}
onClose={() => setIsOpenDescription(false)}
title={title || ''}
/>
</div>
)
}

View file

@ -22,14 +22,14 @@ const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }
}, [source])
// Pause video cover when playing another video
const { playingVideoID } = useSnapshot(uiStates)
const { playingVideoID, isPauseVideos } = useSnapshot(uiStates)
useEffect(() => {
if (playingVideoID) {
if (playingVideoID || isPauseVideos) {
videoRef?.current?.pause()
} else {
videoRef?.current?.play()
}
}, [playingVideoID])
}, [playingVideoID, isPauseVideos])
return (
<motion.div