feat: updates

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}&apos;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={() =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
const Podcast = () => {
return <div>...</div>
}
export default Podcast

View file

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