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
196a974a64
commit
8f4c3d8e5b
24 changed files with 572 additions and 93 deletions
|
|
@ -22,7 +22,7 @@ const Image = ({
|
|||
className?: string
|
||||
alt: string
|
||||
lazyLoad?: boolean
|
||||
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | null
|
||||
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | false
|
||||
onClick?: (e: React.MouseEvent<HTMLImageElement>) => void
|
||||
onMouseOver?: (e: React.MouseEvent<HTMLImageElement>) => void
|
||||
animation?: boolean
|
||||
|
|
@ -48,6 +48,7 @@ const Image = ({
|
|||
? {
|
||||
animate,
|
||||
initial: { opacity: 0 },
|
||||
exit: { opacity: 0 },
|
||||
transition,
|
||||
}
|
||||
: {}
|
||||
|
|
@ -60,22 +61,30 @@ const Image = ({
|
|||
: {}
|
||||
|
||||
return (
|
||||
<div className={cx('relative overflow-hidden', className)}>
|
||||
<div
|
||||
className={cx(
|
||||
'overflow-hidden',
|
||||
className,
|
||||
className?.includes('absolute') === false && 'relative'
|
||||
)}
|
||||
>
|
||||
{/* Image */}
|
||||
<motion.img
|
||||
alt={alt}
|
||||
className='absolute inset-0 h-full w-full'
|
||||
src={src}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
decoding='async'
|
||||
loading={lazyLoad ? 'lazy' : undefined}
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
onClick={onClick}
|
||||
onMouseOver={onMouseOver}
|
||||
{...motionProps}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
<motion.img
|
||||
alt={alt}
|
||||
className='absolute inset-0 h-full w-full'
|
||||
src={src}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
decoding='async'
|
||||
loading={lazyLoad ? 'lazy' : undefined}
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
onClick={onClick}
|
||||
onMouseOver={onMouseOver}
|
||||
{...motionProps}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Placeholder / Error fallback */}
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Router from './Router'
|
|||
const Main = () => {
|
||||
return (
|
||||
<main
|
||||
id='main'
|
||||
className={cx(
|
||||
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
|
||||
css`
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||
import Image from './Image'
|
||||
import Wave from './Wave'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useRef } from 'react'
|
||||
import { useWindowSize } from 'react-use'
|
||||
import { playerWidth, topbarHeight } from '@/web/utils/const'
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
|
|
@ -46,13 +50,13 @@ const Track = ({
|
|||
return (
|
||||
<motion.div
|
||||
className='flex items-center justify-between'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ x: '100%', opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.24,
|
||||
}}
|
||||
layout
|
||||
// initial={{ opacity: 0 }}
|
||||
// animate={{ opacity: 1 }}
|
||||
// exit={{ x: '100%', opacity: 0 }}
|
||||
// transition={{
|
||||
// duration: 0.24,
|
||||
// }}
|
||||
// layout
|
||||
onClick={e => {
|
||||
if (e.detail === 2 && track?.id) player.playTrack(track.id)
|
||||
}}
|
||||
|
|
@ -62,6 +66,8 @@ const Track = ({
|
|||
alt='Cover'
|
||||
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
|
||||
src={resizeImage(track?.al?.picUrl || '', 'sm')}
|
||||
animation={false}
|
||||
placeholder={false}
|
||||
/>
|
||||
|
||||
{/* Track info */}
|
||||
|
|
@ -93,41 +99,82 @@ const Track = ({
|
|||
)
|
||||
}
|
||||
|
||||
const PlayingNext = ({ className }: { className?: string }) => {
|
||||
const TrackList = ({ className }: { className?: string }) => {
|
||||
const { trackList, trackIndex, state } = useSnapshot(player)
|
||||
const { data: tracks } = useTracks({ ids: trackList })
|
||||
const { data: tracksRaw } = useTracks({ ids: trackList })
|
||||
const tracks = tracksRaw?.songs || []
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const { height } = useWindowSize()
|
||||
|
||||
const listHeight = height - topbarHeight - playerWidth - 24 - 20 // 24是封面与底部间距,20是list与封面间距
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tracks.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 76,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
style={{
|
||||
height: `${listHeight}px`,
|
||||
}}
|
||||
className={cx(
|
||||
'no-scrollbar relative z-10 overflow-scroll',
|
||||
'no-scrollbar relative z-10 w-full overflow-auto',
|
||||
className,
|
||||
css`
|
||||
padding-top: 42px;
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 42px);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
black 42px
|
||||
); // 顶部渐变遮罩
|
||||
`
|
||||
)}
|
||||
>
|
||||
<motion.div className='grid gap-4'>
|
||||
<AnimatePresence>
|
||||
{tracks?.songs?.map((track, index) => (
|
||||
<div
|
||||
className='relative w-full'
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((row: any) => (
|
||||
<div
|
||||
key={row.index}
|
||||
className='absolute top-0 left-0 w-full'
|
||||
style={{
|
||||
height: `${row.size}px`,
|
||||
transform: `translateY(${row.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Track
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={index}
|
||||
track={tracks?.[row.index]}
|
||||
index={row.index}
|
||||
playingTrackIndex={trackIndex}
|
||||
state={state}
|
||||
/>
|
||||
))}
|
||||
|
||||
{(tracks?.songs?.length || 0) >= 4 && (
|
||||
<div className='pointer-events-none sticky bottom-0 h-8 w-full bg-gradient-to-t from-black'></div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部渐变遮罩 */}
|
||||
<div
|
||||
className='pointer-events-none absolute right-0 left-0 z-20 h-14 bg-gradient-to-t from-black to-transparent'
|
||||
style={{ top: `${listHeight - 56}px` }}
|
||||
></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PlayingNext = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<TrackList className={className} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,134 @@
|
|||
import { formatDate, formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
isIOS,
|
||||
isSafari,
|
||||
resizeImage,
|
||||
} from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import Image from './Image'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { memo, useEffect, useMemo, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import Plyr, { APITypes, PlyrProps, PlyrInstance } from 'plyr-react'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { injectGlobal } from '@emotion/css'
|
||||
|
||||
injectGlobal`
|
||||
.plyr__video-wrapper,
|
||||
.plyr--video {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
`
|
||||
|
||||
const VideoCover = ({ source }: { source?: string }) => {
|
||||
const ref = useRef<APITypes>(null)
|
||||
useEffect(() => {
|
||||
const loadVideo = async () => {
|
||||
if (!source || !Hls.isSupported()) return
|
||||
const video = document.getElementById('plyr') as HTMLVideoElement
|
||||
const hls = new Hls()
|
||||
hls.loadSource(source)
|
||||
hls.attachMedia(video)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
ref.current!.plyr.media = video
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-extra-semi
|
||||
;(ref.current!.plyr as PlyrInstance).play()
|
||||
})
|
||||
}
|
||||
loadVideo()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='z-10 aspect-square overflow-hidden rounded-24'>
|
||||
<Plyr
|
||||
id='plyr'
|
||||
options={{
|
||||
volume: 0,
|
||||
controls: [],
|
||||
autoplay: true,
|
||||
clickToPlay: false,
|
||||
loop: {
|
||||
active: true,
|
||||
},
|
||||
}}
|
||||
source={{} as PlyrProps['source']}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Cover = memo(
|
||||
({ album, playlist }: { album?: Album; playlist?: Playlist }) => {
|
||||
const isMobile = useIsMobile()
|
||||
const { data: videoCover } = useVideoCover({
|
||||
id: album?.id,
|
||||
name: album?.name,
|
||||
artist: album?.artist.name,
|
||||
})
|
||||
const cover = album?.picUrl || playlist?.coverImgUrl || ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative z-10 aspect-square w-full overflow-auto rounded-24 '>
|
||||
<Image
|
||||
className='absolute inset-0 h-full w-full'
|
||||
src={resizeImage(cover, 'lg')}
|
||||
alt='Cover'
|
||||
/>
|
||||
|
||||
{videoCover && (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className='absolute inset-0 h-full w-full'
|
||||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
src={videoCover}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
></video>
|
||||
) : (
|
||||
<VideoCover source={videoCover} />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blur bg */}
|
||||
{!isMobile && (
|
||||
<img
|
||||
className={cx(
|
||||
'absolute z-0 object-cover opacity-70',
|
||||
css`
|
||||
top: -400px;
|
||||
left: -370px;
|
||||
width: 1572px;
|
||||
height: 528px;
|
||||
filter: blur(256px) saturate(1.2);
|
||||
`
|
||||
)}
|
||||
src={resizeImage(cover, 'sm')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Cover.displayName = 'Cover'
|
||||
|
||||
const TrackListHeader = ({
|
||||
album,
|
||||
|
|
@ -15,18 +139,16 @@ const TrackListHeader = ({
|
|||
playlist?: Playlist
|
||||
onPlay: () => void
|
||||
}) => {
|
||||
const isMobile = useIsMobile()
|
||||
const albumDuration = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||
}, [album?.songs])
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const cover = album?.picUrl || playlist?.coverImgUrl || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'mx-2.5 rounded-48 p-8 dark:bg-white/10',
|
||||
'z-10 mx-2.5 rounded-48 p-8 dark:bg-white/10',
|
||||
'lg:mx-0 lg:grid lg:grid-rows-1 lg:gap-10 lg:rounded-none lg:p-0 lg:dark:bg-transparent',
|
||||
!isMobile &&
|
||||
css`
|
||||
|
|
@ -34,29 +156,7 @@ const TrackListHeader = ({
|
|||
`
|
||||
)}
|
||||
>
|
||||
{/* Cover */}
|
||||
<Image
|
||||
className='z-10 aspect-square w-full rounded-24'
|
||||
src={resizeImage(cover, 'lg')}
|
||||
alt='Cover'
|
||||
/>
|
||||
|
||||
{/* Blur bg */}
|
||||
{!isMobile && (
|
||||
<img
|
||||
className={cx(
|
||||
'absolute z-0 object-cover opacity-70',
|
||||
css`
|
||||
top: -400px;
|
||||
left: -370px;
|
||||
width: 1572px;
|
||||
height: 528px;
|
||||
filter: blur(256px) saturate(1.2);
|
||||
`
|
||||
)}
|
||||
src={resizeImage(cover, 'sm')}
|
||||
/>
|
||||
)}
|
||||
<Cover {...{ album, playlist }} />
|
||||
|
||||
<div className='flex flex-col justify-between'>
|
||||
<div>
|
||||
|
|
|
|||
4
packages/web/global.d.ts
vendored
4
packages/web/global.d.ts
vendored
|
|
@ -10,6 +10,10 @@ declare global {
|
|||
channel: T,
|
||||
params?: IpcChannelsParams[T]
|
||||
) => IpcChannelsReturns[T]
|
||||
invoke: <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
params?: IpcChannelsParams[T]
|
||||
) => Promise<IpcChannelsReturns[T]>
|
||||
send: <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
params?: IpcChannelsParams[T]
|
||||
|
|
|
|||
43
packages/web/hooks/useVideoCover.ts
Normal file
43
packages/web/hooks/useVideoCover.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import axios from 'axios'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function useVideoCover(props: {
|
||||
id?: number
|
||||
name?: string
|
||||
artist?: string
|
||||
}) {
|
||||
const { id, name, artist } = props
|
||||
return useQuery(
|
||||
['useVideoCover', props],
|
||||
async () => {
|
||||
if (!id || !name || !artist) return
|
||||
|
||||
const fromCache = window.ipcRenderer?.sendSync(
|
||||
IpcChannels.GetVideoCover,
|
||||
{
|
||||
id,
|
||||
}
|
||||
)
|
||||
if (fromCache) {
|
||||
return fromCache === 'no' ? undefined : fromCache
|
||||
}
|
||||
|
||||
const fromRemote = await axios.get('/yesplaymusic/video-cover', {
|
||||
params: props,
|
||||
})
|
||||
window.ipcRenderer?.send(IpcChannels.SetVideoCover, {
|
||||
id,
|
||||
url: fromRemote.data.url || '',
|
||||
})
|
||||
if (fromRemote?.data?.url) {
|
||||
return fromRemote.data.url
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!name && !!artist,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' www.googletagmanager.com;" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>YesPlayMusic</title>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import ReactGA from 'react-ga4'
|
|||
import { ipcRenderer } from './ipcRenderer'
|
||||
import { QueryClientProvider } from 'react-query'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
ReactGA.initialize('G-KMJJCFZDKF')
|
||||
|
||||
|
|
@ -34,12 +35,23 @@ ipcRenderer()
|
|||
const container = document.getElementById('root') as HTMLElement
|
||||
const root = ReactDOMClient.createRoot(container)
|
||||
|
||||
root.render(
|
||||
// root.render(
|
||||
// <StrictMode>
|
||||
// <BrowserRouter>
|
||||
// <QueryClientProvider client={reactQueryClient}>
|
||||
// <App />
|
||||
// </QueryClientProvider>
|
||||
// </BrowserRouter>
|
||||
// </StrictMode>
|
||||
// )
|
||||
|
||||
ReactDOM.render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={reactQueryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,15 +25,19 @@
|
|||
"@emotion/css": "^11.9.0",
|
||||
"@sentry/react": "^6.19.7",
|
||||
"@sentry/tracing": "^6.19.7",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.2",
|
||||
"axios": "^0.27.2",
|
||||
"color.js": "^1.2.0",
|
||||
"colord": "^2.9.2",
|
||||
"dayjs": "^1.11.1",
|
||||
"framer-motion": "^6.3.4",
|
||||
"hls.js": "^1.1.5",
|
||||
"howler": "^2.2.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"plyr": "^3.7.2",
|
||||
"plyr-react": "^5.0.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
|||
import { css, cx } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import { useMemo } from 'react'
|
||||
import 'plyr-react/plyr.css'
|
||||
|
||||
const MoreByArtist = ({ album }: { album?: Album }) => {
|
||||
const { data: albums } = useArtistAlbums({
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ const Albums = () => {
|
|||
const Playlists = () => {
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
return (
|
||||
<CoverRow playlists={playlists?.playlist} className='mt-6 px-2.5 lg:px-0' />
|
||||
<CoverRow
|
||||
playlists={playlists?.playlist?.slice(1)}
|
||||
className='mt-6 px-2.5 lg:px-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,9 @@ export async function calcCoverColor(coverUrl: string) {
|
|||
}
|
||||
|
||||
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
export const isSafari = /Safari/.test(navigator.userAgent)
|
||||
export const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent
|
||||
)
|
||||
export const isPWA =
|
||||
(navigator as any).standalone ||
|
||||
window.matchMedia('(display-mode: standalone)').matches
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
// 动画曲线
|
||||
export const ease: [number, number, number, number] = [0.4, 0, 0.2, 1]
|
||||
|
||||
// 屏幕断点
|
||||
export const breakpoint = {
|
||||
sm: '@media (min-width: 640px)',
|
||||
md: '@media (min-width: 768px)',
|
||||
|
|
@ -6,3 +9,6 @@ export const breakpoint = {
|
|||
xl: '@media (min-width: 1280px)',
|
||||
'2xl': '@media (min-width: 1536px)',
|
||||
}
|
||||
|
||||
export const topbarHeight = 132 // 桌面端顶栏高度 (px)
|
||||
export const playerWidth = 318 // 桌面端播放器宽度 (px)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ export default defineConfig({
|
|||
changeOrigin: true,
|
||||
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
|
||||
},
|
||||
'/yesplaymusic/video-cover': {
|
||||
target: `http://168.138.40.199:51324`,
|
||||
// target: `http://127.0.0.1:51324`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/yesplaymusic/': {
|
||||
target: `http://127.0.0.1:${
|
||||
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue