mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 21:28:06 +00:00
feat: updates
This commit is contained in:
parent
884f3df41a
commit
c6c59b2cd9
84 changed files with 3531 additions and 2616 deletions
|
|
@ -52,7 +52,7 @@ const ArtistInline = ({
|
|||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
12
packages/web/components/ContextMenus/types.ts
Normal file
12
packages/web/components/ContextMenus/types.ts
Normal 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[]
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 />)}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
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
|
||||
25
packages/web/components/VideoRow.tsx
Normal file
25
packages/web/components/VideoRow.tsx
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue