feat: updates

This commit is contained in:
qier222 2023-01-07 14:39:03 +08:00
parent 884f3df41a
commit c6c59b2cd9
No known key found for this signature in database
84 changed files with 3531 additions and 2616 deletions

View file

@ -52,7 +52,7 @@ const ArtistInline = ({
>
{artist.name}
</span>
{index < artists.length - 1 ? ', ' : ''}&nbsp;
{index < artists.length - 1 ? ', ' : ''}
</span>
))}
</div>

View file

@ -1,6 +1,4 @@
import useUserAlbums, {
useMutationLikeAAlbum,
} from '@/web/api/hooks/useUserAlbums'
import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import player from '@/web/states/player'
import { AnimatePresence } from 'framer-motion'
@ -14,16 +12,15 @@ import BasicContextMenu from './BasicContextMenu'
const AlbumContextMenu = () => {
const { t } = useTranslation()
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus)
const likeAAlbum = useMutationLikeAAlbum()
const [, copyToClipboard] = useCopyToClipboard()
const { data: likedAlbums } = useUserAlbums()
const addToLibraryLabel = useMemo(() => {
return likedAlbums?.data?.find(a => a.id === Number(dataSourceID))
? 'Remove from Library'
: 'Add to Library'
? t`context-menu.remove-from-library`
: t`context-menu.add-to-library`
}, [dataSourceID, likedAlbums?.data])
return (
@ -82,19 +79,15 @@ const AlbumContextMenu = () => {
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

@ -4,6 +4,8 @@ import useLockMainScroll from '@/web/hooks/useLockMainScroll'
import useMeasure from 'react-use-measure'
import { ContextMenuItem } from './MenuItem'
import MenuPanel from './MenuPanel'
import { createPortal } from 'react-dom'
import { ContextMenuPosition } from './types'
const BasicContextMenu = ({
onClose,
@ -19,15 +21,14 @@ const BasicContextMenu = ({
cursorPosition: { x: number; y: number }
options?: {
useCursorPosition?: boolean
fixedPosition?: `${'top' | 'bottom'}-${'left' | 'right'}`
} | null
classNames?: string
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const [measureRef, menu] = useMeasure()
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
)
const [position, setPosition] = useState<ContextMenuPosition | null>(null)
useClickAway(menuRef, onClose)
useLockMainScroll(!!position)
@ -43,6 +44,22 @@ const BasicContextMenu = ({
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
}
setPosition(position)
} else if (options?.fixedPosition) {
const [vertical, horizontal] = options.fixedPosition.split('-') as [
'top' | 'bottom',
'left' | 'right'
]
const button = target.getBoundingClientRect()
const leftX = button.x
const rightX = button.x - menu.width + button.width
const bottomY = button.y + button.height + 8
const topY = button.y - menu.height - 8
const position: ContextMenuPosition = {
x: horizontal === 'left' ? leftX : rightX,
y: vertical === 'bottom' ? bottomY : topY,
transformOrigin: `origin-${options.fixedPosition}`,
}
setPosition(position)
} else {
const button = target.getBoundingClientRect()
const leftX = button.x
@ -57,7 +74,7 @@ const BasicContextMenu = ({
}
}, [target, menu, options?.useCursorPosition, cursorPosition])
return (
return createPortal(
<>
<MenuPanel
position={{ x: 99999, y: 99999 }}
@ -78,7 +95,8 @@ const BasicContextMenu = ({
classNames={classNames}
/>
)}
</>
</>,
document.body
)
}

View file

@ -1,13 +1,7 @@
import { css, cx } from '@emotion/css'
import { ForwardedRef, forwardRef, useRef, useState } from 'react'
import Icon from '../Icon'
export interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider'
label?: string
onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[]
}
import { ContextMenuItem } from './types'
const MenuItem = ({
item,
@ -63,7 +57,7 @@ const MenuItem = ({
onSubmenuClose()
}}
className={cx(
'relative',
'relative cursor-default',
className,
css`
padding-right: 9px;

View file

@ -7,14 +7,11 @@ import {
useState,
} from 'react'
import { motion } from 'framer-motion'
import MenuItem, { ContextMenuItem } from './MenuItem'
import MenuItem from './MenuItem'
import { ContextMenuItem, ContextMenuPosition } from './types'
interface PanelProps {
position: {
x: number
y: number
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
}
position: ContextMenuPosition
items: ContextMenuItem[]
onClose: (e: MouseEvent) => void
forMeasure?: boolean
@ -36,33 +33,33 @@ const MenuPanel = forwardRef(
return (
// Container (to add padding for submenus)
<motion.div
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.1,
},
}}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
<div
ref={ref}
className={cx(
'fixed',
position.transformOrigin || 'origin-top-left',
isSubmenu ? 'submenu z-20 px-1' : 'z-10'
'fixed select-none',
isSubmenu ? 'submenu z-30 px-1' : 'z-20'
)}
style={{ left: position.x, top: position.y }}
>
{/* The real panel */}
<div
<motion.div
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.1,
},
}}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
className={cx(
'rounded-12 border border-white/[.06] bg-gray-900/95 p-px py-2.5 shadow-xl outline outline-1 outline-black backdrop-blur-3xl',
css`
min-width: 200px;
`,
classNames
classNames,
position.transformOrigin || 'origin-top-left'
)}
>
{items.map((item, index) => (
@ -76,7 +73,7 @@ const MenuPanel = forwardRef(
className={isSubmenu ? 'submenu' : ''}
/>
))}
</div>
</motion.div>
{/* Submenu */}
<SubMenu
@ -86,7 +83,7 @@ const MenuPanel = forwardRef(
itemRect={submenuProps?.itemRect}
onClose={onClose}
/>
</motion.div>
</div>
)
}
)

View file

@ -0,0 +1,12 @@
export interface ContextMenuPosition {
x: number
y: number
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
}
export interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider'
label?: string
onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[]
}

View file

@ -1 +1 @@
export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | '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' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'
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'

View file

@ -2,13 +2,12 @@ import Main from '@/web/components/Main'
import Player from '@/web/components/Player'
import MenuBar from '@/web/components/MenuBar'
import Topbar from '@/web/components/Topbar/TopbarDesktop'
import { css, cx } from '@emotion/css'
import { cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Login from './Login'
import TrafficLight from './TrafficLight'
import BlurBackground from './BlurBackground'
import Airplay from './Airplay'
import TitleBar from './TitleBar'
import uiStates from '@/web/states/uiStates'
import ContextMenus from './ContextMenus/ContextMenus'
@ -39,7 +38,11 @@ const Layout = () => {
</div>
)}
{(window.env?.isWindows || window.env?.isLinux) && <TitleBar />}
{(window.env?.isWindows ||
window.env?.isLinux ||
window.localStorage.getItem('showWindowsTitleBar') === 'true') && (
<TitleBar />
)}
<ContextMenus />

View file

@ -151,7 +151,7 @@ const Login = () => {
onClick={() => (uiStates.showLoginPanel = false)}
className='mt-10 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-7 w-7 ' />
<Icon name='x' className='h-6 w-6' />
</motion.div>
</AnimatePresence>
</motion.div>

View file

@ -9,6 +9,7 @@ import persistedUiStates from '@/web/states/persistedUiStates'
import { motion, useAnimation } from 'framer-motion'
import { sleep } from '@/web/utils/common'
import player from '@/web/states/player'
import VideoPlayer from './VideoPlayer'
const Main = () => {
const playerSnapshot = useSnapshot(player)

View file

@ -19,8 +19,8 @@ const Progress = () => {
/>
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
<span>{formatDuration(progress * 1000, 'en-US', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en-US', 'hh:mm:ss')}</span>
</div>
</div>
)

View file

@ -1,6 +1,7 @@
import { Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence } from 'framer-motion'
import React, { ReactNode, Suspense } from 'react'
import VideoPlayer from './VideoPlayer'
const My = React.lazy(() => import('@/web/pages/My'))
const Discover = React.lazy(() => import('@/web/pages/Discover'))
@ -8,7 +9,6 @@ const Browse = React.lazy(() => import('@/web/pages/Browse'))
const Album = React.lazy(() => import('@/web/pages/Album'))
const Playlist = React.lazy(() => import('@/web/pages/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/Artist'))
const MV = React.lazy(() => import('@/web/pages/MV'))
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics'))
const Search = React.lazy(() => import('@/web/pages/Search'))
@ -20,7 +20,8 @@ const Router = () => {
const location = useLocation()
return (
<AnimatePresence exitBeforeEnter>
<AnimatePresence mode='wait'>
<VideoPlayer />
<Routes location={location} key={location.pathname}>
<Route path='/' element={lazy(<My />)} />
<Route path='/discover' element={lazy(<Discover />)} />
@ -28,7 +29,6 @@ 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='/mv/:id' element={lazy(<MV />)} />
{/* <Route path='/settings' element={lazy(<Settings />)} /> */}
<Route path='/lyrics' element={lazy(<Lyrics />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}>

View file

@ -1,9 +1,7 @@
import player from '@/web/states/player'
import Icon from './Icon'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useState, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { useState } from 'react'
import { css, cx } from '@emotion/css'
const Controls = () => {
@ -50,7 +48,8 @@ const Controls = () => {
className={cx(
classNames,
css`
margin-right: 5px;
border-radius: 4px 22px 4px 4px;
margin-right: 4px;
`
)}
>

View file

@ -1,5 +1,8 @@
import useHoverLightSpot from '@/web/hooks/useHoverLightSpot'
import { openContextMenu } from '@/web/states/contextMenus'
import { cx } from '@emotion/css'
import { css, cx } from '@emotion/css'
import { motion, useMotionValue } from 'framer-motion'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import Icon from '../Icon'
@ -15,60 +18,101 @@ const Actions = ({
onPlay: () => void
onLike?: () => void
}) => {
const params = useParams()
const { t } = useTranslation()
return (
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
<div className='flex items-end'>
{/* Menu */}
<button
onClick={event => {
params?.id &&
openContextMenu({
event,
type: 'album',
dataSourceID: params.id,
})
}}
className={cx(
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
</button>
{/* Like */}
{onLike && (
<button
onClick={() => onLike()}
className={cx(
'flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400 lg:mr-2.5',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7'
/>
</button>
)}
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start lg:gap-2.5'>
<div className='flex items-end gap-2.5'>
<MenuButton isLoading={isLoading} />
<LikeButton {...{ isLiked, isLoading, onLike }} />
</div>
<button
onClick={() => onPlay()}
className={cx(
'h-14 rounded-full px-10 text-18 font-medium',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
{t`player.play`}
</button>
<PlayButton onPlay={onPlay} isLoading={isLoading} />
</div>
)
}
const MenuButton = ({ isLoading }: { isLoading?: boolean }) => {
const params = useParams()
// hover animation
const { buttonRef, buttonStyle, LightSpot } = useHoverLightSpot({
opacity: 0.8,
size: 16,
})
return (
<motion.button
ref={buttonRef}
style={buttonStyle}
onClick={event => {
params?.id &&
openContextMenu({
event,
type: 'album',
dataSourceID: params.id,
})
}}
className={cx(
'relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-white/10 transition duration-300 ease-linear',
isLoading ? 'text-transparent' : 'text-white/40'
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
{LightSpot()}
</motion.button>
)
}
const LikeButton = ({
onLike,
isLiked,
isLoading,
}: {
onLike?: () => void
isLiked?: boolean
isLoading?: boolean
}) => {
// hover animation
const { buttonRef, buttonStyle, LightSpot } = useHoverLightSpot({
opacity: 0.8,
size: 16,
})
if (!onLike) return null
return (
<motion.button
ref={buttonRef}
onClick={() => onLike()}
style={buttonStyle}
className={cx(
'relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-white/10 transition-transform duration-300 ease-linear',
isLoading ? 'text-transparent' : 'text-white/40 '
)}
>
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
{LightSpot()}
</motion.button>
)
}
const PlayButton = ({ onPlay, isLoading }: { onPlay: () => void; isLoading?: boolean }) => {
const { t } = useTranslation()
// hover animation
const { buttonRef, buttonStyle, LightSpot } = useHoverLightSpot()
return (
<motion.button
ref={buttonRef}
style={buttonStyle}
onClick={() => onPlay()}
className={cx(
'relative h-14 overflow-hidden rounded-full px-10 text-18 font-medium transition-transform duration-300 ease-linear',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
{t`player.play`}
{LightSpot()}
</motion.button>
)
}
export default Actions

View file

@ -2,7 +2,7 @@ import { resizeImage } from '@/web/utils/common'
import Image from '@/web/components/Image'
import { memo, useEffect } from 'react'
import uiStates from '@/web/states/uiStates'
import VideoCover from './VideoCover'
import VideoCover from '@/web/components/VideoCover'
const Cover = memo(
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
@ -18,7 +18,7 @@ const Cover = memo(
src={resizeImage(cover || '', 'lg')}
/>
{videoCover && <VideoCover videoCover={videoCover} />}
{videoCover && <VideoCover source={videoCover} />}
</div>
</>
)

View file

@ -1,51 +0,0 @@
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'
injectGlobal`
.plyr__video-wrapper,
.plyr--video {
background-color: transparent !important;
}
`
const VideoCover = ({ videoCover }: { videoCover?: string }) => {
const ref = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>(new Hls())
useEffect(() => {
if (videoCover && Hls.isSupported()) {
const video = document.querySelector('#video-cover') as HTMLVideoElement
hls.current.loadSource(videoCover)
hls.current.attachMedia(video)
}
}, [videoCover])
return (
<motion.div
initial={{ opacity: isIOS ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className='absolute inset-0'
>
{isSafari ? (
<video
src={videoCover}
className='h-full w-full'
autoPlay
loop
muted
playsInline
></video>
) : (
<div className='aspect-square'>
<video id='video-cover' ref={ref} autoPlay muted loop />
</div>
)}
</motion.div>
)
}
export default VideoCover

View file

@ -3,32 +3,34 @@ 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'
injectGlobal`
.plyr__video-wrapper,
.plyr--video {
background-color: transparent !important;
}
`
const VideoCover = ({
source,
onPlay,
}: {
source?: string
onPlay?: () => void
}) => {
const ref = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>(new Hls())
const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>()
useEffect(() => {
if (source && Hls.isSupported()) {
const video = document.querySelector('#video-cover') as HTMLVideoElement
if (source && Hls.isSupported() && videoRef.current) {
if (hls.current) hls.current.destroy()
hls.current = new Hls()
hls.current.loadSource(source)
hls.current.attachMedia(video)
hls.current.attachMedia(videoRef.current)
}
return () => hls.current && hls.current.destroy()
}, [source])
// Pause video cover when playing another video
const { playingVideoID } = useSnapshot(uiStates)
useEffect(() => {
if (playingVideoID) {
videoRef?.current?.pause()
} else {
videoRef?.current?.play()
}
}, [playingVideoID])
return (
<motion.div
initial={{ opacity: isIOS ? 1 : 0 }}
@ -38,23 +40,26 @@ const VideoCover = ({
>
{isSafari ? (
<video
ref={videoRef}
src={source}
className='h-full w-full'
autoPlay
loop
muted
playsInline
preload='auto'
onPlay={() => onPlay?.()}
></video>
) : (
<div className='aspect-square'>
<video
id='video-cover'
ref={ref}
ref={videoRef}
autoPlay
muted
loop
preload='auto'
onPlay={() => onPlay?.()}
className='h-full w-full'
/>
</div>
)}

View 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

View 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

View file

@ -0,0 +1,2 @@
import VideoPlayer from './VideoPlayer'
export default VideoPlayer

View file

@ -0,0 +1,25 @@
import useUserVideos from '../api/hooks/useUserVideos'
import uiStates from '../states/uiStates'
const VideoRow = ({ videos }: { videos: Video[] }) => {
return (
<div className='grid grid-cols-3 gap-6'>
{videos.map(video => (
<div
key={video.vid}
onClick={() => (uiStates.playingVideoID = Number(video.vid))}
>
<img
src={video.coverUrl}
className='aspect-video w-full rounded-24 border border-white/5 object-contain'
/>
<div className='line-clamp-2 mt-2 text-12 font-medium text-neutral-600'>
{video.creator?.at(0)?.userName} - {video.title}
</div>
</div>
))}
</div>
)
}
export default VideoRow