feat: monorepo

This commit is contained in:
qier222 2022-05-12 02:45:43 +08:00
parent 4d54060a4f
commit 42089d4996
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
200 changed files with 1530 additions and 1521 deletions

View file

@ -0,0 +1,51 @@
import { useNavigate } from 'react-router-dom'
import cx from 'classnames'
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

@ -0,0 +1,41 @@
import { resizeImage } from '../utils/common'
import useUser from '../hooks/useUser'
import SvgIcon from './SvgIcon'
import cx from 'classnames'
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')}>
<SvgIcon
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

@ -0,0 +1,47 @@
import { ReactNode } from 'react'
import cx from 'classnames'
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-lg 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

@ -0,0 +1,66 @@
import SvgIcon from '@/web/components/SvgIcon'
import cx from 'classnames'
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 '>
<SvgIcon 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]'>
<SvgIcon className='ml-0.5 h-6 w-6' name='play-fill' />
</button>
</div>
)}
</div>
)
}
export default Cover

View file

@ -0,0 +1,229 @@
import Cover from '@/web/components/Cover'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import { prefetchAlbum } from '@/web/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/hooks/usePlaylist'
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
import cx from 'classnames'
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
export enum Subtitle {
Copywriter = 'copywriter',
Creator = 'creator',
TypeReleaseYear = 'type+releaseYear',
Artist = 'artist',
}
const Title = ({
title,
seeMoreLink,
}: {
title: string
seeMoreLink: string
}) => {
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>
)}
</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 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')
}
const CoverRow = ({
title,
albums,
artists,
playlists,
subtitle = Subtitle.Copywriter,
seeMoreLink,
isSkeleton,
className,
rows = 2,
navigateCallback, // Callback function when click on the cover/title
}: {
title?: string
albums?: Album[]
artists?: Artist[]
playlists?: Playlist[]
subtitle?: Subtitle
seeMoreLink?: string
isSkeleton?: boolean
className?: string
rows?: number
navigateCallback?: () => void
}) => {
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={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 && (
<SvgIcon
name='lock'
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
/>
)}
{/* Explicit icon */}
{(item as Album)?.mark === 1056768 && (
<SvgIcon
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>
))}
</div>
</div>
)
}
export default CoverRow

View file

@ -0,0 +1,13 @@
@keyframes move {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animation {
animation: move 38s infinite;
animation-direction: alternate;
}

View file

@ -0,0 +1,34 @@
import SvgIcon from './SvgIcon'
import style from './DailyTracksCard.module.scss'
import cx from 'classnames'
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',
style.animation
)}
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]'>
<SvgIcon name='play-fill' className='ml-0.5 h-6 w-6' />
</button>
</div>
)
}
export default DailyTracksCard

View file

@ -0,0 +1,139 @@
import { player } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import SvgIcon from './SvgIcon'
import ArtistInline from './ArtistsInline'
import {
State as PlayerState,
Mode as PlayerMode,
} from '@/web/utils/player'
import useCoverColor from '../hooks/useCoverColor'
import cx from 'classnames'
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()}
>
<SvgIcon name='dislike' className='h-6 w-6' />
</button>
<button key='play' className={classes} onClick={playOrPause}>
<SvgIcon
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)}
>
<SvgIcon name='next' className='h-6 w-6' />
</button>
</div>
)
}
const FMCard = () => {
const navigate = useNavigate()
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.fmTrack, [playerSnapshot.fmTrack])
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'
)}
>
<SvgIcon name='fm' className='mr-1 h-6 w-6' />
<span className='font-semibold'>FM</span>
</div>
</div>
</div>
</div>
)
}
export default FMCard

View file

@ -0,0 +1,32 @@
import { ReactNode } from 'react'
import cx from 'classnames'
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

