mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 05:38:04 +00:00
feat: updates
This commit is contained in:
parent
7ce516877e
commit
ccebe0a67a
74 changed files with 56065 additions and 2810 deletions
|
|
@ -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
|
||||
|
|
@ -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`)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
100
packages/web/components/DescriptionViewer.tsx
Normal file
100
packages/web/components/DescriptionViewer.tsx
Normal 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
|
||||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
5
packages/web/components/Tooltip.tsx
Normal file
5
packages/web/components/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
function Tooltip() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue