mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 13:17:46 +00:00
feat: updates
This commit is contained in:
parent
a1b0bcf4d3
commit
884f3df41a
198 changed files with 4572 additions and 5336 deletions
|
|
@ -1,361 +0,0 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import TracksAlbum from '@/web/components/TracksAlbum'
|
||||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import player from '@/web/states/player'
|
||||
import {
|
||||
Mode as PlayerMode,
|
||||
State as PlayerState,
|
||||
TrackListSourceType,
|
||||
} from '@/web/utils/player'
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
resizeImage,
|
||||
scrollToTop,
|
||||
} from '@/web/utils/common'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import useUserAlbums, {
|
||||
useMutationLikeAAlbum,
|
||||
} from '@/web/api/hooks/useUserAlbums'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const PlayButton = ({
|
||||
album,
|
||||
handlePlay,
|
||||
isLoading,
|
||||
}: {
|
||||
album: Album | undefined
|
||||
handlePlay: () => void
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const isThisAlbumPlaying = useMemo(
|
||||
() =>
|
||||
playerSnapshot.mode === PlayerMode.TrackList &&
|
||||
playerSnapshot.trackListSource?.type === TrackListSourceType.Album &&
|
||||
playerSnapshot.trackListSource?.id === album?.id,
|
||||
[
|
||||
playerSnapshot.mode,
|
||||
playerSnapshot.trackListSource?.type,
|
||||
playerSnapshot.trackListSource?.id,
|
||||
album?.id,
|
||||
]
|
||||
)
|
||||
|
||||
const isPlaying =
|
||||
isThisAlbumPlaying &&
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(playerSnapshot.state)
|
||||
|
||||
const wrappedHandlePlay = () => {
|
||||
if (isThisAlbumPlaying) {
|
||||
player.playOrPause()
|
||||
} else {
|
||||
handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||
<Icon
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
className='mr-1 -ml-1 h-6 w-6'
|
||||
/>
|
||||
{isPlaying ? '暂停' : '播放'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
album,
|
||||
isLoading,
|
||||
handlePlay,
|
||||
}: {
|
||||
album: Album | undefined
|
||||
isLoading: boolean
|
||||
handlePlay: () => void
|
||||
}) => {
|
||||
const coverUrl = resizeImage(album?.picUrl || '', 'lg')
|
||||
|
||||
const albumDuration = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
return formatDuration(duration, 'zh-CN', 'hh[hr] mm[min]')
|
||||
}, [album?.songs])
|
||||
|
||||
const [isCoverError, setCoverError] = useState(
|
||||
coverUrl.includes('3132508627578625')
|
||||
)
|
||||
|
||||
const { data: userAlbums } = useUserAlbums()
|
||||
const isThisAlbumLiked = useMemo(() => {
|
||||
if (!album) return false
|
||||
return !!userAlbums?.data?.find(a => a.id === album.id)
|
||||
}, [album, userAlbums?.data])
|
||||
const mutationLikeAAlbum = useMutationLikeAAlbum()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header background */}
|
||||
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
|
||||
{coverUrl && !isCoverError && (
|
||||
<>
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[17rem_auto] items-center gap-9'>
|
||||
{/* Cover */}
|
||||
<div className='relative z-0 aspect-square self-start'>
|
||||
{/* Neon shadow */}
|
||||
{!isLoading && coverUrl && !isCoverError && (
|
||||
<div
|
||||
className='absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter'
|
||||
style={{
|
||||
backgroundImage: `url("${coverUrl}")`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{!isLoading && isCoverError ? (
|
||||
// Fallback cover
|
||||
<div className='flex h-full w-full items-center justify-center rounded-2xl border border-black border-opacity-5 bg-gray-100 text-gray-300'>
|
||||
<Icon name='music-note' className='h-1/2 w-1/2' />
|
||||
</div>
|
||||
) : (
|
||||
coverUrl && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='w-full rounded-2xl border border-b-0 border-black border-opacity-5 dark:border-white dark:border-opacity-5'
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{isLoading && <Skeleton className='h-full w-full rounded-2xl' />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className='z-10 flex h-full flex-col justify-between'>
|
||||
{/* Name */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='w-3/4 text-6xl'>PLACEHOLDER</Skeleton>
|
||||
) : (
|
||||
<div className='text-6xl font-bold dark:text-white'>
|
||||
{album?.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artist */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='mt-5 w-64 text-lg'>PLACEHOLDER</Skeleton>
|
||||
) : (
|
||||
<div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'>
|
||||
Album ·{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.id}`}
|
||||
className='cursor-default font-semibold hover:underline'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Release date & track count & album duration */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='w-72 translate-y-px text-sm'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
) : (
|
||||
<div className='flex items-center text-sm text-gray-500 dark:text-gray-400'>
|
||||
{album?.mark === 1056768 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className='mt-px mr-1 h-4 w-4 text-gray-400 dark:text-gray-500'
|
||||
/>
|
||||
)}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.size} 首歌 ·{' '}
|
||||
{albumDuration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='mt-5 min-h-[2.5rem] w-1/2 text-sm'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
) : (
|
||||
<div className='line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400'>
|
||||
{album?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className='mt-5 flex gap-4'>
|
||||
<PlayButton {...{ album, handlePlay, isLoading }} />
|
||||
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={
|
||||
isThisAlbumLiked ? ButtonColor.Primary : ButtonColor.Gray
|
||||
}
|
||||
isSkelton={isLoading}
|
||||
onClick={() => album?.id && mutationLikeAAlbum.mutate(album)}
|
||||
>
|
||||
<Icon
|
||||
name={isThisAlbumLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-6 w-6'
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={ButtonColor.Gray}
|
||||
isSkelton={isLoading}
|
||||
onClick={() => toast('施工中...')}
|
||||
>
|
||||
<Icon name='more' className='h-6 w-6' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MoreAlbum = ({ album }: { album: Album | undefined }) => {
|
||||
// Fetch artist's albums
|
||||
const { data: albums, isLoading } = useArtistAlbums({
|
||||
id: album?.artist.id ?? 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const filteredAlbums = useMemo((): Album[] => {
|
||||
if (!albums) return []
|
||||
const allReleases = albums?.hotAlbums || []
|
||||
const filteredAlbums = allReleases.filter(
|
||||
album =>
|
||||
['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
|
||||
)
|
||||
const singles = allReleases.filter(album => album.type === 'Single')
|
||||
|
||||
const qualifiedAlbums = [...filteredAlbums, ...singles]
|
||||
|
||||
const formatName = (name: string) =>
|
||||
name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
|
||||
|
||||
const uniqueAlbums: Album[] = []
|
||||
qualifiedAlbums.forEach(a => {
|
||||
// 去除当前页面的专辑
|
||||
if (formatName(a.name) === formatName(album?.name ?? '')) return
|
||||
|
||||
// 去除重复的专辑(包含 deluxe edition 的专辑会视为重复)
|
||||
if (
|
||||
uniqueAlbums.findIndex(aa => {
|
||||
return formatName(a.name) === formatName(aa.name)
|
||||
}) !== -1
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 去除 remix 专辑
|
||||
if (
|
||||
a.name.toLowerCase().includes('remix)') ||
|
||||
a.name.toLowerCase().includes('remixes)')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
uniqueAlbums.push(a)
|
||||
})
|
||||
|
||||
return uniqueAlbums.slice(0, 5)
|
||||
}, [album?.name, albums])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='my-5 h-px w-full bg-gray-100 dark:bg-gray-800'></div>
|
||||
{!isLoading && albums?.hotAlbums?.length && (
|
||||
<div className='pl-px text-[1.375rem] font-semibold text-gray-800 dark:text-gray-100'>
|
||||
More by{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist?.id}`}
|
||||
className='cursor-default hover:underline'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
<div className='mt-3'>
|
||||
<CoverRow
|
||||
albums={
|
||||
filteredAlbums.length ? filteredAlbums : albums?.hotAlbums || []
|
||||
}
|
||||
subtitle={Subtitle.TypeReleaseYear}
|
||||
isSkeleton={isLoading}
|
||||
rows={1}
|
||||
navigateCallback={scrollToTop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const { data: album, isLoading } = useAlbum({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const { data: tracks } = useTracks({
|
||||
ids: album?.songs?.map(track => track.id) ?? [],
|
||||
})
|
||||
|
||||
const handlePlay = async (trackID: number | null = null) => {
|
||||
if (!album?.album.id) {
|
||||
toast('无法播放专辑,该专辑不存在')
|
||||
return
|
||||
}
|
||||
await player.playAlbum(album.album.id, trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-10'>
|
||||
<Header
|
||||
album={album?.album}
|
||||
isLoading={isLoading}
|
||||
handlePlay={handlePlay}
|
||||
/>
|
||||
<TracksAlbum
|
||||
tracks={tracks?.songs ?? album?.album.songs ?? []}
|
||||
onTrackDoubleClick={handlePlay}
|
||||
isSkeleton={isLoading}
|
||||
/>
|
||||
{album?.album && (
|
||||
<div className='mt-5 text-xs text-gray-400'>
|
||||
<div> Released {formatDate(album.album.publishTime || 0, 'en')} </div>
|
||||
{album.album.company && (
|
||||
<div className='mt-[2px]'>© {album.album.company} </div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && <MoreAlbum album={album?.album} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Album
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import TrackList from '@/web/components/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useCallback } from 'react'
|
||||
|
|
@ -3,7 +3,7 @@ import useUserAlbums, {
|
|||
useMutationLikeAAlbum,
|
||||
} from '@/web/api/hooks/useUserAlbums'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import TrackListHeader from '@/web/components/TrackListHeader'
|
||||
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import player from '@/web/states/player'
|
||||
|
|
@ -12,9 +12,12 @@ import dayjs from 'dayjs'
|
|||
import { useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Header = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const params = useParams()
|
||||
|
||||
const { data: userLikedAlbums } = useUserAlbums()
|
||||
|
||||
const { data: albumRaw, isLoading: isLoadingAlbum } = useAlbum({
|
||||
|
|
@ -51,7 +54,11 @@ const Header = () => {
|
|||
: albumFromApple?.attributes?.editorialNotes?.standard || album?.description
|
||||
const extraInfo = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
const albumDuration = formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||
const albumDuration = formatDuration(
|
||||
duration,
|
||||
i18n.language,
|
||||
'hh[hr] mm[min]'
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{album?.mark === 1056768 && (
|
||||
|
|
@ -60,11 +67,12 @@ const Header = () => {
|
|||
className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5'
|
||||
/>
|
||||
)}{' '}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length} tracks,{' '}
|
||||
{dayjs(album?.publishTime || 0).year()} ·{' '}
|
||||
{t('common.track-with-count', { count: album?.songs?.length })},{' '}
|
||||
{albumDuration}
|
||||
</>
|
||||
)
|
||||
}, [album])
|
||||
}, [album?.mark, album?.publishTime, album?.songs, i18n.language, t])
|
||||
|
||||
// For <Actions />
|
||||
const isLiked = useMemo(() => {
|
||||
|
|
@ -1,15 +1,7 @@
|
|||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import { cx } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const MoreByArtist = ({ album }: { album?: Album }) => {
|
||||
|
|
@ -69,13 +61,21 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
|
|||
|
||||
{/* Title */}
|
||||
<div className='mx-2.5 mb-5 text-14 font-bold text-neutral-300 lg:mx-0'>
|
||||
MORE BY{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.id}`}
|
||||
className='transition duration-300 ease-in-out hover:text-neutral-100'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
{album?.artist.name ? (
|
||||
<>
|
||||
MORE BY{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.id}`}
|
||||
className='transition duration-300 ease-in-out hover:text-neutral-100'
|
||||
>
|
||||
{album.artist.name}
|
||||
</NavLink>
|
||||
</>
|
||||
) : (
|
||||
<span className='inline-block h-full rounded-full bg-white/10 text-transparent'>
|
||||
MORE BY PLACEHOLDER
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CoverRow
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import Cover from '@/web/components/Cover'
|
||||
import useArtist from '@/web/api/hooks/useArtist'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import dayjs from 'dayjs'
|
||||
import TracksGrid from '@/web/components/TracksGrid'
|
||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import player from '@/web/states/player'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
const Header = ({ artist }: { artist: Artist | undefined }) => {
|
||||
const coverImage = resizeImage(artist?.img1v1Url || '', 'md')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
|
||||
{coverImage && (
|
||||
<>
|
||||
<img src={coverImage} className='absolute w-full blur-[100px]' />
|
||||
<img src={coverImage} className='absolute w-full blur-[100px]' />
|
||||
</>
|
||||
)}
|
||||
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-6 overflow-hidden rounded-2xl bg-gray-500/10 dark:bg-gray-800/20'>
|
||||
<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?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const LatestRelease = ({
|
||||
album,
|
||||
isLoading,
|
||||
}: {
|
||||
album: Album | undefined
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const toAlbum = () => navigate(`/album/${album?.id}`)
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
最新发行
|
||||
</div>
|
||||
<div className='flex-grow rounded-xl'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='aspect-square w-full rounded-xl'></Skeleton>
|
||||
) : (
|
||||
<Cover
|
||||
imageUrl={resizeImage(album?.picUrl ?? '', 'md')}
|
||||
showPlayButton={true}
|
||||
onClick={toAlbum}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onClick={toAlbum}
|
||||
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'
|
||||
>
|
||||
{album?.name}
|
||||
</div>
|
||||
<div className='text-[12px] text-gray-500 dark:text-gray-400'>
|
||||
{album?.type} · {dayjs(album?.publishTime || 0).year()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopularTracks = ({
|
||||
tracks,
|
||||
isLoadingArtist,
|
||||
}: {
|
||||
tracks: Track[] | undefined
|
||||
isLoadingArtist: boolean
|
||||
}) => {
|
||||
const { data: tracksWithExtraInfo } = useTracks({
|
||||
ids: tracks?.slice(0, 10)?.map(t => t.id) ?? [],
|
||||
})
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(trackID: number | null = null) => {
|
||||
if (!tracks?.length) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playAList(
|
||||
tracks.map(t => t.id),
|
||||
trackID
|
||||
)
|
||||
},
|
||||
[tracks]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
热门歌曲
|
||||
</div>
|
||||
<div className='rounded-xl'>
|
||||
<TracksGrid
|
||||
tracks={tracksWithExtraInfo?.songs ?? tracks?.slice(0, 10) ?? []}
|
||||
isSkeleton={isLoadingArtist}
|
||||
onTrackDoubleClick={handlePlay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Artist = () => {
|
||||
const params = useParams()
|
||||
|
||||
const { data: artist, isLoading: isLoadingArtist } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||
id: Number(params.id) || 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const albums = useMemo(() => {
|
||||
if (!albumsRaw?.hotAlbums) return []
|
||||
const albums: Album[] = []
|
||||
albumsRaw.hotAlbums.forEach(album => {
|
||||
if (album.type !== '专辑') return false
|
||||
if (['混音版', '精选集', 'Remix'].includes(album.subType)) return false
|
||||
|
||||
// No singles
|
||||
if (album.size <= 1) return false
|
||||
|
||||
// No remixes
|
||||
if (
|
||||
/(\(|\[)(.*)(Remix|remix)(.*)(\)|\])/.test(
|
||||
album.name.toLocaleLowerCase()
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If have same name album only keep the Explicit version
|
||||
const sameNameAlbumIndex = albums.findIndex(a => a.name === album.name)
|
||||
if (sameNameAlbumIndex !== -1) {
|
||||
if (album.mark === 1056768) albums[sameNameAlbumIndex] = album
|
||||
return
|
||||
}
|
||||
|
||||
albums.push(album)
|
||||
})
|
||||
return albums
|
||||
}, [albumsRaw?.hotAlbums])
|
||||
|
||||
const singles = useMemo(() => {
|
||||
if (!albumsRaw?.hotAlbums) return []
|
||||
const albumsIds = albums.map(album => album.id)
|
||||
return albumsRaw.hotAlbums.filter(
|
||||
album => albumsIds.includes(album.id) === false
|
||||
)
|
||||
}, [albums, albumsRaw?.hotAlbums])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header artist={artist?.artist} />
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'mt-12 px-2',
|
||||
albumsRaw?.hotAlbums?.length !== 0 &&
|
||||
'grid h-[20rem] grid-cols-[14rem,_auto] grid-rows-1 gap-16'
|
||||
)}
|
||||
>
|
||||
{albumsRaw?.hotAlbums?.length !== 0 && (
|
||||
<LatestRelease
|
||||
album={albumsRaw?.hotAlbums[0]}
|
||||
isLoading={isLoadingAlbums}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PopularTracks
|
||||
tracks={artist?.hotSongs}
|
||||
isLoadingArtist={isLoadingArtist}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Albums */}
|
||||
{albums.length !== 0 && (
|
||||
<div className='mt-20 px-2'>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
专辑
|
||||
</div>
|
||||
<CoverRow
|
||||
albums={albums.slice(0, 10)}
|
||||
subtitle={Subtitle.TypeReleaseYear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Singles/EP */}
|
||||
{singles.length !== 0 && (
|
||||
<div className='mt-16 px-2'>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
单曲和EP
|
||||
</div>
|
||||
<CoverRow
|
||||
albums={singles.slice(0, 5)}
|
||||
subtitle={Subtitle.TypeReleaseYear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Artist
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const ArtistAlbum = () => {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||
|
|
@ -28,7 +30,7 @@ const ArtistAlbum = () => {
|
|||
return (
|
||||
<div>
|
||||
<div className='mb-4 mt-11 text-12 font-medium uppercase text-neutral-300'>
|
||||
Albums
|
||||
{t`common.album_other`}
|
||||
</div>
|
||||
|
||||
<div className='no-scrollbar flex gap-6 overflow-y-hidden overflow-x-scroll'>
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import useArtistMV from '@/web/api/hooks/useArtistMV'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ArtistMVs = () => {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
|
||||
|
|
@ -9,7 +11,7 @@ const ArtistMVs = () => {
|
|||
return (
|
||||
<div>
|
||||
<div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'>
|
||||
MV
|
||||
{t`common.video_other`}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-6'>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||
import ArtistRow from '@/web/components/ArtistRow'
|
||||
import useSimilarArtists from '@/web/api/hooks/useSimilarArtists'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
|
|
@ -6,9 +6,12 @@ import { openContextMenu } from '@/web/states/contextMenus'
|
|||
import player from '@/web/states/player'
|
||||
import { cx } from '@emotion/css'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Actions = ({ isLoading }: { isLoading: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: likedArtists } = useUserArtists()
|
||||
const params = useParams()
|
||||
const id = Number(params.id) || 0
|
||||
|
|
@ -62,7 +65,7 @@ const Actions = ({ isLoading }: { isLoading: boolean }) => {
|
|||
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
|
||||
)}
|
||||
>
|
||||
Listen
|
||||
{t`artist.listen`}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ArtistInfo = ({
|
||||
artist,
|
||||
|
|
@ -9,6 +10,8 @@ const ArtistInfo = ({
|
|||
artist?: Artist
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
|
||||
useAppleMusicArtist({
|
||||
|
|
@ -47,8 +50,9 @@ const ArtistInfo = ({
|
|||
</div>
|
||||
) : (
|
||||
<div className='mt-1 text-12 font-medium text-white/40'>
|
||||
{artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '}
|
||||
{artist?.mvSize} Videos
|
||||
{t('common.track-with-count', { count: artist?.musicSize })} ·{' '}
|
||||
{t('common.album-with-count', { count: artist?.albumSize })} ·{' '}
|
||||
{t('common.video-with-count', { count: artist?.mvSize })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
59
packages/web/pages/Artist/Header/Cover.tsx
Normal file
59
packages/web/pages/Artist/Header/Cover.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/Image'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { motion } from 'framer-motion'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from '@/web/components/VideoCover'
|
||||
|
||||
const Cover = ({ artist }: { artist?: Artist }) => {
|
||||
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
|
||||
useAppleMusicArtist({
|
||||
id: artist?.id,
|
||||
name: artist?.name,
|
||||
})
|
||||
|
||||
const video =
|
||||
artistFromApple?.attributes?.editorialVideo?.motionArtistSquare1x1?.video
|
||||
const cover = isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url
|
||||
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
'relative overflow-hidden lg:rounded-24',
|
||||
css`
|
||||
grid-area: cover;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
className={cx(
|
||||
'aspect-square h-full w-full lg:z-10',
|
||||
video ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
src={resizeImage(
|
||||
isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url ||
|
||||
artist?.img1v1Url ||
|
||||
'',
|
||||
'lg'
|
||||
)}
|
||||
/>
|
||||
|
||||
{video && <VideoCover source={video} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
|
|
@ -2,11 +2,12 @@ import { resizeImage } from '@/web/utils/common'
|
|||
import dayjs from 'dayjs'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import Image from '@/web/components/Image'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { useMemo } from 'react'
|
||||
import useArtistMV from '@/web/api/hooks/useArtistMV'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Album = ({ album }: { album?: Album }) => {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -85,6 +86,8 @@ const Video = ({ video }: { video?: any }) => {
|
|||
}
|
||||
|
||||
const LatestRelease = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const params = useParams()
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||
|
|
@ -108,7 +111,7 @@ const LatestRelease = () => {
|
|||
className='mx-2.5 lg:mx-0'
|
||||
>
|
||||
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
|
||||
Latest Releases
|
||||
{t`artist.latest-releases`}
|
||||
</div>
|
||||
|
||||
<Album album={album} />
|
||||
|
|
@ -3,9 +3,10 @@ import player from '@/web/states/player'
|
|||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import Image from '@/web/components/Image'
|
||||
import useArtist from '@/web/api/hooks/useArtist'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Track = ({
|
||||
track,
|
||||
|
|
@ -52,6 +53,8 @@ const Track = ({
|
|||
}
|
||||
|
||||
const Popular = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const params = useParams()
|
||||
const { data: artist, isLoading: isLoadingArtist } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
|
|
@ -69,7 +72,7 @@ const Popular = () => {
|
|||
return (
|
||||
<div>
|
||||
<div className='mb-4 text-12 font-medium uppercase text-neutral-300'>
|
||||
Popular
|
||||
{t`artist.popular`}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import Tabs from '@/web/components/New/Tabs'
|
||||
import Tabs from '@/web/components/Tabs'
|
||||
import {
|
||||
fetchDailyRecommendPlaylists,
|
||||
fetchRecommendedPlaylists,
|
||||
|
|
@ -6,11 +6,11 @@ import {
|
|||
import { PlaylistApiNames } from '@/shared/api/Playlists'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import CoverRowVirtual from '@/web/components/New/CoverRowVirtual'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import CoverRowVirtual from '@/web/components/CoverRowVirtual'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import { playerWidth, topbarHeight } from '@/web/utils/const'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import topbarBackground from '@/web/assets/images/topbar-background.png'
|
||||
|
||||
const reactQueryOptions = {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import CoverWall from '@/web/components/New/CoverWall'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import CoverWall from '@/web/components/CoverWall'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import {
|
||||
fetchPlaylistWithReactQuery,
|
||||
fetchFromCache,
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import {
|
||||
fetchRecommendedPlaylists,
|
||||
fetchDailyRecommendPlaylists,
|
||||
} from '@/web/api/playlist'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import DailyTracksCard from '@/web/components/DailyTracksCard'
|
||||
import FMCard from '@/web/components/FMCard'
|
||||
import { PlaylistApiNames } from '@/shared/api/Playlists'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
data: dailyRecommendPlaylists,
|
||||
isLoading: isLoadingDailyRecommendPlaylists,
|
||||
} = useQuery(
|
||||
[PlaylistApiNames.FetchDailyRecommendPlaylists],
|
||||
fetchDailyRecommendPlaylists,
|
||||
{
|
||||
retry: false,
|
||||
placeholderData: () =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.RecommendResource,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: recommendedPlaylists,
|
||||
isLoading: isLoadingRecommendedPlaylists,
|
||||
} = useQuery(
|
||||
[PlaylistApiNames.FetchRecommendedPlaylists],
|
||||
() => {
|
||||
return fetchRecommendedPlaylists({})
|
||||
},
|
||||
{
|
||||
placeholderData: () =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Personalized,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const playlists = [
|
||||
...(dailyRecommendPlaylists?.recommend?.slice(1).slice(0, 8) ?? []),
|
||||
...(recommendedPlaylists?.result ?? []),
|
||||
].slice(0, 10)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoverRow
|
||||
title='推荐歌单'
|
||||
playlists={playlists}
|
||||
isSkeleton={
|
||||
isLoadingRecommendedPlaylists || isLoadingDailyRecommendPlaylists
|
||||
}
|
||||
/>
|
||||
|
||||
<div className='mt-10 mb-4 text-[28px] font-bold text-black dark:text-white'>
|
||||
For You
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-6'>
|
||||
<DailyTracksCard />
|
||||
<FMCard />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||
import Icon, { SvgName } from '@/web/components/Icon'
|
||||
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||
import useLyric from '@/web/api/hooks/useLyric'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { sample, chunk } from 'lodash-es'
|
||||
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const LikedTracksCard = ({ className }: { className?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
|
||||
const { data: likedSongsPlaylist } = usePlaylist({
|
||||
id: playlists?.playlist?.[0].id ?? 0,
|
||||
})
|
||||
|
||||
// Lyric
|
||||
const [trackID, setTrackID] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (trackID === 0) {
|
||||
setTrackID(
|
||||
sample(likedSongsPlaylist?.playlist.trackIds?.map(t => t.id) ?? []) ?? 0
|
||||
)
|
||||
}
|
||||
}, [likedSongsPlaylist?.playlist.trackIds, trackID])
|
||||
|
||||
const { data: lyric } = useLyric({
|
||||
id: trackID,
|
||||
})
|
||||
|
||||
const lyricLines = useMemo(() => {
|
||||
return (
|
||||
sample(
|
||||
chunk(
|
||||
lyric?.lrc.lyric
|
||||
?.split('\n')
|
||||
?.map(l => l.split(']').pop()?.trim())
|
||||
?.filter(
|
||||
l =>
|
||||
l &&
|
||||
!l.includes('作词') &&
|
||||
!l.includes('作曲') &&
|
||||
!l.includes('纯音乐,请欣赏')
|
||||
),
|
||||
3
|
||||
)
|
||||
) ?? []
|
||||
)
|
||||
}, [lyric])
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!likedSongsPlaylist?.playlist.id) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playPlaylist(likedSongsPlaylist.playlist.id)
|
||||
},
|
||||
[likedSongsPlaylist?.playlist.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() =>
|
||||
likedSongsPlaylist?.playlist.id &&
|
||||
navigate(`/playlist/${likedSongsPlaylist.playlist.id}`)
|
||||
}
|
||||
className={cx(
|
||||
'relative flex h-full w-full flex-col justify-between rounded-2xl bg-brand-50 py-5 px-6 text-brand-600 dark:bg-brand-600 dark:text-brand-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='text-sm'>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-2xl font-bold'>我喜欢的音乐</div>
|
||||
<div className='mt-0.5 text-[15px]'>
|
||||
{likedSongsPlaylist?.playlist.trackCount ?? 0} 首歌
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className='btn-pressed-animation absolute bottom-6 right-6 grid h-11 w-11 cursor-default place-content-center rounded-full bg-brand-600 text-brand-50 shadow-lg dark:bg-white dark:text-brand-600'
|
||||
>
|
||||
<Icon name='play-fill' className='ml-0.5 h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OtherCard = ({
|
||||
name,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
icon: SvgName
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-full w-full flex-col justify-between rounded-2xl bg-gray-100 text-lg font-bold dark:bg-gray-800 dark:text-gray-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon name={icon} className='ml-3 mt-3 h-12 w-12' />
|
||||
<span className='m-4'>{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Playlists = () => {
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
return (
|
||||
<div>
|
||||
<CoverRow
|
||||
playlists={playlists?.playlist?.slice(1) ?? []}
|
||||
subtitle={Subtitle.Creator}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums({
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoverRow albums={albums?.data ?? []} subtitle={Subtitle.Artist} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Artists = () => {
|
||||
const { data: artists } = useUserArtists()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoverRow artists={artists?.data ?? []} subtitle={Subtitle.Artist} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MVs = () => {
|
||||
return <div>施工中</div>
|
||||
}
|
||||
|
||||
const Podcasts = () => {
|
||||
return <div>施工中</div>
|
||||
}
|
||||
interface TabsType {
|
||||
playlist: string
|
||||
album: string
|
||||
artist: string
|
||||
mv: string
|
||||
podcast: string
|
||||
}
|
||||
|
||||
const TabHeader = ({
|
||||
activeTab,
|
||||
tabs,
|
||||
setActiveTab,
|
||||
}: {
|
||||
activeTab: keyof TabsType
|
||||
tabs: TabsType
|
||||
setActiveTab: (tab: keyof TabsType) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='mt-10 flex text-lg dark:text-white'>
|
||||
{Object.entries(tabs).map(([id, name]) => (
|
||||
<div
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id as keyof TabsType)}
|
||||
className={cx(
|
||||
'btn-pressed-animation mr-3 rounded-lg px-3.5 py-1.5 font-medium',
|
||||
activeTab === id
|
||||
? 'bg-black/[.04] dark:bg-white/10'
|
||||
: 'btn-hover-animation after:bg-black/[.04] dark:after:bg-white/10'
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs = () => {
|
||||
const tabs = {
|
||||
playlist: '全部歌单',
|
||||
album: '专辑',
|
||||
artist: '艺人',
|
||||
mv: 'MV',
|
||||
podcast: '播客',
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState<keyof TabsType>('playlist')
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabHeader
|
||||
activeTab={activeTab}
|
||||
tabs={tabs}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
<div className='mt-6'>
|
||||
{activeTab === 'playlist' && <Playlists />}
|
||||
{activeTab === 'album' && <Albums />}
|
||||
{activeTab === 'artist' && <Artists />}
|
||||
{activeTab === 'mv' && <MVs />}
|
||||
{activeTab === 'podcast' && <Podcasts />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Library = () => {
|
||||
const { data: user } = useUser()
|
||||
|
||||
const avatarUrl = useMemo(
|
||||
() => resizeImage(user?.profile?.avatarUrl ?? '', 'sm'),
|
||||
[user?.profile?.avatarUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='mt-8'>
|
||||
<div className='flex items-center text-[2.625rem] font-semibold dark:text-white'>
|
||||
<img src={avatarUrl} className='mr-3 mt-1 h-12 w-12 rounded-full' />
|
||||
{user?.profile?.nickname}的音乐库
|
||||
</div>
|
||||
|
||||
<div className='mt-8 grid grid-cols-[2fr_1fr_1fr] grid-rows-2 gap-4'>
|
||||
<LikedTracksCard className='row-span-2' />
|
||||
<OtherCard name='云盘' icon='fm' className='' />
|
||||
<OtherCard name='本地音乐' icon='music-note' className='' />
|
||||
<OtherCard name='最近播放' icon='playlist' className='' />
|
||||
<OtherCard name='听歌排行' icon='music-library' className='' />
|
||||
</div>
|
||||
|
||||
<Tabs />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Library
|
||||
|
|
@ -1,424 +0,0 @@
|
|||
import md5 from 'md5'
|
||||
import QRCode from 'qrcode'
|
||||
import {
|
||||
checkLoginQrCodeStatus,
|
||||
fetchLoginQrCodeKey,
|
||||
loginWithEmail,
|
||||
loginWithPhone,
|
||||
} from '@/web/api/auth'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import { state } from '@/web/store'
|
||||
import { setCookies } from '@/web/utils/cookie'
|
||||
import { useInterval } from 'react-use'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
enum Method {
|
||||
QrCode = 'qrcode',
|
||||
Email = 'email',
|
||||
Phone = 'phone',
|
||||
}
|
||||
|
||||
const domParser = new DOMParser()
|
||||
|
||||
// Shared components and methods
|
||||
const EmailInput = ({
|
||||
email,
|
||||
setEmail,
|
||||
}: {
|
||||
email: string
|
||||
setEmail: (email: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
邮箱
|
||||
</div>
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className='w-full rounded-md border border-gray-300 px-2 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
type='email'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PhoneInput = ({
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
phone,
|
||||
setPhone,
|
||||
}: {
|
||||
countryCode: string
|
||||
setCountryCode: (code: string) => void
|
||||
phone: string
|
||||
setPhone: (phone: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
手机
|
||||
</div>
|
||||
<div className='flex w-full'>
|
||||
<input
|
||||
className={cx(
|
||||
'rounded-md rounded-r-none border border-r-0 border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
countryCode.length <= 3 && 'w-14',
|
||||
countryCode.length == 4 && 'w-16',
|
||||
countryCode.length >= 5 && 'w-20'
|
||||
)}
|
||||
type='text'
|
||||
placeholder='+86'
|
||||
value={countryCode}
|
||||
onChange={e => setCountryCode(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className='flex-grow rounded-md rounded-l-none border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
type='text'
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PasswordInput = ({
|
||||
password,
|
||||
setPassword,
|
||||
}: {
|
||||
password: string
|
||||
setPassword: (password: string) => void
|
||||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
return (
|
||||
<div className='mt-3 flex w-full flex-col'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
密码
|
||||
</div>
|
||||
<div className='flex w-full'>
|
||||
<input
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className='w-full rounded-md rounded-r-none border border-r-0 border-gray-300 px-2 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
<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-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'
|
||||
>
|
||||
<Icon className='h-5 w-5' name={showPassword ? 'eye-off' : 'eye'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
}) => {
|
||||
// TODO: Add loading indicator
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'my-2 mt-6 flex w-full cursor-default items-center justify-center rounded-lg py-2 text-lg font-semibold transition duration-200',
|
||||
!disabled &&
|
||||
'bg-brand-100 text-brand-500 dark:bg-brand-600 dark:text-white',
|
||||
disabled &&
|
||||
'bg-brand-100 text-brand-300 dark:bg-brand-700 dark:text-white/50'
|
||||
)}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const OtherLoginMethods = ({
|
||||
method,
|
||||
setMethod,
|
||||
}: {
|
||||
method: Method
|
||||
setMethod: (method: Method) => void
|
||||
}) => {
|
||||
const otherLoginMethods: {
|
||||
id: Method
|
||||
name: string
|
||||
}[] = [
|
||||
{
|
||||
id: Method.QrCode,
|
||||
name: '二维码',
|
||||
},
|
||||
{
|
||||
id: Method.Email,
|
||||
name: '邮箱',
|
||||
},
|
||||
{
|
||||
id: Method.Phone,
|
||||
name: '手机',
|
||||
},
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<div className='mt-8 mb-4 flex w-full items-center'>
|
||||
<span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span>
|
||||
<span className='mx-2 text-sm text-gray-400 '>or</span>
|
||||
<span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span>
|
||||
</div>
|
||||
<div className='flex gap-3'>
|
||||
{otherLoginMethods.map(
|
||||
({ id, name }) =>
|
||||
method !== id && (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setMethod(id)}
|
||||
className='flex w-full cursor-default items-center justify-center rounded-lg bg-gray-100 py-2 text-gray-600 transition duration-300 hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500 dark:hover:text-gray-100'
|
||||
>
|
||||
<Icon className='mr-2 h-5 w-5' name={id} />
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const saveCookie = (cookies: string) => {
|
||||
setCookies(cookies)
|
||||
}
|
||||
|
||||
// Login with Email
|
||||
const LoginWithEmail = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const doLogin = useMutation(
|
||||
() =>
|
||||
loginWithEmail({
|
||||
email: email.trim(),
|
||||
md5_password: md5(password.trim()),
|
||||
}),
|
||||
{
|
||||
onSuccess: result => {
|
||||
if (result?.code !== 200) {
|
||||
toast(`Login failed: ${result.code}`)
|
||||
return
|
||||
}
|
||||
saveCookie(result.cookie)
|
||||
navigate(-1)
|
||||
},
|
||||
onError: error => {
|
||||
toast(`Login failed: ${error}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!email) {
|
||||
toast.error('Please enter email')
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
toast.error('Please enter password')
|
||||
return
|
||||
}
|
||||
if (
|
||||
email.match(
|
||||
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
|
||||
) == null
|
||||
) {
|
||||
toast.error('Please use netease email')
|
||||
return
|
||||
}
|
||||
|
||||
doLogin.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmailInput {...{ email, setEmail }} />
|
||||
<PasswordInput {...{ password, setPassword }} />
|
||||
<LoginButton onClick={handleLogin} disabled={doLogin.isLoading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Login with Phone
|
||||
const LoginWithPhone = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const countryCode = useSnapshot(state).uiStates.loginPhoneCountryCode
|
||||
const setCountryCode = (countryCode: string) => {
|
||||
state.uiStates.loginPhoneCountryCode = countryCode
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
|
||||
const doLogin = useMutation(
|
||||
() => {
|
||||
return loginWithPhone({
|
||||
countrycode: Number(countryCode.replace('+', '').trim()) || 86,
|
||||
phone: phone.trim(),
|
||||
md5_password: md5(password.trim()),
|
||||
})
|
||||
},
|
||||
{
|
||||
onSuccess: result => {
|
||||
if (result?.code !== 200) {
|
||||
toast(`Login failed: ${result.code}`)
|
||||
return
|
||||
}
|
||||
saveCookie(result.cookie)
|
||||
navigate(-1)
|
||||
},
|
||||
onError: error => {
|
||||
toast(`Login failed: ${error}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!countryCode || !Number(countryCode.replace('+', '').trim())) {
|
||||
toast.error('Please enter country code')
|
||||
return
|
||||
}
|
||||
if (!phone) {
|
||||
toast.error('Please enter phone number')
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
toast.error('Please enter password')
|
||||
return
|
||||
}
|
||||
|
||||
doLogin.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhoneInput {...{ countryCode, setCountryCode, phone, setPhone }} />
|
||||
<PasswordInput {...{ password, setPassword }} />
|
||||
<LoginButton onClick={handleLogin} disabled={doLogin.isLoading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Login with QRCode
|
||||
const LoginWithQRCode = () => {
|
||||
const [qrCodeMessage, setQrCodeMessage] = useState('打开网易云音乐,扫码登录')
|
||||
const [qrCodeImage, setQrCodeImage] = useState('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const {
|
||||
data: key = { code: 200, data: { code: 200, unikey: 'Not Ready' } },
|
||||
status: keyStatus,
|
||||
refetch: refetchKey,
|
||||
} = useQuery(
|
||||
['qrCodeKey'],
|
||||
async () => {
|
||||
const result = await fetchLoginQrCodeKey()
|
||||
if (result.data.code !== 200) {
|
||||
throw Error(`Failed to fetch QR code key: ${result.data.code}`)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{
|
||||
retry: true,
|
||||
retryDelay: 500,
|
||||
}
|
||||
)
|
||||
|
||||
useInterval(async () => {
|
||||
if (keyStatus !== 'success') return
|
||||
const qrCodeStatus = await checkLoginQrCodeStatus({ key: key.data.unikey })
|
||||
switch (qrCodeStatus.code) {
|
||||
case 800:
|
||||
refetchKey()
|
||||
break
|
||||
case 801:
|
||||
setQrCodeMessage('打开网易云音乐,扫码登录')
|
||||
break
|
||||
case 802:
|
||||
setQrCodeMessage('等待确认')
|
||||
break
|
||||
case 803:
|
||||
if (qrCodeStatus.cookie === undefined) {
|
||||
toast('checkLoginQrCodeStatus returned 803 without cookie')
|
||||
break
|
||||
}
|
||||
saveCookie(qrCodeStatus.cookie)
|
||||
navigate(-1)
|
||||
break
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const qrCodeUrl = useMemo(
|
||||
() => `https://music.163.com/login?codekey=${key.data.unikey}`,
|
||||
[key]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateImage = async () => {
|
||||
const svg = await QRCode.toString(qrCodeUrl, {
|
||||
margin: 0,
|
||||
color: {
|
||||
light: '#ffffff00',
|
||||
},
|
||||
type: 'svg',
|
||||
})
|
||||
const path = domParser
|
||||
.parseFromString(svg, 'text/xml')
|
||||
.getElementsByTagName('path')[0]
|
||||
|
||||
setQrCodeImage(path?.getAttribute('d') ?? '')
|
||||
}
|
||||
updateImage()
|
||||
}, [qrCodeUrl])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='rounded-3xl border p-6 text-brand-500 dark:border-gray-700'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='270'
|
||||
height='270'
|
||||
viewBox='0 0 37 37'
|
||||
shapeRendering='crispEdges'
|
||||
>
|
||||
<path stroke='currentColor' d={qrCodeImage} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='mt-4 text-sm text-gray-500 dark:text-gray-200'>
|
||||
{qrCodeMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [method, setMethod] = useState<Method>(Method.Phone)
|
||||
|
||||
return (
|
||||
<div className='grid h-full place-content-center'>
|
||||
<div className='w-80'>
|
||||
{method === Method.Email && <LoginWithEmail />}
|
||||
{method === Method.Phone && <LoginWithPhone />}
|
||||
{method === Method.QrCode && <LoginWithQRCode />}
|
||||
<OtherLoginMethods {...{ method, setMethod }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import useMV, { useMVUrl } from '@/web/api/hooks/useMV'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import Plyr, { PlyrOptions, PlyrSource } from 'plyr-react'
|
||||
|
|
@ -1,38 +1,20 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||
import Tabs from '@/web/components/New/Tabs'
|
||||
import Tabs from '@/web/components/Tabs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||
import ArtistRow from '@/web/components/ArtistRow'
|
||||
import { playerWidth, topbarHeight } from '@/web/utils/const'
|
||||
import topbarBackground from '@/web/assets/images/topbar-background.png'
|
||||
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { scrollToBottom } from '@/web/utils/common'
|
||||
import { throttle } from 'lodash-es'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'playlists',
|
||||
name: 'Playlists',
|
||||
},
|
||||
{
|
||||
id: 'albums',
|
||||
name: 'Albums',
|
||||
},
|
||||
{
|
||||
id: 'artists',
|
||||
name: 'Artists',
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
name: 'Videos',
|
||||
},
|
||||
]
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums()
|
||||
|
|
@ -54,6 +36,27 @@ const Artists = () => {
|
|||
}
|
||||
|
||||
const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'playlists',
|
||||
name: t`common.playlist_other`,
|
||||
},
|
||||
{
|
||||
id: 'albums',
|
||||
name: t`common.album_other`,
|
||||
},
|
||||
{
|
||||
id: 'artists',
|
||||
name: t`common.artist_other`,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
name: t`common.video_other`,
|
||||
},
|
||||
]
|
||||
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
const setSelectedTab = (
|
||||
id: 'playlists' | 'albums' | 'artists' | 'videos'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import PlayLikedSongsCard from './PlayLikedSongsCard'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import RecentlyListened from './RecentlyListened'
|
||||
import Collections from './Collections'
|
||||
|
||||
|
|
@ -9,12 +9,14 @@ import toast from 'react-hot-toast'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import { lyricParser } from '@/web/utils/lyric'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import Image from '@/web/components/Image'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
||||
const { t } = useTranslation()
|
||||
const [id, setId] = useState(0)
|
||||
const { data: user } = useUser()
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
)}
|
||||
>
|
||||
<div className='mb-3.5 text-18 font-medium text-white/70'>
|
||||
{user?.profile?.nickname}'S LIKED TRACKS
|
||||
{t('my.xxxs-liked-tracks', { nickname: user?.profile?.nickname })}
|
||||
</div>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div
|
||||
|
|
@ -87,6 +89,8 @@ const Covers = memo(({ tracks }: { tracks: Track[] }) => {
|
|||
Covers.displayName = 'Covers'
|
||||
|
||||
const PlayLikedSongsCard = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
|
|
@ -98,11 +102,7 @@ const PlayLikedSongsCard = () => {
|
|||
const handlePlay = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!likedSongsPlaylist?.playlist.id) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playPlaylist(likedSongsPlaylist.playlist.id)
|
||||
player.playPlaylist(likedSongsPlaylist?.playlist.id)
|
||||
},
|
||||
[likedSongsPlaylist?.playlist.id]
|
||||
)
|
||||
|
|
@ -139,7 +139,7 @@ const PlayLikedSongsCard = () => {
|
|||
onClick={handlePlay}
|
||||
className='rounded-full bg-brand-700 py-5 px-6 text-16 font-medium text-white'
|
||||
>
|
||||
Play Now
|
||||
{t`my.playNow`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords'
|
||||
import useArtists from '@/web/api/hooks/useArtists'
|
||||
import { useMemo } from 'react'
|
||||
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||
import ArtistRow from '@/web/components/ArtistRow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RecentlyListened = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: listenedRecords } = useUserListenedRecords({ type: 'week' })
|
||||
const recentListenedArtistsIDs = useMemo(() => {
|
||||
const artists: {
|
||||
|
|
@ -35,7 +38,11 @@ const RecentlyListened = () => {
|
|||
)
|
||||
|
||||
return (
|
||||
<ArtistRow artists={artist} placeholderRow={1} title='RECENTLY LISTENED' />
|
||||
<ArtistRow
|
||||
artists={artist}
|
||||
placeholderRow={1}
|
||||
title={t`my.recently-listened`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { motion } from 'framer-motion'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const VideoCover = ({ source }: { source?: string }) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>(new Hls())
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported()) {
|
||||
const video = document.querySelector('#video-cover') as HTMLVideoElement
|
||||
hls.current.loadSource(source)
|
||||
hls.current.attachMedia(video)
|
||||
}
|
||||
}, [source])
|
||||
|
||||
return (
|
||||
<div className='z-10 aspect-square overflow-hidden rounded-24'>
|
||||
<video
|
||||
id='video-cover'
|
||||
className='h-full w-full'
|
||||
ref={ref}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Cover = ({ artist }: { artist?: Artist }) => {
|
||||
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
|
||||
useAppleMusicArtist({
|
||||
id: artist?.id,
|
||||
name: artist?.name,
|
||||
})
|
||||
|
||||
const video =
|
||||
artistFromApple?.attributes?.editorialVideo?.motionArtistSquare1x1?.video
|
||||
const cover = isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url
|
||||
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
'relative',
|
||||
css`
|
||||
grid-area: cover;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
className='aspect-square h-full w-full lg:z-10 lg:rounded-24'
|
||||
src={resizeImage(
|
||||
isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url ||
|
||||
artist?.img1v1Url ||
|
||||
'',
|
||||
'lg'
|
||||
)}
|
||||
/>
|
||||
|
||||
{video && (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className='absolute inset-0 z-10 h-full w-full'
|
||||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
src={video}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
></video>
|
||||
) : (
|
||||
<VideoCover source={video} />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import TracksList from '@/web/components/TracksList'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import useScroll from '@/web/hooks/useScroll'
|
||||
import useTracksInfinite from '@/web/api/hooks/useTracksInfinite'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDate, resizeImage } from '@/web/utils/common'
|
||||
import useUserPlaylists, {
|
||||
useMutationLikeAPlaylist,
|
||||
} from '@/web/api/hooks/useUserPlaylists'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import {
|
||||
Mode as PlayerMode,
|
||||
TrackListSourceType,
|
||||
State as PlayerState,
|
||||
} from '@/web/utils/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const PlayButton = ({
|
||||
playlist,
|
||||
handlePlay,
|
||||
isLoading,
|
||||
}: {
|
||||
playlist: Playlist | undefined
|
||||
handlePlay: () => void
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const isThisPlaylistPlaying = useMemo(
|
||||
() =>
|
||||
playerSnapshot.mode === PlayerMode.TrackList &&
|
||||
playerSnapshot.trackListSource?.type === TrackListSourceType.Playlist &&
|
||||
playerSnapshot.trackListSource?.id === playlist?.id,
|
||||
[
|
||||
playerSnapshot.mode,
|
||||
playerSnapshot.trackListSource?.id,
|
||||
playerSnapshot.trackListSource?.type,
|
||||
playlist?.id,
|
||||
]
|
||||
)
|
||||
|
||||
const wrappedHandlePlay = () => {
|
||||
if (isThisPlaylistPlaying) {
|
||||
player.playOrPause()
|
||||
} else {
|
||||
handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
const isPlaying =
|
||||
isThisPlaylistPlaying &&
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(playerSnapshot.state)
|
||||
|
||||
return (
|
||||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||
<Icon
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
className='-ml-1 mr-1 h-6 w-6'
|
||||
/>
|
||||
{isPlaying ? '暂停' : '播放'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = memo(
|
||||
({
|
||||
playlist,
|
||||
isLoading,
|
||||
handlePlay,
|
||||
}: {
|
||||
playlist: Playlist | undefined
|
||||
isLoading: boolean
|
||||
handlePlay: () => void
|
||||
}) => {
|
||||
const coverUrl = resizeImage(playlist?.coverImgUrl || '', 'lg')
|
||||
|
||||
const mutationLikeAPlaylist = useMutationLikeAPlaylist()
|
||||
const { data: userPlaylists } = useUserPlaylists()
|
||||
|
||||
const isThisPlaylistLiked = useMemo(() => {
|
||||
if (!playlist) return false
|
||||
return !!userPlaylists?.playlist?.find(p => p.id === playlist.id)
|
||||
}, [playlist, userPlaylists?.playlist])
|
||||
|
||||
const { data: user } = useUser()
|
||||
const isThisPlaylistCreatedByCurrentUser = useMemo(() => {
|
||||
if (!playlist || !user) return false
|
||||
return playlist.creator.userId === user?.profile?.userId
|
||||
}, [playlist, user])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header background */}
|
||||
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
|
||||
<img src={coverUrl} className='absolute top-0 w-full blur-[100px]' />
|
||||
<img src={coverUrl} className='absolute top-0 w-full blur-[100px]' />
|
||||
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[17rem_auto] items-center gap-9'>
|
||||
{/* Cover */}
|
||||
<div className='relative z-0 aspect-square self-start'>
|
||||
{!isLoading && (
|
||||
<div
|
||||
className='absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter'
|
||||
style={{
|
||||
backgroundImage: `url("${coverUrl}")`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='rounded-2xl border border-black border-opacity-5'
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='h-full w-full rounded-2xl' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <!-- Playlist info --> */}
|
||||
<div className='z-10 flex h-full flex-col justify-between'>
|
||||
{/* <!-- Playlist name --> */}
|
||||
{!isLoading && (
|
||||
<div className='text-4xl font-bold dark:text-white'>
|
||||
{playlist?.name}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='w-3/4 text-4xl'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist creator --> */}
|
||||
{!isLoading && (
|
||||
<div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'>
|
||||
歌单 · <span>{playlist?.creator?.nickname}</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='mt-5 w-64 text-lg'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist last update time & track count --> */}
|
||||
{!isLoading && (
|
||||
<div className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
更新于 {formatDate(playlist?.updateTime || 0, 'zh-CN')} ·{' '}
|
||||
{playlist?.trackCount} 首歌
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='w-72 translate-y-px text-sm'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist description --> */}
|
||||
{!isLoading && (
|
||||
<div className='line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400'>
|
||||
{playlist?.description}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='mt-5 min-h-[2.5rem] w-1/2 text-sm'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Buttons --> */}
|
||||
<div className='mt-5 flex gap-4'>
|
||||
<PlayButton {...{ playlist, handlePlay, isLoading }} />
|
||||
|
||||
{!isThisPlaylistCreatedByCurrentUser && (
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={
|
||||
isThisPlaylistLiked ? ButtonColor.Primary : ButtonColor.Gray
|
||||
}
|
||||
isSkelton={isLoading}
|
||||
onClick={() =>
|
||||
playlist?.id && mutationLikeAPlaylist.mutate(playlist)
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={isThisPlaylistLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-6 w-6'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={ButtonColor.Gray}
|
||||
isSkelton={isLoading}
|
||||
onClick={() => toast('施工中...')}
|
||||
>
|
||||
<Icon name='more' className='h-6 w-6' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Header.displayName = 'Header'
|
||||
|
||||
const Tracks = memo(
|
||||
({
|
||||
playlist,
|
||||
handlePlay,
|
||||
isLoadingPlaylist,
|
||||
}: {
|
||||
playlist: Playlist | undefined
|
||||
handlePlay: (trackID: number | null) => void
|
||||
isLoadingPlaylist: boolean
|
||||
}) => {
|
||||
const {
|
||||
data: tracksPages,
|
||||
hasNextPage,
|
||||
isLoading: isLoadingTracks,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useTracksInfinite({
|
||||
ids: playlist?.trackIds?.map(t => t.id) || [],
|
||||
})
|
||||
|
||||
const scroll = useScroll(document.getElementById('mainContainer'), {
|
||||
throttle: 500,
|
||||
offset: {
|
||||
bottom: 256,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!scroll.arrivedState.bottom || !hasNextPage || isFetchingNextPage)
|
||||
return
|
||||
fetchNextPage()
|
||||
}, [
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
scroll.arrivedState.bottom,
|
||||
])
|
||||
|
||||
const tracks = useMemo(() => {
|
||||
if (!tracksPages) return []
|
||||
const allTracks: Track[] = []
|
||||
tracksPages.pages.forEach(page => allTracks.push(...(page?.songs ?? [])))
|
||||
return allTracks
|
||||
}, [tracksPages])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingPlaylist ? (
|
||||
<TracksList tracks={[]} isSkeleton={true} />
|
||||
) : isLoadingTracks ? (
|
||||
<TracksList
|
||||
tracks={playlist?.tracks ?? []}
|
||||
onTrackDoubleClick={handlePlay}
|
||||
/>
|
||||
) : (
|
||||
<TracksList tracks={tracks} onTrackDoubleClick={handlePlay} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Tracks.displayName = 'Tracks'
|
||||
|
||||
const Playlist = () => {
|
||||
const params = useParams()
|
||||
const { data: playlist, isLoading } = usePlaylist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(trackID: number | null = null) => {
|
||||
if (!playlist?.playlist?.id) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playPlaylist(playlist.playlist.id, trackID)
|
||||
},
|
||||
[playlist]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='mt-10'>
|
||||
<Header
|
||||
playlist={playlist?.playlist}
|
||||
isLoading={isLoading}
|
||||
handlePlay={handlePlay}
|
||||
/>
|
||||
|
||||
<Tracks
|
||||
playlist={playlist?.playlist}
|
||||
handlePlay={handlePlay}
|
||||
isLoadingPlaylist={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlist
|
||||
|
|
@ -3,7 +3,7 @@ import useUser from '@/web/api/hooks/useUser'
|
|||
import useUserPlaylists, {
|
||||
useMutationLikeAPlaylist,
|
||||
} from '@/web/api/hooks/useUserPlaylists'
|
||||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import TrackListHeader from '@/web/components/TrackListHeader'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDate } from '@/web/utils/common'
|
||||
import { useMemo } from 'react'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import TrackList from '@/web/components/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import Header from './Header'
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
const Podcast = () => {
|
||||
return <div>施工中...</div>
|
||||
}
|
||||
|
||||
export default Podcast
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { multiMatchSearch, search } from '@/web/api/search'
|
||||
import Cover from '@/web/components/Cover'
|
||||
import TrackGrid from '@/web/components/TracksGrid'
|
||||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { SearchTypes, SearchApiNames } from '@/shared/api/Search'
|
||||
|
|
@ -9,6 +7,8 @@ import { useMemo, useCallback } from 'react'
|
|||
import toast from 'react-hot-toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Image from '@/web/components/Image'
|
||||
import { cx } from '@emotion/css'
|
||||
|
||||
const Artists = ({ artists }: { artists: Artist[] }) => {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -21,10 +21,9 @@ const Artists = ({ artists }: { artists: Artist[] }) => {
|
|||
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-4 h-14 w-14'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(artist.img1v1Url, 'xs')}
|
||||
roundedClass='rounded-full'
|
||||
showHover={false}
|
||||
<img
|
||||
src={resizeImage(artist.img1v1Url, 'xs')}
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -52,10 +51,9 @@ const Albums = ({ albums }: { albums: Album[] }) => {
|
|||
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-4 h-14 w-14'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(album.picUrl, 'xs')}
|
||||
roundedClass='rounded-lg'
|
||||
showHover={false}
|
||||
<img
|
||||
src={resizeImage(album.picUrl, 'xs')}
|
||||
className='h-12 w-12 rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -72,6 +70,50 @@ const Albums = ({ albums }: { albums: Album[] }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const Track = ({
|
||||
track,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
}: {
|
||||
track?: Track
|
||||
isPlaying?: boolean
|
||||
onPlay: (id: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-between'
|
||||
onClick={e => {
|
||||
if (e.detail === 2 && track?.id) onPlay(track.id)
|
||||
}}
|
||||
>
|
||||
{/* Cover */}
|
||||
<Image
|
||||
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
|
||||
src={resizeImage(track?.al?.picUrl || '', 'sm')}
|
||||
animation={false}
|
||||
placeholder={false}
|
||||
/>
|
||||
|
||||
{/* Track info */}
|
||||
<div className='mr-3 flex-grow'>
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 text-16 font-medium ',
|
||||
isPlaying
|
||||
? 'text-brand-700'
|
||||
: 'text-neutral-700 dark:text-neutral-200'
|
||||
)}
|
||||
>
|
||||
{track?.name}
|
||||
</div>
|
||||
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'>
|
||||
{track?.ar.map(a => a.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Search = () => {
|
||||
const { keywords = '', type = 'all' } = useParams()
|
||||
|
||||
|
|
@ -145,10 +187,9 @@ const Search = () => {
|
|||
className='btn-hover-animation flex items-center p-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-6 h-24 w-24'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(match.picUrl, 'xs')}
|
||||
showHover={false}
|
||||
roundedClass='rounded-full'
|
||||
<img
|
||||
src={resizeImage(match.picUrl, 'xs')}
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -184,11 +225,11 @@ const Search = () => {
|
|||
{searchResult?.result?.song?.songs && (
|
||||
<div className='col-span-2'>
|
||||
<div className='mb-2 text-sm font-medium text-gray-400'>歌曲</div>
|
||||
<TrackGrid
|
||||
tracks={searchResult.result.song.songs}
|
||||
cols={3}
|
||||
onTrackDoubleClick={handlePlayTracks}
|
||||
/>
|
||||
<div className='mt-4 grid grid-cols-3 grid-rows-3 gap-5 gap-y-6 overflow-hidden pb-12'>
|
||||
{searchResult.result.song.songs.map(track => (
|
||||
<Track key={track.id} track={track} onPlay={handlePlayTracks} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue