mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 13:17:46 +00:00
feat: updates
This commit is contained in:
parent
9a52681687
commit
840a5b8e9b
104 changed files with 1645 additions and 13494 deletions
|
|
@ -2,7 +2,6 @@ import useAlbum from '@/web/api/hooks/useAlbum'
|
|||
import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import TrackListHeader from '@/web/components/TrackListHeader'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import dayjs from 'dayjs'
|
||||
|
|
@ -11,6 +10,7 @@ import toast from 'react-hot-toast'
|
|||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAppleMusicAlbum from '@/web/api/hooks/useAppleMusicAlbum'
|
||||
import { SupportedLanguage } from '@/web/i18n/i18n'
|
||||
|
||||
const Header = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
|
@ -35,14 +35,21 @@ const Header = () => {
|
|||
const title = album?.name
|
||||
const creatorName = album?.artist.name
|
||||
const creatorLink = `/artist/${album?.artist.id}`
|
||||
const description = isLoadingAppleMusicAlbum
|
||||
? ''
|
||||
: appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_')] ||
|
||||
album?.description ||
|
||||
appleMusicAlbum?.editorialNote?.en_US
|
||||
const description = useMemo(() => {
|
||||
if (isLoadingAppleMusicAlbum) return ''
|
||||
const fromApple =
|
||||
appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_') as 'zh_CN' | 'en_US']
|
||||
if (fromApple) return fromApple
|
||||
if (i18n.language === 'zh-CN' && album?.description) return album?.description
|
||||
return appleMusicAlbum?.editorialNote?.en_US
|
||||
}, [isLoadingAppleMusicAlbum, appleMusicAlbum, i18n.language, appleMusicAlbum])
|
||||
const extraInfo = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
const albumDuration = formatDuration(duration, i18n.language, 'hh[hr] mm[min]')
|
||||
const albumDuration = formatDuration(
|
||||
duration,
|
||||
i18n.language as SupportedLanguage,
|
||||
'hh[hr] mm[min]'
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{album?.mark === 1056768 && (
|
||||
|
|
@ -58,7 +65,7 @@ const Header = () => {
|
|||
const isLiked = useMemo(() => {
|
||||
const id = Number(params.id)
|
||||
if (!id) return false
|
||||
return !!userLikedAlbums?.data.find(item => item.id === id)
|
||||
return !!userLikedAlbums?.data?.find(item => item.id === id)
|
||||
}, [params.id, userLikedAlbums?.data])
|
||||
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import useIsMobile from '@/web/hooks/useIsMobile'
|
|||
import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import i18next from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import DescriptionViewer from '@/web/components/DescriptionViewer'
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
|
|||
const [isOpenDescription, setIsOpenDescription] = useState(false)
|
||||
const description =
|
||||
artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] ||
|
||||
artist?.briefDesc ||
|
||||
(i18n.language === 'zh-CN' && artist?.briefDesc) ||
|
||||
artistFromApple?.artistBio?.en_US
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Lyrics = () => {
|
||||
return <div className='text-white'>开发中</div>
|
||||
return <div className='text-white'>歌词页面开发中</div>
|
||||
}
|
||||
|
||||
export default Lyrics
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||
import Tabs from '@/web/components/Tabs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||
|
|
@ -18,6 +18,10 @@ import { useTranslation } from 'react-i18next'
|
|||
import VideoRow from '@/web/components/VideoRow'
|
||||
import useUserVideos from '@/web/api/hooks/useUserVideos'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import settings from '@/web/states/settings'
|
||||
|
||||
const collections = ['playlists', 'albums', 'artists', 'videos'] as const
|
||||
type Collection = typeof collections[number]
|
||||
|
||||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums()
|
||||
|
|
@ -43,8 +47,9 @@ const Videos = () => {
|
|||
|
||||
const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
||||
|
||||
const tabs = [
|
||||
const tabs: { id: Collection; name: string }[] = [
|
||||
{
|
||||
id: 'playlists',
|
||||
name: t`common.playlist_other`,
|
||||
|
|
@ -63,10 +68,10 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
|||
},
|
||||
]
|
||||
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(persistedUiStates)
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
const setSelectedTab = (id: 'playlists' | 'albums' | 'artists' | 'videos') => {
|
||||
uiStates.librarySelectedTab = id
|
||||
const setSelectedTab = (id: Collection) => {
|
||||
persistedUiStates.librarySelectedTab = id
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -94,9 +99,14 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
|||
</AnimatePresence>
|
||||
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
tabs={tabs.filter(tab => {
|
||||
if (!displayPlaylistsFromNeteaseMusic && tab.id === 'playlists') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})}
|
||||
value={selectedTab}
|
||||
onChange={(id: string) => {
|
||||
onChange={(id: Collection) => {
|
||||
setSelectedTab(id)
|
||||
scrollToBottom(true)
|
||||
}}
|
||||
|
|
@ -110,7 +120,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
|||
}
|
||||
|
||||
const Collections = () => {
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(persistedUiStates)
|
||||
|
||||
const observePoint = useRef<HTMLDivElement | null>(null)
|
||||
const { onScreen: isScrollReachBottom } = useIntersectionObserver(observePoint)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import PlayLikedSongsCard from './PlayLikedSongsCard'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import RecentlyListened from './RecentlyListened'
|
||||
import Collections from './Collections'
|
||||
import { useIsLoggedIn } from '@/web/api/hooks/useUser'
|
||||
|
||||
function PleaseLogin() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const My = () => {
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className='grid grid-cols-1 gap-10'>
|
||||
<PlayLikedSongsCard />
|
||||
<RecentlyListened />
|
||||
<Collections />
|
||||
</div>
|
||||
{isLoggedIn ? (
|
||||
<div className='grid grid-cols-1 gap-10'>
|
||||
<PlayLikedSongsCard />
|
||||
<RecentlyListened />
|
||||
<Collections />
|
||||
</div>
|
||||
) : (
|
||||
<PleaseLogin />
|
||||
)}
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,23 +41,17 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-5',
|
||||
css`
|
||||
height: 86px;
|
||||
${bp.lg} {
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
'line-clamp-4'
|
||||
// css`
|
||||
// height: 86px;
|
||||
// `
|
||||
)}
|
||||
>
|
||||
<div className='mb-3.5 text-18 font-medium text-white/70'>
|
||||
{t('my.xxxs-liked-tracks', { nickname: user?.profile?.nickname })}
|
||||
</div>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
className='text-18 font-medium text-white/20'
|
||||
>
|
||||
<div key={`${index}-${line}`} className='text-18 font-medium text-white/20'>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -66,21 +60,15 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
}
|
||||
|
||||
const Covers = memo(({ tracks }: { tracks: Track[] }) => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className='mt-6 grid w-full flex-shrink-0 grid-cols-3 gap-2.5 lg:mt-0 lg:ml-8 lg:w-auto'>
|
||||
{tracks.map(track => (
|
||||
<Image
|
||||
src={resizeImage(track.al.picUrl || '', 'md')}
|
||||
className={cx(
|
||||
'aspect-square rounded-24',
|
||||
css`
|
||||
${bp.lg} {
|
||||
height: 125px;
|
||||
width: 125px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
className={cx('aspect-square rounded-24 lg:h-32 lg:w-32')}
|
||||
key={track.id}
|
||||
onClick={() => navigate(`/album/${track.al.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -142,9 +130,7 @@ const PlayLikedSongsCard = () => {
|
|||
{t`my.playNow`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)
|
||||
}
|
||||
onClick={() => navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)}
|
||||
className={cx(
|
||||
'flex items-center justify-center rounded-full bg-white/10 text-night-400 transition duration-400 hover:bg-white/20 hover:text-neutral-300',
|
||||
css`
|
||||
|
|
|
|||
|
|
@ -32,18 +32,9 @@ const RecentlyListened = () => {
|
|||
.map(artist => artist.id)
|
||||
}, [listenedRecords])
|
||||
const { data: recentListenedArtists } = useArtists(recentListenedArtistsIDs)
|
||||
const artist = useMemo(
|
||||
() => recentListenedArtists?.map(a => a.artist),
|
||||
[recentListenedArtists]
|
||||
)
|
||||
const artist = useMemo(() => recentListenedArtists?.map(a => a.artist), [recentListenedArtists])
|
||||
|
||||
return (
|
||||
<ArtistRow
|
||||
artists={artist}
|
||||
placeholderRow={1}
|
||||
title={t`my.recently-listened`}
|
||||
/>
|
||||
)
|
||||
return <ArtistRow artists={artist} placeholderRow={1} title={t`my.recently-listened`} />
|
||||
}
|
||||
|
||||
export default RecentlyListened
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/PageTransition'
|
||||
import TrackList from '@/web/components/TrackList'
|
||||
import TrackList from './TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import Header from './Header'
|
||||
|
|
@ -18,11 +18,13 @@ const Playlist = () => {
|
|||
return (
|
||||
<PageTransition>
|
||||
<Header />
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
<div className='pb-10'>
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
</div>
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
141
packages/web/pages/Playlist/TrackList.tsx
Normal file
141
packages/web/pages/Playlist/TrackList.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import Wave from '@/web/components/Wave'
|
||||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Track = ({
|
||||
track,
|
||||
index,
|
||||
playingTrackID,
|
||||
state,
|
||||
handleClick,
|
||||
}: {
|
||||
track?: Track
|
||||
index: number
|
||||
playingTrackID: number
|
||||
state: PlayerState
|
||||
handleClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'mb-5 grid',
|
||||
css`
|
||||
grid-template-columns: 3fr 2fr 1fr;
|
||||
`
|
||||
)}
|
||||
onClick={e => track && handleClick(e, track.id)}
|
||||
onContextMenu={e => track && handleClick(e, track.id)}
|
||||
>
|
||||
{/* Right part */}
|
||||
<div className='flex items-center'>
|
||||
{/* Cover */}
|
||||
<img
|
||||
alt='Cover'
|
||||
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
|
||||
src={resizeImage(track?.al?.picUrl || '', 'sm')}
|
||||
/>
|
||||
|
||||
{/* Track Name and Artists */}
|
||||
<div className='mr-3'>
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 flex items-center text-16 font-medium',
|
||||
playingTrackID === track?.id
|
||||
? 'text-brand-700'
|
||||
: 'text-neutral-700 dark:text-neutral-200'
|
||||
)}
|
||||
>
|
||||
{track?.name}
|
||||
|
||||
{[1318912, 1310848].includes(track?.mark || 0) && (
|
||||
<Icon name='explicit' className='ml-2 mt-px mr-4 h-3.5 w-3.5 text-white/20' />
|
||||
)}
|
||||
</div>
|
||||
<div className='line-clamp-1 mt-1 text-14 font-bold text-white/30'>
|
||||
{track?.ar.map(a => a.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wave icon */}
|
||||
{playingTrackID === track?.id && (
|
||||
<div className='ml-5'>
|
||||
<Wave playing={state === 'playing'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Album Name */}
|
||||
<div className='flex items-center'>
|
||||
<NavLink
|
||||
to={`/album/${track?.al.id}`}
|
||||
className='line-clamp-1 text-14 font-bold text-white/40 transition-colors duration-300 hover:text-white/70'
|
||||
>
|
||||
{track?.al?.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='line-clamp-1 flex items-center justify-end text-14 font-bold text-white/40'>
|
||||
{formatDuration(track?.dt || 0, 'en-US', 'hh:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackList({
|
||||
tracks,
|
||||
onPlay,
|
||||
className,
|
||||
isLoading,
|
||||
placeholderRows = 12,
|
||||
}: {
|
||||
tracks?: Track[]
|
||||
onPlay: (id: number) => void
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
placeholderRows?: number
|
||||
}) {
|
||||
const { trackID, state } = useSnapshot(player)
|
||||
const playingTrackIndex = tracks?.findIndex(track => track.id === trackID) ?? -1
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (isLoading) return
|
||||
if (e.type === 'contextmenu') {
|
||||
e.preventDefault()
|
||||
openContextMenu({
|
||||
event: e,
|
||||
type: 'track',
|
||||
dataSourceID: trackID,
|
||||
options: {
|
||||
useCursorPosition: true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (e.detail === 2) onPlay?.(trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{tracks?.map((track, index) => (
|
||||
<Track
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={index}
|
||||
playingTrackIndex={playingTrackIndex}
|
||||
state={state}
|
||||
handleClick={handleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackList
|
||||
|
|
@ -18,21 +18,12 @@ const Artists = ({ artists }: { artists: 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'
|
||||
className='flex items-center py-2.5'
|
||||
>
|
||||
<div className='mr-4 h-14 w-14'>
|
||||
<img
|
||||
src={resizeImage(artist.img1v1Url, 'xs')}
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<img src={resizeImage(artist.img1v1Url, 'xs')} className='mr-4 h-14 w-14 rounded-full' />
|
||||
<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 className='text-lg font-semibold text-neutral-200'>{artist.name}</div>
|
||||
<div className='mt-0.5 text-sm font-semibold text-white/30'>艺人</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -48,19 +39,12 @@ const Albums = ({ albums }: { albums: 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'
|
||||
className='flex items-center py-2.5 text-neutral-200'
|
||||
>
|
||||
<div className='mr-4 h-14 w-14'>
|
||||
<img
|
||||
src={resizeImage(album.picUrl, 'xs')}
|
||||
className='h-12 w-12 rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
<img src={resizeImage(album.picUrl, 'xs')} className='mr-4 h-14 w-14 rounded-lg' />
|
||||
<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'>
|
||||
<div className='text-lg font-semibold text-neutral-200'>{album.name}</div>
|
||||
<div className='mt-0.5 text-sm font-semibold text-white/30'>
|
||||
专辑 · {album?.artist.name} · {dayjs(album.publishTime).year()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,14 +83,12 @@ const Track = ({
|
|||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 text-16 font-medium ',
|
||||
isPlaying
|
||||
? 'text-brand-700'
|
||||
: 'text-neutral-700 dark:text-neutral-200'
|
||||
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'>
|
||||
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 text-white/30'>
|
||||
{track?.ar.map(a => a.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -118,9 +100,7 @@ const Search = () => {
|
|||
const { keywords = '', type = 'all' } = useParams()
|
||||
|
||||
const searchType: keyof typeof SearchTypes =
|
||||
type.toUpperCase() in SearchTypes
|
||||
? (type.toUpperCase() as keyof typeof SearchTypes)
|
||||
: 'All'
|
||||
type.toUpperCase() in SearchTypes ? (type.toUpperCase() as keyof typeof SearchTypes) : 'All'
|
||||
|
||||
const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery(
|
||||
[SearchApiNames.MultiMatchSearch, keywords],
|
||||
|
|
@ -172,32 +152,37 @@ const Search = () => {
|
|||
return (
|
||||
<div>
|
||||
<div className='mt-6 mb-8 text-4xl font-semibold dark:text-white'>
|
||||
<span className='text-gray-500'>搜索</span> "{keywords}"
|
||||
<span className='text-white/40'>搜索</span> "{keywords}"
|
||||
</div>
|
||||
|
||||
{/* Best match */}
|
||||
{bestMatch.length !== 0 && (
|
||||
<div className='mb-6'>
|
||||
<div className='mb-2 text-sm font-medium text-gray-400'>最佳匹配</div>
|
||||
{/* mx-2.5 mb-6 text-12 font-medium uppercase dark:text-neutral-300 lg:mx-0 lg:text-14
|
||||
lg:font-bold */}
|
||||
<div className='mb-2 text-14 font-bold uppercase text-neutral-300'>最佳匹配</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'
|
||||
className='btn-hover-animation flex items-center py-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-6 h-24 w-24'>
|
||||
<img
|
||||
src={resizeImage(match.picUrl, 'xs')}
|
||||
className='h-12 w-12 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={resizeImage(match.picUrl, 'xs')}
|
||||
className={cx(
|
||||
'mr-6 h-20 w-20',
|
||||
(match as Artist).occupation === '歌手' ? 'rounded-full' : 'rounded-xl'
|
||||
)}
|
||||
/>
|
||||
<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 className='text-xl font-semibold text-neutral-200'>{match.name}</div>
|
||||
<div className='mt-0.5 font-medium text-white/30'>
|
||||
{(match as Artist).occupation === '歌手'
|
||||
? '艺人'
|
||||
: `专辑 · ${(match as Album).artist.name} · ${dayjs(
|
||||
match.publishTime
|
||||
).year()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -210,21 +195,21 @@ const Search = () => {
|
|||
<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>
|
||||
<div className='mb-2 text-14 font-bold uppercase text-neutral-300'>艺人</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>
|
||||
<div className='mb-2 text-14 font-bold uppercase text-neutral-300'>专辑</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>
|
||||
<div className='mb-2 text-14 font-bold uppercase text-neutral-300'>歌曲</div>
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -37,15 +37,10 @@ const AccentColor = () => {
|
|||
{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'
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
{color === accentColor && <div className='h-1.5 w-1.5 rounded-full bg-white'></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -55,23 +50,19 @@ const AccentColor = () => {
|
|||
|
||||
const Theme = () => {
|
||||
return (
|
||||
<div className='mt-4'>
|
||||
<div className='mb-2 dark:text-white'>主题</div>
|
||||
<div></div>
|
||||
</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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 />
|
||||
<span className='text-white'>开发中</span>
|
||||
{/* <Theme /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
108
packages/web/pages/Settings/Controls.tsx
Normal file
108
packages/web/pages/Settings/Controls.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function Switch({
|
||||
enabled,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: boolean
|
||||
onChange: (enabled: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cx(
|
||||
'flex w-11 items-center justify-start rounded-full p-1 transition-colors duration-500',
|
||||
enabled ? 'bg-brand-700' : 'bg-white/10'
|
||||
)}
|
||||
onClick={() => onChange(!enabled)}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: enabled ? 16 : 0 }}
|
||||
className='h-5 w-5 rounded-full bg-white shadow-sm'
|
||||
></motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { name: string; value: T }[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className='relative inline-block rounded-md bg-neutral-800 font-medium text-neutral-400'>
|
||||
<select
|
||||
onChange={e => onChange(e.target.value as T)}
|
||||
value={value}
|
||||
className='h-full w-full appearance-none bg-transparent py-1 pr-7 pl-3 focus:outline-none'
|
||||
>
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Icon
|
||||
name='dropdown-triangle'
|
||||
className='pointer-events-none absolute right-2.5 h-2.5 w-2.5 text-white/15'
|
||||
style={{ top: '11px' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: 'text' | 'password' | 'number'
|
||||
}) {
|
||||
return (
|
||||
<div className=''>
|
||||
<div className='mb-1 text-14 font-medium text-white/30'>Host</div>
|
||||
<div className='inline-block rounded-md bg-neutral-800 font-medium text-neutral-400'>
|
||||
<input
|
||||
className='appearance-none bg-transparent py-1 px-3'
|
||||
onChange={e => onChange(e.target.value)}
|
||||
{...{ type, value }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className='rounded-md bg-neutral-800 py-1 px-3 font-medium text-neutral-400 transition-colors duration-300 hover:bg-neutral-700 hover:text-neutral-300'
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlockTitle({ children }: { children: React.ReactNode }) {
|
||||
return <div className='text-21 font-medium text-neutral-100'>{children}</div>
|
||||
}
|
||||
|
||||
export function BlockDescription({ children }: { children: React.ReactNode }) {
|
||||
return <div className='my-1 text-16 font-medium text-white/30'>{children}</div>
|
||||
}
|
||||
|
||||
export function Option({ children }: { children: React.ReactNode }) {
|
||||
return <div className='my-3 flex items-center justify-between'>{children}</div>
|
||||
}
|
||||
|
||||
export function OptionText({ children }: { children: React.ReactNode }) {
|
||||
return <div className='text-16 font-medium text-neutral-400'>{children}</div>
|
||||
}
|
||||
86
packages/web/pages/Settings/General.tsx
Normal file
86
packages/web/pages/Settings/General.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { SupportedLanguage } from '@/web/i18n/i18n'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import settings from '@/web/states/settings'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { BlockTitle, OptionText, Select, Option, Switch } from './Controls'
|
||||
|
||||
function General() {
|
||||
return (
|
||||
<div>
|
||||
<Language />
|
||||
<AppleMusic />
|
||||
<NeteaseMusic />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Language() {
|
||||
const { t } = useTranslation()
|
||||
const supportedLanguages: { name: string; value: SupportedLanguage }[] = [
|
||||
{ name: 'English', value: 'en-US' },
|
||||
{ name: '简体中文', value: 'zh-CN' },
|
||||
]
|
||||
const { language } = useSnapshot(settings)
|
||||
const setLanguage = (language: SupportedLanguage) => {
|
||||
settings.language = language
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BlockTitle>Language</BlockTitle>
|
||||
<Option>
|
||||
<OptionText>{t`settings.general-choose-language`}</OptionText>
|
||||
<Select options={supportedLanguages} value={language} onChange={setLanguage} />
|
||||
</Option>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppleMusic() {
|
||||
const { playAnimatedArtworkFromApple, priorityDisplayOfAlbumArtistDescriptionFromAppleMusic } =
|
||||
useSnapshot(settings)
|
||||
|
||||
return (
|
||||
<div className='mt-7'>
|
||||
<BlockTitle>Apple Music</BlockTitle>
|
||||
<Option>
|
||||
<OptionText>Play Animated Artwork from Apple Music</OptionText>
|
||||
<Switch
|
||||
enabled={playAnimatedArtworkFromApple}
|
||||
onChange={v => (settings.playAnimatedArtworkFromApple = v)}
|
||||
/>
|
||||
</Option>
|
||||
<Option>
|
||||
<OptionText>Priority Display of Album/Artist Description from Apple Music</OptionText>
|
||||
<Switch
|
||||
enabled={priorityDisplayOfAlbumArtistDescriptionFromAppleMusic}
|
||||
onChange={v => (settings.priorityDisplayOfAlbumArtistDescriptionFromAppleMusic = v)}
|
||||
/>
|
||||
</Option>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NeteaseMusic() {
|
||||
const { displayPlaylistsFromNeteaseMusic } = useSnapshot(settings)
|
||||
return (
|
||||
<div className='mt-7'>
|
||||
<BlockTitle>Netease Music</BlockTitle>
|
||||
<Option>
|
||||
<OptionText>Display Playlists from Netease Music</OptionText>
|
||||
<Switch
|
||||
enabled={displayPlaylistsFromNeteaseMusic}
|
||||
onChange={v => {
|
||||
settings.displayPlaylistsFromNeteaseMusic = v
|
||||
if (persistedUiStates.librarySelectedTab === 'playlists') {
|
||||
persistedUiStates.librarySelectedTab = 'albums'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Option>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default General
|
||||
53
packages/web/pages/Settings/Player.tsx
Normal file
53
packages/web/pages/Settings/Player.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import settings from '@/web/states/settings'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { BlockDescription, BlockTitle, Button, Option, OptionText, Switch } from './Controls'
|
||||
|
||||
function Player() {
|
||||
return (
|
||||
<div>
|
||||
<FindTrackOnYouTube />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FindTrackOnYouTube() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { enableFindTrackOnYouTube, httpProxyForYouTube } = useSnapshot(settings)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BlockTitle>{t`settings.player-youtube-unlock`}</BlockTitle>
|
||||
<BlockDescription>
|
||||
Find alternative track on YouTube if not available on NetEase.
|
||||
</BlockDescription>
|
||||
|
||||
{/* Switch */}
|
||||
<Option>
|
||||
<OptionText>Enable YouTube Unlock </OptionText>
|
||||
<Switch
|
||||
enabled={enableFindTrackOnYouTube}
|
||||
onChange={value => (settings.enableFindTrackOnYouTube = value)}
|
||||
/>
|
||||
</Option>
|
||||
|
||||
{/* Proxy */}
|
||||
{/* <Option>
|
||||
<OptionText>
|
||||
HTTP Proxy config for connecting to YouTube {httpProxyForYouTube?.host && '(Configured)'}
|
||||
</OptionText>
|
||||
<Button
|
||||
onClick={() => {
|
||||
toast('开发中')
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Option> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,44 +0,0 @@
|
|||
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
|
||||
44
packages/web/pages/Settings/UserCard.tsx
Normal file
44
packages/web/pages/Settings/UserCard.tsx
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue