feat: monorepo

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

View file

@ -0,0 +1,361 @@
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 SvgIcon from '@/web/components/SvgIcon'
import TracksAlbum from '@/web/components/TracksAlbum'
import useAlbum from '@/web/hooks/useAlbum'
import useArtistAlbums from '@/web/hooks/useArtistAlbums'
import { player } from '@/web/store'
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/hooks/useTracks'
import useUserAlbums, {
useMutationLikeAAlbum,
} from '@/web/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}>
<SvgIcon
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'>
<SvgIcon 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 && (
<SvgIcon
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)}
>
<SvgIcon
name={isThisAlbumLiked ? 'heart' : 'heart-outline'}
className='h-6 w-6'
/>
</Button>
<Button
color={ButtonColor.Gray}
iconColor={ButtonColor.Gray}
isSkelton={isLoading}
onClick={() => toast('施工中...')}
>
<SvgIcon 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

@ -0,0 +1,235 @@
import Button, { Color as ButtonColor } from '@/web/components/Button'
import SvgIcon from '@/web/components/SvgIcon'
import Cover from '@/web/components/Cover'
import useArtist from '@/web/hooks/useArtist'
import useArtistAlbums from '@/web/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/hooks/useTracks'
import { player } from '@/web/store'
import cx from 'classnames'
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

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

@ -0,0 +1,263 @@
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
import SvgIcon, { SvgName } from '@/web/components/SvgIcon'
import useUserAlbums from '@/web/hooks/useUserAlbums'
import useLyric from '@/web/hooks/useLyric'
import usePlaylist from '@/web/hooks/usePlaylist'
import useUser from '@/web/hooks/useUser'
import useUserPlaylists from '@/web/hooks/useUserPlaylists'
import { player } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import { sample, chunk } from 'lodash-es'
import useUserArtists from '@/web/hooks/useUserArtists'
import cx from 'classnames'
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'
>
<SvgIcon 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
)}
>
<SvgIcon 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

@ -0,0 +1,427 @@
import md5 from 'md5'
import QRCode from 'qrcode'
import {
checkLoginQrCodeStatus,
fetchLoginQrCodeKey,
loginWithEmail,
loginWithPhone,
} from '@/web/api/auth'
import SvgIcon from '@/web/components/SvgIcon'
import { state } from '@/web/store'
import { setCookies } from '@/web/utils/cookie'
import { useInterval } from 'react-use'
import cx from 'classnames'
import { useState, useMemo, useEffect } from 'react'
import toast from 'react-hot-toast'
import { useMutation, useQuery } from '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'
>
<SvgIcon
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'
>
<SvgIcon 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

@ -0,0 +1,315 @@
import { memo, useCallback, useEffect, useMemo } from 'react'
import Button, { Color as ButtonColor } from '@/web/components/Button'
import Skeleton from '@/web/components/Skeleton'
import SvgIcon from '@/web/components/SvgIcon'
import TracksList from '@/web/components/TracksList'
import usePlaylist from '@/web/hooks/usePlaylist'
import useScroll from '@/web/hooks/useScroll'
import useTracksInfinite from '@/web/hooks/useTracksInfinite'
import { player } from '@/web/store'
import { formatDate, resizeImage } from '@/web/utils/common'
import useUserPlaylists, {
useMutationLikeAPlaylist,
} from '@/web/hooks/useUserPlaylists'
import useUser from '@/web/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}>
<SvgIcon
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)
}
>
<SvgIcon
name={isThisPlaylistLiked ? 'heart' : 'heart-outline'}
className='h-6 w-6'
/>
</Button>
)}
<Button
color={ButtonColor.Gray}
iconColor={ButtonColor.Gray}
isSkelton={isLoading}
onClick={() => toast('施工中...')}
>
<SvgIcon 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

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

View file