@ -0,0 +1,115 @@
import useLyric from '@/web/hooks/useLyric'
import { player } from '@/web/store'
import { motion } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
const lyric = useMemo(() => {
return lyricRaw && lyricParser(lyricRaw)
}, [lyricRaw])
const progress = playerSnapshot.progress + 0.3
const currentLine = useMemo(() => {
const index =
(lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
return {
index: index < 1 ? 0 : index,
time: lyric?.lyric?.[index]?.time ?? 0,
}
}, [lyric?.lyric, progress])
const displayLines = useMemo(() => {
const index = currentLine.index
const lines =
lyric?.lyric.slice(index === 0 ? 0 : index - 1, currentLine.index + 7) ??
[]
if (index === 0) {
lines.unshift({
time: 0,
content: '',
rawTime: '[00:00:00]',
})
}
return lines
}, [currentLine.index, lyric?.lyric])
const variants = {
initial: { opacity: [0, 0.2], y: ['24%', 0] },
current: {
opacity: 1,
y: 0,
transition: {
ease: [0.5, 0.2, 0.2, 0.8],
duration: 0.7,
},
},
rest: (index: number) => ({
opacity: 0.2,
y: 0,
transition: {
delay: index * 0.04,
ease: [0.5, 0.2, 0.2, 0.8],
duration: 0.7,
},
}),
exit: {
opacity: 0,
y: -132,
height: 0,
paddingTop: 0,
paddingBottom: 0,
transition: {
duration: 0.7,
ease: [0.5, 0.2, 0.2, 0.8],
},
},
}
return (
<div
className={cx(
'max-h-screen cursor-default overflow-hidden font-semibold',
className
)}
style={{
paddingTop: 'calc(100vh / 7 * 3)',
paddingBottom: 'calc(100vh / 7 * 3)',
fontSize: 'calc(100vw * 0.0264)',
lineHeight: 'calc(100vw * 0.032)',
}}
>
{displayLines.map(({ content, time }, index) => {
return (
<motion.div
key={`${String(index)}-${String(time)}`}
custom={index}
variants={variants}
initial={'initial'}
animate={
time === currentLine.time
? 'current'
: time < currentLine.time
? 'exit'
: 'rest'
}
layout
className={cx('max-w-[78%] py-[calc(100vw_*_0.0111)] text-white')}
>
{content}
</motion.div>
)
})}
</div>
)
}
export default Lyric

View file

@ -0,0 +1,101 @@
import useLyric from '@/web/hooks/useLyric'
import { player } from '@/web/store'
import { motion, useMotionValue } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useWindowSize } from 'react-use'
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
const lyric = useMemo(() => {
return lyricRaw && lyricParser(lyricRaw)
}, [lyricRaw])
const [progress, setProgress] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setProgress(player.howler.seek() + 0.3)
}, 300)
return () => clearInterval(timer)
}, [])
const currentIndex = useMemo(() => {
return (lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
}, [lyric?.lyric, progress])
const y = useMotionValue(1000)
const { height: windowHight } = useWindowSize()
useLayoutEffect(() => {
const top = (
document.getElementById('lyrics')?.children?.[currentIndex] as any
)?.offsetTop
if (top) {
y.set((windowHight / 9) * 4 - top)
}
}, [currentIndex, windowHight, y])
useEffect(() => {
y.set(0)
}, [track, y])
return (
<div
className={cx(
'max-h-screen cursor-default select-none overflow-hidden font-semibold',
className
)}
style={{
paddingTop: 'calc(100vh / 9 * 4)',
paddingBottom: 'calc(100vh / 9 * 4)',
fontSize: 'calc(100vw * 0.0264)',
lineHeight: 'calc(100vw * 0.032)',
}}
id='lyrics'
>
{lyric?.lyric.map(({ content, time }, index) => {
return (
<motion.div
id={String(time)}
key={`${String(index)}-${String(time)}`}
className={cx(
'max-w-[78%] py-[calc(100vw_*_0.0111)] text-white duration-700'
)}
style={{
y,
opacity:
index === currentIndex
? 1
: index > currentIndex && index < currentIndex + 8
? 0.2
: 0,
transitionProperty:
index > currentIndex - 2 && index < currentIndex + 8
? 'transform, opacity'
: 'none',
transitionTimingFunction:
index > currentIndex - 2 && index < currentIndex + 8
? 'cubic-bezier(0.5, 0.2, 0.2, 0.8)'
: 'none',
transitionDelay: `${
index < currentIndex + 8 && index > currentIndex
? 0.04 * (index - currentIndex)
: 0
}s`,
}}
>
{content}
</motion.div>
)
})}
</div>
)
}
export default Lyric

