feat: updates

This commit is contained in:
qier222 2022-10-28 20:29:04 +08:00
parent a1b0bcf4d3
commit 884f3df41a
No known key found for this signature in database
198 changed files with 4572 additions and 5336 deletions

View file

@ -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 ? ', ' : ''}&nbsp;
</span>
))}
</div>
)
}
export default ArtistInline

View file

@ -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

View file

@ -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>
)
}

View file

@ -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

View file

@ -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`)
},
},
],

View file

@ -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`)
},
},
],

View 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

View 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

View 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}
/>
</>
)
}

View file

@ -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`)
},
},
],

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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 />

View file

@ -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'

View file

@ -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>

View file

@ -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>
</>
)

View file

@ -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'>

View file

@ -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>
)
}

View file

@ -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 = [
{

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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}`)
}}
/>

View file

@ -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'

View file

@ -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>
)
}

View file

@ -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'

View file

@ -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'>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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

View 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

View file

@ -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'

View file

@ -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>
)
}

View file

@ -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 */}

View file

@ -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>
))}

View file

@ -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>
)

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View 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