feat: updates

This commit is contained in:
qier222 2022-08-22 16:51:23 +08:00
parent ebebf2a733
commit a1b0bcf4d3
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
68 changed files with 4776 additions and 5559 deletions

View file

@ -1,52 +0,0 @@
export type SvgName =
| 'back'
| 'dislike'
| 'dj'
| 'email'
| 'explicit'
| 'eye-off'
| 'eye'
| 'fm'
| 'forward'
| 'heart-outline'
| 'heart'
| 'home'
| 'lock'
| 'lyrics'
| 'more'
| 'music-library'
| 'music-note'
| 'next'
| 'pause'
| 'phone'
| 'play-fill'
| 'play'
| 'playlist'
| 'podcast'
| 'previous'
| 'qrcode'
| 'repeat'
| 'repeat-1'
| 'search'
| 'settings'
| 'shuffle'
| 'user'
| 'volume-half'
| 'volume-mute'
| 'volume'
| 'windows-close'
| 'windows-minimize'
| 'windows-maximize'
| 'windows-un-maximize'
| 'x'
const Icon = ({ name, className }: { name: SvgName; className?: string }) => {
const symbolId = `#icon-${name}`
return (
<svg aria-hidden='true' className={className}>
<use href={symbolId} fill='currentColor' />
</svg>
)
}
export default Icon

View file

@ -0,0 +1,12 @@
import { IconNames } from './iconNamesType'
const Icon = ({ name, className }: { name: IconNames; className?: string }) => {
const symbolId = `#icon-${name}`
return (
<svg aria-hidden='true' className={className}>
<use href={symbolId} fill='currentColor' />
</svg>
)
}
export default Icon

View file

@ -0,0 +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'

View file

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

View file

@ -1,5 +1,6 @@
import { useNavigate } from 'react-router-dom'
import { cx } from '@emotion/css'
import { openContextMenu } from '@/web/states/contextMenus'
const ArtistInline = ({
artists,
@ -37,6 +38,16 @@ const ArtistInline = ({
<span key={`${artist.id}-${artist.name}`}>
<span
onClick={() => handleClick(artist.id)}
onContextMenu={event => {
openContextMenu({
event,
type: 'artist',
dataSourceID: artist.id,
options: {
useCursorPosition: true,
},
})
}}
className={cx(!!artist.id && !disableLink && hoverClassName)}
>
{artist.name}

View file

@ -0,0 +1,107 @@
import useUserAlbums, {
useMutationLikeAAlbum,
} from '@/web/api/hooks/useUserAlbums'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import player from '@/web/states/player'
import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const AlbumContextMenu = () => {
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const likeAAlbum = useMutationLikeAAlbum()
const [, copyToClipboard] = useCopyToClipboard()
const { data: likedAlbums } = useUserAlbums()
const addToLibraryLabel = useMemo(() => {
return likedAlbums?.data?.find(a => a.id === Number(dataSourceID))
? 'Remove from Library'
: 'Add to Library'
}, [dataSourceID, likedAlbums?.data])
return (
<AnimatePresence>
{cursorPosition && type === 'album' && dataSourceID && target && (
<BasicContextMenu
target={target}
cursorPosition={cursorPosition}
onClose={closeContextMenu}
options={options}
items={[
{
type: 'item',
label: 'Add to Queue',
onClick: () => {
toast('开发中')
// toast.success('Added to Queue', { duration: 100000000 })
// toast.error('Not implemented yet', { duration: 100000000 })
// toast.loading('Loading...')
// toast('ADADADAD', { duration: 100000000 })
},
},
{
type: 'divider',
},
{
type: 'item',
label: addToLibraryLabel,
onClick: () => {
if (type !== 'album' || !dataSourceID) {
return
}
likeAAlbum.mutateAsync(Number(dataSourceID)).then(res => {
if (res?.code === 200) {
toast.success('Added to Library')
}
})
},
},
{
type: 'item',
label: 'Add to playlist',
onClick: () => {
toast('开发中')
},
},
{
type: 'divider',
},
{
type: 'submenu',
label: 'Share',
items: [
{
type: 'item',
label: 'Copy Netease Link',
onClick: () => {
copyToClipboard(
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success('Copied')
},
},
{
type: 'item',
label: 'Copy YPM Link',
onClick: () => {
copyToClipboard(
`${window.location.origin}/album/${dataSourceID}`
)
toast.success('Copied')
},
},
],
},
]}
/>
)}
</AnimatePresence>
)
}
export default AlbumContextMenu

View file

@ -0,0 +1,86 @@
import useUserArtists, {
useMutationLikeAArtist,
} from '@/web/api/hooks/useUserArtists'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion'
import { useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const ArtistContextMenu = () => {
const { cursorPosition, type, dataSourceID, target, options } =
useSnapshot(contextMenus)
const likeAArtist = useMutationLikeAArtist()
const [, copyToClipboard] = useCopyToClipboard()
const { data: likedArtists } = useUserArtists()
const followLabel = useMemo(() => {
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
? 'Follow'
: 'Unfollow'
}, [dataSourceID, likedArtists?.data])
return (
<AnimatePresence>
{cursorPosition && type === 'artist' && dataSourceID && target && (
<BasicContextMenu
target={target}
cursorPosition={cursorPosition}
onClose={closeContextMenu}
options={options}
items={[
{
type: 'item',
label: followLabel,
onClick: () => {
if (type !== 'artist' || !dataSourceID) {
return
}
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
if (res?.code === 200) {
toast.success(
followLabel === 'Unfollow' ? 'Followed' : 'Unfollowed'
)
}
})
},
},
{
type: 'divider',
},
{
type: 'submenu',
label: 'Share',
items: [
{
type: 'item',
label: 'Copy Netease Link',
onClick: () => {
copyToClipboard(
`https://music.163.com/#/artist?id=${dataSourceID}`
)
toast.success('Copied')
},
},
{
type: 'item',
label: 'Copy YPM Link',
onClick: () => {
copyToClipboard(
`${window.location.origin}/artist/${dataSourceID}`
)
toast.success('Copied')
},
},
],
},
]}
/>
)}
</AnimatePresence>
)
}
export default ArtistContextMenu

View file

@ -0,0 +1,238 @@
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

@ -0,0 +1,15 @@
import AlbumContextMenu from './AlbumContextMenu'
import ArtistContextMenu from './ArtistContextMenu'
import TrackContextMenu from './TrackContextMenu'
const ContextMenus = () => {
return (
<>
<TrackContextMenu />
<AlbumContextMenu />
<ArtistContextMenu />
</>
)
}
export default ContextMenus

View file

@ -0,0 +1,99 @@
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import { AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
import { useCopyToClipboard } from 'react-use'
import { useSnapshot } from 'valtio'
import BasicContextMenu from './BasicContextMenu'
const TrackContextMenu = () => {
const navigate = useNavigate()
const [, copyToClipboard] = useCopyToClipboard()
const { type, dataSourceID, target, cursorPosition, options } =
useSnapshot(contextMenus)
return (
<AnimatePresence>
{type === 'track' && dataSourceID && target && cursorPosition && (
<BasicContextMenu
target={target}
cursorPosition={cursorPosition}
onClose={closeContextMenu}
options={options}
items={[
{
type: 'item',
label: 'Add to Queue',
onClick: () => {
toast('开发中')
},
},
{
type: 'divider',
},
{
type: 'item',
label: 'Go to artist',
onClick: () => {
toast('开发中')
},
},
{
type: 'item',
label: 'Go to album',
onClick: () => {
toast('开发中')
},
},
{
type: 'divider',
},
{
type: 'item',
label: 'Add to Liked Tracks',
onClick: () => {
toast('开发中')
},
},
{
type: 'item',
label: 'Add to playlist',
onClick: () => {
toast('开发中')
},
},
{
type: 'submenu',
label: 'Share',
items: [
{
type: 'item',
label: 'Copy Netease Link',
onClick: () => {
copyToClipboard(
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success('Copied')
},
},
{
type: 'item',
label: 'Copy YPM Link',
onClick: () => {
copyToClipboard(
`${window.location.origin}/album/${dataSourceID}`
)
toast.success('Copied')
},
},
],
},
]}
/>
)}
</AnimatePresence>
)
}
export default TrackContextMenu

View file

@ -6,8 +6,20 @@ 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'
const Album = ({ album }: { album: Album }) => {
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}`)
@ -16,6 +28,24 @@ const Album = ({ album }: { album: Album }) => {
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
@ -25,12 +55,16 @@ const Album = ({ album }: { album: Album }) => {
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{album.name}
</div>
<div className='mt-1 text-14 font-medium text-neutral-700'>
{dayjs(album.publishTime || 0).year()}
</div>
{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>
)
}
@ -60,11 +94,15 @@ const CoverRow = ({
playlists,
title,
className,
itemTitle,
itemSubtitle,
}: {
title?: string
className?: string
albums?: Album[]
playlists?: Playlist[]
itemTitle?: ItemTitle
itemSubtitle?: ItemSubTitle
}) => {
return (
<div className={className}>
@ -78,7 +116,12 @@ const CoverRow = ({
{/* 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} />
<Album
key={album.id}
album={album}
itemTitle={itemTitle}
itemSubtitle={itemSubtitle}
/>
))}
{playlists?.map(playlist => (
<Playlist key={playlist.id} playlist={playlist} />

View file

@ -82,7 +82,7 @@ const CoverRow = ({
'',
'md'
)}
className='rounded-24'
className='aspect-square w-full rounded-24'
onMouseOver={() => prefetch(item.id)}
/>
))}

View file

@ -11,6 +11,7 @@ import BlurBackground from './BlurBackground'
import Airplay from './Airplay'
import TitleBar from './TitleBar'
import uiStates from '@/web/states/uiStates'
import ContextMenus from './ContextMenus/ContextMenus'
const Layout = () => {
const playerSnapshot = useSnapshot(player)
@ -21,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 />
@ -38,7 +39,9 @@ const Layout = () => {
</div>
)}
{window.env?.isWindows && <TitleBar />}
{(window.env?.isWindows || window.env?.isLinux) && <TitleBar />}
<ContextMenus />
{/* {window.env?.isElectron && <Airplay />} */}
</div>

View file

@ -56,7 +56,7 @@ const NowPlaying = () => {
)}
</AnimatePresence>
{/* Controls (for Animation) */}
{/* Controls */}
<Controls />
</>
)

View file

@ -10,6 +10,8 @@ import { useWindowSize } from 'react-use'
import { playerWidth, topbarHeight } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile'
import { Virtuoso } from 'react-virtuoso'
import toast from 'react-hot-toast'
import { openContextMenu } from '@/web/states/contextMenus'
const Header = () => {
return (
@ -23,10 +25,10 @@ const Header = () => {
PLAYING NEXT
</div>
<div className='flex'>
<div className='mr-2'>
<div onClick={() => toast('开发中')} className='mr-2'>
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
</div>
<div>
<div onClick={() => toast('开发中')}>
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
</div>
</div>
@ -51,6 +53,17 @@ const Track = ({
onClick={e => {
if (e.detail === 2 && track?.id) player.playTrack(track.id)
}}
onContextMenu={event => {
track?.id &&
openContextMenu({
event,
type: 'track',
dataSourceID: track.id,
options: {
useCursorPosition: true,
},
})
}}
>
{/* Cover */}
<img
@ -71,7 +84,7 @@ const Track = ({
>
{track?.name}
</div>
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'>
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-white/25'>
{track?.ar.map(a => a.name).join(', ')}
</div>
</div>

View file

@ -11,6 +11,7 @@ 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>
@ -30,6 +31,7 @@ const Router = () => {
<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>

View file

@ -51,7 +51,6 @@ const Controls = () => {
classNames,
css`
margin-right: 5px;
border-radius: 4px 20px 4px 4px;
`
)}
>

View file

@ -0,0 +1,37 @@
import { css, cx } from '@emotion/css'
import { Toaster as ReactHotToaster } from 'react-hot-toast'
const Toaster = () => {
return (
<ReactHotToaster
position='top-center'
containerStyle={{ top: '80px' }}
toastOptions={{
className: cx(
css`
border-radius: 99999px !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #000 !important;
box-shadow: none !important;
line-height: unset !important;
user-select: none !important;
font-size: 12px !important;
padding: 10px 16px !important;
font-weight: 500 !important;
& div[role='status'] {
margin: 0 8px !important;
}
`
),
success: {
iconTheme: {
primary: 'green',
secondary: '#fff',
},
},
}}
/>
)
}
export default Toaster

View file

@ -2,42 +2,158 @@ import { css, cx } from '@emotion/css'
import Icon from '../../Icon'
import { breakpoint as bp } from '@/web/utils/const'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useMemo, useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchSearchSuggestions } from '@/web/api/search'
import { SearchApiNames } from '@/shared/api/Search'
import { useClickAway, useDebounce } from 'react-use'
import { AnimatePresence, motion } from 'framer-motion'
const SearchSuggestions = ({ searchText }: { searchText: string }) => {
const navigate = useNavigate()
const [debouncedSearchText, setDebouncedSearchText] = useState('')
useDebounce(() => setDebouncedSearchText(searchText), 500, [searchText])
const { data: suggestions } = useQuery(
[SearchApiNames.FetchSearchSuggestions, debouncedSearchText],
() => fetchSearchSuggestions({ keywords: debouncedSearchText }),
{
enabled: debouncedSearchText.length > 0,
keepPreviousData: true,
}
)
const suggestionsArray = useMemo(() => {
if (suggestions?.code !== 200) {
return []
}
const suggestionsArray: {
name: string
type: 'album' | 'artist' | 'track'
id: number
}[] = []
const rawItems = [
...(suggestions.result.artists || []),
...(suggestions.result.albums || []),
...(suggestions.result.songs || []),
]
rawItems.forEach(item => {
const type = (item as Artist).albumSize
? 'artist'
: (item as Track).duration
? 'track'
: 'album'
suggestionsArray.push({
name: item.name,
type,
id: item.id,
})
})
return suggestionsArray
}, [suggestions])
const [clickedSearchText, setClickedSearchText] = useState('')
useEffect(() => {
if (clickedSearchText !== searchText) {
setClickedSearchText('')
}
}, [clickedSearchText, searchText])
const panelRef = useRef<HTMLDivElement>(null)
useClickAway(panelRef, () => setClickedSearchText(searchText))
return (
<AnimatePresence>
{searchText.length > 0 &&
suggestionsArray.length > 0 &&
!clickedSearchText &&
searchText === debouncedSearchText && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, scaleY: 0.96 }}
animate={{
opacity: 1,
scaleY: 1,
transition: {
duration: 0.1,
},
}}
exit={{
opacity: 0,
scaleY: 0.96,
transition: {
duration: 0.2,
},
}}
className={cx(
'absolute mt-2 origin-top rounded-24 border border-white/10 bg-white/10 p-2 backdrop-blur-3xl',
css`
width: 286px;
`
)}
>
{suggestionsArray?.map(suggestion => (
<div
key={`${suggestion.type}-${suggestion.id}`}
className='line-clamp-1 rounded-12 p-2 text-white hover:bg-white/10'
onClick={() => {
setClickedSearchText(searchText)
if (['album', 'artist'].includes(suggestion.type)) {
navigate(`${suggestion.type}/${suggestion.id}`)
}
if (suggestion.type === 'track') {
// TODO: play song
}
}}
>
{suggestion.type} -{suggestion.name}
</div>
))}
</motion.div>
)}
</AnimatePresence>
)
}
const SearchBox = () => {
const navigate = useNavigate()
const [searchText, setSearchText] = useState('')
return (
<div
className={cx(
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
css`
${bp.lg} {
min-width: 284px;
}
`
)}
>
<Icon name='search' className='mr-2.5 h-7 w-7' />
<input
placeholder='Search'
<div className='relative'>
{/* Input */}
<div
className={cx(
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
css`
@media (max-width: 420px) {
width: 142px;
${bp.lg} {
min-width: 284px;
}
`
)}
value={searchText}
onChange={e => setSearchText(e.target.value)}
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
navigate(`/search/${searchText}`)
}}
/>
>
<Icon name='search' className='mr-2.5 h-7 w-7' />
<input
placeholder='Search'
className={cx(
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',
css`
@media (max-width: 420px) {
width: 142px;
}
`
)}
value={searchText}
onChange={e => setSearchText(e.target.value)}
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
navigate(`/search/${searchText}`)
}}
/>
</div>
<SearchSuggestions searchText={searchText} />
</div>
)
}

View file

@ -1,15 +1,17 @@
import Icon from '@/web/components/Icon'
import { cx } from '@emotion/css'
import toast from 'react-hot-toast'
const SettingsButton = ({ className }: { className?: string }) => {
return (
<button
onClick={() => toast('开发中')}
className={cx(
'app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600',
'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300',
className
)}
>
<Icon name='placeholder' className='h-7 w-7 text-neutral-500' />
<Icon name='settings' className='h-5 w-5 ' />
</button>
)
}

View file

@ -45,7 +45,7 @@ const TopbarDesktop = () => {
return (
<div
className={cx(
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between overflow-hidden bg-contain pt-11 pb-10 pr-6',
'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;
`,

View file

@ -1,25 +1,127 @@
import { formatDuration } from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import { useMemo } from 'react'
import { cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Wave from './Wave'
import Icon from '@/web/components/Icon'
import useIsMobile from '@/web/hooks/useIsMobile'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import toast from 'react-hot-toast'
import { memo, useEffect, useState } from 'react'
import contextMenus, { openContextMenu } from '@/web/states/contextMenus'
const Actions = ({ track }: { track: Track }) => {
const { data: likedTracksIDs } = useUserLikedTracksIDs()
const likeATrack = useMutationLikeATrack()
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const menu = useSnapshot(contextMenus)
useEffect(() => {
if (menu.type !== 'track' || !menu.dataSourceID) {
setIsContextMenuOpen(false)
}
}, [menu.dataSourceID, menu.type])
return (
<div className='mr-5 lg:flex' onClick={e => e.stopPropagation()}>
{/* Context menu */}
<div
className={cx(
'transition-opacity group-hover:opacity-100',
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
)}
>
<button
onClick={event => {
setIsContextMenuOpen(true)
openContextMenu({
event,
type: 'track',
dataSourceID: track.id,
})
}}
className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/40 transition-colors duration-400 hover:bg-white/30 hover:text-white/70'
>
<Icon name='more' className='pointer-events-none h-5 w-5' />
</button>
</div>
{/* Add to playlist */}
<button
className={cx(
'opacity-0 transition-opacity group-hover:opacity-100',
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
)}
>
<div
onClick={() => toast('开发中...')}
className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/40 transition-colors duration-400 hover:bg-white/30 hover:text-white/70'
>
<Icon name='plus' className='h-5 w-5' />
</div>
</button>
{/* Like */}
<button
className={cx(
'rounded-full ',
likedTracksIDs?.ids.includes(track.id)
? 'group-hover:bg-white/10'
: cx(
'bg-white/10 transition-opacity group-hover:opacity-100',
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
)
)}
>
<div
onClick={() => likeATrack.mutateAsync(track.id)}
className='flex h-10 w-10 items-center justify-center rounded-full text-white/40 transition duration-400 hover:bg-white/20 hover:text-white/70'
>
<Icon
name={
likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'
}
className='h-5 w-5'
/>
</div>
</button>
</div>
)
}
const TrackList = ({
tracks,
onPlay,
className,
isLoading,
placeholderRows = 12,
}: {
tracks?: Track[]
onPlay: (id: number) => void
className?: string
isLoading?: boolean
placeholderRows?: number
}) => {
const { track: playingTrack, state } = useSnapshot(player)
const isMobile = useIsMobile()
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
if (isLoading) return
if (e.type === 'contextmenu') {
e.preventDefault()
openContextMenu({
event: e,
type: 'track',
dataSourceID: trackID,
options: {
useCursorPosition: true,
},
})
return
}
if (isMobile) {
onPlay?.(trackID)
} else {
@ -27,15 +129,14 @@ const TrackList = ({
}
}
const playing = state === 'playing'
return (
<div className={className}>
{tracks?.map(track => (
{(isLoading ? [] : tracks)?.map(track => (
<div
key={track.id}
onClick={e => handleClick(e, track.id)}
className='group relative flex items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
onContextMenu={e => handleClick(e, track.id)}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
>
{/* Track no */}
<div className='mr-3 lg:mr-6'>
@ -47,23 +148,13 @@ const TrackList = ({
<span className='line-clamp-1 mr-4'>{track.name}</span>
{playingTrack?.id === track.id && (
<span className='mr-4 inline-block'>
<Wave playing={playing} />
<Wave playing={state === 'playing'} />
</span>
)}
</div>
{/* Desktop context menu */}
<div className='mr-12 hidden opacity-0 transition-opacity group-hover:opacity-100 lg:flex'>
<div className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-brand-600 text-white/80'>
{/* <Icon name='play' className='h-7 w-7' /> */}
</div>
<div className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-night-900 text-white/80'>
{/* <Icon name='play' className='h-7 w-7' /> */}
</div>
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-night-900 text-white/80'>
{/* <Icon name='play' className='h-7 w-7' /> */}
</div>
</div>
{/* Desktop menu */}
<Actions track={track} />
{/* Mobile menu */}
<div className='lg:hidden'>
@ -76,8 +167,35 @@ const TrackList = ({
</div>
</div>
))}
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(
index => (
<div
key={index}
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
>
{/* Track no */}
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>
00
</div>
{/* Track name */}
<div className='flex flex-grow items-center text-transparent'>
<span className='mr-4 rounded-full bg-white/10'>
PLACEHOLDER1234567
</span>
</div>
{/* Track duration */}
<div className='hidden text-right text-transparent lg:block'>
<span className='rounded-full bg-white/10'>00:00</span>
</div>
</div>
)
)}
</div>
)
}
export default TrackList
const memorizedTrackList = memo(TrackList)
memorizedTrackList.displayName = 'TrackList'
export default memorizedTrackList

View file

@ -1,26 +1,52 @@
import { openContextMenu } from '@/web/states/contextMenus'
import { cx } from '@emotion/css'
import { useParams } from 'react-router-dom'
import Icon from '../../Icon'
const Actions = ({
onPlay,
onLike,
isLiked,
isLoading,
}: {
isLiked?: boolean
isLoading?: boolean
onPlay: () => void
onLike?: () => void
}) => {
const params = useParams()
return (
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
<div className='flex items-end'>
{/* Menu */}
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
<Icon name='more' className='h-7 w-7' />
<button
onClick={event => {
params?.id &&
openContextMenu({
event,
type: 'album',
dataSourceID: params.id,
})
}}
className={cx(
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
</button>
{/* Like */}
{onLike && (
<button
onClick={() => onLike()}
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30 lg:mr-2.5'
className={cx(
'flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400 lg:mr-2.5',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
@ -31,7 +57,10 @@ const Actions = ({
</div>
<button
onClick={() => onPlay()}
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
className={cx(
'h-14 rounded-full px-10 text-18 font-medium',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
Play
</button>

View file

@ -1,10 +1,6 @@
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
import { resizeImage } from '@/web/utils/common'
import Image from '@/web/components/New/Image'
import { memo, useEffect } from 'react'
import useVideoCover from '@/web/hooks/useVideoCover'
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
import uiStates from '@/web/states/uiStates'
import VideoCover from './VideoCover'

View file

@ -4,6 +4,7 @@ import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom'
import useIsMobile from '@/web/hooks/useIsMobile'
import { ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
const Info = ({
title,
@ -11,12 +12,14 @@ const Info = ({
creatorLink,
description,
extraInfo,
isLoading,
}: {
title?: string
creatorName?: string
creatorLink?: string
description?: string
extraInfo?: string | ReactNode
isLoading?: boolean
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
@ -24,33 +27,56 @@ const Info = ({
return (
<div>
{/* Title */}
<div className='mt-2.5 text-28 font-semibold dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
{title}
</div>
{isLoading ? (
<div className='mt-2.5 text-28 font-semibold text-transparent lg:mt-0 lg:text-36 lg:font-medium'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</div>
) : (
<div className='mt-2.5 text-28 font-semibold transition-colors duration-300 dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
{title}
</div>
)}
{/* Creator */}
<div className='mt-2.5 lg:mt-6'>
<span
onClick={() => creatorLink && navigate(creatorLink)}
className='text-24 font-medium transition-colors duration-300 dark:text-white/40 hover:dark:text-neutral-100 '
>
{creatorName}
</span>
</div>
{isLoading ? (
<div className='mt-2.5 lg:mt-6'>
<span className='text-24 font-medium text-transparent'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</span>
</div>
) : (
<div className='mt-2.5 lg:mt-6'>
<span
onClick={() => creatorLink && navigate(creatorLink)}
className='text-24 font-medium transition-colors duration-300 dark:text-white/40 hover:dark:text-neutral-100'
>
{creatorName}
</span>
</div>
)}
{/* Extra info */}
<div className='mt-1 flex items-center text-12 font-medium dark:text-white/40 lg:text-14 lg:font-bold'>
{extraInfo}
</div>
{isLoading ? (
<div className='mt-1 flex items-center text-12 font-medium text-transparent lg:text-14 lg:font-bold'>
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</div>
) : (
<div className='mt-1 flex items-center text-12 font-medium transition-colors duration-300 dark:text-white/40 lg:text-14 lg:font-bold'>
{extraInfo}
</div>
)}
{/* Description */}
{!isMobile && (
<div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
dangerouslySetInnerHTML={{
__html: description || '',
}}
></div>
></motion.div>
)}
</div>
)

View file

@ -6,6 +6,7 @@ import React from 'react'
interface Props {
className?: string
isLoading?: boolean
title?: string
creatorName?: string
creatorLink?: string
@ -20,6 +21,7 @@ interface Props {
const TrackListHeader = ({
className,
isLoading,
title,
creatorName,
creatorLink,
@ -46,9 +48,16 @@ const TrackListHeader = ({
<div className='flex flex-col justify-between'>
<Info
{...{ title, creatorName, creatorLink, description, extraInfo }}
{...{
title,
creatorName,
creatorLink,
description,
extraInfo,
isLoading,
}}
/>
<Actions {...{ onPlay, onLike, isLiked }} />
<Actions {...{ onPlay, onLike, isLiked, isLoading }} />
</div>
</div>
)