View file

@ -0,0 +1,68 @@
import Player from './Player'
import { player, state } from '@/web/store'
import { getCoverColor } from '@/web/utils/common'
import { colord } from 'colord'
import IconButton from '../IconButton'
import SvgIcon from '../SvgIcon'
import Lyric from './Lyric'
import { motion, AnimatePresence } from 'framer-motion'
import Lyric2 from './Lyric2'
import useCoverColor from '@/web/hooks/useCoverColor'
import cx from 'classnames'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
const LyricPanel = () => {
const stateSnapshot = useSnapshot(state)
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
return (
<AnimatePresence>
{stateSnapshot.uiStates.showLyricPanel && (
<motion.div
initial={{
y: '100%',
}}
animate={{
y: 0,
transition: {
ease: 'easeIn',
duration: 0.4,
},
}}
exit={{
y: '100%',
transition: {
ease: 'easeIn',
duration: 0.4,
},
}}
className={cx(
'fixed inset-0 z-40 grid grid-cols-[repeat(13,_minmax(0,_1fr))] gap-[8%] bg-gray-800'
)}
style={{
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
}}
>
{/* Drag area */}
<div className='app-region-drag absolute top-0 right-0 left-0 h-16'></div>
<Player className='col-span-6' />
{/* <Lyric className='col-span-7' /> */}
<Lyric2 className='col-span-7' />
<div className='absolute bottom-3.5 right-7 text-white'>
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
<SvgIcon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
export default LyricPanel

View file

@ -0,0 +1,168 @@
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
import { player, state } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import ArtistInline from '../ArtistsInline'
import Cover from '../Cover'
import IconButton from '../IconButton'
import SvgIcon from '../SvgIcon'
import {
State as PlayerState,
Mode as PlayerMode,
} from '@/web/utils/player'
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
const PlayingTrack = () => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
const navigate = useNavigate()
const toAlbum = () => {
const id = track?.al?.id
if (!id) return
navigate(`/album/${id}`)
state.uiStates.showLyricPanel = false
}
const trackListSource = useMemo(
() => playerSnapshot.trackListSource,
[playerSnapshot.trackListSource]
)
const hasListSource =
playerSnapshot.mode !== PlayerMode.FM && trackListSource?.type
const toTrackListSource = () => {
if (!hasListSource) return
navigate(`/${trackListSource.type}/${trackListSource.id}`)
state.uiStates.showLyricPanel = false
}
const toArtist = (id: number) => {
navigate(`/artist/${id}`)
state.uiStates.showLyricPanel = false
}
return (
<div>
<div
onClick={toTrackListSource}
className={cx(
'line-clamp-1 text-[22px] font-semibold text-white',
hasListSource && 'hover:underline'
)}
>
{track?.name}
</div>
<div className='line-clamp-1 -mt-0.5 inline-flex max-h-7 text-white opacity-60'>
<ArtistInline artists={track?.ar ?? []} onClick={toArtist} />
{!!track?.al?.id && (
<span>
{' '}
-{' '}
<span onClick={toAlbum} className='hover:underline'>
{track?.al.name}
</span>
</span>
)}
</div>
</div>
)
}
const LikeButton = ({ track }: { track: Track | undefined | null }) => {
const { data: userLikedSongs } = useUserLikedTracksIDs()
const mutationLikeATrack = useMutationLikeATrack()
return (
<div className='mr-1 '>
<IconButton
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
>
<SvgIcon
className='h-6 w-6 text-white'
name={
track?.id && userLikedSongs?.ids?.includes(track.id)
? 'heart'
: 'heart-outline'
}
/>
</IconButton>
</div>
)
}
const Controls = () => {
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-white'>
{mode === PlayerMode.TrackList && (
<IconButton
onClick={() => track && player.prevTrack()}
disabled={!track}
>
<SvgIcon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<SvgIcon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
onClick={() => track && player.playOrPause()}
disabled={!track}
className='after:rounded-xl'
>
<SvgIcon
className='h-7 w-7'
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon className='h-6 w-6' name='next' />
</IconButton>
</div>
)
}
const Player = ({ className }: { className?: string }) => {
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return (
<div className={cx('flex w-full items-center justify-end', className)}>
<div className='relative w-[74%]'>
<Cover
imageUrl={resizeImage(track?.al.picUrl ?? '', 'lg')}
roundedClass='rounded-2xl'
alwaysShowShadow={true}
/>
<div className='absolute -bottom-32 right-0 left-0'>
<div className='mt-6 flex cursor-default justify-between'>
<PlayingTrack />
<LikeButton track={track} />
</div>
<Controls />
</div>
</div>
</div>
)
}
export default Player

View file

@ -0,0 +1,3 @@
import LyricPanel from './LyricPanel'
export default LyricPanel

View file

@ -0,0 +1,25 @@
import Router from './Router'
import Topbar from './Topbar'
import cx from 'classnames'
const Main = () => {
return (
<div
id='mainContainer'
className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
>
<Topbar />
<main
id='main'
className={cx(
'mb-24 flex-grow px-8',
window.env?.isEnableTitlebar && 'mt-8'
)}
>
<Router />
</main>
</div>
)
}
export default Main

View file

@ -0,0 +1,244 @@
import ArtistInline from './ArtistsInline'
import IconButton from './IconButton'
import Slider from './Slider'
import SvgIcon from './SvgIcon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
import { player, state } from '@/web/store'
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 'classnames'
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 snappedPlayer = useSnapshot(player)
const track = useMemo(() => snappedPlayer.track, [snappedPlayer.track])
const trackListSource = useMemo(
() => snappedPlayer.trackListSource,
[snappedPlayer.trackListSource]
)
// Liked songs ids
const { data: userLikedSongs } = useUserLikedTracksIDs()
const mutationLikeATrack = useMutationLikeATrack()
const hasTrackListSource =
snappedPlayer.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'
>
<SvgIcon 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)}
>
<SvgIcon
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}
>
<SvgIcon className='h-6 w-6' name='previous' />
</IconButton>
)}
{mode === PlayerMode.FM && (
<IconButton onClick={() => player.fmTrash()}>
<SvgIcon className='h-6 w-6' name='dislike' />
</IconButton>
)}
<IconButton
onClick={() => track && player.playOrPause()}
disabled={!track}
className='after:rounded-xl'
>
<SvgIcon
className='h-7 w-7'
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon 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}
>
<SvgIcon className='h-6 w-6' name='playlist' />
</IconButton>
<IconButton
onClick={switchRepeatMode}
disabled={playerSnapshot.mode === PlayerMode.FM}
>
<SvgIcon
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}
>
<SvgIcon className='h-6 w-6' name='shuffle' />
</IconButton>
<IconButton onClick={() => toast('施工中...')}>
<SvgIcon className='h-6 w-6' name='volume' />
</IconButton>
{/* Lyric */}
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
<SvgIcon 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>
)
}
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 />
<PlayingTrack />
<MediaControls />
<Others />
</div>
)
}
export default Player

