mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2025-03-03 18:33:10 +08:00
feat: 增加歌手页面
This commit is contained in:
parent
7d20e6c5de
commit
36603dc3a0
@ -44,6 +44,11 @@ export async function setCache(api: string, data: any, query: any) {
|
||||
db.set(ModelNames.PLAYLIST, Number(data.playlist.id), data)
|
||||
break
|
||||
}
|
||||
case 'artists': {
|
||||
if (!data.artist) return
|
||||
db.set(ModelNames.ARTIST, Number(data.artist.id), data)
|
||||
break
|
||||
}
|
||||
case 'artist/album': {
|
||||
if (!data.hotAlbums) return
|
||||
db.set(ModelNames.ARTIST_ALBUMS, Number(data.artist.id), data)
|
||||
@ -117,6 +122,13 @@ export function getCache(
|
||||
if (playlist?.json) return JSON.parse(playlist.json)
|
||||
break
|
||||
}
|
||||
case 'artists': {
|
||||
if (!query?.id) return
|
||||
const artist = db.get(ModelNames.ARTIST, Number(query?.id)) as any
|
||||
if (checkIsExpired && isCacheExpired(artist?.updateAt, 30)) return
|
||||
if (artist?.json) return JSON.parse(artist.json)
|
||||
break
|
||||
}
|
||||
case 'artist/album': {
|
||||
if (!query?.id) return
|
||||
const artistAlbums = db.get(
|
||||
|
@ -30,6 +30,7 @@ const RegularSchemas = [
|
||||
ModelNames.PLAYLIST,
|
||||
ModelNames.ALBUM,
|
||||
ModelNames.TRACK,
|
||||
ModelNames.ARTIST,
|
||||
].map(name => ({
|
||||
primaryKey: 'id',
|
||||
name,
|
||||
|
@ -1,3 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 14.5V5.5M9 5H8.5C7.39543 5 6.5 5.89543 6.5 7V14.0668C6.5 14.3523 6.56113 14.6345 6.67927 14.8944L8.46709 18.8276C8.79163 19.5416 9.50354 20 10.2878 20H10.8815C12.2002 20 13.158 18.746 12.811 17.4738L12.3445 15.7631C12.171 15.127 12.6499 14.5 13.3093 14.5H17V14.5C19.1704 14.5 20.489 12.1076 19.33 10.2726L16.5517 5.87354C16.2083 5.32974 15.61 5 14.9668 5H9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M4 10V18M9 19V19C7.89543 19 7 18.1046 7 17V9.38516C7 9.13073 7.04855 8.87862 7.14305 8.64238L8.39102 5.52246C8.75882 4.60294 9.64939 4 10.6397 4V4C12.2928 4 13.4602 5.61954 12.9374 7.18783L12.2857 9.14286C12.1452 9.56454 12.459 10 12.9035 10H17V10C19.1372 10 20.412 12.382 19.2265 14.1603L16.5229 18.2156C16.1962 18.7057 15.6462 19 15.0573 19H9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 538 B After Width: | Height: | Size: 523 B |
@ -1 +1,4 @@
|
||||
<svg viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg"><g><path d="M256 431.1c0 45-50.1 80.9-112 80.9s-112-35.9-112-80.9c0-45 50.13-80.01 112-80.01 16.38 0 32.5 2.75 48 8V60.33c0-14 9.125-26.38 22.38-30.5L256 17.55V431.1Z"/><path opacity=".4" d="M256 148.1V17.55l55-16.22c9.625-2.88 20.12-1.001 28.12 5C347.3 12.31 352 21.83 352 31.95v64c0 14.25-9.375 26.75-23 30.75l-73 21.39Z"/></g></svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 16.5C13 17.8807 11.8807 19 10.5 19C9.11929 19 8 17.8807 8 16.5C8 15.1193 9.11929 14 10.5 14C11.8807 14 13 15.1193 13 16.5Z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M13.2572 5.49711L15.9021 4.43917C16.4828 4.20687 17.1351 4.54037 17.2868 5.14719C17.4097 5.63869 17.1577 6.14671 16.692 6.34628L14.6061 7.24025C14.2384 7.39783 14 7.75937 14 8.1594V16.5L12 15V7.35407C12 6.53626 12.4979 5.80084 13.2572 5.49711Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 557 B |
@ -1,17 +1,29 @@
|
||||
const ArtistInline = ({
|
||||
artists,
|
||||
className,
|
||||
disableLink,
|
||||
}: {
|
||||
artists: Artist[]
|
||||
className?: string
|
||||
disableLink?: boolean
|
||||
}) => {
|
||||
if (!artists) return <div></div>
|
||||
|
||||
const navigate = useNavigate()
|
||||
const handleClick = () => {
|
||||
disableLink ? null : navigate(`/artist/${artists[0].id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('line-clamp-1', className)}>
|
||||
{artists.map((artist, index) => (
|
||||
<span key={artist.name}>
|
||||
<span className={classNames({ 'hover:underline': !!artist.id })}>
|
||||
<span key={`${artist.id}-${artist.name}`}>
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className={classNames({
|
||||
'hover:underline': !!artist.id && !disableLink,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
|
@ -32,7 +32,7 @@ const Button = ({
|
||||
'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-900 dark:text-gray-400': iconColor === Color.Gray,
|
||||
'text-gray-600 dark:text-gray-400': iconColor === Color.Gray,
|
||||
'animate-pulse bg-gray-100 !text-transparent dark:bg-gray-800':
|
||||
isSkelton,
|
||||
}
|
||||
|
@ -125,7 +125,7 @@ const CoverRow = ({
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'grid gap-x-[24px] gap-y-7',
|
||||
'grid gap-x-6 gap-y-7',
|
||||
className,
|
||||
!className &&
|
||||
'grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
||||
@ -135,7 +135,7 @@ const CoverRow = ({
|
||||
<div
|
||||
key={item.id ?? index}
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
className='grid gap-x-[24px] gap-y-7'
|
||||
className='grid gap-x-6 gap-y-7'
|
||||
>
|
||||
<div>
|
||||
{/* Cover */}
|
||||
|
@ -5,6 +5,7 @@ import Album from '@/pages/Album'
|
||||
import Home from '@/pages/Home'
|
||||
import Login from '@/pages/Login'
|
||||
import Playlist from '@/pages/Playlist'
|
||||
import Artist from '@/pages/Artist'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
@ -23,11 +24,15 @@ const routes: RouteObject[] = [
|
||||
path: '/album/:id',
|
||||
element: <Album />,
|
||||
},
|
||||
{
|
||||
path: '/artist/:id',
|
||||
element: <Artist />,
|
||||
},
|
||||
]
|
||||
|
||||
const router = () => {
|
||||
const Router = () => {
|
||||
const element = useRoutes(routes)
|
||||
return <Fragment>{element}</Fragment>
|
||||
}
|
||||
|
||||
export default router
|
||||
export default Router
|
||||
|
@ -7,7 +7,7 @@ import { prefetchPlaylist } from '@/hooks/usePlaylist'
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon?: string
|
||||
icon: string
|
||||
route: string
|
||||
}
|
||||
interface PrimaryTab extends Tab {
|
||||
@ -41,7 +41,7 @@ const PrimaryTabs = () => {
|
||||
onClick={() => scrollToTop()}
|
||||
key={tab.route}
|
||||
to={tab.route}
|
||||
className={({ isActive }: { isActive: boolean }) =>
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
'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',
|
||||
|
@ -131,7 +131,7 @@ const Slider = ({
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute h-[2px] group-hover:bg-brand-500',
|
||||
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-500'
|
||||
)}
|
||||
style={usedTrackStyle}
|
||||
></div>
|
||||
|
@ -2,18 +2,25 @@ import ArtistInline from '@/components/ArtistsInline'
|
||||
import Skeleton from '@/components/Skeleton'
|
||||
import { resizeImage } from '@/utils/common'
|
||||
|
||||
const TrackListGrid = ({
|
||||
const Track = ({
|
||||
track,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
}: {
|
||||
track: Track
|
||||
isSkeleton: boolean
|
||||
isHighlight: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl',
|
||||
'grid-cols-1 py-1.5 px-2'
|
||||
'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'>
|
||||
@ -35,17 +42,19 @@ const TrackListGrid = ({
|
||||
{!isSkeleton && (
|
||||
<div
|
||||
v-if='!isSkeleton'
|
||||
className='line-clamp-1 break-all text-base font-semibold'
|
||||
className='line-clamp-1 break-all text-base font-semibold dark:text-white'
|
||||
>
|
||||
{track.name}
|
||||
</div>
|
||||
)}
|
||||
{isSkeleton && (
|
||||
<Skeleton className='text-base'>PLACEHOLDER12345</Skeleton>
|
||||
<Skeleton className='text-base '>PLACEHOLDER12345</Skeleton>
|
||||
)}
|
||||
|
||||
<div className='text-xs'>
|
||||
{!isSkeleton && <ArtistInline artists={track.ar} />}
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{!isSkeleton && (
|
||||
<ArtistInline artists={track.ar} disableLink={true} />
|
||||
)}
|
||||
{isSkeleton && (
|
||||
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
|
||||
)}
|
||||
@ -56,4 +65,22 @@ const TrackListGrid = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackListGrid
|
||||
const TrackGrid = ({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-x-2'>
|
||||
{tracks.map((track, index) => (
|
||||
<Track key={track.id} track={track} isSkeleton={isSkeleton} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackGrid
|
||||
|
@ -1,14 +1,24 @@
|
||||
import { fetchArtist } from '@/api/artist'
|
||||
import { ArtistApiNames } from '@/api/artist'
|
||||
import type { FetchArtistParams } from '@/api/artist'
|
||||
import type { FetchArtistParams, FetchArtistResponse } from '@/api/artist'
|
||||
|
||||
export default function useArtist(params: FetchArtistParams, noCache: boolean) {
|
||||
export default function useArtist(
|
||||
params: FetchArtistParams,
|
||||
noCache?: boolean
|
||||
) {
|
||||
return useQuery(
|
||||
[ArtistApiNames.FETCH_ARTIST, params],
|
||||
() => fetchArtist(params, noCache),
|
||||
() => fetchArtist(params, !!noCache),
|
||||
{
|
||||
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
||||
staleTime: 3600000,
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
placeholderData: (): FetchArtistResponse =>
|
||||
window.ipcRenderer.sendSync('getApiCacheSync', {
|
||||
api: 'artists',
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -146,7 +146,7 @@ const Header = ({
|
||||
<div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'>
|
||||
Album by{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.name}`}
|
||||
to={`/artist/${album?.artist.id}`}
|
||||
className='cursor-default font-semibold hover:underline'
|
||||
>
|
||||
{album?.artist.name}
|
||||
|
148
packages/renderer/src/pages/Artist.tsx
Normal file
148
packages/renderer/src/pages/Artist.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import Button, { Color as ButtonColor } from '@/components/Button'
|
||||
import SvgIcon from '@/components/SvgIcon'
|
||||
import Cover from '@/components/Cover'
|
||||
import useArtist from '@/hooks/useArtist'
|
||||
import useArtistAlbums from '@/hooks/useArtistAlbums'
|
||||
import { resizeImage } from '@/utils/common'
|
||||
import dayjs from 'dayjs'
|
||||
import TracksGrid from '@/components/TracksGrid'
|
||||
import CoverRow, { Subtitle } from '@/components/CoverRow'
|
||||
import Skeleton from '@/components/Skeleton'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
const Artist = () => {
|
||||
const params = useParams()
|
||||
|
||||
const { data: artist, isLoading } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbum } = useArtistAlbums({
|
||||
id: Number(params.id) || 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const albums = useMemo(() => {
|
||||
if (!albumsRaw?.hotAlbums) return []
|
||||
return albumsRaw.hotAlbums.filter(
|
||||
album =>
|
||||
album.type === '专辑' &&
|
||||
['混音版', '精选集', 'Remix'].includes(album.subType) === false &&
|
||||
album.size > 1
|
||||
)
|
||||
}, [albumsRaw?.hotAlbums])
|
||||
|
||||
const singles = useMemo(() => {
|
||||
if (!albumsRaw?.hotAlbums) return []
|
||||
return albumsRaw.hotAlbums.filter(
|
||||
album =>
|
||||
album.type !== '专辑' ||
|
||||
['混音版', '精选集', 'Remix'].includes(album.subType) ||
|
||||
album.size === 1
|
||||
)
|
||||
}, [albumsRaw?.hotAlbums])
|
||||
|
||||
const latestAlbum = useMemo(() => {
|
||||
if (!albumsRaw || !albumsRaw.hotAlbums) return
|
||||
return albumsRaw.hotAlbums[0]
|
||||
}, [albumsRaw])
|
||||
|
||||
const coverImage = resizeImage(artist?.artist?.img1v1Url || '', 'md')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
|
||||
{coverImage && (
|
||||
<Fragment>
|
||||
<img
|
||||
src={coverImage}
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
<img
|
||||
src={coverImage}
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.84] to-white dark:from-black/[.6] dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className='relative mt-6 overflow-hidden rounded-2xl'>
|
||||
<div className='flex h-[26rem] justify-center overflow-hidden'>
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
<img src={coverImage} />
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
</div>
|
||||
|
||||
<div className='absolute right-0 left-0 top-[18rem] h-32 bg-gradient-to-t from-[#222]/60 to-transparent'></div>
|
||||
|
||||
<div className='absolute top-0 right-0 left-0 flex h-[26rem] items-end justify-between p-8 pb-6'>
|
||||
<div className='text-7xl font-bold text-white'>
|
||||
{artist?.artist.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-12 grid h-[20rem] grid-cols-[14rem,_auto] grid-rows-1 gap-16 px-2'>
|
||||
{/* Latest release */}
|
||||
<div>
|
||||
<div className='mb-6 text-2xl font-semibold dark:text-white'>
|
||||
最新发行
|
||||
</div>
|
||||
<div className='flex-grow rounded-xl '>
|
||||
{isLoadingAlbum ? (
|
||||
<Skeleton className='aspect-square w-full rounded-xl'></Skeleton>
|
||||
) : (
|
||||
<Cover imageUrl={latestAlbum?.picUrl ?? ''} />
|
||||
)}
|
||||
<div className='line-clamp-2 line-clamp-1 mt-2 font-semibold leading-tight decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'>
|
||||
{latestAlbum?.name}
|
||||
</div>
|
||||
<div className='text-[12px] text-gray-500 dark:text-gray-400'>
|
||||
{latestAlbum?.type} ·{' '}
|
||||
{dayjs(latestAlbum?.publishTime || 0).year()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popular tracks */}
|
||||
<div>
|
||||
<div className='mb-6 text-2xl font-semibold dark:text-white'>
|
||||
热门歌曲
|
||||
</div>
|
||||
<div className='overflow-scroll rounded-xl '>
|
||||
<TracksGrid
|
||||
tracks={artist?.hotSongs.slice(0, 10) ?? []}
|
||||
isSkeleton={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Albums */}
|
||||
<div className='mt-20 px-2'>
|
||||
<div className='mb-6 text-2xl font-semibold dark:text-white'>专辑</div>
|
||||
<CoverRow
|
||||
albums={albums.slice(0, 10)}
|
||||
subtitle={Subtitle.TYPE_RELEASE_YEAR}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Singles/EP */}
|
||||
<div className='mt-16 px-2'>
|
||||
<div className='mb-6 text-2xl font-semibold dark:text-white'>
|
||||
单曲和EP
|
||||
</div>
|
||||
<CoverRow
|
||||
albums={singles.slice(0, 5)}
|
||||
subtitle={Subtitle.TYPE_RELEASE_YEAR}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Artist
|
@ -22,7 +22,9 @@ const EmailInput = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>Email</div>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Email
|
||||
</div>
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
@ -96,7 +98,7 @@ const PasswordInput = ({
|
||||
<div className='flex items-center justify-center rounded-md rounded-l-none border border-l-0 border-gray-300 pr-1 dark:border-gray-600 dark:bg-gray-700'>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='dark:hover-text-white cursor-default rounded p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-white'
|
||||
className='dark:hover-text-white cursor-default rounded-md p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-white'
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-5 w-5'
|
||||
|
@ -194,7 +194,6 @@ export class Player {
|
||||
if (_howler.playing()) return
|
||||
_howler.play()
|
||||
this.state = State.PLAYING
|
||||
this._progress = _howler.seek()
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user