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
a1b0bcf4d3
commit
884f3df41a
198 changed files with 4572 additions and 5336 deletions
|
|
@ -1,51 +0,0 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const ArtistInline = ({
|
||||
artists,
|
||||
className,
|
||||
disableLink,
|
||||
onClick,
|
||||
}: {
|
||||
artists: Artist[]
|
||||
className?: string
|
||||
disableLink?: boolean
|
||||
onClick?: (artistId: number) => void
|
||||
}) => {
|
||||
if (!artists) return <div></div>
|
||||
|
||||
const navigate = useNavigate()
|
||||
const handleClick = (id: number) => {
|
||||
if (id === 0 || disableLink) return
|
||||
if (!onClick) {
|
||||
navigate(`/artist/${id}`)
|
||||
} else {
|
||||
onClick(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
!className?.includes('line-clamp') && 'line-clamp-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{artists.map((artist, index) => (
|
||||
<span key={`${artist.id}-${artist.name}`}>
|
||||
<span
|
||||
onClick={() => handleClick(artist.id)}
|
||||
className={cx({
|
||||
'hover:underline': !!artist.id && !disableLink,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistInline
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { resizeImage } from '../utils/common'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import Icon from './Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const Avatar = ({ size }: { size?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
const { data: user } = useUser()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
onClick={() => navigate('/login')}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full bg-gray-100 dark:bg-gray-700',
|
||||
size || 'h-9 w-9'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div onClick={() => navigate('/login')}>
|
||||
<Icon
|
||||
name='user'
|
||||
className={cx(
|
||||
'rounded-full bg-black/[.06] p-1 text-gray-500 dark:bg-white/5',
|
||||
size || 'h-9 w-9'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
|
|
@ -6,7 +6,7 @@ import uiStates from '@/web/states/uiStates'
|
|||
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const BlurBackground = () => {
|
||||
const isMobile = useIsMobile()
|
||||
|
|
@ -18,19 +18,29 @@ const BlurBackground = () => {
|
|||
uiStates.blurBackgroundImage = null
|
||||
}, [location.pathname])
|
||||
|
||||
const onLoad = async () => {
|
||||
animate.start({ opacity: 1 })
|
||||
}
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsLoaded(false)
|
||||
}, [blurBackgroundImage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && blurBackgroundImage && hideTopbarBackground && isLoaded) {
|
||||
animate.start({ opacity: 1 })
|
||||
} else {
|
||||
animate.start({ opacity: 0 })
|
||||
}
|
||||
}, [animate, blurBackgroundImage, hideTopbarBackground, isLoaded, isMobile])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!isMobile && blurBackgroundImage && hideTopbarBackground && (
|
||||
<motion.img
|
||||
initial={{ opacity: 0 }}
|
||||
animate={animate}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ease }}
|
||||
onLoad={onLoad}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={animate}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ease }}
|
||||
>
|
||||
<img
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
className={cx(
|
||||
'absolute z-0 object-cover opacity-70',
|
||||
css`
|
||||
|
|
@ -41,9 +51,9 @@ const BlurBackground = () => {
|
|||
filter: blur(256px) saturate(1.2);
|
||||
`
|
||||
)}
|
||||
src={resizeImage(blurBackgroundImage, 'sm')}
|
||||
src={resizeImage(blurBackgroundImage || '', 'sm')}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
export enum Color {
|
||||
Primary = 'primary',
|
||||
Gray = 'gray',
|
||||
}
|
||||
|
||||
export enum Shape {
|
||||
Default = 'default',
|
||||
Square = 'square',
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
onClick,
|
||||
color = Color.Primary,
|
||||
iconColor = Color.Primary,
|
||||
isSkelton = false,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
color?: Color
|
||||
iconColor?: Color
|
||||
isSkelton?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'btn-pressed-animation flex cursor-default items-center rounded-20 px-4 py-1.5 text-lg font-medium',
|
||||
{
|
||||
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
|
||||
'text-brand-500 dark:text-white': iconColor === Color.Primary,
|
||||
'bg-gray-100 dark:bg-gray-700': color === Color.Gray,
|
||||
'text-gray-600 dark:text-gray-400': iconColor === Color.Gray,
|
||||
'animate-pulse bg-gray-100 !text-transparent dark:bg-gray-800':
|
||||
isSkelton,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
|
|
@ -6,11 +6,14 @@ import player from '@/web/states/player'
|
|||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCopyToClipboard } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import BasicContextMenu from './BasicContextMenu'
|
||||
|
||||
const AlbumContextMenu = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const likeAAlbum = useMutationLikeAAlbum()
|
||||
|
|
@ -34,7 +37,7 @@ const AlbumContextMenu = () => {
|
|||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to Queue',
|
||||
label: t`context-menu.add-to-queue`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
|
||||
|
|
@ -63,7 +66,7 @@ const AlbumContextMenu = () => {
|
|||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to playlist',
|
||||
label: t`context-menu.add-to-playlist`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
|
|
@ -73,16 +76,16 @@ const AlbumContextMenu = () => {
|
|||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Share',
|
||||
label: t`context-menu.share`,
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy Netease Link',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -92,7 +95,7 @@ const AlbumContextMenu = () => {
|
|||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -5,11 +5,14 @@ import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
|||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCopyToClipboard } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import BasicContextMenu from './BasicContextMenu'
|
||||
|
||||
const ArtistContextMenu = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const likeAArtist = useMutationLikeAArtist()
|
||||
|
|
@ -18,9 +21,9 @@ const ArtistContextMenu = () => {
|
|||
const { data: likedArtists } = useUserArtists()
|
||||
const followLabel = useMemo(() => {
|
||||
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
|
||||
? 'Follow'
|
||||
: 'Unfollow'
|
||||
}, [dataSourceID, likedArtists?.data])
|
||||
? t`context-menu.unfollow`
|
||||
: t`context-menu.follow`
|
||||
}, [dataSourceID, likedArtists?.data, t])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
@ -41,7 +44,9 @@ const ArtistContextMenu = () => {
|
|||
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
|
||||
if (res?.code === 200) {
|
||||
toast.success(
|
||||
followLabel === 'Unfollow' ? 'Followed' : 'Unfollowed'
|
||||
followLabel === t`context-menu.unfollow`
|
||||
? t`context-menu.unfollowed`
|
||||
: t`context-menu.followed`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -52,16 +57,16 @@ const ArtistContextMenu = () => {
|
|||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Share',
|
||||
label: t`context-menu.share`,
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy Netease Link',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/artist?id=${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -71,7 +76,7 @@ const ArtistContextMenu = () => {
|
|||
copyToClipboard(
|
||||
`${window.location.origin}/artist/${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
],
|
||||
85
packages/web/components/ContextMenus/BasicContextMenu.tsx
Normal file
85
packages/web/components/ContextMenus/BasicContextMenu.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useClickAway } from 'react-use'
|
||||
import useLockMainScroll from '@/web/hooks/useLockMainScroll'
|
||||
import useMeasure from 'react-use-measure'
|
||||
import { ContextMenuItem } from './MenuItem'
|
||||
import MenuPanel from './MenuPanel'
|
||||
|
||||
const BasicContextMenu = ({
|
||||
onClose,
|
||||
items,
|
||||
target,
|
||||
cursorPosition,
|
||||
options,
|
||||
classNames,
|
||||
}: {
|
||||
onClose: (e: MouseEvent) => void
|
||||
items: ContextMenuItem[]
|
||||
target: HTMLElement
|
||||
cursorPosition: { x: number; y: number }
|
||||
options?: {
|
||||
useCursorPosition?: boolean
|
||||
} | null
|
||||
classNames?: string
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [measureRef, menu] = useMeasure()
|
||||
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useClickAway(menuRef, onClose)
|
||||
useLockMainScroll(!!position)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (options?.useCursorPosition) {
|
||||
const leftX = cursorPosition.x
|
||||
const rightX = cursorPosition.x - menu.width
|
||||
const bottomY = cursorPosition.y
|
||||
const topY = cursorPosition.y - menu.height
|
||||
const position = {
|
||||
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
|
||||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
setPosition(position)
|
||||
} else {
|
||||
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 = {
|
||||
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
|
||||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
setPosition(position)
|
||||
}
|
||||
}, [target, menu, options?.useCursorPosition, cursorPosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuPanel
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
items={items}
|
||||
ref={measureRef}
|
||||
onClose={() => {
|
||||
//
|
||||
}}
|
||||
forMeasure={true}
|
||||
classNames={classNames}
|
||||
/>
|
||||
{position && (
|
||||
<MenuPanel
|
||||
position={position}
|
||||
items={items}
|
||||
ref={menuRef}
|
||||
onClose={onClose}
|
||||
classNames={classNames}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasicContextMenu
|
||||
112
packages/web/components/ContextMenus/MenuItem.tsx
Normal file
112
packages/web/components/ContextMenus/MenuItem.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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[]
|
||||
}
|
||||
|
||||
const MenuItem = ({
|
||||
item,
|
||||
index,
|
||||
onClose,
|
||||
onSubmenuOpen,
|
||||
onSubmenuClose,
|
||||
className,
|
||||
}: {
|
||||
item: ContextMenuItem
|
||||
index: number
|
||||
onClose: (e: MouseEvent) => void
|
||||
onSubmenuOpen: (props: { itemRect: DOMRect; index: number }) => void
|
||||
onSubmenuClose: () => void
|
||||
className?: string
|
||||
}) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const [isHover, setIsHover] = useState(false)
|
||||
|
||||
if (item.type === 'divider') {
|
||||
return (
|
||||
<div className='my-2 h-px w-full px-3'>
|
||||
<div className='h-full w-full bg-white/20'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
onClick={e => {
|
||||
if (!item.onClick) {
|
||||
return
|
||||
}
|
||||
const event = e as unknown as MouseEvent
|
||||
item.onClick?.(event)
|
||||
onClose(event)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (item.type !== 'submenu') return
|
||||
setIsHover(true)
|
||||
onSubmenuOpen({
|
||||
itemRect: itemRef.current!.getBoundingClientRect(),
|
||||
index,
|
||||
})
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
if (relatedTarget?.classList?.contains('submenu')) {
|
||||
return
|
||||
}
|
||||
setIsHover(false)
|
||||
onSubmenuClose()
|
||||
}}
|
||||
className={cx(
|
||||
'relative',
|
||||
className,
|
||||
css`
|
||||
padding-right: 9px;
|
||||
padding-left: 9px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex w-full items-center justify-between whitespace-nowrap rounded-[5px] p-3 text-16 font-medium text-neutral-200 transition-colors duration-400 hover:bg-white/[.06]',
|
||||
item.type !== 'submenu' && !isHover && 'active:bg-gray/50',
|
||||
isHover && 'bg-white/[.06]'
|
||||
)}
|
||||
>
|
||||
<div>{item.label}</div>
|
||||
{item.type === 'submenu' && (
|
||||
<>
|
||||
<Icon
|
||||
name='caret-right'
|
||||
className={cx(
|
||||
'ml-10 text-neutral-600',
|
||||
css`
|
||||
height: 8px;
|
||||
width: 5px;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 将item变宽一点,避免移动鼠标时还没移动到submenu就关闭submenu了 */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute h-full',
|
||||
css`
|
||||
left: -24px;
|
||||
width: calc(100% + 48px);
|
||||
`
|
||||
)}
|
||||
></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MenuItem
|
||||
173
packages/web/components/ContextMenus/MenuPanel.tsx
Normal file
173
packages/web/components/ContextMenus/MenuPanel.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import MenuItem, { ContextMenuItem } from './MenuItem'
|
||||
|
||||
interface PanelProps {
|
||||
position: {
|
||||
x: number
|
||||
y: number
|
||||
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
|
||||
}
|
||||
items: ContextMenuItem[]
|
||||
onClose: (e: MouseEvent) => void
|
||||
forMeasure?: boolean
|
||||
classNames?: string
|
||||
isSubmenu?: boolean
|
||||
}
|
||||
|
||||
interface SubmenuProps {
|
||||
itemRect: DOMRect
|
||||
index: number
|
||||
}
|
||||
|
||||
const MenuPanel = forwardRef(
|
||||
(
|
||||
{ position, items, onClose, forMeasure, classNames, isSubmenu }: PanelProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const [submenuProps, setSubmenuProps] = useState<SubmenuProps | null>(null)
|
||||
|
||||
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 }}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'fixed',
|
||||
position.transformOrigin || 'origin-top-left',
|
||||
isSubmenu ? 'submenu z-20 px-1' : 'z-10'
|
||||
)}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{/* The real panel */}
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
index={index}
|
||||
item={item}
|
||||
onClose={onClose}
|
||||
onSubmenuOpen={(props: SubmenuProps) => setSubmenuProps(props)}
|
||||
onSubmenuClose={() => setSubmenuProps(null)}
|
||||
className={isSubmenu ? 'submenu' : ''}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Submenu */}
|
||||
<SubMenu
|
||||
items={
|
||||
submenuProps?.index ? items[submenuProps?.index]?.items : undefined
|
||||
}
|
||||
itemRect={submenuProps?.itemRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
)
|
||||
MenuPanel.displayName = 'Menu'
|
||||
|
||||
export default MenuPanel
|
||||
|
||||
const SubMenu = ({
|
||||
items,
|
||||
itemRect,
|
||||
onClose,
|
||||
}: {
|
||||
items?: ContextMenuItem[]
|
||||
itemRect?: DOMRect
|
||||
onClose: (e: MouseEvent) => void
|
||||
}) => {
|
||||
const submenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [position, setPosition] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
transformOrigin: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
|
||||
}>()
|
||||
useLayoutEffect(() => {
|
||||
if (!itemRect || !submenuRef.current) {
|
||||
return
|
||||
}
|
||||
const item = itemRect
|
||||
const submenu = submenuRef.current.getBoundingClientRect()
|
||||
|
||||
const isRightSide = item.x + item.width + submenu.width <= window.innerWidth
|
||||
const x = isRightSide ? item.x + item.width : item.x - submenu.width
|
||||
|
||||
const isTopSide = item.y - 10 + submenu.height <= window.innerHeight
|
||||
const y = isTopSide
|
||||
? item.y - 10
|
||||
: item.y + item.height + 10 - submenu.height
|
||||
|
||||
const transformOriginTable = {
|
||||
top: {
|
||||
right: 'origin-top-left',
|
||||
left: 'origin-top-right',
|
||||
},
|
||||
bottom: {
|
||||
right: 'origin-bottom-left',
|
||||
left: 'origin-bottom-right',
|
||||
},
|
||||
} as const
|
||||
|
||||
setPosition({
|
||||
x,
|
||||
y,
|
||||
transformOrigin:
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][
|
||||
isRightSide ? 'right' : 'left'
|
||||
],
|
||||
})
|
||||
}, [itemRect])
|
||||
|
||||
if (!items || !itemRect) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuPanel
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
items={items || []}
|
||||
ref={submenuRef}
|
||||
onClose={() => {
|
||||
// Do nothing
|
||||
}}
|
||||
forMeasure={true}
|
||||
isSubmenu={true}
|
||||
/>
|
||||
<MenuPanel
|
||||
position={position || { x: 99999, y: 99999 }}
|
||||
items={items || []}
|
||||
onClose={onClose}
|
||||
isSubmenu={true}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
||||
import { fetchTracks } from '@/web/api/track'
|
||||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useCopyToClipboard } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
|
@ -8,6 +11,8 @@ import BasicContextMenu from './BasicContextMenu'
|
|||
|
||||
const TrackContextMenu = () => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { type, dataSourceID, target, cursorPosition, options } =
|
||||
|
|
@ -24,7 +29,7 @@ const TrackContextMenu = () => {
|
|||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to Queue',
|
||||
label: t`context-menu.add-to-queue`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
|
|
@ -34,16 +39,24 @@ const TrackContextMenu = () => {
|
|||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Go to artist',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
label: t`context-menu.go-to-artist`,
|
||||
onClick: async () => {
|
||||
const tracks = await fetchTracksWithReactQuery({
|
||||
ids: [Number(dataSourceID)],
|
||||
})
|
||||
const track = tracks?.songs?.[0]
|
||||
if (track) navigate(`/artist/${track.ar[0].id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Go to album',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
label: t`context-menu.go-to-album`,
|
||||
onClick: async () => {
|
||||
const tracks = await fetchTracksWithReactQuery({
|
||||
ids: [Number(dataSourceID)],
|
||||
})
|
||||
const track = tracks?.songs?.[0]
|
||||
if (track) navigate(`/album/${track.al.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -51,30 +64,30 @@ const TrackContextMenu = () => {
|
|||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to Liked Tracks',
|
||||
label: t`context-menu.add-to-liked-tracks`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to playlist',
|
||||
label: t`context-menu.add-to-playlist`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Share',
|
||||
label: t`context-menu.share`,
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy Netease Link',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -84,7 +97,7 @@ const TrackContextMenu = () => {
|
|||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useState } from 'react'
|
||||
|
||||
const Cover = ({
|
||||
imageUrl,
|
||||
onClick,
|
||||
roundedClass = 'rounded-xl',
|
||||
showPlayButton = false,
|
||||
showHover = true,
|
||||
alwaysShowShadow = false,
|
||||
}: {
|
||||
imageUrl: string
|
||||
onClick?: () => void
|
||||
roundedClass?: string
|
||||
showPlayButton?: boolean
|
||||
showHover?: boolean
|
||||
alwaysShowShadow?: boolean
|
||||
}) => {
|
||||
const [isError, setIsError] = useState(imageUrl.includes('3132508627578625'))
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className='group relative z-0'>
|
||||
{/* Neon shadow */}
|
||||
{showHover && (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] bg-cover blur-lg filter transition duration-300 ',
|
||||
roundedClass,
|
||||
!alwaysShowShadow && 'opacity-0 group-hover:opacity-60'
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url("${imageUrl}")`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Cover */}
|
||||
{isError ? (
|
||||
<div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300 '>
|
||||
<Icon name='music-note' className='h-1/2 w-1/2' />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
className={cx(
|
||||
'box-content aspect-square h-full w-full border border-black border-opacity-5 dark:border-white dark:border-opacity-[.03]',
|
||||
roundedClass
|
||||
)}
|
||||
src={imageUrl}
|
||||
onError={() => imageUrl && setIsError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Play button */}
|
||||
{showPlayButton && (
|
||||
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
|
||||
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
||||
<Icon className='ml-0.5 h-6 w-6' name='play-fill' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
|
|
@ -1,229 +1,136 @@
|
|||
import Cover from '@/web/components/Cover'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Image from './Image'
|
||||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { memo, useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import ArtistInline from './ArtistsInLine'
|
||||
|
||||
export enum Subtitle {
|
||||
Copywriter = 'copywriter',
|
||||
Creator = 'creator',
|
||||
TypeReleaseYear = 'type+releaseYear',
|
||||
Artist = 'artist',
|
||||
}
|
||||
type ItemTitle = undefined | 'name'
|
||||
type ItemSubTitle = undefined | 'artist' | 'year'
|
||||
|
||||
const Title = ({
|
||||
title,
|
||||
seeMoreLink,
|
||||
const Album = ({
|
||||
album,
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
}: {
|
||||
title: string
|
||||
seeMoreLink: string
|
||||
album: Album
|
||||
itemTitle?: ItemTitle
|
||||
itemSubtitle?: ItemSubTitle
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const goTo = () => {
|
||||
navigate(`/album/${album.id}`)
|
||||
}
|
||||
const prefetch = () => {
|
||||
prefetchAlbum({ id: album.id })
|
||||
}
|
||||
|
||||
const title =
|
||||
itemTitle &&
|
||||
{
|
||||
name: album.name,
|
||||
}[itemTitle]
|
||||
|
||||
const subtitle =
|
||||
itemSubtitle &&
|
||||
{
|
||||
artist: (
|
||||
<ArtistInline
|
||||
artists={album.artists}
|
||||
hoverClassName='hover:text-white/50 transition-colors duration-400'
|
||||
/>
|
||||
),
|
||||
year: dayjs(album.publishTime || 0).year(),
|
||||
}[itemSubtitle]
|
||||
|
||||
return (
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<div className='my-4 text-[28px] font-bold text-black dark:text-white'>
|
||||
{title}
|
||||
</div>
|
||||
{seeMoreLink && (
|
||||
<div className='text-13px font-semibold text-gray-600 hover:underline'>
|
||||
See More
|
||||
<div>
|
||||
<Image
|
||||
onClick={goTo}
|
||||
key={album.id}
|
||||
src={resizeImage(album?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
{title && (
|
||||
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className='mt-1 text-14 font-medium text-neutral-700'>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getSubtitleText = (
|
||||
item: Album | Playlist | Artist,
|
||||
subtitle: Subtitle
|
||||
) => {
|
||||
const nickname = 'creator' in item ? item.creator.nickname : 'someone'
|
||||
const artist =
|
||||
'artist' in item
|
||||
? item.artist.name
|
||||
: 'artists' in item
|
||||
? item.artists?.[0]?.name
|
||||
: 'unknown'
|
||||
const copywriter = 'copywriter' in item ? item.copywriter : 'unknown'
|
||||
const releaseYear =
|
||||
('publishTime' in item &&
|
||||
formatDate(item.publishTime ?? 0, 'en', 'YYYY')) ||
|
||||
'unknown'
|
||||
const Playlist = ({ playlist }: { playlist: Playlist }) => {
|
||||
const navigate = useNavigate()
|
||||
const goTo = useCallback(() => {
|
||||
navigate(`/playlist/${playlist.id}`)
|
||||
}, [navigate, playlist.id])
|
||||
const prefetch = useCallback(() => {
|
||||
prefetchPlaylist({ id: playlist.id })
|
||||
}, [playlist.id])
|
||||
|
||||
const type = {
|
||||
playlist: 'playlist',
|
||||
album: 'Album',
|
||||
专辑: 'Album',
|
||||
Single: 'Single',
|
||||
'EP/Single': 'EP',
|
||||
EP: 'EP',
|
||||
unknown: 'unknown',
|
||||
精选集: 'Collection',
|
||||
}[('type' in item && typeof item.type !== 'number' && item.type) || 'unknown']
|
||||
|
||||
const table = {
|
||||
[Subtitle.Creator]: `by ${nickname}`,
|
||||
[Subtitle.TypeReleaseYear]: `${type} · ${releaseYear}`,
|
||||
[Subtitle.Artist]: artist,
|
||||
[Subtitle.Copywriter]: copywriter,
|
||||
}
|
||||
|
||||
return table[subtitle]
|
||||
}
|
||||
|
||||
const getImageUrl = (item: Album | Playlist | Artist) => {
|
||||
let cover: string | undefined = ''
|
||||
if ('coverImgUrl' in item) cover = item.coverImgUrl
|
||||
if ('picUrl' in item) cover = item.picUrl
|
||||
if ('img1v1Url' in item) cover = item.img1v1Url
|
||||
return resizeImage(cover || '', 'md')
|
||||
return (
|
||||
<Image
|
||||
onClick={goTo}
|
||||
key={playlist.id}
|
||||
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CoverRow = ({
|
||||
title,
|
||||
albums,
|
||||
artists,
|
||||
playlists,
|
||||
subtitle = Subtitle.Copywriter,
|
||||
seeMoreLink,
|
||||
isSkeleton,
|
||||
title,
|
||||
className,
|
||||
rows = 2,
|
||||
navigateCallback, // Callback function when click on the cover/title
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
}: {
|
||||
title?: string
|
||||
albums?: Album[]
|
||||
artists?: Artist[]
|
||||
playlists?: Playlist[]
|
||||
subtitle?: Subtitle
|
||||
seeMoreLink?: string
|
||||
isSkeleton?: boolean
|
||||
className?: string
|
||||
rows?: number
|
||||
navigateCallback?: () => void
|
||||
albums?: Album[]
|
||||
playlists?: Playlist[]
|
||||
itemTitle?: ItemTitle
|
||||
itemSubtitle?: ItemSubTitle
|
||||
}) => {
|
||||
const renderItems = useMemo(() => {
|
||||
if (isSkeleton) {
|
||||
return new Array(rows * 5).fill({}) as Array<Album | Playlist | Artist>
|
||||
}
|
||||
return albums ?? playlists ?? artists ?? []
|
||||
}, [albums, artists, isSkeleton, playlists, rows])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const goTo = (id: number) => {
|
||||
if (isSkeleton) return
|
||||
if (albums) navigate(`/album/${id}`)
|
||||
if (playlists) navigate(`/playlist/${id}`)
|
||||
if (artists) navigate(`/artist/${id}`)
|
||||
if (navigateCallback) navigateCallback()
|
||||
scrollToTop()
|
||||
}
|
||||
|
||||
const prefetch = (id: number) => {
|
||||
if (albums) prefetchAlbum({ id })
|
||||
if (playlists) prefetchPlaylist({ id })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title && <Title title={title} seeMoreLink={seeMoreLink ?? ''} />}
|
||||
<div className={className}>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'grid',
|
||||
className,
|
||||
!className &&
|
||||
'grid-cols-3 gap-x-6 gap-y-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
||||
)}
|
||||
>
|
||||
{renderItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id ?? index}
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
className='grid gap-x-6 gap-y-7'
|
||||
>
|
||||
<div>
|
||||
{/* Cover */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='box-content aspect-square w-full rounded-xl border border-black border-opacity-0' />
|
||||
) : (
|
||||
<Cover
|
||||
onClick={() => goTo(item.id)}
|
||||
imageUrl={getImageUrl(item)}
|
||||
showPlayButton={true}
|
||||
roundedClass={artists ? 'rounded-full' : 'rounded-xl'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className='mt-2'>
|
||||
<div className='font-semibold'>
|
||||
{/* Name */}
|
||||
{isSkeleton ? (
|
||||
<div className='flex w-full -translate-y-px flex-col'>
|
||||
<Skeleton className='w-full leading-tight'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
<Skeleton className='w-1/3 translate-y-px leading-tight'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={cx(
|
||||
'line-clamp-2 leading-tight',
|
||||
artists && 'mt-3 text-center'
|
||||
)}
|
||||
>
|
||||
{/* Playlist private icon */}
|
||||
{(item as Playlist).privacy === 10 && (
|
||||
<Icon
|
||||
name='lock'
|
||||
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Explicit icon */}
|
||||
{(item as Album)?.mark === 1056768 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className='float-right mt-[2px] h-4 w-4 text-gray-300'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
onClick={() => goTo(item.id)}
|
||||
className='decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='w-3/5 translate-y-px text-[12px]'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
) : (
|
||||
!artists && (
|
||||
<div className='flex text-[12px] text-gray-500 dark:text-gray-400'>
|
||||
<span>{getSubtitleText(item, subtitle)}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Items */}
|
||||
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
|
||||
{albums?.map(album => (
|
||||
<Album
|
||||
key={album.id}
|
||||
album={album}
|
||||
itemTitle={itemTitle}
|
||||
itemSubtitle={itemSubtitle}
|
||||
/>
|
||||
))}
|
||||
{playlists?.map(playlist => (
|
||||
<Playlist key={playlist.id} playlist={playlist} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverRow
|
||||
const memoizedCoverRow = memo(CoverRow)
|
||||
memoizedCoverRow.displayName = 'CoverRow'
|
||||
export default memoizedCoverRow
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import Icon from './Icon'
|
||||
import { cx, css, keyframes } from '@emotion/css'
|
||||
|
||||
const move = keyframes`
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
`
|
||||
|
||||
const DailyTracksCard = () => {
|
||||
return (
|
||||
<div className='relative h-[198px] cursor-pointer overflow-hidden rounded-2xl'>
|
||||
{/* Cover */}
|
||||
<img
|
||||
className={cx(
|
||||
'absolute top-0 left-0 w-full will-change-transform',
|
||||
css`
|
||||
animation: ${move} 38s infinite;
|
||||
animation-direction: alternate;
|
||||
`
|
||||
)}
|
||||
src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
|
||||
/>
|
||||
|
||||
{/* 每日推荐 */}
|
||||
<div className='absolute flex h-full w-1/2 items-center bg-gradient-to-r from-[#0000004d] to-transparent pl-8'>
|
||||
<div className='grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]'>
|
||||
{Array.from('每日推荐').map(word => (
|
||||
<div key={word}>{word}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play button */}
|
||||
<button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
||||
<Icon name='play-fill' className='ml-0.5 h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DailyTracksCard
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import Icon from './Icon'
|
||||
import ArtistInline from './ArtistsInline'
|
||||
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||
import useCoverColor from '../hooks/useCoverColor'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const MediaControls = () => {
|
||||
const classes =
|
||||
'btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-1.5 transition duration-200 after:bg-white/10'
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
|
||||
const playOrPause = () => {
|
||||
if (playerSnapshot.mode === PlayerMode.FM) {
|
||||
player.playOrPause()
|
||||
} else {
|
||||
player.playFM()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
key='dislike'
|
||||
className={classes}
|
||||
onClick={() => player.fmTrash()}
|
||||
>
|
||||
<Icon name='dislike' className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button key='play' className={classes} onClick={playOrPause}>
|
||||
<Icon
|
||||
className='h-6 w-6'
|
||||
name={
|
||||
playerSnapshot.mode === PlayerMode.FM &&
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
key='next'
|
||||
className={classes}
|
||||
onClick={() => player.nextTrack(true)}
|
||||
>
|
||||
<Icon name='next' className='h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FMCard = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { track } = useSnapshot(player)
|
||||
const coverUrl = useMemo(
|
||||
() => resizeImage(track?.al?.picUrl ?? '', 'md'),
|
||||
[track?.al?.picUrl]
|
||||
)
|
||||
|
||||
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative flex h-[198px] overflow-hidden rounded-2xl bg-gray-100 p-4 dark:bg-gray-800'
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
|
||||
}}
|
||||
>
|
||||
{coverUrl ? (
|
||||
<img
|
||||
onClick={() => track?.al?.id && navigate(`/album/${track.al.id}`)}
|
||||
className='rounded-lg shadow-2xl'
|
||||
src={coverUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className='aspect-square h-full rounded-lg bg-gray-200 dark:bg-white/5'></div>
|
||||
)}
|
||||
|
||||
<div className='ml-5 flex w-full flex-col justify-between text-white'>
|
||||
{/* Track info */}
|
||||
<div>
|
||||
{track ? (
|
||||
<div className='line-clamp-2 text-xl font-semibold'>
|
||||
{track?.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex'>
|
||||
<div className='bg-gray-200 text-xl text-transparent dark:bg-white/5'>
|
||||
PLACEHOLDER12345
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{track ? (
|
||||
<ArtistInline
|
||||
className='line-clamp-2 opacity-75'
|
||||
artists={track?.ar ?? []}
|
||||
/>
|
||||
) : (
|
||||
<div className='mt-1 flex'>
|
||||
<div className='bg-gray-200 text-transparent dark:bg-white/5'>
|
||||
PLACEHOLDER
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='-mb-1 flex items-center justify-between'>
|
||||
{track ? <MediaControls /> : <div className='h-9'></div>}
|
||||
|
||||
{/* FM logo */}
|
||||
<div
|
||||
className={cx(
|
||||
'right-4 bottom-5 flex opacity-20',
|
||||
track ? 'text-white ' : 'text-gray-700 dark:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon name='fm' className='mr-1 h-6 w-6' />
|
||||
<span className='font-semibold'>私人FM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FMCard
|
||||
|
|
@ -1 +1 @@
|
|||
export type IconNames = 'back' | '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' | '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'
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const IconButton = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean | undefined
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cx(
|
||||
className,
|
||||
'relative transform cursor-default p-1.5 transition duration-200',
|
||||
!disabled &&
|
||||
'btn-pressed-animation btn-hover-animation after:bg-black/[.06] dark:after:bg-white/10',
|
||||
disabled && 'opacity-30'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconButton
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import Main from '@/web/components/New/Main'
|
||||
import Player from '@/web/components/New/Player'
|
||||
import MenuBar from '@/web/components/New/MenuBar'
|
||||
import Topbar from '@/web/components/New/Topbar/TopbarDesktop'
|
||||
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 player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
|
@ -22,8 +22,8 @@ const Layout = () => {
|
|||
<div
|
||||
id='layout'
|
||||
className={cx(
|
||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black'
|
||||
// window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
|
||||
window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||
)}
|
||||
>
|
||||
<BlurBackground />
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import Player from '@/web/components/New/PlayerMobile'
|
||||
import Player from '@/web/components/PlayerMobile'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Router from '@/web/components/New/Router'
|
||||
import Router from '@/web/components/Router'
|
||||
import MenuBar from './MenuBar'
|
||||
import Topbar from './Topbar/TopbarMobile'
|
||||
import { isIOS, isIosPwa, isPWA, isSafari } from '@/web/utils/common'
|
||||
|
|
@ -9,6 +9,7 @@ import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
|
|||
import LoginWithQRCode from './LoginWithQRCode'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const OR = ({
|
||||
children,
|
||||
|
|
@ -17,11 +18,13 @@ const OR = ({
|
|||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mt-4 flex items-center'>
|
||||
<div className='h-px flex-grow bg-white/20'></div>
|
||||
<div className='mx-2 text-16 font-medium text-white'>or</div>
|
||||
<div className='mx-2 text-16 font-medium text-white'>{t`auth.or`}</div>
|
||||
<div className='h-px flex-grow bg-white/20'></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -38,6 +41,8 @@ const OR = ({
|
|||
}
|
||||
|
||||
const Login = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: user, isLoading: isLoadingUser } = useUser()
|
||||
const { loginType } = useSnapshot(persistedUiStates)
|
||||
const { showLoginPanel } = useSnapshot(uiStates)
|
||||
|
|
@ -132,8 +137,8 @@ const Login = () => {
|
|||
|
||||
<OR onClick={handleSwitchCard}>
|
||||
{cardType === 'qrCode'
|
||||
? 'Use Phone or Email'
|
||||
: 'Scan QR Code'}
|
||||
? t`auth.use-phone-or-email`
|
||||
: t`auth.scan-qr-code`}
|
||||
</OR>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
|
@ -17,8 +17,12 @@ import uiStates from '@/web/states/uiStates'
|
|||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { UserApiNames } from '@/shared/api/User'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LoginWithPhoneOrEmail = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const isZH = i18n.language.startsWith('zh')
|
||||
|
||||
const { loginPhoneCountryCode, loginType: persistedLoginType } =
|
||||
useSnapshot(persistedUiStates)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
|
|
@ -130,7 +134,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
return (
|
||||
<>
|
||||
<div className='text-center text-18 font-medium text-white/20'>
|
||||
Log in with{' '}
|
||||
{!isZH && 'Login with '}
|
||||
<span
|
||||
className={cx(
|
||||
'transition-colors duration-300',
|
||||
|
|
@ -142,9 +146,10 @@ const LoginWithPhoneOrEmail = () => {
|
|||
persistedUiStates.loginType = type
|
||||
}}
|
||||
>
|
||||
Phone
|
||||
</span>{' '}
|
||||
/{' '}
|
||||
{t`auth.phone`}
|
||||
{isZH && '登录'}
|
||||
</span>
|
||||
{' / '}
|
||||
<span
|
||||
className={cx(
|
||||
'transition-colors duration-300',
|
||||
|
|
@ -154,7 +159,8 @@ const LoginWithPhoneOrEmail = () => {
|
|||
if (loginType !== 'email') setLoginType('email')
|
||||
}}
|
||||
>
|
||||
Email
|
||||
{t`auth.email`}
|
||||
{isZH && '登录'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -167,7 +173,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
initial='hidden'
|
||||
animate='show'
|
||||
exit='hidden'
|
||||
className='flex items-center'
|
||||
className='flex items-center '
|
||||
>
|
||||
<input
|
||||
onChange={e => {
|
||||
|
|
@ -175,7 +181,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
persistedUiStates.loginPhoneCountryCode = e.target.value
|
||||
}}
|
||||
className={cx(
|
||||
'my-3.5 flex-shrink-0 bg-transparent',
|
||||
'my-3.5 flex-shrink-0 bg-transparent placeholder:text-white/30',
|
||||
css`
|
||||
width: 28px;
|
||||
`
|
||||
|
|
@ -186,8 +192,8 @@ const LoginWithPhoneOrEmail = () => {
|
|||
<div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div>
|
||||
<input
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
className='my-3.5 flex-grow appearance-none bg-transparent'
|
||||
placeholder='Phone'
|
||||
className='my-3.5 flex-grow appearance-none bg-transparent placeholder:text-white/30'
|
||||
placeholder={t`auth.phone`}
|
||||
type='tel'
|
||||
value={phone}
|
||||
/>
|
||||
|
|
@ -209,8 +215,8 @@ const LoginWithPhoneOrEmail = () => {
|
|||
>
|
||||
<input
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className='w-full flex-grow appearance-none bg-transparent'
|
||||
placeholder='Email'
|
||||
className='w-full flex-grow appearance-none bg-transparent placeholder:text-white/30'
|
||||
placeholder={t`auth.email`}
|
||||
type='email'
|
||||
value={email}
|
||||
/>
|
||||
|
|
@ -223,8 +229,8 @@ const LoginWithPhoneOrEmail = () => {
|
|||
<div className='mt-4 flex items-center rounded-12 bg-black/50 p-3 text-16 font-medium text-night-50'>
|
||||
<input
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className='w-full bg-transparent'
|
||||
placeholder='Password'
|
||||
className='w-full bg-transparent placeholder:text-white/30'
|
||||
placeholder={t`auth.password`}
|
||||
type='password'
|
||||
value={password}
|
||||
/>
|
||||
|
|
@ -237,7 +243,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
}
|
||||
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
|
||||
>
|
||||
LOG IN
|
||||
{t`auth.login`}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ import { setCookies } from '@/web/utils/cookie'
|
|||
import uiStates from '@/web/states/uiStates'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { UserApiNames } from '@/shared/api/User'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
||||
const [image, setImage] = useState<string>('')
|
||||
|
|
@ -37,6 +38,8 @@ const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
|||
}
|
||||
|
||||
const LoginWithQRCode = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
data: key,
|
||||
status: keyStatus,
|
||||
|
|
@ -101,7 +104,7 @@ const LoginWithQRCode = () => {
|
|||
return (
|
||||
<>
|
||||
<div className='text-center text-18 font-medium text-white/20'>
|
||||
Log in with NetEase QR
|
||||
{t`auth.login-with-netease-qr`}
|
||||
</div>
|
||||
|
||||
<div className='mt-4 rounded-24 bg-white p-2.5'>
|
||||
|
|
@ -1,24 +1,67 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Router from './Router'
|
||||
import Topbar from './Topbar'
|
||||
import { cx } from '@emotion/css'
|
||||
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { breakpoint as bp, ease } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { sleep } from '@/web/utils/common'
|
||||
import player from '@/web/states/player'
|
||||
|
||||
const Main = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
// Show/hide topbar background
|
||||
const observePoint = useRef<HTMLDivElement | null>(null)
|
||||
const { onScreen } = useIntersectionObserver(observePoint)
|
||||
useEffect(() => {
|
||||
uiStates.hideTopbarBackground = onScreen
|
||||
return () => {
|
||||
uiStates.hideTopbarBackground = false
|
||||
}
|
||||
}, [onScreen])
|
||||
|
||||
// Change width when player is minimized
|
||||
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
|
||||
const controlsMain = useAnimation()
|
||||
useEffect(() => {
|
||||
const animate = async () => {
|
||||
await controlsMain.start({ opacity: 0 })
|
||||
await sleep(100)
|
||||
setIsMaxWidth(minimizePlayer)
|
||||
await controlsMain.start({ opacity: 1 })
|
||||
}
|
||||
if (minimizePlayer !== isMaxWidth) animate()
|
||||
}, [controlsMain, isMaxWidth, minimizePlayer])
|
||||
|
||||
return (
|
||||
<div
|
||||
id='mainContainer'
|
||||
className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
|
||||
<motion.main
|
||||
id='main'
|
||||
animate={controlsMain}
|
||||
transition={{ ease, duration: 0.4 }}
|
||||
className={cx(
|
||||
'no-scrollbar z-10 h-screen overflow-y-auto',
|
||||
css`
|
||||
${bp.lg} {
|
||||
margin-left: 144px;
|
||||
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Topbar />
|
||||
<main
|
||||
id='main'
|
||||
className={cx(
|
||||
'mb-24 flex-grow px-8',
|
||||
window.env?.isEnableTitlebar && 'mt-8'
|
||||
)}
|
||||
<div ref={observePoint}></div>
|
||||
<div
|
||||
className={css`
|
||||
margin-top: 132px;
|
||||
`}
|
||||
>
|
||||
<Router />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import Icon from './Icon'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAnimation, motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useClickAway } from 'react-use'
|
||||
import Icon from '../../Icon'
|
||||
import useLockMainScroll from '@/web/hooks/useLockMainScroll'
|
||||
import { motion } from 'framer-motion'
|
||||
import useMeasure from 'react-use-measure'
|
||||
|
||||
interface ContextMenuItem {
|
||||
type: 'item' | 'submenu' | 'divider'
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
items?: ContextMenuItem[]
|
||||
}
|
||||
|
||||
const Divider = () => (
|
||||
<div className='my-2 h-px w-full px-3'>
|
||||
<div className='h-full w-full bg-white/5'></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Item = ({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: ContextMenuItem
|
||||
onClose: (e: MouseEvent) => void
|
||||
}) => {
|
||||
const [isHover, setIsHover] = useState(false)
|
||||
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const submenuRef = useRef<HTMLDivElement>(null)
|
||||
const getSubmenuPosition = () => {
|
||||
if (!itemRef.current || !submenuRef.current) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
const item = itemRef.current.getBoundingClientRect()
|
||||
const submenu = submenuRef.current.getBoundingClientRect()
|
||||
|
||||
const isRightSide = item.x + item.width + submenu.width <= window.innerWidth
|
||||
const x = isRightSide ? item.x + item.width : item.x - submenu.width
|
||||
|
||||
const isTopSide = item.y - 8 + submenu.height <= window.innerHeight
|
||||
const y = isTopSide ? item.y - 8 : item.y + item.height + 8 - submenu.height
|
||||
|
||||
const transformOriginTable = {
|
||||
top: {
|
||||
right: 'origin-top-left',
|
||||
left: 'origin-top-right',
|
||||
},
|
||||
bottom: {
|
||||
right: 'origin-bottom-left',
|
||||
left: 'origin-bottom-right',
|
||||
},
|
||||
} as const
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
transformOrigin:
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][
|
||||
isRightSide ? 'right' : 'left'
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'divider') return <Divider />
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
onClick={e => {
|
||||
if (!item.onClick) {
|
||||
return
|
||||
}
|
||||
const event = e as unknown as MouseEvent
|
||||
item.onClick?.(event)
|
||||
onClose(event)
|
||||
}}
|
||||
onMouseOver={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
className='relative px-2'
|
||||
>
|
||||
<div className='flex w-full items-center justify-between whitespace-nowrap rounded-md px-3 py-2 text-white/70 transition-colors duration-400 hover:bg-white/10 hover:text-white/80'>
|
||||
<div>{item.label}</div>
|
||||
{item.type === 'submenu' && (
|
||||
<Icon name='more' className='ml-8 h-4 w-4' />
|
||||
)}
|
||||
{item.type === 'submenu' && item.items && (
|
||||
<Menu
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
items={item.items}
|
||||
ref={submenuRef}
|
||||
onClose={onClose}
|
||||
forMeasure={true}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'submenu' && item.items && isHover && (
|
||||
<Menu
|
||||
position={getSubmenuPosition()}
|
||||
items={item.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Menu = forwardRef(
|
||||
(
|
||||
{
|
||||
position,
|
||||
items,
|
||||
onClose,
|
||||
forMeasure,
|
||||
}: {
|
||||
position: {
|
||||
x: number
|
||||
y: number
|
||||
transformOrigin?:
|
||||
| 'origin-top-left'
|
||||
| 'origin-top-right'
|
||||
| 'origin-bottom-left'
|
||||
| 'origin-bottom-right'
|
||||
}
|
||||
items: ContextMenuItem[]
|
||||
onClose: (e: MouseEvent) => void
|
||||
forMeasure?: boolean
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<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 }}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'fixed z-10 rounded-12 border border-day-500 bg-day-600 py-2 font-medium',
|
||||
position.transformOrigin || 'origin-top-left'
|
||||
)}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Item key={index} item={item} onClose={onClose} />
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Menu.displayName = 'Menu'
|
||||
|
||||
const BasicContextMenu = ({
|
||||
onClose,
|
||||
items,
|
||||
target,
|
||||
cursorPosition,
|
||||
options,
|
||||
}: {
|
||||
onClose: (e: MouseEvent) => void
|
||||
items: ContextMenuItem[]
|
||||
target: HTMLElement
|
||||
cursorPosition: { x: number; y: number }
|
||||
options?: {
|
||||
useCursorPosition?: boolean
|
||||
} | null
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [measureRef, menu] = useMeasure()
|
||||
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useClickAway(menuRef, onClose)
|
||||
useLockMainScroll(!!position)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (options?.useCursorPosition) {
|
||||
const leftX = cursorPosition.x
|
||||
const rightX = cursorPosition.x - menu.width
|
||||
const bottomY = cursorPosition.y
|
||||
const topY = cursorPosition.y - menu.height
|
||||
const position = {
|
||||
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
|
||||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
setPosition(position)
|
||||
} else {
|
||||
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 = {
|
||||
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
|
||||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
setPosition(position)
|
||||
}
|
||||
}, [target, menu, options?.useCursorPosition, cursorPosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
items={items}
|
||||
ref={measureRef}
|
||||
onClose={onClose}
|
||||
forMeasure={true}
|
||||
/>
|
||||
{position && (
|
||||
<Menu
|
||||
position={position}
|
||||
items={items}
|
||||
ref={menuRef}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasicContextMenu
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Image from './Image'
|
||||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
import { memo, useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import ArtistInline from './ArtistsInLine'
|
||||
|
||||
type ItemTitle = undefined | 'name'
|
||||
type ItemSubTitle = undefined | 'artist' | 'year'
|
||||
|
||||
const Album = ({
|
||||
album,
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
}: {
|
||||
album: Album
|
||||
itemTitle?: ItemTitle
|
||||
itemSubtitle?: ItemSubTitle
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const goTo = () => {
|
||||
navigate(`/album/${album.id}`)
|
||||
}
|
||||
const prefetch = () => {
|
||||
prefetchAlbum({ id: album.id })
|
||||
}
|
||||
|
||||
const title =
|
||||
itemTitle &&
|
||||
{
|
||||
name: album.name,
|
||||
}[itemTitle]
|
||||
|
||||
const subtitle =
|
||||
itemSubtitle &&
|
||||
{
|
||||
artist: (
|
||||
<ArtistInline
|
||||
artists={album.artists}
|
||||
hoverClassName='hover:text-white/50 transition-colors duration-400'
|
||||
/>
|
||||
),
|
||||
year: dayjs(album.publishTime || 0).year(),
|
||||
}[itemSubtitle]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Image
|
||||
onClick={goTo}
|
||||
key={album.id}
|
||||
src={resizeImage(album?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
{title && (
|
||||
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className='mt-1 text-14 font-medium text-neutral-700'>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Playlist = ({ playlist }: { playlist: Playlist }) => {
|
||||
const navigate = useNavigate()
|
||||
const goTo = useCallback(() => {
|
||||
navigate(`/playlist/${playlist.id}`)
|
||||
}, [navigate, playlist.id])
|
||||
const prefetch = useCallback(() => {
|
||||
prefetchPlaylist({ id: playlist.id })
|
||||
}, [playlist.id])
|
||||
|
||||
return (
|
||||
<Image
|
||||
onClick={goTo}
|
||||
key={playlist.id}
|
||||
src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CoverRow = ({
|
||||
albums,
|
||||
playlists,
|
||||
title,
|
||||
className,
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
}: {
|
||||
title?: string
|
||||
className?: string
|
||||
albums?: Album[]
|
||||
playlists?: Playlist[]
|
||||
itemTitle?: ItemTitle
|
||||
itemSubtitle?: ItemSubTitle
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
|
||||
{albums?.map(album => (
|
||||
<Album
|
||||
key={album.id}
|
||||
album={album}
|
||||
itemTitle={itemTitle}
|
||||
itemSubtitle={itemSubtitle}
|
||||
/>
|
||||
))}
|
||||
{playlists?.map(playlist => (
|
||||
<Playlist key={playlist.id} playlist={playlist} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const memoizedCoverRow = memo(CoverRow)
|
||||
memoizedCoverRow.displayName = 'CoverRow'
|
||||
export default memoizedCoverRow
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Router from './Router'
|
||||
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { breakpoint as bp, ease } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { sleep } from '@/web/utils/common'
|
||||
import player from '@/web/states/player'
|
||||
|
||||
const Main = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
// Show/hide topbar background
|
||||
const observePoint = useRef<HTMLDivElement | null>(null)
|
||||
const { onScreen } = useIntersectionObserver(observePoint)
|
||||
useEffect(() => {
|
||||
uiStates.hideTopbarBackground = onScreen
|
||||
return () => {
|
||||
uiStates.hideTopbarBackground = false
|
||||
}
|
||||
}, [onScreen])
|
||||
|
||||
// Change width when player is minimized
|
||||
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
|
||||
const controlsMain = useAnimation()
|
||||
useEffect(() => {
|
||||
const animate = async () => {
|
||||
await controlsMain.start({ opacity: 0 })
|
||||
await sleep(100)
|
||||
setIsMaxWidth(minimizePlayer)
|
||||
await controlsMain.start({ opacity: 1 })
|
||||
}
|
||||
if (minimizePlayer !== isMaxWidth) animate()
|
||||
}, [controlsMain, isMaxWidth, minimizePlayer])
|
||||
|
||||
return (
|
||||
<motion.main
|
||||
id='main'
|
||||
animate={controlsMain}
|
||||
transition={{ ease, duration: 0.4 }}
|
||||
className={cx(
|
||||
'no-scrollbar z-10 h-screen overflow-y-auto',
|
||||
css`
|
||||
${bp.lg} {
|
||||
margin-left: 144px;
|
||||
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div ref={observePoint}></div>
|
||||
<div
|
||||
className={css`
|
||||
margin-top: 132px;
|
||||
`}
|
||||
>
|
||||
<Router />
|
||||
</div>
|
||||
</motion.main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Main
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import NowPlaying from './NowPlaying'
|
||||
import PlayingNext from './PlayingNext'
|
||||
import { AnimatePresence, motion, MotionConfig } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const Player = () => {
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<MotionConfig transition={{ duration: 0.6 }}>
|
||||
<div
|
||||
className={cx(
|
||||
'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden',
|
||||
css`
|
||||
width: 318px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!minimizePlayer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<PlayingNext />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<NowPlaying />
|
||||
</div>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import Search from '@/web/pages/Search'
|
||||
import Settings from '@/web/pages/Settings'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import React, { ReactNode, Suspense } from 'react'
|
||||
|
||||
const My = React.lazy(() => import('@/web/pages/New/My'))
|
||||
const Discover = React.lazy(() => import('@/web/pages/New/Discover'))
|
||||
const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
|
||||
const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
||||
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
|
||||
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
|
||||
const MV = React.lazy(() => import('@/web/pages/New/MV'))
|
||||
const Lyrics = React.lazy(() => import('@/web/pages/New/Lyrics'))
|
||||
|
||||
const lazy = (component: ReactNode) => {
|
||||
return <Suspense>{component}</Suspense>
|
||||
}
|
||||
|
||||
const Router = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path='/' element={lazy(<My />)} />
|
||||
<Route path='/discover' element={lazy(<Discover />)} />
|
||||
<Route path='/browse' element={lazy(<Browse />)} />
|
||||
<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 />)}>
|
||||
<Route path=':type' element={lazy(<Search />)} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default Router
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const Slider = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
onlyCallOnChangeAfterDragEnded = false,
|
||||
orientation = 'horizontal',
|
||||
alwaysShowThumb = false,
|
||||
}: {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
onChange: (value: number) => void
|
||||
onlyCallOnChangeAfterDragEnded?: boolean
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
alwaysShowTrack?: boolean
|
||||
alwaysShowThumb?: boolean
|
||||
}) => {
|
||||
const sliderRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [draggingValue, setDraggingValue] = useState(value)
|
||||
const memoedValue = useMemo(
|
||||
() =>
|
||||
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
|
||||
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the value of the slider based on the position of the pointer
|
||||
*/
|
||||
const getNewValue = useCallback(
|
||||
(pointer: { x: number; y: number }) => {
|
||||
if (!sliderRef?.current) return 0
|
||||
const slider = sliderRef.current.getBoundingClientRect()
|
||||
const newValue =
|
||||
orientation === 'horizontal'
|
||||
? ((pointer.x - slider.x) / slider.width) * max
|
||||
: ((slider.height - (pointer.y - slider.y)) / slider.height) * max
|
||||
if (newValue < min) return min
|
||||
if (newValue > max) return max
|
||||
return newValue
|
||||
},
|
||||
[sliderRef, max, min, orientation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle slider click event
|
||||
*/
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
|
||||
[getNewValue, onChange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle pointer down event
|
||||
*/
|
||||
const handlePointerDown = () => {
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pointer move events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
||||
if (!isDragging) return
|
||||
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
|
||||
onlyCallOnChangeAfterDragEnded
|
||||
? setDraggingValue(newValue)
|
||||
: onChange(newValue)
|
||||
}
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
onChange,
|
||||
setDraggingValue,
|
||||
onlyCallOnChangeAfterDragEnded,
|
||||
getNewValue,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handle pointer up events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
if (onlyCallOnChangeAfterDragEnded) {
|
||||
onChange(draggingValue)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
onlyCallOnChangeAfterDragEnded,
|
||||
draggingValue,
|
||||
onChange,
|
||||
])
|
||||
|
||||
/**
|
||||
* Track and thumb styles
|
||||
*/
|
||||
const usedTrackStyle = useMemo(() => {
|
||||
const percentage = `${(memoedValue / max) * 100}%`
|
||||
return orientation === 'horizontal'
|
||||
? { width: percentage }
|
||||
: { height: percentage }
|
||||
}, [max, memoedValue, orientation])
|
||||
const thumbStyle = useMemo(() => {
|
||||
const percentage = `${(memoedValue / max) * 100}%`
|
||||
return orientation === 'horizontal'
|
||||
? { left: percentage }
|
||||
: { bottom: percentage }
|
||||
}, [max, memoedValue, orientation])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'group relative flex items-center',
|
||||
orientation === 'horizontal' && 'h-2',
|
||||
orientation === 'vertical' && 'h-full w-2 flex-col'
|
||||
)}
|
||||
ref={sliderRef}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Track */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10',
|
||||
orientation === 'horizontal' && 'h-[3px] w-full',
|
||||
orientation === 'vertical' && 'h-full w-[3px]'
|
||||
)}
|
||||
>
|
||||
{/* Passed track */}
|
||||
<div
|
||||
className={cx(
|
||||
'bg-black dark:bg-white',
|
||||
orientation === 'horizontal' && 'h-full rounded-r-full',
|
||||
orientation === 'vertical' && 'bottom-0 w-full rounded-t-full'
|
||||
)}
|
||||
style={usedTrackStyle}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
|
||||
isDragging || alwaysShowThumb
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100',
|
||||
orientation === 'horizontal' && '-translate-x-1',
|
||||
orientation === 'vertical' && 'translate-y-1'
|
||||
)}
|
||||
style={thumbStyle}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPointerDown={handlePointerDown}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Slider
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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 { css, cx } from '@emotion/css'
|
||||
|
||||
const Controls = () => {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
||||
setIsMaximized(value)
|
||||
})
|
||||
|
||||
const minimize = () => {
|
||||
window.ipcRenderer?.send(IpcChannels.Minimize)
|
||||
}
|
||||
|
||||
const maxRestore = () => {
|
||||
window.ipcRenderer?.send(IpcChannels.MaximizeOrUnmaximize)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
window.ipcRenderer?.send(IpcChannels.Close)
|
||||
}
|
||||
|
||||
const classNames = cx(
|
||||
'flex items-center justify-center text-white/80 hover:text-white hover:bg-white/20 transition duration-400',
|
||||
css`
|
||||
height: 28px;
|
||||
width: 48px;
|
||||
border-radius: 4px;
|
||||
`
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='app-region-no-drag flex h-full items-center'>
|
||||
<button onClick={minimize} className={classNames}>
|
||||
<Icon className='h-3 w-3' name='windows-minimize' />
|
||||
</button>
|
||||
<button onClick={maxRestore} className={classNames}>
|
||||
<Icon
|
||||
className='h-3 w-3'
|
||||
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className={cx(
|
||||
classNames,
|
||||
css`
|
||||
margin-right: 5px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Icon className='h-3 w-3' name='windows-close' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleBar = () => {
|
||||
return (
|
||||
<div className='app-region-drag fixed z-30'>
|
||||
<div className='flex h-9 w-screen items-center justify-between'>
|
||||
<div></div>
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleBar
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../../Icon'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const Avatar = ({ className }: { className?: string }) => {
|
||||
const { data: user } = useUser()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
onClick={() => (uiStates.showLoginPanel = true)}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => (uiStates.showLoginPanel = true)}
|
||||
className={cx(
|
||||
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
>
|
||||
<Icon name='user' className='h-7 w-7 text-neutral-500' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
|
|
@ -4,7 +4,7 @@ import { ease } from '@/web/utils/const'
|
|||
import { cx, css } from '@emotion/css'
|
||||
import { MotionConfig, motion } from 'framer-motion'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Icon from '../../Icon'
|
||||
import Icon from '../Icon'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
|
|
@ -8,31 +8,28 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Cover = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const [cover, setCover] = useState('')
|
||||
const { track } = useSnapshot(player)
|
||||
const [cover, setCover] = useState(track?.al.picUrl)
|
||||
const animationStartTime = useRef(0)
|
||||
const controls = useAnimation()
|
||||
const duration = 150 // ms
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const resizedCover = resizeImage(
|
||||
playerSnapshot.track?.al.picUrl || '',
|
||||
'lg'
|
||||
)
|
||||
const resizedCover = resizeImage(track?.al.picUrl || '', 'lg')
|
||||
const animate = async () => {
|
||||
animationStartTime.current = Date.now()
|
||||
await controls.start({ opacity: 0 })
|
||||
setCover(resizedCover)
|
||||
}
|
||||
animate()
|
||||
}, [controls, playerSnapshot.track?.al.picUrl])
|
||||
}, [controls, track?.al.picUrl])
|
||||
|
||||
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||
useEffect(() => {
|
||||
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
|
||||
const realCover = resizeImage(track?.al.picUrl ?? '', 'lg')
|
||||
if (cover !== realCover) setCover(realCover)
|
||||
}, [cover, playerSnapshot.track?.al.picUrl])
|
||||
}, [cover, track?.al.picUrl])
|
||||
|
||||
const onLoad = () => {
|
||||
const passedTime = Date.now() - animationStartTime.current
|
||||
|
|
@ -52,7 +49,7 @@ const Cover = () => {
|
|||
src={cover}
|
||||
onLoad={onLoad}
|
||||
onClick={() => {
|
||||
const id = playerSnapshot.track?.al.id
|
||||
const id = track?.al.id
|
||||
if (id) navigate(`/album/${id}`)
|
||||
}}
|
||||
/>
|
||||
|
|
@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'
|
|||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import ArtistInline from '@/web/components/New/ArtistsInLine'
|
||||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import Controls from './Controls'
|
||||
import Cover from './Cover'
|
||||
|
|
@ -1,238 +1,39 @@
|
|||
import ArtistInline from './ArtistsInline'
|
||||
import IconButton from './IconButton'
|
||||
import Slider from './Slider'
|
||||
import Icon from './Icon'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
|
||||
import { cx } from '@emotion/css'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const PlayingTrack = () => {
|
||||
const navigate = useNavigate()
|
||||
const { track, trackListSource, mode } = useSnapshot(player)
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
const hasTrackListSource = mode !== PlayerMode.FM && trackListSource?.type
|
||||
|
||||
const toAlbum = () => {
|
||||
const id = track?.al?.id
|
||||
if (id) navigate(`/album/${id}`)
|
||||
}
|
||||
|
||||
const toTrackListSource = () => {
|
||||
if (hasTrackListSource)
|
||||
navigate(`/${trackListSource.type}/${trackListSource.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{track && (
|
||||
<div className='flex items-center gap-3'>
|
||||
{track?.al?.picUrl && (
|
||||
<img
|
||||
onClick={toAlbum}
|
||||
className='aspect-square h-full rounded-md shadow-md'
|
||||
src={resizeImage(track.al.picUrl, 'xs')}
|
||||
/>
|
||||
)}
|
||||
{!track?.al?.picUrl && (
|
||||
<div
|
||||
onClick={toAlbum}
|
||||
className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm'
|
||||
>
|
||||
<Icon className='h-6 w-6 text-gray-300' name='music-note' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col justify-center leading-tight'>
|
||||
<div
|
||||
onClick={toTrackListSource}
|
||||
className={cx(
|
||||
'line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 dark:text-white dark:decoration-gray-300',
|
||||
hasTrackListSource && 'hover:underline'
|
||||
)}
|
||||
>
|
||||
{track?.name}
|
||||
</div>
|
||||
<div className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
|
||||
<ArtistInline artists={track?.ar ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
>
|
||||
<Icon
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
name={
|
||||
track?.id && userLikedSongs?.ids?.includes(track.id)
|
||||
? 'heart'
|
||||
: 'heart-outline'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{!track && <div></div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MediaControls = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center gap-2 text-black dark:text-white'>
|
||||
{mode === PlayerMode.TrackList && (
|
||||
<IconButton
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
>
|
||||
<Icon className='h-6 w-6' name='previous' />
|
||||
</IconButton>
|
||||
)}
|
||||
{mode === PlayerMode.FM && (
|
||||
<IconButton onClick={() => player.fmTrash()}>
|
||||
<Icon className='h-6 w-6' name='dislike' />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => track && player.playOrPause()}
|
||||
disabled={!track}
|
||||
className='after:rounded-xl'
|
||||
>
|
||||
<Icon
|
||||
className='h-7 w-7'
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||
<Icon className='h-6 w-6' name='next' />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Others = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
const switchRepeatMode = () => {
|
||||
if (playerSnapshot.repeatMode === PlayerRepeatMode.Off) {
|
||||
player.repeatMode = PlayerRepeatMode.On
|
||||
} else if (playerSnapshot.repeatMode === PlayerRepeatMode.On) {
|
||||
player.repeatMode = PlayerRepeatMode.One
|
||||
} else {
|
||||
player.repeatMode = PlayerRepeatMode.Off
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-2 pr-2 text-black dark:text-white'>
|
||||
<IconButton
|
||||
onClick={() => toast('Work in progress')}
|
||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||
>
|
||||
<Icon className='h-6 w-6' name='playlist' />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={switchRepeatMode}
|
||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||
>
|
||||
<Icon
|
||||
className={cx(
|
||||
'h-6 w-6',
|
||||
playerSnapshot.repeatMode !== PlayerRepeatMode.Off &&
|
||||
'text-brand-500'
|
||||
)}
|
||||
name={
|
||||
playerSnapshot.repeatMode === PlayerRepeatMode.One
|
||||
? 'repeat-1'
|
||||
: 'repeat'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => toast('施工中...')}
|
||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||
>
|
||||
<Icon className='h-6 w-6' name='shuffle' />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => toast('施工中...')}>
|
||||
<Icon className='h-6 w-6' name='volume' />
|
||||
</IconButton>
|
||||
|
||||
{/* Lyric */}
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
<Icon className='h-6 w-6' name='lyrics' />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Progress = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const progress = useMemo(
|
||||
() => playerSnapshot.progress,
|
||||
[playerSnapshot.progress]
|
||||
)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div className='absolute w-screen'>
|
||||
{track && (
|
||||
<Slider
|
||||
min={0}
|
||||
max={(track.dt ?? 0) / 1000}
|
||||
value={
|
||||
state === PlayerState.Playing || state === PlayerState.Paused
|
||||
? progress
|
||||
: 0
|
||||
}
|
||||
onChange={value => {
|
||||
player.progress = value
|
||||
}}
|
||||
onlyCallOnChangeAfterDragEnded={true}
|
||||
/>
|
||||
)}
|
||||
{!track && (
|
||||
<div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import NowPlaying from './NowPlaying'
|
||||
import PlayingNext from './PlayingNext'
|
||||
import { AnimatePresence, motion, MotionConfig } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const Player = () => {
|
||||
return (
|
||||
<div className='fixed bottom-0 left-0 right-0 grid h-16 grid-cols-3 grid-rows-1 bg-white bg-opacity-[.86] py-2.5 px-5 backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'>
|
||||
<Progress />
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
|
||||
<PlayingTrack />
|
||||
<MediaControls />
|
||||
<Others />
|
||||
</div>
|
||||
return (
|
||||
<MotionConfig transition={{ duration: 0.6 }}>
|
||||
<div
|
||||
className={cx(
|
||||
'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden',
|
||||
css`
|
||||
width: 318px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!minimizePlayer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<PlayingNext />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<NowPlaying />
|
||||
</div>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import player from '@/web/states/player'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import Image from '@/web/components/Image'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import useCoverColor from '@/web/hooks/useCoverColor'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
|
|
@ -12,8 +12,10 @@ import useIsMobile from '@/web/hooks/useIsMobile'
|
|||
import { Virtuoso } from 'react-virtuoso'
|
||||
import toast from 'react-hot-toast'
|
||||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
|
|
@ -22,7 +24,7 @@ const Header = () => {
|
|||
>
|
||||
<div className='flex'>
|
||||
<div className='mr-2 h-4 w-1 bg-brand-700'></div>
|
||||
PLAYING NEXT
|
||||
{t`player.queue`}
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div onClick={() => toast('开发中')} className='mr-2'>
|
||||
|
|
@ -1,63 +1,42 @@
|
|||
import type { RouteObject } from 'react-router-dom'
|
||||
import { useRoutes } from 'react-router-dom'
|
||||
import Album from '@/web/pages/Album'
|
||||
import Home from '@/web/pages/Home'
|
||||
import Login from '@/web/pages/Login'
|
||||
import Playlist from '@/web/pages/Playlist'
|
||||
import Artist from '@/web/pages/Artist'
|
||||
import Search from '@/web/pages/Search'
|
||||
import Library from '@/web/pages/Library'
|
||||
import Podcast from '@/web/pages/Podcast'
|
||||
import Settings from '@/web/pages/Settings'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import React, { ReactNode, Suspense } from 'react'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: '/podcast',
|
||||
element: <Podcast />,
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
element: <Library />,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/search/:keywords',
|
||||
element: <Search />,
|
||||
children: [
|
||||
{
|
||||
path: ':type',
|
||||
element: <Search />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/playlist/:id',
|
||||
element: <Playlist />,
|
||||
},
|
||||
{
|
||||
path: '/album/:id',
|
||||
element: <Album />,
|
||||
},
|
||||
{
|
||||
path: '/artist/:id',
|
||||
element: <Artist />,
|
||||
},
|
||||
]
|
||||
const My = React.lazy(() => import('@/web/pages/My'))
|
||||
const Discover = React.lazy(() => import('@/web/pages/Discover'))
|
||||
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'))
|
||||
|
||||
const lazy = (component: ReactNode) => {
|
||||
return <Suspense>{component}</Suspense>
|
||||
}
|
||||
|
||||
const Router = () => {
|
||||
const element = useRoutes(routes)
|
||||
return <>{element}</>
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path='/' element={lazy(<My />)} />
|
||||
<Route path='/discover' element={lazy(<Discover />)} />
|
||||
<Route path='/browse' element={lazy(<Browse />)} />
|
||||
<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 />)}>
|
||||
<Route path=':type' element={lazy(<Search />)} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default Router
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import Icon from './Icon'
|
||||
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import { scrollToTop } from '@/web/utils/common'
|
||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
import player from '@/web/states/player'
|
||||
import { Mode, TrackListSourceType } from '@/web/utils/player'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const primaryTabs = [
|
||||
{
|
||||
name: '主页',
|
||||
icon: 'home',
|
||||
route: '/',
|
||||
},
|
||||
{
|
||||
name: '播客',
|
||||
icon: 'podcast',
|
||||
route: '/podcast',
|
||||
},
|
||||
{
|
||||
name: '音乐库',
|
||||
icon: 'music-library',
|
||||
route: '/library',
|
||||
},
|
||||
] as const
|
||||
|
||||
const PrimaryTabs = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={cx(window.env?.isMac && 'app-region-drag', 'h-14')}></div>
|
||||
{primaryTabs.map(tab => (
|
||||
<NavLink
|
||||
onClick={() => scrollToTop()}
|
||||
key={tab.route}
|
||||
to={tab.route}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20',
|
||||
!isActive && 'text-gray-700 dark:text-white',
|
||||
isActive && 'text-brand-500 '
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className='mr-3 h-6 w-6' name={tab.icon} />
|
||||
<span className='font-semibold'>{tab.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<div className='mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-10'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Playlists = () => {
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const currentPlaylistID = useMemo(
|
||||
() => playerSnapshot.trackListSource?.id,
|
||||
[playerSnapshot.trackListSource]
|
||||
)
|
||||
const playlistMode = useMemo(
|
||||
() => playerSnapshot.trackListSource?.type,
|
||||
[playerSnapshot.trackListSource]
|
||||
)
|
||||
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
|
||||
|
||||
return (
|
||||
<div className='mb-16 overflow-auto pb-2'>
|
||||
{playlists?.playlist?.map(playlist => (
|
||||
<NavLink
|
||||
onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
|
||||
key={playlist.id}
|
||||
onClick={() => scrollToTop()}
|
||||
to={`/playlist/${playlist.id}`}
|
||||
className={({ isActive }: { isActive: boolean }) =>
|
||||
cx(
|
||||
'btn-hover-animation line-clamp-1 my-px mx-3 flex cursor-default items-center justify-between rounded-lg px-3 py-[0.38rem] text-sm text-black opacity-70 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/20',
|
||||
isActive && 'after:scale-100 after:opacity-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className='line-clamp-1'>{playlist.name}</span>
|
||||
{playlistMode === TrackListSourceType.Playlist &&
|
||||
mode === Mode.TrackList &&
|
||||
currentPlaylistID === playlist.id && (
|
||||
<Icon className='h-5 w-5' name='volume-half' />
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<div
|
||||
id='sidebar'
|
||||
className='grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:border-gray-500/10 dark:bg-gray-900 dark:bg-opacity-80'
|
||||
>
|
||||
<PrimaryTabs />
|
||||
<Playlists />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const Skeleton = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'relative animate-pulse bg-gray-100 text-transparent dark:bg-gray-800',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Skeleton
|
||||
|
|
@ -8,7 +8,6 @@ const Slider = ({
|
|||
onChange,
|
||||
onlyCallOnChangeAfterDragEnded = false,
|
||||
orientation = 'horizontal',
|
||||
alwaysShowTrack = false,
|
||||
alwaysShowThumb = false,
|
||||
}: {
|
||||
value: number
|
||||
|
|
@ -95,7 +94,6 @@ const Slider = ({
|
|||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
if (onlyCallOnChangeAfterDragEnded) {
|
||||
console.log('draggingValue', draggingValue)
|
||||
onChange(draggingValue)
|
||||
}
|
||||
}
|
||||
|
|
@ -141,41 +139,36 @@ const Slider = ({
|
|||
{/* Track */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute bg-gray-500 bg-opacity-10',
|
||||
orientation === 'horizontal' && 'h-[2px] w-full',
|
||||
orientation === 'vertical' && 'h-full w-[2px]'
|
||||
'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10',
|
||||
orientation === 'horizontal' && 'h-[3px] w-full',
|
||||
orientation === 'vertical' && 'h-full w-[3px]'
|
||||
)}
|
||||
></div>
|
||||
|
||||
{/* Passed track */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute group-hover:bg-brand-500',
|
||||
isDragging || alwaysShowTrack
|
||||
? 'bg-brand-500'
|
||||
: 'bg-gray-300 dark:bg-gray-500',
|
||||
orientation === 'horizontal' && 'h-[2px]',
|
||||
orientation === 'vertical' && 'bottom-0 w-[2px]'
|
||||
)}
|
||||
style={usedTrackStyle}
|
||||
></div>
|
||||
>
|
||||
{/* Passed track */}
|
||||
<div
|
||||
className={cx(
|
||||
'bg-black dark:bg-white',
|
||||
orientation === 'horizontal' && 'h-full rounded-r-full',
|
||||
orientation === 'vertical' && 'bottom-0 w-full rounded-t-full'
|
||||
)}
|
||||
style={usedTrackStyle}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity',
|
||||
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
|
||||
isDragging || alwaysShowThumb
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100',
|
||||
orientation === 'horizontal' && '-translate-x-2.5',
|
||||
orientation === 'vertical' && 'translate-y-2.5'
|
||||
orientation === 'horizontal' && '-translate-x-1',
|
||||
orientation === 'vertical' && 'translate-y-1'
|
||||
)}
|
||||
style={thumbStyle}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
<div className='absolute h-2 w-2 rounded-full bg-brand-500'></div>
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { IpcChannels } from '@/shared/IpcChannels'
|
|||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { cx } from '@emotion/css'
|
||||
import { css, cx } from '@emotion/css'
|
||||
|
||||
const Controls = () => {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
|
@ -25,18 +25,21 @@ const Controls = () => {
|
|||
window.ipcRenderer?.send(IpcChannels.Close)
|
||||
}
|
||||
|
||||
const classNames = cx(
|
||||
'flex items-center justify-center text-white/80 hover:text-white hover:bg-white/20 transition duration-400',
|
||||
css`
|
||||
height: 28px;
|
||||
width: 48px;
|
||||
border-radius: 4px;
|
||||
`
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='app-region-no-drag flex h-full'>
|
||||
<button
|
||||
onClick={minimize}
|
||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
||||
>
|
||||
<div className='app-region-no-drag flex h-full items-center'>
|
||||
<button onClick={minimize} className={classNames}>
|
||||
<Icon className='h-3 w-3' name='windows-minimize' />
|
||||
</button>
|
||||
<button
|
||||
onClick={maxRestore}
|
||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
||||
>
|
||||
<button onClick={maxRestore} className={classNames}>
|
||||
<Icon
|
||||
className='h-3 w-3'
|
||||
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
||||
|
|
@ -44,7 +47,12 @@ const Controls = () => {
|
|||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'
|
||||
className={cx(
|
||||
classNames,
|
||||
css`
|
||||
margin-right: 5px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Icon className='h-3 w-3' name='windows-close' />
|
||||
</button>
|
||||
|
|
@ -52,46 +60,13 @@ const Controls = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const Title = ({ className }: { className?: string }) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div className={cx('text-sm text-gray-500', className)}>
|
||||
{track?.name && (
|
||||
<>
|
||||
<span>{track.name}</span>
|
||||
<span className='mx-2'>-</span>
|
||||
</>
|
||||
)}
|
||||
<span>YesPlayMusic</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Win = () => {
|
||||
return (
|
||||
<div className='flex h-8 w-screen items-center justify-between bg-gray-50'>
|
||||
<Title className='ml-3' />
|
||||
<Controls />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Linux = () => {
|
||||
return (
|
||||
<div className='flex h-8 w-screen items-center justify-between bg-gray-50'>
|
||||
<div></div>
|
||||
<Title className='text-center' />
|
||||
<Controls />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleBar = () => {
|
||||
return (
|
||||
<div className='app-region-drag fixed z-30'>
|
||||
{window.env?.isWindows ? <Win /> : <Linux />}
|
||||
<div className='flex h-9 w-screen items-center justify-between'>
|
||||
<div></div>
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import useScroll from '@/web/hooks/useScroll'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Avatar from './Avatar'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const NavigationButtons = () => {
|
||||
const navigate = useNavigate()
|
||||
enum ACTION {
|
||||
Back = 'back',
|
||||
Forward = 'forward',
|
||||
}
|
||||
const handleNavigate = (action: ACTION) => {
|
||||
if (action === ACTION.Back) navigate(-1)
|
||||
if (action === ACTION.Forward) navigate(1)
|
||||
}
|
||||
return (
|
||||
<div className='flex gap-1'>
|
||||
{[ACTION.Back, ACTION.Forward].map(action => (
|
||||
<div
|
||||
onClick={() => handleNavigate(action)}
|
||||
key={action}
|
||||
className='app-region-no-drag btn-hover-animation rounded-lg p-2 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
|
||||
>
|
||||
<Icon className='h-5 w-5' name={action} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchBox = () => {
|
||||
const { type } = useParams()
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const toSearch = (e: React.KeyboardEvent) => {
|
||||
if (!keywords) return
|
||||
if (e.key === 'Enter') {
|
||||
navigate(`/search/${keywords}${type ? `/${type}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 pl-2.5 pr-2 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5'>
|
||||
<Icon
|
||||
className='mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200'
|
||||
name='search'
|
||||
/>
|
||||
<input
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onKeyDown={toSearch}
|
||||
type='text'
|
||||
className='flex-grow bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
|
||||
placeholder='搜索'
|
||||
/>
|
||||
<div
|
||||
onClick={() => setKeywords('')}
|
||||
className={cx(
|
||||
'cursor-default rounded-full p-1 text-gray-600 transition hover:bg-gray-400/20 dark:text-white/50 dark:hover:bg-white/20',
|
||||
!keywords && 'hidden'
|
||||
)}
|
||||
>
|
||||
<Icon className='h-4 w-4' name='x' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate('/settings')}
|
||||
className='app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
|
||||
>
|
||||
<Icon className='h-[1.125rem] w-[1.125rem]' name='settings' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Topbar = () => {
|
||||
/**
|
||||
* Show topbar background when scroll down
|
||||
*/
|
||||
const [mainContainer, setMainContainer] = useState<HTMLElement | null>(null)
|
||||
const scroll = useScroll(mainContainer, { throttle: 100 })
|
||||
|
||||
useEffect(() => {
|
||||
setMainContainer(document.getElementById('mainContainer'))
|
||||
}, [setMainContainer])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'sticky z-30 flex h-16 min-h-[4rem] w-full cursor-default items-center justify-between px-8 transition duration-300',
|
||||
window.env?.isMac && 'app-region-drag',
|
||||
window.env?.isEnableTitlebar ? 'top-8' : 'top-0',
|
||||
!scroll.arrivedState.top &&
|
||||
'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'
|
||||
)}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<NavigationButtons />
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Settings />
|
||||
<Avatar />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Topbar
|
||||
104
packages/web/components/Topbar/Avatar.tsx
Normal file
104
packages/web/components/Topbar/Avatar.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import useUser, { useMutationLogout } from '@/web/api/hooks/useUser'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { useRef, useState } from 'react'
|
||||
import BasicContextMenu from '../ContextMenus/BasicContextMenu'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Avatar = ({ className }: { className?: string }) => {
|
||||
const { data: user } = useUser()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
: ''
|
||||
|
||||
const avatarRef = useRef<HTMLImageElement>(null)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const logout = useMutationLogout()
|
||||
|
||||
return (
|
||||
<>
|
||||
{avatarUrl ? (
|
||||
<div>
|
||||
<img
|
||||
ref={avatarRef}
|
||||
src={avatarUrl}
|
||||
onClick={event => {
|
||||
if (event.target === avatarRef.current && showMenu) {
|
||||
setShowMenu(false)
|
||||
return
|
||||
}
|
||||
setShowMenu(true)
|
||||
}}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{avatarRef.current && showMenu && (
|
||||
<BasicContextMenu
|
||||
classNames={css`
|
||||
min-width: 162px;
|
||||
`}
|
||||
onClose={event => {
|
||||
if (event.target === avatarRef.current) return
|
||||
setShowMenu(false)
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Profile',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: t`settings.settings`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: t`auth.logout`,
|
||||
onClick: () => {
|
||||
logout.mutateAsync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
target={avatarRef.current}
|
||||
cursorPosition={{
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => (uiStates.showLoginPanel = true)}
|
||||
className={cx(
|
||||
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
>
|
||||
<Icon name='user' className='h-7 w-7 text-neutral-500' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
|
|
@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'
|
|||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import Icon from '../../Icon'
|
||||
import Icon from '../Icon'
|
||||
|
||||
const buttonClassNames =
|
||||
'app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl transition-colors duration-400 hover:bg-white/20 hover:text-white/60'
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../../Icon'
|
||||
import Icon from '../Icon'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
|
|
@ -8,8 +8,15 @@ import { fetchSearchSuggestions } from '@/web/api/search'
|
|||
import { SearchApiNames } from '@/shared/api/Search'
|
||||
import { useClickAway, useDebounce } from 'react-use'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SearchSuggestions = ({ searchText }: { searchText: string }) => {
|
||||
const SearchSuggestions = ({
|
||||
searchText,
|
||||
isInputFocused,
|
||||
}: {
|
||||
searchText: string
|
||||
isInputFocused: boolean
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useState('')
|
||||
|
|
@ -64,7 +71,8 @@ const SearchSuggestions = ({ searchText }: { searchText: string }) => {
|
|||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{searchText.length > 0 &&
|
||||
{isInputFocused &&
|
||||
searchText.length > 0 &&
|
||||
suggestionsArray.length > 0 &&
|
||||
!clickedSearchText &&
|
||||
searchText === debouncedSearchText && (
|
||||
|
|
@ -118,6 +126,8 @@ const SearchSuggestions = ({ searchText }: { searchText: string }) => {
|
|||
const SearchBox = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
|
|
@ -134,7 +144,7 @@ const SearchBox = () => {
|
|||
>
|
||||
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||
<input
|
||||
placeholder='Search'
|
||||
placeholder={t`search.search`}
|
||||
className={cx(
|
||||
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',
|
||||
css`
|
||||
|
|
@ -143,6 +153,8 @@ const SearchBox = () => {
|
|||
}
|
||||
`
|
||||
)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
|
|
@ -153,7 +165,7 @@ const SearchBox = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<SearchSuggestions searchText={searchText} />
|
||||
<SearchSuggestions searchText={searchText} isInputFocused={isFocused} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ const Background = () => {
|
|||
transition={{ ease }}
|
||||
className={cx(
|
||||
'absolute inset-0 z-0 bg-contain bg-repeat-x',
|
||||
window.env?.isElectron && 'rounded-t-24',
|
||||
css`
|
||||
background-image: url(${topbarBackground});
|
||||
`
|
||||
|
|
@ -48,8 +49,7 @@ const TopbarDesktop = () => {
|
|||
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between bg-contain pt-11 pb-10 pr-6',
|
||||
css`
|
||||
padding-left: 144px;
|
||||
`,
|
||||
window.env?.isElectron && 'rounded-t-24'
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Background */}
|
||||
|
|
@ -16,6 +16,7 @@ const Actions = ({ track }: { track: Track }) => {
|
|||
const { data: likedTracksIDs } = useUserLikedTracksIDs()
|
||||
const likeATrack = useMutationLikeATrack()
|
||||
|
||||
// 当右键菜单开启时,让按钮组在鼠标移走了后也能继续显示
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const menu = useSnapshot(contextMenus)
|
||||
useEffect(() => {
|
||||
|
|
@ -51,7 +52,7 @@ const Actions = ({ track }: { track: Track }) => {
|
|||
{/* Add to playlist */}
|
||||
<button
|
||||
className={cx(
|
||||
'opacity-0 transition-opacity group-hover:opacity-100',
|
||||
'transition-opacity group-hover:opacity-100',
|
||||
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
|
|
@ -163,7 +164,7 @@ const TrackList = ({
|
|||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right lg:block'>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
{formatDuration(track.dt, 'en-US', 'hh:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import Icon from '../../Icon'
|
||||
import Icon from '../Icon'
|
||||
|
||||
const Actions = ({
|
||||
onPlay,
|
||||
|
|
@ -15,6 +16,8 @@ const Actions = ({
|
|||
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'>
|
||||
|
|
@ -62,7 +65,7 @@ const Actions = ({
|
|||
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
|
||||
)}
|
||||
>
|
||||
Play
|
||||
{t`player.play`}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import Image from '@/web/components/Image'
|
||||
import { memo, useEffect } from 'react'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from './VideoCover'
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
import { memo, useCallback, useMemo } from 'react'
|
||||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const PlayOrPauseButtonInTrack = memo(
|
||||
({ isHighlight, trackID }: { isHighlight: boolean; trackID: number }) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const isPlaying = useMemo(
|
||||
() => playerSnapshot.state === PlayerState.Playing,
|
||||
[playerSnapshot.state]
|
||||
)
|
||||
|
||||
const onClick = () => {
|
||||
isHighlight ? player.playOrPause() : player.playTrack(trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'btn-pressed-animation -ml-1 self-center',
|
||||
!isHighlight && 'hidden group-hover:block'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className='h-5 w-5 text-brand-500'
|
||||
name={isPlaying && isHighlight ? 'pause' : 'play'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
PlayOrPauseButtonInTrack.displayName = 'PlayOrPauseButtonInTrack'
|
||||
|
||||
const Track = memo(
|
||||
({
|
||||
track,
|
||||
isLiked = false,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
onClick,
|
||||
}: {
|
||||
track: Track
|
||||
isLiked?: boolean
|
||||
isSkeleton?: boolean
|
||||
isHighlight?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
const subtitle = useMemo(
|
||||
() => track.tns?.at(0) ?? track.alia?.at(0),
|
||||
[track.alia, track.tns]
|
||||
)
|
||||
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={e => onClick(e, track.id)}
|
||||
className={cx(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl',
|
||||
'grid-cols-12 py-2.5 px-4',
|
||||
!isSkeleton && {
|
||||
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10':
|
||||
!isHighlight,
|
||||
'bg-brand-50 dark:bg-gray-800': isHighlight,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{/* Track name and number */}
|
||||
<div className='col-span-6 grid grid-cols-[2rem_auto] pr-8'>
|
||||
{/* Track number */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='h-6.5 w-6.5 -translate-x-1'></Skeleton>
|
||||
) : (
|
||||
!isHighlight && (
|
||||
<div
|
||||
className={cx(
|
||||
'self-center group-hover:hidden',
|
||||
isHighlight && 'text-brand-500',
|
||||
!isHighlight && 'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{track.no}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Play or pause button for playing track */}
|
||||
{!isSkeleton && (
|
||||
<PlayOrPauseButtonInTrack
|
||||
isHighlight={isHighlight}
|
||||
trackID={track.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='text-lg'>
|
||||
PLACEHOLDER123456789012345
|
||||
</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 break-all text-lg font-semibold',
|
||||
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center'>
|
||||
{track.name}
|
||||
{track.mark === 1318912 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className='ml-1.5 mt-[2px] h-4 w-4 text-gray-300 dark:text-gray-500'
|
||||
/>
|
||||
)}
|
||||
{subtitle && (
|
||||
<span
|
||||
className={cx(
|
||||
'ml-1',
|
||||
isHighlight ? 'text-brand-500/[.8]' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
({subtitle})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artists */}
|
||||
<div className='col-span-4 flex items-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton>PLACEHOLDER1234</Skeleton>
|
||||
) : (
|
||||
<ArtistInline
|
||||
className={
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
artists={track.ar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions & Track duration */}
|
||||
<div className='col-span-2 flex items-center justify-end'>
|
||||
{/* Like button */}
|
||||
{!isSkeleton && (
|
||||
<button
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
className={cx(
|
||||
'mr-5 cursor-default transition duration-300 hover:scale-[1.2]',
|
||||
isLiked
|
||||
? 'text-brand-500 opacity-100'
|
||||
: 'text-gray-600 opacity-0 dark:text-gray-400',
|
||||
!isSkeleton && 'group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Track duration */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton>0:00</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'min-w-[2.5rem] text-right',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Track.displayName = 'Track'
|
||||
|
||||
const TracksAlbum = ({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
}) => {
|
||||
// Fake data when isSkeleton is true
|
||||
const skeletonTracks: Track[] = new Array(1).fill({})
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onTrackDoubleClick?.(trackID)
|
||||
},
|
||||
[onTrackDoubleClick]
|
||||
)
|
||||
|
||||
const { track } = useSnapshot(player)
|
||||
|
||||
return (
|
||||
<div className='grid w-full'>
|
||||
{/* Tracks table header */}
|
||||
<div className='mx-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'>
|
||||
<div className='col-span-6 grid grid-cols-[2rem_auto]'>
|
||||
<div>#</div>
|
||||
<div>标题</div>
|
||||
</div>
|
||||
<div className='col-span-4'>艺人</div>
|
||||
<div className='col-span-2 justify-self-end'>时长</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks */}
|
||||
{isSkeleton
|
||||
? skeletonTracks.map((track, index) => (
|
||||
<Track
|
||||
key={index}
|
||||
track={track}
|
||||
onClick={() => null}
|
||||
isSkeleton={true}
|
||||
/>
|
||||
))
|
||||
: tracks.map(track => (
|
||||
<Track
|
||||
key={track.id}
|
||||
track={track}
|
||||
onClick={handleClick}
|
||||
isLiked={userLikedSongs?.ids?.includes(track.id) ?? false}
|
||||
isSkeleton={false}
|
||||
isHighlight={track.id === track?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TracksAlbum
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import Icon from './Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Track = ({
|
||||
track,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
onClick,
|
||||
}: {
|
||||
track: Track
|
||||
isSkeleton?: boolean
|
||||
isHighlight?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={e => onClick(e, track.id)}
|
||||
className={cx(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ',
|
||||
'grid-cols-1 py-1.5 px-2',
|
||||
!isSkeleton && {
|
||||
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10':
|
||||
!isHighlight,
|
||||
'bg-brand-50 dark:bg-gray-800': isHighlight,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className='grid grid-cols-[3rem_auto] items-center'>
|
||||
{/* Cover */}
|
||||
<div>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='mr-4 h-9 w-9 rounded-md border border-gray-100' />
|
||||
) : (
|
||||
<img
|
||||
src={resizeImage(track.al.picUrl, 'xs')}
|
||||
className='box-content h-9 w-9 rounded-md border border-black border-opacity-[.03]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track name & Artists */}
|
||||
<div className='flex flex-col justify-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='text-base '>PLACEHOLDER12345</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 break-all text-base font-semibold ',
|
||||
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
|
||||
)}
|
||||
>
|
||||
{track.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
|
||||
) : (
|
||||
<span className='flex items-center'>
|
||||
{track.mark === 1318912 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className={cx(
|
||||
'mr-1 h-3 w-3',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-300 dark:text-gray-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<ArtistInline
|
||||
artists={track.ar}
|
||||
disableLink={true}
|
||||
className={
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TrackGrid = ({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
cols = 2,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
cols?: number
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onTrackDoubleClick?.(trackID)
|
||||
}
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const playingTrack = useMemo(
|
||||
() => playerSnapshot.track,
|
||||
[playerSnapshot.track]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='grid gap-x-2'
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{tracks.map((track, index) => (
|
||||
<Track
|
||||
onClick={handleClick}
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSkeleton={isSkeleton}
|
||||
isHighlight={track.id === playingTrack?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackGrid
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import { memo, useMemo } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import player from '@/web/states/player'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Track = memo(
|
||||
({
|
||||
track,
|
||||
isLiked = false,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
onClick,
|
||||
}: {
|
||||
track: Track
|
||||
isLiked?: boolean
|
||||
isSkeleton?: boolean
|
||||
isHighlight?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
const subtitle = useMemo(
|
||||
() => track.tns?.at(0) ?? track.alia?.at(0),
|
||||
[track.alia, track.tns]
|
||||
)
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={e => onClick(e, track.id)}
|
||||
className={cx(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ',
|
||||
'grid-cols-12 p-2 pr-4',
|
||||
!isSkeleton &&
|
||||
!isHighlight &&
|
||||
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10',
|
||||
!isSkeleton && isHighlight && 'bg-brand-50 dark:bg-gray-800'
|
||||
)}
|
||||
>
|
||||
{/* Track info */}
|
||||
<div className='col-span-6 grid grid-cols-[4.2rem_auto] pr-8'>
|
||||
{/* Cover */}
|
||||
<div>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='mr-4 h-12 w-12 rounded-md border border-gray-100 dark:border-gray-800' />
|
||||
) : (
|
||||
<img
|
||||
src={resizeImage(track.al.picUrl, 'xs')}
|
||||
className='box-content h-12 w-12 rounded-md border border-black border-opacity-[.03]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track name & Artists */}
|
||||
<div className='flex flex-col justify-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='text-lg'>PLACEHOLDER12345</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 break-all text-lg font-semibold',
|
||||
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
|
||||
)}
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
{subtitle && (
|
||||
<span
|
||||
title={subtitle}
|
||||
className={cx(
|
||||
'ml-1',
|
||||
isHighlight ? 'text-brand-500/[.8]' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
({subtitle})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'text-sm',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
|
||||
) : (
|
||||
<span className='inline-flex items-center'>
|
||||
{track.mark === 1318912 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className='mr-1 h-3.5 w-3.5 text-gray-300 dark:text-gray-500'
|
||||
/>
|
||||
)}
|
||||
<ArtistInline artists={track.ar} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Album name */}
|
||||
<div className='col-span-4 flex items-center text-gray-600 dark:text-gray-400'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton>PLACEHOLDER1234567890</Skeleton>
|
||||
) : (
|
||||
<>
|
||||
<NavLink
|
||||
to={`/album/${track.al.id}`}
|
||||
className={cx(
|
||||
'hover:underline',
|
||||
isHighlight && 'text-brand-500'
|
||||
)}
|
||||
>
|
||||
{track.al.name}
|
||||
</NavLink>
|
||||
<span className='flex-grow'></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions & Track duration */}
|
||||
<div className='col-span-2 flex items-center justify-end'>
|
||||
{/* Like button */}
|
||||
{!isSkeleton && (
|
||||
<button
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
className={cx(
|
||||
'mr-5 cursor-default transition duration-300 hover:scale-[1.2]',
|
||||
!isLiked && 'text-gray-600 opacity-0 dark:text-gray-400',
|
||||
isLiked && 'text-brand-500 opacity-100',
|
||||
!isSkeleton && 'group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Track duration */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton>0:00</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'min-w-[2.5rem] text-right',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Track.displayName = 'Track'
|
||||
|
||||
const TracksList = memo(
|
||||
({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
}) => {
|
||||
// Fake data when isLoading is true
|
||||
const skeletonTracks: Track[] = new Array(12).fill({})
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onTrackDoubleClick?.(trackID)
|
||||
}
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const playingTrack = useMemo(
|
||||
() => playerSnapshot.track,
|
||||
[playerSnapshot.track]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tracks table header */}
|
||||
<div className='ml-2 mr-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'>
|
||||
<div className='col-span-6 grid grid-cols-[4.2rem_auto]'>
|
||||
<div></div>
|
||||
<div>标题</div>
|
||||
</div>
|
||||
<div className='col-span-4'>专辑</div>
|
||||
<div className='col-span-2 justify-self-end'>时长</div>
|
||||
</div>
|
||||
|
||||
<div className='grid w-full'>
|
||||
{/* Tracks */}
|
||||
{isSkeleton
|
||||
? skeletonTracks.map((track, index) => (
|
||||
<Track
|
||||
key={index}
|
||||
track={track}
|
||||
onClick={() => null}
|
||||
isSkeleton={true}
|
||||
/>
|
||||
))
|
||||
: tracks.map(track => (
|
||||
<Track
|
||||
onClick={handleClick}
|
||||
key={track.id}
|
||||
track={track}
|
||||
isLiked={userLikedSongs?.ids?.includes(track.id) ?? false}
|
||||
isSkeleton={false}
|
||||
isHighlight={track.id === playingTrack?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
TracksList.displayName = 'TracksList'
|
||||
|
||||
export default TracksList
|
||||
65
packages/web/components/VideoCover.tsx
Normal file
65
packages/web/components/VideoCover.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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 = ({
|
||||
source,
|
||||
onPlay,
|
||||
}: {
|
||||
source?: string
|
||||
onPlay?: () => void
|
||||
}) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>(new Hls())
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported()) {
|
||||
const video = document.querySelector('#video-cover') as HTMLVideoElement
|
||||
hls.current.loadSource(source)
|
||||
hls.current.attachMedia(video)
|
||||
}
|
||||
}, [source])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className='absolute inset-0'
|
||||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
src={source}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onPlay={() => onPlay?.()}
|
||||
></video>
|
||||
) : (
|
||||
<div className='aspect-square'>
|
||||
<video
|
||||
id='video-cover'
|
||||
ref={ref}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
onPlay={() => onPlay?.()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoCover
|
||||
Loading…
Add table
Add a link
Reference in a new issue