View file

@ -0,0 +1,63 @@
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'
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 Router = () => {
const element = useRoutes(routes)
return <>{element}</>
}
export default Router

View file

@ -0,0 +1,109 @@
import { NavLink } from 'react-router-dom'
import SvgIcon from './SvgIcon'
import useUserPlaylists from '@/web/hooks/useUserPlaylists'
import { scrollToTop } from '@/web/utils/common'
import { prefetchPlaylist } from '@/web/hooks/usePlaylist'
import { player } from '@/web/store'
import { Mode, TrackListSourceType } from '@/web/utils/player'
import cx from 'classnames'
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 '
)
}
>
<SvgIcon 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 && (
<SvgIcon 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

@ -0,0 +1,23 @@
import { ReactNode } from 'react'
import cx from 'classnames'
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

@ -0,0 +1,158 @@
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
import cx from 'classnames'
const Slider = ({
value,
min,
max,
onChange,
onlyCallOnChangeAfterDragEnded = false,
orientation = 'horizontal',
}: {
value: number
min: number
max: number
onChange: (value: number) => void
onlyCallOnChangeAfterDragEnded?: boolean
orientation?: 'horizontal' | 'vertical'
}) => {
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(
(val: number) => {
if (!sliderRef?.current) return 0
const sliderWidth = sliderRef.current.getBoundingClientRect().width
const newValue = (val / sliderWidth) * max
if (newValue < min) return min
if (newValue > max) return max
return newValue
},
[sliderRef, max, min]
)
/**
* Handle slider click event
*/
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
onChange(getNewValue(e.clientX))
},
[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(e.clientX)
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) {
console.log('draggingValue', draggingValue)
onChange(draggingValue)
}
}
document.addEventListener('pointerup', handlePointerUp)
return () => {
document.removeEventListener('pointerup', handlePointerUp)
}
}, [
isDragging,
setIsDragging,
onlyCallOnChangeAfterDragEnded,
draggingValue,
onChange,
])
/**
* Track and thumb styles
*/
const usedTrackStyle = useMemo(
() => ({ width: `${(memoedValue / max) * 100}%` }),
[max, memoedValue]
)
const thumbStyle = useMemo(
() => ({
left: `${(memoedValue / max) * 100}%`,
transform: `translateX(-10px)`,
}),
[max, memoedValue]
)
return (
<div
className='group flex h-2 -translate-y-[3px] items-center'
ref={sliderRef}
onClick={handleClick}
>
{/* Track */}
<div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
{/* Passed track */}
<div
className={cx(
'absolute h-[2px] group-hover:bg-brand-500',
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-500'
)}
style={usedTrackStyle}
></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 ',
isDragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
style={thumbStyle}
onClick={e => e.stopPropagation()}
onPointerDown={handlePointerDown}
>
<div className='absolute h-2 w-2 rounded-full bg-brand-500'></div>
</div>
</div>
)
}
export default Slider

