mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 13:48:02 +00:00
feat: updates
This commit is contained in:
parent
884f3df41a
commit
c6c59b2cd9
84 changed files with 3531 additions and 2616 deletions
260
packages/web/components/VideoPlayer/VideoInstance.tsx
Normal file
260
packages/web/components/VideoPlayer/VideoInstance.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import useUserVideos, { useMutationLikeAVideo } from '@/web/api/hooks/useUserVideos'
|
||||
import player from '@/web/states/player'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { motion, useAnimationControls } from 'framer-motion'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import Icon from '../Icon'
|
||||
import Slider from '../Slider'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { throttle } from 'lodash-es'
|
||||
|
||||
const videoStates = proxy({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isPaused: true,
|
||||
isFullscreen: false,
|
||||
})
|
||||
|
||||
const VideoInstance = ({ src, poster }: { src: string; poster: string }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null)
|
||||
const video = videoRef.current
|
||||
const { isPaused, isFullscreen } = useSnapshot(videoStates)
|
||||
|
||||
useEffect(() => {
|
||||
if (!video || !src) return
|
||||
const handleDurationChange = () => (videoStates.duration = video.duration * 1000)
|
||||
const handleTimeUpdate = () => (videoStates.currentTime = video.currentTime * 1000)
|
||||
const handleFullscreenChange = () => (videoStates.isFullscreen = !!document.fullscreenElement)
|
||||
const handlePause = () => (videoStates.isPaused = true)
|
||||
const handlePlay = () => (videoStates.isPaused = false)
|
||||
video.addEventListener('timeupdate', handleTimeUpdate)
|
||||
video.addEventListener('durationchange', handleDurationChange)
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
video.addEventListener('pause', handlePause)
|
||||
video.addEventListener('play', handlePlay)
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate)
|
||||
video.removeEventListener('durationchange', handleDurationChange)
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
video.removeEventListener('pause', handlePause)
|
||||
video.removeEventListener('play', handlePlay)
|
||||
}
|
||||
})
|
||||
|
||||
// if video is playing, pause music
|
||||
useEffect(() => {
|
||||
if (!isPaused) player.pause()
|
||||
}, [isPaused])
|
||||
|
||||
const togglePlay = () => {
|
||||
videoStates.isPaused ? videoRef.current?.play() : videoRef.current?.pause()
|
||||
}
|
||||
const toggleFullscreen = async () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
videoStates.isFullscreen = false
|
||||
} else {
|
||||
if (videoContainerRef.current) {
|
||||
videoContainerRef.current.requestFullscreen()
|
||||
videoStates.isFullscreen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset video state when src changes
|
||||
useEffect(() => {
|
||||
videoStates.currentTime = 0
|
||||
videoStates.duration = 0
|
||||
videoStates.isPaused = true
|
||||
videoStates.isFullscreen = false
|
||||
}, [src])
|
||||
|
||||
// animation controls
|
||||
const animationControls = useAnimationControls()
|
||||
const controlsTimestamp = useRef(0)
|
||||
const isControlsVisible = useRef(false)
|
||||
const isMouseOverControls = useRef(false)
|
||||
|
||||
// hide controls after 2 seconds
|
||||
const showControls = () => {
|
||||
isControlsVisible.current = true
|
||||
controlsTimestamp.current = Date.now()
|
||||
animationControls.start('visible')
|
||||
}
|
||||
const hideControls = () => {
|
||||
isControlsVisible.current = false
|
||||
animationControls.start('hidden')
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
isControlsVisible.current &&
|
||||
Date.now() - controlsTimestamp.current > 2000 &&
|
||||
!isMouseOverControls.current
|
||||
) {
|
||||
hideControls()
|
||||
}
|
||||
}, 300)
|
||||
return () => clearInterval(interval)
|
||||
}, [isFullscreen])
|
||||
|
||||
if (!src) return null
|
||||
return (
|
||||
<motion.div
|
||||
initial='hidden'
|
||||
animate={animationControls}
|
||||
ref={videoContainerRef}
|
||||
className={cx(
|
||||
'relative aspect-video overflow-hidden rounded-24 bg-black',
|
||||
css`
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
!isFullscreen &&
|
||||
css`
|
||||
height: 60vh;
|
||||
`
|
||||
)}
|
||||
onClick={togglePlay}
|
||||
onMouseOver={showControls}
|
||||
onMouseOut={hideControls}
|
||||
onMouseMove={() => !isControlsVisible.current && isFullscreen && showControls()}
|
||||
>
|
||||
<video ref={videoRef} src={src} controls={false} poster={poster} className='h-full w-full' />
|
||||
<Controls
|
||||
videoRef={videoRef}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
togglePlay={togglePlay}
|
||||
onMouseOver={() => (isMouseOverControls.current = true)}
|
||||
onMouseOut={() => (isMouseOverControls.current = false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const Controls = ({
|
||||
videoRef,
|
||||
toggleFullscreen,
|
||||
togglePlay,
|
||||
onMouseOver,
|
||||
onMouseOut,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
toggleFullscreen: () => void
|
||||
togglePlay: () => void
|
||||
onMouseOver: () => void
|
||||
onMouseOut: () => void
|
||||
}) => {
|
||||
const video = videoRef.current
|
||||
const { playingVideoID } = useSnapshot(uiStates)
|
||||
const { currentTime, duration, isPaused, isFullscreen } = useSnapshot(videoStates)
|
||||
const { data: likedVideos } = useUserVideos()
|
||||
const isLiked = useMemo(() => {
|
||||
return !!likedVideos?.data?.find(video => String(video.vid) === String(playingVideoID))
|
||||
}, [likedVideos])
|
||||
const likeAVideo = useMutationLikeAVideo()
|
||||
const onLike = async () => {
|
||||
if (playingVideoID) likeAVideo.mutateAsync(playingVideoID)
|
||||
}
|
||||
|
||||
// keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
toggleFullscreen()
|
||||
break
|
||||
case ' ':
|
||||
togglePlay()
|
||||
break
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const animationVariants = {
|
||||
hidden: { y: '48px', opacity: 0 },
|
||||
visible: { y: 0, opacity: 1 },
|
||||
}
|
||||
const animationTransition = { type: 'spring', bounce: 0.4, duration: 0.5 }
|
||||
|
||||
return (
|
||||
<div onClick={e => e.stopPropagation()} onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
|
||||
{/* Current Time */}
|
||||
<motion.div
|
||||
variants={animationVariants}
|
||||
transition={animationTransition}
|
||||
className={cx(
|
||||
'pointer-events-none absolute left-5 cursor-default select-none font-extrabold text-white/40',
|
||||
css`
|
||||
bottom: 100px;
|
||||
font-size: 120px;
|
||||
line-height: 120%;
|
||||
letter-spacing: 0.02em;
|
||||
-webkit-text-stroke-width: 1px;
|
||||
-webkit-text-stroke-color: rgba(255, 255, 255, 0.7);
|
||||
`
|
||||
)}
|
||||
>
|
||||
{formatDuration(currentTime || 0, 'en-US', 'hh:mm:ss')}
|
||||
</motion.div>
|
||||
|
||||
{/* Controls */}
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { y: '48px', opacity: 0 },
|
||||
visible: { y: 0, opacity: 1 },
|
||||
}}
|
||||
transition={animationTransition}
|
||||
className='absolute bottom-5 left-5 flex rounded-20 bg-black/70 py-3 px-5 backdrop-blur-3xl'
|
||||
>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className='flex h-11 w-11 items-center justify-center rounded-full bg-white/20 text-white/80 transition-colors duration-400 hover:bg-white/30'
|
||||
>
|
||||
<Icon name={isPaused ? 'play' : 'pause'} className='h-6 w-6' />
|
||||
</button>
|
||||
<button
|
||||
onClick={onLike}
|
||||
className='ml-3 flex h-11 w-11 items-center justify-center rounded-full bg-white/20 text-white/80 transition-colors duration-400 hover:bg-white/30'
|
||||
>
|
||||
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-6 w-6' />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className='ml-3 flex h-11 w-11 items-center justify-center rounded-full bg-white/20 text-white/80 transition-colors duration-400 hover:bg-white/30'
|
||||
>
|
||||
<Icon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen-enter'} className='h-6 w-6' />
|
||||
</button>
|
||||
{/* Slider */}
|
||||
<div className='ml-5 flex items-center'>
|
||||
<div
|
||||
className={css`
|
||||
width: 214px;
|
||||
`}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
max={duration || 99999}
|
||||
value={currentTime || 0}
|
||||
onChange={value => video?.currentTime && (video.currentTime = value)}
|
||||
onlyCallOnChangeAfterDragEnded={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Duration */}
|
||||
<span className='ml-4 text-14 font-bold text-white/20'>
|
||||
{formatDuration(duration || 0, 'en-US', 'hh:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoInstance
|
||||
117
packages/web/components/VideoPlayer/VideoPlayer.tsx
Normal file
117
packages/web/components/VideoPlayer/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { createPortal } from 'react-dom'
|
||||
import useMV, { useMVUrl } from '../../api/hooks/useMV'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import Icon from '../Icon'
|
||||
import VideoInstance from './VideoInstance'
|
||||
import { toHttps } from '@/web/utils/common'
|
||||
import uiStates, { closeVideoPlayer } from '@/web/states/uiStates'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const VideoPlayer = () => {
|
||||
const { playingVideoID } = useSnapshot(uiStates)
|
||||
const { fullscreen } = useSnapshot(uiStates)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: mv, isLoading } = useMV({ mvid: playingVideoID || 0 })
|
||||
const { data: mvDetails } = useMVUrl({ id: playingVideoID || 0 })
|
||||
const mvUrl = toHttps(mvDetails?.data?.url)
|
||||
const poster = toHttps(mv?.data.cover)
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{playingVideoID && (
|
||||
<div
|
||||
id='video-player'
|
||||
className={cx(
|
||||
'fixed inset-0 z-20 flex select-none items-center justify-center overflow-hidden',
|
||||
window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||
)}
|
||||
>
|
||||
{/* Blur bg */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ease, duration: 0.5 }}
|
||||
className='absolute inset-0 bg-gray-50/80 backdrop-blur-3xl'
|
||||
></motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={{
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease,
|
||||
delay: 0.2,
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 100,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease,
|
||||
},
|
||||
},
|
||||
}}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='hidden'
|
||||
className='relative'
|
||||
>
|
||||
{/* Video Title */}
|
||||
<div className='absolute -top-16 flex cursor-default text-32 font-medium text-white transition-all'>
|
||||
{isLoading ? (
|
||||
<span className='rounded-full bg-white/10 text-transparent'>PLACEHOLDER2023</span>
|
||||
) : (
|
||||
<>
|
||||
<div className='line-clamp-1' title={mv?.data.artistName + ' - ' + mv?.data.name}>
|
||||
<a
|
||||
onClick={() => {
|
||||
if (!mv?.data.artistId) return
|
||||
closeVideoPlayer()
|
||||
navigate('/artist/' + mv.data.artistId)
|
||||
}}
|
||||
className='transition duration-400 hover:underline'
|
||||
>
|
||||
{mv?.data.artistName}
|
||||
</a>{' '}
|
||||
- {mv?.data.name}
|
||||
</div>
|
||||
<div className='ml-4 text-white/20'>{mv?.data.publishTime.slice(0, 4)}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video */}
|
||||
<VideoInstance src={mvUrl} poster={poster} />
|
||||
|
||||
{/* Close button */}
|
||||
<div className='absolute -bottom-24 flex w-full justify-center'>
|
||||
<motion.div
|
||||
layout='position'
|
||||
transition={{ ease }}
|
||||
onClick={() => {
|
||||
const video = document.querySelector('#video-player video') as HTMLVideoElement
|
||||
video?.pause()
|
||||
closeVideoPlayer()
|
||||
}}
|
||||
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' />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPlayer
|
||||
2
packages/web/components/VideoPlayer/index.tsx
Normal file
2
packages/web/components/VideoPlayer/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import VideoPlayer from './VideoPlayer'
|
||||
export default VideoPlayer
|
||||
Loading…
Add table
Add a link
Reference in a new issue