@ -0,0 +1,199 @@
import { multiMatchSearch, search } from '@/web/api/search'
import Cover from '@/web/components/Cover'
import TrackGrid from '@/web/components/TracksGrid'
import { player } from '@/web/store'
import { resizeImage } from '@/web/utils/common'
import { SearchTypes, SearchApiNames } from '@/shared/api/Search'
import dayjs from 'dayjs'
import { useMemo, useCallback } from 'react'
import toast from 'react-hot-toast'
import { useQuery } from 'react-query'
import { useNavigate, useParams } from 'react-router-dom'
const Artists = ({ artists }: { artists: Artist[] }) => {
const navigate = useNavigate()
return (
<>
{artists.map(artist => (
<div
onClick={() => navigate(`/artist/${artist.id}`)}
key={artist.id}
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}
/>
</div>
<div>
<div className='text-lg font-semibold dark:text-white'>
{artist.name}
</div>
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
</div>
</div>
</div>
))}
</>
)
}
const Albums = ({ albums }: { albums: Album[] }) => {
const navigate = useNavigate()
return (
<>
{albums.map(album => (
<div
onClick={() => navigate(`/album/${album.id}`)}
key={album.id}
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}
/>
</div>
<div>
<div className='text-lg font-semibold dark:text-white'>
{album.name}
</div>
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
· {album?.artist.name} · {dayjs(album.publishTime).year()}
</div>
</div>
</div>
))}
</>
)
}
const Search = () => {
const { keywords = '', type = 'all' } = useParams()
const searchType: keyof typeof SearchTypes =
type.toUpperCase() in SearchTypes
? (type.toUpperCase() as keyof typeof SearchTypes)
: 'All'
const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery(
[SearchApiNames.MultiMatchSearch, keywords],
() => multiMatchSearch({ keywords })
)
const bestMatch = useMemo(() => {
if (!bestMatchRaw?.result) return []
return bestMatchRaw.result.orders
.filter(order => ['album', 'artist'].includes(order)) // 暂时只支持专辑和艺人
.map(order => {
return bestMatchRaw.result[order][0]
})
.slice(0, 2)
}, [bestMatchRaw?.result])
const { data: searchResult, isLoading: isLoadingSearchResult } = useQuery(
[SearchApiNames.Search, keywords, searchType],
() => search({ keywords, type: searchType })
)
const handlePlayTracks = useCallback(
(trackID: number | null = null) => {
const tracks = searchResult?.result?.song?.songs
if (!tracks?.length) {
toast('无法播放歌单')
return
}
player.playAList(
tracks.map(t => t.id),
trackID
)
},
[searchResult?.result?.song?.songs]
)
const navigate = useNavigate()
const navigateBestMatch = (match: Artist | Album) => {
if ((match as Artist).albumSize !== undefined) {
navigate(`/artist/${match.id}`)
return
}
if ((match as Album).artist !== undefined) {
navigate(`/album/${match.id}`)
return
}
}
return (
<div>
<div className='mt-6 mb-8 text-4xl font-semibold dark:text-white'>
<span className='text-gray-500'></span> &quot;{keywords}&quot;
</div>
{/* Best match */}
{bestMatch.length !== 0 && (
<div className='mb-6'>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<div className='grid grid-cols-2'>
{bestMatch.map(match => (
<div
onClick={() => navigateBestMatch(match)}
key={`${match.id}${match.picUrl}`}
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'
/>
</div>
<div>
<div className='text-xl font-semibold dark:text-white'>
{match.name}
</div>
<div className='mt-0.5 font-medium text-gray-500 dark:text-gray-400'>
{(match as Artist).occupation === '歌手' ? '艺人' : '专辑'}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Search result */}
<div className='grid grid-cols-2 gap-6'>
{searchResult?.result?.artist?.artists && (
<div>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<Artists artists={searchResult.result.artist.artists} />
</div>
)}
{searchResult?.result?.album?.albums && (
<div>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<Albums albums={searchResult.result.album.albums} />
</div>
)}
{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>
)}
</div>
</div>
)
}
export default Search

View file

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

View file

@ -0,0 +1,79 @@
import { state } from '@/web/store'
import { changeAccentColor } from '@/web/utils/theme'
import { useSnapshot } from 'valtio'
import cx from 'classnames'
const AccentColor = () => {
const colors = {
red: 'bg-red-500',
orange: 'bg-orange-500',
amber: 'bg-amber-500',
yellow: 'bg-yellow-500',
lime: 'bg-lime-500',
green: 'bg-green-500',
emerald: 'bg-emerald-500',
teal: 'bg-teal-500',
cyan: 'bg-cyan-500',
sky: 'bg-sky-500',
blue: 'bg-blue-500',
indigo: 'bg-indigo-500',
violet: 'bg-violet-500',
purple: 'bg-purple-500',
fuchsia: 'bg-fuchsia-500',
pink: 'bg-pink-500',
rose: 'bg-rose-500',
}
const changeColor = (color: string) => {
state.settings.accentColor = color
changeAccentColor(color)
}
const accentColor = useSnapshot(state).settings.accentColor
return (
<div className='mt-4'>
<div className='mb-2 dark:text-white'></div>
<div className=' flex items-center'>
{Object.entries(colors).map(([color, bg]) => (
<div
key={color}
className={cx(
bg,
'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full'
)}
onClick={() => changeColor(color)}
>
{color === accentColor && (
<div className='h-1.5 w-1.5 rounded-full bg-white'></div>
)}
</div>
))}
</div>
</div>
)
}
const Theme = () => {
return (
<div className='mt-4'>
<div className='mb-2 dark:text-white'></div>
<div></div>
</div>
)
}
const Appearance = () => {
return (
<div>
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
</div>
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
<AccentColor />
<Theme />
</div>
)
}
export default Appearance

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,44 @@
const UnblockNeteaseMusic = () => {
return (
<div>
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
UnblockNeteaseMusic
</div>
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
<div>
:
<div>
<input type='checkbox' id='migu' value='migu' />
<label htmlFor='migu'>migu</label>
</div>
<div>
<input type='checkbox' id='youtube' value='youtube' />
<label htmlFor='youtube'>youtube</label>
</div>
<div>
<input type='checkbox' id='kugou' value='kugou' />
<label htmlFor='kugou'>kugou</label>
</div>
<div>
<input type='checkbox' id='kuwo' value='kuwo' />
<label htmlFor='kuwo'>kuwo</label>
</div>
<div>
<input type='checkbox' id='qq' value='qq' />
<label htmlFor='qq'>qq</label>
</div>
<div>
<input type='checkbox' id='bilibili' value='bilibili' />
<label htmlFor='bilibili'>bilibili</label>
</div>
<div>
<input type='checkbox' id='joox' value='joox' />
<label htmlFor='joox'>joox</label>
</div>
</div>
</div>
)
}
export default UnblockNeteaseMusic

View file

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