View file

@ -0,0 +1,57 @@
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 SvgIcon = ({
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 SvgIcon

View file

@ -0,0 +1,98 @@
import { player } from '@/web/store'
import SvgIcon from './SvgIcon'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useState, useMemo } from 'react'
import { useSnapshot } from 'valtio'
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)
}
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]'
>
<SvgIcon className='h-3 w-3' name='windows-minimize' />
</button>
<button
onClick={maxRestore}
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
>
<SvgIcon
className='h-3 w-3'
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
/>
</button>
<button
onClick={close}
className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'
>
<SvgIcon className='h-3 w-3' name='windows-close' />
</button>
</div>
)
}
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?.isWin ? <Win /> : <Linux />}
</div>
)
}
export default TitleBar

View file

@ -0,0 +1,117 @@
import SvgIcon from '@/web/components/SvgIcon'
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 'classnames'
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'
>
<SvgIcon 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'>
<SvgIcon
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'
)}
>
<SvgIcon 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'
>
<SvgIcon 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,265 @@
import { memo, useCallback, useMemo } from 'react'
import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
import { player } from '@/web/store'
import { formatDuration } from '@/web/utils/common'
import { State as PlayerState } from '@/web/utils/player'
import cx from 'classnames'
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'
)}
>
<SvgIcon
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 && (
<SvgIcon
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'
)}
>
<SvgIcon
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 playerSnapshot = useSnapshot(player)
const playingTrack = useMemo(
() => playerSnapshot.track,
[playerSnapshot.track]
)
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 === playingTrack?.id}
/>
))}
</div>
)
}
export default TracksAlbum

View file

@ -0,0 +1,137 @@
import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import { player } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import SvgIcon from './SvgIcon'
import cx from 'classnames'
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 && (
<SvgIcon
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

@ -0,0 +1,239 @@
import { memo, useMemo } from 'react'
import { NavLink } from 'react-router-dom'
import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/hooks/useUserLikedTracksIDs'
import { formatDuration, resizeImage } from '@/web/utils/common'
import { player } from '@/web/store'
import cx from 'classnames'
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 && (
<SvgIcon
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'
)}
>
<SvgIcon
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