feat: monorepo
45
packages/web/App.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Toaster } from 'react-hot-toast'
|
||||
import { QueryClientProvider } from 'react-query'
|
||||
import { ReactQueryDevtools } from 'react-query/devtools'
|
||||
import Player from '@/web/components/Player'
|
||||
import Sidebar from '@/web/components/Sidebar'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import Main from '@/web/components/Main'
|
||||
import TitleBar from '@/web/components/TitleBar'
|
||||
import Lyric from '@/web/components/Lyric'
|
||||
import IpcRendererReact from '@/web/IpcRendererReact'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<QueryClientProvider client={reactQueryClient}>
|
||||
{window.env?.isEnableTitlebar && <TitleBar />}
|
||||
|
||||
<div id='layout' className='grid select-none grid-cols-[16rem_auto]'>
|
||||
<Sidebar />
|
||||
<Main />
|
||||
<Player />
|
||||
</div>
|
||||
|
||||
<Lyric />
|
||||
|
||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||
|
||||
<IpcRendererReact />
|
||||
|
||||
{/* Devtool */}
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
toggleButtonProps={{
|
||||
style: {
|
||||
position: 'fixed',
|
||||
right: '0',
|
||||
left: 'auto',
|
||||
bottom: '4rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
62
packages/web/IpcRendererReact.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
||||
import { player } from '@/web/store'
|
||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffectOnce } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const IpcRendererReact = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const trackIDRef = useRef(0)
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
useIpcRenderer(IpcChannels.Like, () => {
|
||||
const id = trackIDRef.current
|
||||
id && mutationLikeATrack.mutate(id)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
trackIDRef.current = track?.id ?? 0
|
||||
|
||||
const text = track?.name ? `${track.name} - YesPlayMusic` : 'YesPlayMusic'
|
||||
window.ipcRenderer?.send(IpcChannels.SetTrayTooltip, {
|
||||
text,
|
||||
})
|
||||
document.title = text
|
||||
}, [track])
|
||||
|
||||
useEffect(() => {
|
||||
window.ipcRenderer?.send(IpcChannels.Like, {
|
||||
isLiked: userLikedSongs?.ids?.includes(track?.id ?? 0) ?? false,
|
||||
})
|
||||
}, [userLikedSongs, track])
|
||||
|
||||
useEffect(() => {
|
||||
const playing = [PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
if (isPlaying === playing) return
|
||||
|
||||
window.ipcRenderer?.send(playing ? IpcChannels.Play : IpcChannels.Pause)
|
||||
setIsPlaying(playing)
|
||||
}, [state])
|
||||
|
||||
useEffectOnce(() => {
|
||||
// 用于显示 windows taskbar buttons
|
||||
if (playerSnapshot.track?.id) {
|
||||
window.ipcRenderer?.send(IpcChannels.Pause)
|
||||
}
|
||||
})
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default IpcRendererReact
|
||||
34
packages/web/api/album.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import request from '@/web/utils/request'
|
||||
import {
|
||||
FetchAlbumParams,
|
||||
FetchAlbumResponse,
|
||||
LikeAAlbumParams,
|
||||
LikeAAlbumResponse,
|
||||
} from '@/shared/api/Album'
|
||||
|
||||
// 专辑详情
|
||||
export function fetchAlbum(
|
||||
params: FetchAlbumParams,
|
||||
noCache: boolean
|
||||
): Promise<FetchAlbumResponse> {
|
||||
const otherParams: { timestamp?: number } = {}
|
||||
if (noCache) otherParams.timestamp = new Date().getTime()
|
||||
return request({
|
||||
url: '/album',
|
||||
method: 'get',
|
||||
params: { ...params, ...otherParams },
|
||||
})
|
||||
}
|
||||
|
||||
export function likeAAlbum(
|
||||
params: LikeAAlbumParams
|
||||
): Promise<LikeAAlbumResponse> {
|
||||
return request({
|
||||
url: '/album/sub',
|
||||
method: 'post',
|
||||
params: {
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
32
packages/web/api/artist.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import request from '@/web/utils/request'
|
||||
import {
|
||||
FetchArtistParams,
|
||||
FetchArtistResponse,
|
||||
FetchArtistAlbumsParams,
|
||||
FetchArtistAlbumsResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
|
||||
// 歌手详情
|
||||
export function fetchArtist(
|
||||
params: FetchArtistParams,
|
||||
noCache: boolean
|
||||
): Promise<FetchArtistResponse> {
|
||||
const otherParams: { timestamp?: number } = {}
|
||||
if (noCache) otherParams.timestamp = new Date().getTime()
|
||||
return request({
|
||||
url: '/artists',
|
||||
method: 'get',
|
||||
params: { ...params, ...otherParams },
|
||||
})
|
||||
}
|
||||
|
||||
// 获取歌手的专辑列表
|
||||
export function fetchArtistAlbums(
|
||||
params: FetchArtistAlbumsParams
|
||||
): Promise<FetchArtistAlbumsResponse> {
|
||||
return request({
|
||||
url: 'artist/album',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
115
packages/web/api/auth.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import request from '@/web/utils/request'
|
||||
import { FetchUserAccountResponse } from '@/shared/api/User'
|
||||
|
||||
// 手机号登录
|
||||
interface LoginWithPhoneParams {
|
||||
countrycode: number | string
|
||||
phone: string
|
||||
password?: string
|
||||
md5_password?: string
|
||||
captcha?: string | number
|
||||
}
|
||||
export interface LoginWithPhoneResponse {
|
||||
loginType: number
|
||||
code: number
|
||||
cookie: string
|
||||
}
|
||||
export function loginWithPhone(
|
||||
params: LoginWithPhoneParams
|
||||
): Promise<LoginWithPhoneResponse> {
|
||||
return request({
|
||||
url: '/login/cellphone',
|
||||
method: 'post',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 邮箱登录
|
||||
export interface LoginWithEmailParams {
|
||||
email: string
|
||||
password?: string
|
||||
md5_password?: string
|
||||
}
|
||||
export interface loginWithEmailResponse extends FetchUserAccountResponse {
|
||||
code: number
|
||||
cookie: string
|
||||
loginType: number
|
||||
token: string
|
||||
binding: {
|
||||
bindingTime: number
|
||||
expired: boolean
|
||||
expiresIn: number
|
||||
id: number
|
||||
refreshTime: number
|
||||
tokenJsonStr: string
|
||||
type: number
|
||||
url: string
|
||||
userId: number
|
||||
}[]
|
||||
}
|
||||
export function loginWithEmail(
|
||||
params: LoginWithEmailParams
|
||||
): Promise<loginWithEmailResponse> {
|
||||
return request({
|
||||
url: '/login',
|
||||
method: 'post',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 生成二维码key
|
||||
export interface fetchLoginQrCodeKeyResponse {
|
||||
code: number
|
||||
data: {
|
||||
code: number
|
||||
unikey: string
|
||||
}
|
||||
}
|
||||
export function fetchLoginQrCodeKey(): Promise<fetchLoginQrCodeKeyResponse> {
|
||||
return request({
|
||||
url: '/login/qr/key',
|
||||
method: 'get',
|
||||
params: {
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 二维码检测扫码状态接口
|
||||
// 说明: 轮询此接口可获取二维码扫码状态,800为二维码过期,801为等待扫码,802为待确认,803为授权登录成功(803状态码下会返回cookies)
|
||||
export interface CheckLoginQrCodeStatusParams {
|
||||
key: string
|
||||
}
|
||||
export interface CheckLoginQrCodeStatusResponse {
|
||||
code: number
|
||||
message?: string
|
||||
cookie?: string
|
||||
}
|
||||
export function checkLoginQrCodeStatus(
|
||||
params: CheckLoginQrCodeStatusParams
|
||||
): Promise<CheckLoginQrCodeStatusResponse> {
|
||||
return request({
|
||||
url: '/login/qr/check',
|
||||
method: 'get',
|
||||
params: {
|
||||
key: params.key,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新登录
|
||||
export function refreshCookie() {
|
||||
return request({
|
||||
url: '/login/refresh',
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
export function logout() {
|
||||
return request({
|
||||
url: '/logout',
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
119
packages/web/api/personalFM.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import request from '@/web/utils/request'
|
||||
|
||||
export enum PersonalFMApiNames {
|
||||
FetchPersonalFm = 'fetchPersonalFM',
|
||||
}
|
||||
|
||||
export interface PersonalMusic {
|
||||
name: null | string
|
||||
id: number
|
||||
size: number
|
||||
extension: 'mp3' | 'flac' | null
|
||||
sr: number
|
||||
dfsId: number
|
||||
bitrate: number
|
||||
playTime: number
|
||||
volumeDelta: number
|
||||
}
|
||||
|
||||
export interface FetchPersonalFMResponse {
|
||||
code: number
|
||||
popAdjust: boolean
|
||||
data: {
|
||||
name: string
|
||||
id: number
|
||||
position: number
|
||||
alias: string[]
|
||||
status: number
|
||||
fee: number
|
||||
copyrightId: number
|
||||
disc?: string
|
||||
no: number
|
||||
artists: Artist[]
|
||||
album: Album
|
||||
starred: boolean
|
||||
popularity: number
|
||||
score: number
|
||||
starredNum: number
|
||||
duration: number
|
||||
playedNum: number
|
||||
dayPlays: number
|
||||
hearTime: number
|
||||
ringtone: null
|
||||
crbt: null
|
||||
audition: null
|
||||
copyFrom: string
|
||||
commentThreadId: string
|
||||
rtUrl: string | null
|
||||
ftype: number
|
||||
rtUrls: (string | null)[]
|
||||
copyright: number
|
||||
transName: null | string
|
||||
sign: null
|
||||
mark: number
|
||||
originCoverType: number
|
||||
originSongSimpleData: null
|
||||
single: number
|
||||
noCopyrightRcmd: null
|
||||
mvid: number
|
||||
bMusic?: PersonalMusic
|
||||
lMusic?: PersonalMusic
|
||||
mMusic?: PersonalMusic
|
||||
hMusic?: PersonalMusic
|
||||
reason: string
|
||||
privilege: {
|
||||
id: number
|
||||
fee: number
|
||||
payed: number
|
||||
st: number
|
||||
pl: number
|
||||
dl: number
|
||||
sp: number
|
||||
cp: number
|
||||
subp: number
|
||||
cs: boolean
|
||||
maxbr: number
|
||||
fl: number
|
||||
toast: boolean
|
||||
flag: number
|
||||
preShell: boolean
|
||||
playMaxbr: number
|
||||
downloadMaxbr: number
|
||||
rscl: null
|
||||
freeTrialPrivilege: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
chargeInfoList: {
|
||||
[key: string]: unknown
|
||||
}[]
|
||||
}
|
||||
alg: string
|
||||
s_ctrp: string
|
||||
}[]
|
||||
}
|
||||
export function fetchPersonalFM(): Promise<FetchPersonalFMResponse> {
|
||||
return request({
|
||||
url: '/personal/fm',
|
||||
method: 'get',
|
||||
params: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface FMTrashResponse {
|
||||
songs: null[]
|
||||
code: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export function fmTrash(id: number): Promise<FMTrashResponse> {
|
||||
return request({
|
||||
url: '/fm/trash',
|
||||
method: 'post',
|
||||
params: {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
64
packages/web/api/playlist.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import request from '@/web/utils/request'
|
||||
import {
|
||||
FetchPlaylistParams,
|
||||
FetchPlaylistResponse,
|
||||
FetchRecommendedPlaylistsParams,
|
||||
FetchRecommendedPlaylistsResponse,
|
||||
FetchDailyRecommendPlaylistsResponse,
|
||||
LikeAPlaylistParams,
|
||||
LikeAPlaylistResponse,
|
||||
} from '@/shared/api/Playlists'
|
||||
|
||||
// 歌单详情
|
||||
export function fetchPlaylist(
|
||||
params: FetchPlaylistParams,
|
||||
noCache: boolean
|
||||
): Promise<FetchPlaylistResponse> {
|
||||
const otherParams: { timestamp?: number } = {}
|
||||
if (noCache) otherParams.timestamp = new Date().getTime()
|
||||
if (!params.s) params.s = 0 // 网易云默认返回8个收藏者,这里设置为0,减少返回的JSON体积
|
||||
return request({
|
||||
url: '/playlist/detail',
|
||||
method: 'get',
|
||||
params: {
|
||||
...params,
|
||||
...otherParams,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 推荐歌单
|
||||
export function fetchRecommendedPlaylists(
|
||||
params: FetchRecommendedPlaylistsParams
|
||||
): Promise<FetchRecommendedPlaylistsResponse> {
|
||||
return request({
|
||||
url: '/personalized',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 每日推荐歌单(需要登录)
|
||||
|
||||
export function fetchDailyRecommendPlaylists(): Promise<FetchDailyRecommendPlaylistsResponse> {
|
||||
return request({
|
||||
url: '/recommend/resource',
|
||||
method: 'get',
|
||||
params: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function likeAPlaylist(
|
||||
params: LikeAPlaylistParams
|
||||
): Promise<LikeAPlaylistResponse> {
|
||||
return request({
|
||||
url: '/playlist/subscribe',
|
||||
method: 'post',
|
||||
params: {
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
31
packages/web/api/search.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import request from '@/web/utils/request'
|
||||
import {
|
||||
SearchParams,
|
||||
SearchResponse,
|
||||
SearchTypes,
|
||||
MultiMatchSearchParams,
|
||||
MultiMatchSearchResponse,
|
||||
} from '@/shared/api/Search'
|
||||
|
||||
// 搜索
|
||||
export function search(params: SearchParams): Promise<SearchResponse> {
|
||||
return request({
|
||||
url: '/search',
|
||||
method: 'get',
|
||||
params: {
|
||||
...params,
|
||||
type: SearchTypes[params.type ?? SearchTypes.All],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索多重匹配
|
||||
export function multiMatchSearch(
|
||||
params: MultiMatchSearchParams
|
||||
): Promise<MultiMatchSearchResponse> {
|
||||
return request({
|
||||
url: '/search/multimatch',
|
||||
method: 'get',
|
||||
params: params,
|
||||
})
|
||||
}
|
||||
60
packages/web/api/track.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import request from '@/web/utils/request'
|
||||
import {
|
||||
FetchAudioSourceParams,
|
||||
FetchAudioSourceResponse,
|
||||
FetchLyricParams,
|
||||
FetchLyricResponse,
|
||||
FetchTracksParams,
|
||||
FetchTracksResponse,
|
||||
LikeATrackParams,
|
||||
LikeATrackResponse,
|
||||
} from '@/shared/api/Track'
|
||||
|
||||
// 获取歌曲详情
|
||||
export function fetchTracks(
|
||||
params: FetchTracksParams
|
||||
): Promise<FetchTracksResponse> {
|
||||
return request({
|
||||
url: '/song/detail',
|
||||
method: 'get',
|
||||
params: {
|
||||
ids: params.ids.join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取音源URL
|
||||
export function fetchAudioSource(
|
||||
params: FetchAudioSourceParams
|
||||
): Promise<FetchAudioSourceResponse> {
|
||||
return request({
|
||||
url: '/song/url',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取歌词
|
||||
export function fetchLyric(
|
||||
params: FetchLyricParams
|
||||
): Promise<FetchLyricResponse> {
|
||||
return request({
|
||||
url: '/lyric',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 收藏歌曲
|
||||
export function likeATrack(
|
||||
params: LikeATrackParams
|
||||
): Promise<LikeATrackResponse> {
|
||||
return request({
|
||||
url: '/like',
|
||||
method: 'post',
|
||||
params: {
|
||||
...params,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
188
packages/web/api/user.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import request from '@/web/utils/request'
|
||||
import {
|
||||
FetchUserAccountResponse,
|
||||
FetchUserPlaylistsParams,
|
||||
FetchUserPlaylistsResponse,
|
||||
FetchUserLikedTracksIDsParams,
|
||||
FetchUserLikedTracksIDsResponse,
|
||||
FetchUserAlbumsParams,
|
||||
FetchUserAlbumsResponse,
|
||||
FetchUserArtistsResponse,
|
||||
} from '@/shared/api/User'
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户详情
|
||||
* - uid : 用户 id
|
||||
* @param {number} uid
|
||||
*/
|
||||
export function userDetail(uid: number) {
|
||||
return request({
|
||||
url: '/user/detail',
|
||||
method: 'get',
|
||||
params: {
|
||||
uid,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取账号详情
|
||||
export function fetchUserAccount(): Promise<FetchUserAccountResponse> {
|
||||
return request({
|
||||
url: '/user/account',
|
||||
method: 'get',
|
||||
params: {
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户歌单
|
||||
export function fetchUserPlaylists(
|
||||
params: FetchUserPlaylistsParams
|
||||
): Promise<FetchUserPlaylistsResponse> {
|
||||
return request({
|
||||
url: '/user/playlist',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUserLikedTracksIDs(
|
||||
params: FetchUserLikedTracksIDsParams
|
||||
): Promise<FetchUserLikedTracksIDsResponse> {
|
||||
return request({
|
||||
url: '/likelist',
|
||||
method: 'get',
|
||||
params: {
|
||||
uid: params.uid,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日签到
|
||||
* 说明 : 调用此接口可签到获取积分
|
||||
* - type: 签到类型 , 默认 0, 其中 0 为安卓端签到 ,1 为 web/PC 签到
|
||||
* @param {number} type
|
||||
*/
|
||||
export function dailySignin(type = 0) {
|
||||
return request({
|
||||
url: '/daily_signin',
|
||||
method: 'post',
|
||||
params: {
|
||||
type,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUserAlbums(
|
||||
params: FetchUserAlbumsParams
|
||||
): Promise<FetchUserAlbumsResponse> {
|
||||
return request({
|
||||
url: '/album/sublist',
|
||||
method: 'get',
|
||||
params: {
|
||||
...params,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取收藏的歌手
|
||||
export function fetchUserArtists(): Promise<FetchUserArtistsResponse> {
|
||||
return request({
|
||||
url: '/artist/sublist',
|
||||
method: 'get',
|
||||
params: {
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏的MV(需要登录)
|
||||
* 说明 : 调用此接口可获取到用户收藏的MV
|
||||
*/
|
||||
// export function likedMVs(params) {
|
||||
// return request({
|
||||
// url: '/mv/sublist',
|
||||
// method: 'get',
|
||||
// params: {
|
||||
// limit: params.limit,
|
||||
// timestamp: new Date().getTime(),
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* 上传歌曲到云盘(需要登录)
|
||||
*/
|
||||
// export function uploadSong(file) {
|
||||
// let formData = new FormData()
|
||||
// formData.append('songFile', file)
|
||||
// return request({
|
||||
// url: '/cloud',
|
||||
// method: 'post',
|
||||
// params: {
|
||||
// timestamp: new Date().getTime(),
|
||||
// },
|
||||
// data: formData,
|
||||
// headers: {
|
||||
// 'Content-Type': 'multipart/form-data',
|
||||
// },
|
||||
// timeout: 200000,
|
||||
// }).catch(error => {
|
||||
// alert(`上传失败,Error: ${error}`)
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* 获取云盘歌曲(需要登录)
|
||||
* 说明 : 登录后调用此接口 , 可获取云盘数据 , 获取的数据没有对应 url, 需要再调用一 次 /song/url 获取 url
|
||||
* - limit : 返回数量 , 默认为 200
|
||||
* - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*200, 其中 200 为 limit 的值 , 默认为 0
|
||||
* @param {Object} params
|
||||
* @param {number} params.limit
|
||||
* @param {number=} params.offset
|
||||
*/
|
||||
// export function cloudDisk(params = {}) {
|
||||
// params.timestamp = new Date().getTime()
|
||||
// return request({
|
||||
// url: '/user/cloud',
|
||||
// method: 'get',
|
||||
// params,
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* 获取云盘歌曲详情(需要登录)
|
||||
*/
|
||||
// export function cloudDiskTrackDetail(id) {
|
||||
// return request({
|
||||
// url: '/user/cloud/detail',
|
||||
// method: 'get',
|
||||
// params: {
|
||||
// timestamp: new Date().getTime(),
|
||||
// id,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* 删除云盘歌曲(需要登录)
|
||||
* @param {Array} id
|
||||
*/
|
||||
// export function cloudDiskTrackDelete(id) {
|
||||
// return request({
|
||||
// url: '/user/cloud/del',
|
||||
// method: 'get',
|
||||
// params: {
|
||||
// timestamp: new Date().getTime(),
|
||||
// id,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
29
packages/web/api/yesplaymusic.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import axios, { AxiosInstance } from 'axios'
|
||||
|
||||
const baseURL = String(
|
||||
import.meta.env.DEV ? '/yesplaymusic' : `http://127.0.0.1:42710/yesplaymusic`
|
||||
)
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
export async function cacheAudio(id: number, audio: string) {
|
||||
const file = await axios.get(audio, { responseType: 'arraybuffer' })
|
||||
if (file.status !== 200 && file.status !== 206) return
|
||||
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([file.data], { type: 'multipart/form-data' })
|
||||
formData.append('file', blob)
|
||||
|
||||
request.post(`/audio/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
params: {
|
||||
url: audio,
|
||||
},
|
||||
})
|
||||
}
|
||||
BIN
packages/web/assets/fonts/Barlow-Black.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Black.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-Bold.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Bold.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-ExtraBold.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-ExtraBold.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-Medium.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Medium.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-Regular.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-Regular.woff2
Normal file
BIN
packages/web/assets/fonts/Barlow-SemiBold.ttf
Executable file
BIN
packages/web/assets/fonts/Barlow-SemiBold.woff2
Normal file
3
packages/web/assets/icons/back.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5 5L9.26369 10.6391C8.55114 11.4065 8.55114 12.5935 9.26369 13.3609L14.5 19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
3
packages/web/assets/icons/dislike.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 14.5V5.5M9 5H8.5C7.39543 5 6.5 5.89543 6.5 7V14.0668C6.5 14.3523 6.56113 14.6345 6.67927 14.8944L8.46709 18.8276C8.79163 19.5416 9.50354 20 10.2878 20H10.8815C12.2002 20 13.158 18.746 12.811 17.4738L12.3445 15.7631C12.171 15.127 12.6499 14.5 13.3093 14.5H17V14.5C19.1704 14.5 20.489 12.1076 19.33 10.2726L16.5517 5.87354C16.2083 5.32974 15.61 5 14.9668 5H9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
4
packages/web/assets/icons/dj.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
width="24" height="24"
|
||||
viewBox="0 0 24 24"
|
||||
style=" fill:#000000;"><path d="M19.971,14.583C19.985,14.39,20,14.197,20,14c0-0.513-0.053-1.014-0.145-1.5C19.947,12.014,20,11.513,20,11 c0-4.418-3.582-8-8-8s-8,3.582-8,8c0,0.513,0.053,1.014,0.145,1.5C4.053,12.986,4,13.487,4,14c0,0.197,0.015,0.39,0.029,0.583 C3.433,14.781,3,15.337,3,16c0,0.828,0.672,1.5,1.5,1.5c0.103,0,0.203-0.01,0.3-0.03C6.093,20.148,8.827,22,12,22 s5.907-1.852,7.2-4.53c0.097,0.02,0.197,0.03,0.3,0.03c0.828,0,1.5-0.672,1.5-1.5C21,15.337,20.567,14.781,19.971,14.583z" opacity=".35"></path><path d="M21,18h-2v-6h2c1.105,0,2,0.895,2,2v2C23,17.105,22.105,18,21,18z"></path><path d="M3,12h2v6H3c-1.105,0-2-0.895-2-2v-2C1,12.895,1.895,12,3,12z"></path><path d="M5,13c0-0.843,0-1.638,0-2c0-3.866,3.134-7,7-7s7,3.134,7,7c0,0.362,0,1.157,0,2h2c0-0.859,0-1.617,0-2c0-4.971-4.029-9-9-9 s-9,4.029-9,9c0,0.383,0,1.141,0,2H5z"></path></svg>
|
||||
|
After Width: | Height: | Size: 946 B |
4
packages/web/assets/icons/email.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
3
packages/web/assets/icons/explicit.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 2C3.34315 2 2 3.34315 2 5V19C2 20.6569 3.34315 22 5 22H19C20.6569 22 22 20.6569 22 19V5C22 3.34315 20.6569 2 19 2H5ZM9 6C8.44772 6 8 6.44772 8 7V12V17C8 17.5523 8.44772 18 9 18H15C15.5523 18 16 17.5523 16 17C16 16.4477 15.5523 16 15 16H10V13H15C15.5523 13 16 12.5523 16 12C16 11.4477 15.5523 11 15 11H10V8H15C15.5523 8 16 7.55228 16 7C16 6.44772 15.5523 6 15 6H9Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
3
packages/web/assets/icons/eye-off.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |
4
packages/web/assets/icons/eye.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
7
packages/web/assets/icons/fm.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 18V10C4 8.89543 4.89543 8 6 8H18C19.1046 8 20 8.89543 20 10V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="12" r="1" fill="currentColor"/>
|
||||
<circle cx="16" cy="16" r="1" fill="currentColor"/>
|
||||
<path d="M18 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="10" cy="14" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 540 B |
3
packages/web/assets/icons/forward.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.5 5L14.7363 10.6391C15.4489 11.4065 15.4489 12.5935 14.7363 13.3609L9.5 19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
3
packages/web/assets/icons/heart-outline.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6963 7.8793C11.8739 8.02633 12.1261 8.02633 12.3037 7.8793L13.6433 6.76983C15.2993 5.39827 17.7553 5.95672 18.7038 7.9205C19.345 9.24819 19.0937 10.8517 18.0798 11.9014L12.3437 17.8397C12.1539 18.0362 11.8461 18.0362 11.6563 17.8397L5.92022 11.9014C4.90633 10.8517 4.65498 9.24819 5.29622 7.9205C6.24467 5.95672 8.70067 5.39827 10.3567 6.76983L11.6963 7.8793ZM12 5.55297L12.4286 5.19799C15.0513 3.02586 18.9408 3.91027 20.4429 7.02028C21.4584 9.12294 21.0603 11.6624 19.4547 13.3247L13.7186 19.263C12.7694 20.2457 11.2305 20.2457 10.2814 19.263L4.54533 13.3247C2.93965 11.6624 2.54158 9.12294 3.55711 7.02028C5.05915 3.91027 8.9487 3.02586 11.5714 5.19799L12 5.55297Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 847 B |
3
packages/web/assets/icons/heart.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 5.55297L12.4286 5.19799C15.0513 3.02586 18.9408 3.91027 20.4429 7.02028C21.4584 9.12294 21.0603 11.6624 19.4547 13.3247L13.7186 19.263C12.7694 20.2457 11.2305 20.2457 10.2814 19.263L4.54533 13.3247C2.93965 11.6624 2.54158 9.12294 3.55711 7.02028C5.05915 3.91027 8.9487 3.02586 11.5714 5.19799L12 5.55297Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 484 B |
3
packages/web/assets/icons/home.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.457 6.143L19.9427 5.26889L19.9426 5.26883L19.457 6.143ZM13.457 2.81L13.9426 1.93583L13.9424 1.93571L13.457 2.81ZM10.543 2.81L10.0576 1.93571L10.0574 1.93583L10.543 2.81ZM4.543 6.143L4.05739 5.26883L4.05728 5.26889L4.543 6.143ZM15 21V22C15.5523 22 16 21.5523 16 21H15ZM9 21H8C8 21.5523 8.44772 22 9 22V21ZM6 20C4.89528 20 4 19.1047 4 18H2C2 20.2093 3.79072 22 6 22V20ZM18 20H6V22H18V20ZM20 18C20 19.1047 19.1047 20 18 20V22C20.2093 22 22 20.2093 22 18H20ZM20 8.76501V18H22V8.76501H20ZM18.9713 7.01712C19.606 7.36984 20 8.03935 20 8.76501H22C22 7.31266 21.212 5.97417 19.9427 5.26889L18.9713 7.01712ZM12.9714 3.68418L18.9714 7.01718L19.9426 5.26883L13.9426 1.93583L12.9714 3.68418ZM11.0284 3.6843C11.6325 3.34891 12.3675 3.34891 12.9716 3.6843L13.9424 1.93571C12.7345 1.2651 11.2655 1.2651 10.0576 1.93571L11.0284 3.6843ZM5.02861 7.01718L11.0286 3.68418L10.0574 1.93583L4.05739 5.26883L5.02861 7.01718ZM4 8.76501C4 8.03854 4.39379 7.36993 5.02872 7.01712L4.05728 5.26889C2.78821 5.97408 2 7.31147 2 8.76501H4ZM4 18V8.76501H2V18H4ZM15 20H9V22H15V20ZM14 14.25V21H16V14.25H14ZM13 13C13.4452 13 14 13.4452 14 14.25H16C16 12.5686 14.7648 11 13 11V13ZM11 13H13V11H11V13ZM10 14.25C10 13.4452 10.5548 13 11 13V11C9.23524 11 8 12.5686 8 14.25H10ZM10 21V14.25H8V21H10Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
packages/web/assets/icons/lock.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="lock-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-lock-alt fa-w-14 fa-7x"><path fill="currentColor" d="M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zM264 392c0 22.1-17.9 40-40 40s-40-17.9-40-40v-48c0-22.1 17.9-40 40-40s40 17.9 40 40v48zm32-168H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z" class=""></path></svg>
|
||||
|
After Width: | Height: | Size: 550 B |
6
packages/web/assets/icons/lyrics.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 8C4.66667 8 6 8.4 6 10C6 11.6 6 15.3333 6 17C6 17.6667 6.4 19 8 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="5.5" cy="4.5" r="1.5" fill="currentColor"/>
|
||||
<path d="M9 5H17C17.6667 5 19 5.4 19 7C19 8.6 19 15 19 18C19 18.6667 18.6 20 17 20M9 8H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12.5" cy="14.5" r="3.5" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 535 B |
5
packages/web/assets/icons/more.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="19" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="5" cy="12" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 B |
11
packages/web/assets/icons/music-library.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 18C12.1046 18 13 17.1046 13 16C13 14.8954 12.1046 14 11 14C9.89543 14 9 14.8954 9 16C9 17.1046 9.89543 18 11 18Z" fill="currentColor"/>
|
||||
<path d="M7.15385 3C7.15385 3.55228 6.67169 4 6.07692 4C5.48215 4 5 3.55228 5 3C5 2.44772 5.48215 2 6.07692 2C6.67169 2 7.15385 2.44772 7.15385 3Z" fill="currentColor"/>
|
||||
<path d="M19 3C19 3.55228 18.5178 4 17.9231 4C17.3283 4 16.8462 3.55228 16.8462 3C16.8462 2.44772 17.3283 2 17.9231 2C18.5178 2 19 2.44772 19 3Z" fill="currentColor"/>
|
||||
<path d="M6.07692 2H17.9231V4H6.07692V2Z" fill="currentColor"/>
|
||||
<rect x="11" y="11" width="2" height="5" fill="currentColor"/>
|
||||
<circle cx="12" cy="11" r="1" fill="currentColor"/>
|
||||
<circle cx="14" cy="11" r="1" fill="currentColor"/>
|
||||
<rect x="12" y="10" width="2" height="2" fill="currentColor"/>
|
||||
<path d="M4 10V18C4 19.6569 5.34315 21 7 21H17C18.6569 21 20 19.6569 20 18V10C20 8.34315 18.6569 7 17 7H7C5.34315 7 4 8.34315 4 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
packages/web/assets/icons/music-note.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 16.5C13 17.8807 11.8807 19 10.5 19C9.11929 19 8 17.8807 8 16.5C8 15.1193 9.11929 14 10.5 14C11.8807 14 13 15.1193 13 16.5Z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M13.2572 5.49711L15.9021 4.43917C16.4828 4.20687 17.1351 4.54037 17.2868 5.14719C17.4097 5.63869 17.1577 6.14671 16.692 6.34628L14.6061 7.24025C14.2384 7.39783 14 7.75937 14 8.1594V16.5L12 15V7.35407C12 6.53626 12.4979 5.80084 13.2572 5.49711Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
3
packages/web/assets/icons/next.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 5V19M16.0239 12.7809L8.6247 18.7002C7.96993 19.2241 7 18.7579 7 17.9194V6.08062C7 5.24212 7.96993 4.77595 8.6247 5.29976L16.0239 11.2191C16.5243 11.6195 16.5243 12.3805 16.0239 12.7809Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
4
packages/web/assets/icons/pause.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="5" width="4" height="14" rx="1.1" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="14" y="5" width="4" height="14" rx="1.1" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
3
packages/web/assets/icons/phone.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7 2a2 2 0 00-2 2v12a2 2 0 002 2h6a2 2 0 002-2V4a2 2 0 00-2-2H7zm3 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
3
packages/web/assets/icons/play-fill.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 17.783V6.21701C6 5.03661 7.30033 4.31873 8.29922 4.94766L17.484 10.7307C18.4183 11.319 18.4183 12.681 17.484 13.2693L8.29922 19.0523C7.30033 19.6813 6 18.9634 6 17.783Z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
3
packages/web/assets/icons/play.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 17.783V6.21701C6 5.03661 7.30033 4.31873 8.29922 4.94766L17.484 10.7307C18.4183 11.319 18.4183 12.681 17.484 13.2693L8.29922 19.0523C7.30033 19.6813 6 18.9634 6 17.783Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 349 B |
6
packages/web/assets/icons/playlist.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.5C18 17.8807 16.8807 19 15.5 19C14.1193 19 13 17.8807 13 16.5C13 15.1193 14.1193 14 15.5 14C16.8807 14 18 15.1193 18 16.5Z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M18.2572 5.49711L20.9021 4.43917C21.4828 4.20687 22.1351 4.54037 22.2868 5.14719C22.4097 5.63869 22.1577 6.14671 21.692 6.34628L19.6061 7.24025C19.2384 7.39783 19 7.75937 19 8.1594V16.5L17 15V7.35407C17 6.53626 17.4979 5.80084 18.2572 5.49711Z" fill="currentColor"/>
|
||||
<path d="M5 6H14M5 10H14M5 14H10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M5 18H10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
6
packages/web/assets/icons/podcast.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 14C13.1046 14 14 13.1046 14 12C14 10.8954 13.1046 10 12 10C10.8954 10 10 10.8954 10 12C10 13.1046 10.8954 14 12 14Z" fill="currentColor"/>
|
||||
<path d="M14 17C14 15.895 13.105 15 12 15C10.895 15 10 15.895 10 17C10 17.749 10.677 20.83 10.677 20.83L10.683 20.829C10.762 21.49 11.318 22.004 12 22.004C12.682 22.004 13.238 21.49 13.317 20.829L13.323 20.83C13.323 20.83 14 17.741 14 17Z" fill="currentColor"/>
|
||||
<path d="M11.271 6.043C8.675 6.351 6.517 8.397 6.086 10.975C5.83 12.507 6.161 13.953 6.888 15.131C7.322 15.834 8.361 15.775 8.684 15.015C8.808 14.724 8.798 14.386 8.628 14.12C8.037 13.197 7.803 12.029 8.188 10.781C8.597 9.455 9.718 8.412 11.071 8.105C13.679 7.515 16 9.491 16 12C16 12.783 15.764 13.506 15.371 14.121C15.201 14.386 15.193 14.725 15.316 15.015C15.639 15.775 16.678 15.835 17.112 15.132C17.674 14.22 18 13.148 18 12C18 8.451 14.904 5.613 11.271 6.043Z" fill="currentColor"/>
|
||||
<path d="M10.095 2.177C6.08201 2.922 2.87 6.169 2.16 10.189C1.384 14.584 3.502 18.578 6.921 20.601C7.66 21.038 8.572 20.381 8.406 19.539C8.35 19.256 8.17 19.021 7.922 18.873C5.231 17.271 3.551 14.14 4.106 10.682C4.665 7.203 7.58001 4.443 11.081 4.05C15.902 3.511 20 7.286 20 12C20 14.923 18.422 17.48 16.075 18.875C15.828 19.022 15.647 19.257 15.592 19.539C15.426 20.38 16.335 21.041 17.073 20.604C20.019 18.864 22 15.662 22 12C22 5.863 16.443 0.999003 10.095 2.177Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
packages/web/assets/icons/previous.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 5V19M7.97609 12.7809L15.3753 18.7002C16.0301 19.2241 17 18.7579 17 17.9194V6.08062C17 5.24212 16.0301 4.77595 15.3753 5.29976L7.97609 11.2191C7.47568 11.6195 7.47568 12.3805 7.97609 12.7809Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
4
packages/web/assets/icons/qrcode.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2V5h1v1H5zM3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm2 2v-1h1v1H5zM13 3a1 1 0 00-1 1v3a1 1 0 001 1h3a1 1 0 001-1V4a1 1 0 00-1-1h-3zm1 2v1h1V5h-1z" clip-rule="evenodd" />
|
||||
<path d="M11 4a1 1 0 10-2 0v1a1 1 0 002 0V4zM10 7a1 1 0 011 1v1h2a1 1 0 110 2h-3a1 1 0 01-1-1V8a1 1 0 011-1zM16 9a1 1 0 100 2 1 1 0 000-2zM9 13a1 1 0 011-1h1a1 1 0 110 2v2a1 1 0 11-2 0v-3zM7 11a1 1 0 100-2H4a1 1 0 100 2h3zM17 13a1 1 0 01-1 1h-2a1 1 0 110-2h2a1 1 0 011 1zM16 17a1 1 0 100-2h-3a1 1 0 100 2h3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 708 B |
8
packages/web/assets/icons/repeat-1.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 11.5V11C5 8.79086 6.79086 7 9 7H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M19 12.5V13C19 15.2091 17.2091 17 15 17H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M10.5 9.34761V4.65239C10.5 4.57149 10.5911 4.52408 10.6573 4.57047L14.0111 6.91808C14.0679 6.95789 14.0679 7.04211 14.0111 7.08192L10.6573 9.42953C10.5911 9.47592 10.5 9.42851 10.5 9.34761Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
|
||||
<path d="M8 14.5597V19.2516C8 19.3355 7.90301 19.3821 7.83753 19.3297L4.48617 16.6486C4.43175 16.6051 4.43743 16.5206 4.49719 16.4848L7.84855 14.474C7.9152 14.434 8 14.482 8 14.5597Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
|
||||
<path d="M19 5V9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M17 6L19 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,018 B |
6
packages/web/assets/icons/repeat.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 11.5V11C5 8.79086 6.79086 7 9 7H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M19 12.5V13C19 15.2091 17.2091 17 15 17H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M16 9.34761V4.65239C16 4.57149 16.0911 4.52408 16.1573 4.57047L19.5111 6.91808C19.5679 6.95789 19.5679 7.04211 19.5111 7.08192L16.1573 9.42953C16.0911 9.47592 16 9.42851 16 9.34761Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
|
||||
<path d="M8 14.5597V19.2516C8 19.3355 7.90301 19.3821 7.83753 19.3297L4.48617 16.6486C4.43175 16.6051 4.43743 16.5206 4.49719 16.4848L7.84855 14.474C7.9152 14.434 8 14.482 8 14.5597Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 841 B |
4
packages/web/assets/icons/search.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="7.5" cy="7.5" r="6.5" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M13.2071 11.7929L12.5 11.0858L11.0858 12.5L11.7929 13.2071L13.2071 11.7929ZM16.2929 17.7071C16.6834 18.0976 17.3166 18.0976 17.7071 17.7071C18.0976 17.3166 18.0976 16.6834 17.7071 16.2929L16.2929 17.7071ZM11.7929 13.2071L16.2929 17.7071L17.7071 16.2929L13.2071 11.7929L11.7929 13.2071Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
4
packages/web/assets/icons/settings.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
6
packages/web/assets/icons/shuffle.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 7H5.73524C7.14029 7 8.44232 7.7372 9.16521 8.94202L11 12L13.3057 15.2938C14.0542 16.3632 15.2774 17 16.5826 17H17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M4.5 17H5.73524C7.14029 17 8.44232 16.2628 9.16521 15.058L11 12L13.3057 8.70615C14.0542 7.63685 15.2774 7 16.5826 7H17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M16.5 9.34761V4.65239C16.5 4.57149 16.5911 4.52408 16.6573 4.57047L20.0111 6.91808C20.0679 6.95789 20.0679 7.04211 20.0111 7.08192L16.6573 9.42953C16.5911 9.47592 16.5 9.42851 16.5 9.34761Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
|
||||
<path d="M16.5 19.3476V14.6524C16.5 14.5715 16.5911 14.5241 16.6573 14.5705L20.0111 16.9181C20.0679 16.9579 20.0679 17.0421 20.0111 17.0819L16.6573 19.4295C16.5911 19.4759 16.5 19.4285 16.5 19.3476Z" fill="currentColor" stroke="currentColor" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,023 B |
4
packages/web/assets/icons/user.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="3" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M6 19V19C6 16.7909 7.79086 15 10 15H14C16.2091 15 18 16.7909 18 19V19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
4
packages/web/assets/icons/volume-half.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4966 5.50774L10.4966 5.50774L6.61626 9H4.5C3.67157 9 3 9.67157 3 10.5V13.5C3 14.3284 3.67157 15 4.5 15H6.61626L10.4966 18.4923C11.4618 19.361 13 18.676 13 17.3773V6.62268C13 5.32402 11.4618 4.63899 10.4966 5.50774Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M16.5 9C16.5 9 17.5 10 17.5 12C17.5 14 16.5 15 16.5 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
1
packages/web/assets/icons/volume-mute.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 576 512" xmlns="http://www.w3.org/2000/svg"><path d="M301.2 34.85c-11.5-5.19-25.02-3.122-34.44 5.253L131.8 160H48c-26.51 0-48 21.49-48 47.1v95.1c0 26.51 21.49 47.1 48 47.1h83.84l134.9 119.9c5.98 5.31 13.58 8.09 21.26 8.09 4.43 0 8.97-.94 13.17-2.85 11.5-5.16 18.82-16.56 18.82-29.16V63.99c-.89-12.59-7.29-24-18.79-29.15ZM513.9 255.1l47.03-47.03c9.375-9.375 9.375-24.56 0-33.94 -9.375-9.38-24.56-9.375-33.94 0L480 222.1 432.1 175c-9.375-9.375-24.56-9.375-33.94 0 -9.38 9.375-9.375 24.56 0 33.94l47.03 47.03L398.16 303c-9.375 9.375-9.375 24.56 0 33.94 9.37 9.37 24.56 9.38 33.94 0L480 289.9l47.03 47.03c9.37 9.37 24.56 9.38 33.94 0 9.375-9.375 9.375-24.56 0-33.94L513.9 255.1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 702 B |
4
packages/web/assets/icons/volume.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4966 5.50774L10.4966 5.50774L6.61626 9H4.5C3.67157 9 3 9.67157 3 10.5V13.5C3 14.3284 3.67157 15 4.5 15H6.61626L10.4966 18.4923C11.4618 19.361 13 18.676 13 17.3773V6.62268C13 5.32402 11.4618 4.63899 10.4966 5.50774Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M16.5 9C16.5 9 17.5 10 17.5 12C17.5 14 16.5 15 16.5 15M19 6.5C19 6.5 21 9 21 12C21 15 19 17.5 19 17.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 572 B |
1
packages/web/assets/icons/windows-close.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 425 B |
1
packages/web/assets/icons/windows-maximize.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.75 3h12.5A2.75 2.75 0 0 1 21 5.75v12.5A2.75 2.75 0 0 1 18.25 21H5.75A2.75 2.75 0 0 1 3 18.25V5.75A2.75 2.75 0 0 1 5.75 3Zm0 1.5c-.69 0-1.25.56-1.25 1.25v12.5c0 .69.56 1.25 1.25 1.25h12.5c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H5.75Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
1
packages/web/assets/icons/windows-minimize.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 197 B |
1
packages/web/assets/icons/windows-un-maximize.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 570 B |
3
packages/web/assets/icons/x.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 222 B |
51
packages/web/components/ArtistsInline.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import cx from 'classnames'
|
||||
|
||||
const ArtistInline = ({
|
||||
artists,
|
||||
className,
|
||||
disableLink,
|
||||
onClick,
|
||||
}: {
|
||||
artists: Artist[]
|
||||
className?: string
|
||||
disableLink?: boolean
|
||||
onClick?: (artistId: number) => void
|
||||
}) => {
|
||||
if (!artists) return <div></div>
|
||||
|
||||
const navigate = useNavigate()
|
||||
const handleClick = (id: number) => {
|
||||
if (id === 0 || disableLink) return
|
||||
if (!onClick) {
|
||||
navigate(`/artist/${id}`)
|
||||
} else {
|
||||
onClick(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
!className?.includes('line-clamp') && 'line-clamp-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{artists.map((artist, index) => (
|
||||
<span key={`${artist.id}-${artist.name}`}>
|
||||
<span
|
||||
onClick={() => handleClick(artist.id)}
|
||||
className={cx({
|
||||
'hover:underline': !!artist.id && !disableLink,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistInline
|
||||
41
packages/web/components/Avatar.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { resizeImage } from '../utils/common'
|
||||
import useUser from '../hooks/useUser'
|
||||
import SvgIcon from './SvgIcon'
|
||||
import cx from 'classnames'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const Avatar = ({ size }: { size?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
const { data: user } = useUser()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
onClick={() => navigate('/login')}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full bg-gray-100 dark:bg-gray-700',
|
||||
size || 'h-9 w-9'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div onClick={() => navigate('/login')}>
|
||||
<SvgIcon
|
||||
name='user'
|
||||
className={cx(
|
||||
'rounded-full bg-black/[.06] p-1 text-gray-500 dark:bg-white/5',
|
||||
size || 'h-9 w-9'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
47
packages/web/components/Button.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { ReactNode } from 'react'
|
||||
import cx from 'classnames'
|
||||
|
||||
export enum Color {
|
||||
Primary = 'primary',
|
||||
Gray = 'gray',
|
||||
}
|
||||
|
||||
export enum Shape {
|
||||
Default = 'default',
|
||||
Square = 'square',
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
onClick,
|
||||
color = Color.Primary,
|
||||
iconColor = Color.Primary,
|
||||
isSkelton = false,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
color?: Color
|
||||
iconColor?: Color
|
||||
isSkelton?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'btn-pressed-animation flex cursor-default items-center rounded-lg px-4 py-1.5 text-lg font-medium',
|
||||
{
|
||||
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
|
||||
'text-brand-500 dark:text-white': iconColor === Color.Primary,
|
||||
'bg-gray-100 dark:bg-gray-700': color === Color.Gray,
|
||||
'text-gray-600 dark:text-gray-400': iconColor === Color.Gray,
|
||||
'animate-pulse bg-gray-100 !text-transparent dark:bg-gray-800':
|
||||
isSkelton,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
66
packages/web/components/Cover.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import cx from 'classnames'
|
||||
import { useState } from 'react'
|
||||
|
||||
const Cover = ({
|
||||
imageUrl,
|
||||
onClick,
|
||||
roundedClass = 'rounded-xl',
|
||||
showPlayButton = false,
|
||||
showHover = true,
|
||||
alwaysShowShadow = false,
|
||||
}: {
|
||||
imageUrl: string
|
||||
onClick?: () => void
|
||||
roundedClass?: string
|
||||
showPlayButton?: boolean
|
||||
showHover?: boolean
|
||||
alwaysShowShadow?: boolean
|
||||
}) => {
|
||||
const [isError, setIsError] = useState(imageUrl.includes('3132508627578625'))
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className='group relative z-0'>
|
||||
{/* Neon shadow */}
|
||||
{showHover && (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] bg-cover blur-lg filter transition duration-300 ',
|
||||
roundedClass,
|
||||
!alwaysShowShadow && 'opacity-0 group-hover:opacity-60'
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url("${imageUrl}")`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Cover */}
|
||||
{isError ? (
|
||||
<div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300 '>
|
||||
<SvgIcon name='music-note' className='h-1/2 w-1/2' />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
className={cx(
|
||||
'box-content aspect-square h-full w-full border border-black border-opacity-5 dark:border-white dark:border-opacity-[.03]',
|
||||
roundedClass
|
||||
)}
|
||||
src={imageUrl}
|
||||
onError={() => imageUrl && setIsError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Play button */}
|
||||
{showPlayButton && (
|
||||
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
|
||||
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
||||
<SvgIcon className='ml-0.5 h-6 w-6' name='play-fill' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
229
packages/web/components/CoverRow.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import Cover from '@/web/components/Cover'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import { prefetchAlbum } from '@/web/hooks/useAlbum'
|
||||
import { prefetchPlaylist } from '@/web/hooks/usePlaylist'
|
||||
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
|
||||
import cx from 'classnames'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export enum Subtitle {
|
||||
Copywriter = 'copywriter',
|
||||
Creator = 'creator',
|
||||
TypeReleaseYear = 'type+releaseYear',
|
||||
Artist = 'artist',
|
||||
}
|
||||
|
||||
const Title = ({
|
||||
title,
|
||||
seeMoreLink,
|
||||
}: {
|
||||
title: string
|
||||
seeMoreLink: string
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<div className='my-4 text-[28px] font-bold text-black dark:text-white'>
|
||||
{title}
|
||||
</div>
|
||||
{seeMoreLink && (
|
||||
<div className='text-13px font-semibold text-gray-600 hover:underline'>
|
||||
See More
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getSubtitleText = (
|
||||
item: Album | Playlist | Artist,
|
||||
subtitle: Subtitle
|
||||
) => {
|
||||
const nickname = 'creator' in item ? item.creator.nickname : 'someone'
|
||||
const artist =
|
||||
'artist' in item
|
||||
? item.artist.name
|
||||
: 'artists' in item
|
||||
? item.artists?.[0]?.name
|
||||
: 'unknown'
|
||||
const copywriter = 'copywriter' in item ? item.copywriter : 'unknown'
|
||||
const releaseYear =
|
||||
('publishTime' in item &&
|
||||
formatDate(item.publishTime ?? 0, 'en', 'YYYY')) ||
|
||||
'unknown'
|
||||
|
||||
const type = {
|
||||
playlist: 'playlist',
|
||||
album: 'Album',
|
||||
专辑: 'Album',
|
||||
Single: 'Single',
|
||||
'EP/Single': 'EP',
|
||||
EP: 'EP',
|
||||
unknown: 'unknown',
|
||||
精选集: 'Collection',
|
||||
}[('type' in item && typeof item.type !== 'number' && item.type) || 'unknown']
|
||||
|
||||
const table = {
|
||||
[Subtitle.Creator]: `by ${nickname}`,
|
||||
[Subtitle.TypeReleaseYear]: `${type} · ${releaseYear}`,
|
||||
[Subtitle.Artist]: artist,
|
||||
[Subtitle.Copywriter]: copywriter,
|
||||
}
|
||||
|
||||
return table[subtitle]
|
||||
}
|
||||
|
||||
const getImageUrl = (item: Album | Playlist | Artist) => {
|
||||
let cover: string | undefined = ''
|
||||
if ('coverImgUrl' in item) cover = item.coverImgUrl
|
||||
if ('picUrl' in item) cover = item.picUrl
|
||||
if ('img1v1Url' in item) cover = item.img1v1Url
|
||||
return resizeImage(cover || '', 'md')
|
||||
}
|
||||
|
||||
const CoverRow = ({
|
||||
title,
|
||||
albums,
|
||||
artists,
|
||||
playlists,
|
||||
subtitle = Subtitle.Copywriter,
|
||||
seeMoreLink,
|
||||
isSkeleton,
|
||||
className,
|
||||
rows = 2,
|
||||
navigateCallback, // Callback function when click on the cover/title
|
||||
}: {
|
||||
title?: string
|
||||
albums?: Album[]
|
||||
artists?: Artist[]
|
||||
playlists?: Playlist[]
|
||||
subtitle?: Subtitle
|
||||
seeMoreLink?: string
|
||||
isSkeleton?: boolean
|
||||
className?: string
|
||||
rows?: number
|
||||
navigateCallback?: () => void
|
||||
}) => {
|
||||
const renderItems = useMemo(() => {
|
||||
if (isSkeleton) {
|
||||
return new Array(rows * 5).fill({}) as Array<Album | Playlist | Artist>
|
||||
}
|
||||
return albums ?? playlists ?? artists ?? []
|
||||
}, [albums, artists, isSkeleton, playlists, rows])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const goTo = (id: number) => {
|
||||
if (isSkeleton) return
|
||||
if (albums) navigate(`/album/${id}`)
|
||||
if (playlists) navigate(`/playlist/${id}`)
|
||||
if (artists) navigate(`/artist/${id}`)
|
||||
if (navigateCallback) navigateCallback()
|
||||
scrollToTop()
|
||||
}
|
||||
|
||||
const prefetch = (id: number) => {
|
||||
if (albums) prefetchAlbum({ id })
|
||||
if (playlists) prefetchPlaylist({ id })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title && <Title title={title} seeMoreLink={seeMoreLink ?? ''} />}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'grid',
|
||||
className,
|
||||
!className &&
|
||||
'grid-cols-3 gap-x-6 gap-y-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
||||
)}
|
||||
>
|
||||
{renderItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id ?? index}
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
className='grid gap-x-6 gap-y-7'
|
||||
>
|
||||
<div>
|
||||
{/* Cover */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='box-content aspect-square w-full rounded-xl border border-black border-opacity-0' />
|
||||
) : (
|
||||
<Cover
|
||||
onClick={() => goTo(item.id)}
|
||||
imageUrl={getImageUrl(item)}
|
||||
showPlayButton={true}
|
||||
roundedClass={artists ? 'rounded-full' : 'rounded-xl'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className='mt-2'>
|
||||
<div className='font-semibold'>
|
||||
{/* Name */}
|
||||
{isSkeleton ? (
|
||||
<div className='flex w-full -translate-y-px flex-col'>
|
||||
<Skeleton className='w-full leading-tight'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
<Skeleton className='w-1/3 translate-y-px leading-tight'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={cx(
|
||||
'line-clamp-2 leading-tight',
|
||||
artists && 'mt-3 text-center'
|
||||
)}
|
||||
>
|
||||
{/* Playlist private icon */}
|
||||
{(item as Playlist).privacy === 10 && (
|
||||
<SvgIcon
|
||||
name='lock'
|
||||
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Explicit icon */}
|
||||
{(item as Album)?.mark === 1056768 && (
|
||||
<SvgIcon
|
||||
name='explicit'
|
||||
className='float-right mt-[2px] h-4 w-4 text-gray-300'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
onClick={() => goTo(item.id)}
|
||||
className='decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='w-3/5 translate-y-px text-[12px]'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
) : (
|
||||
!artists && (
|
||||
<div className='flex text-[12px] text-gray-500 dark:text-gray-400'>
|
||||
<span>{getSubtitleText(item, subtitle)}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverRow
|
||||
13
packages/web/components/DailyTracksCard.module.scss
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@keyframes move {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animation {
|
||||
animation: move 38s infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
34
packages/web/components/DailyTracksCard.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import SvgIcon from './SvgIcon'
|
||||
import style from './DailyTracksCard.module.scss'
|
||||
import cx from 'classnames'
|
||||
|
||||
const DailyTracksCard = () => {
|
||||
return (
|
||||
<div className='relative h-[198px] cursor-pointer overflow-hidden rounded-2xl'>
|
||||
{/* Cover */}
|
||||
<img
|
||||
className={cx(
|
||||
'absolute top-0 left-0 w-full will-change-transform',
|
||||
style.animation
|
||||
)}
|
||||
src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
|
||||
/>
|
||||
|
||||
{/* 每日推荐 */}
|
||||
<div className='absolute flex h-full w-1/2 items-center bg-gradient-to-r from-[#0000004d] to-transparent pl-8'>
|
||||
<div className='grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]'>
|
||||
{Array.from('每日推荐').map(word => (
|
||||
<div key={word}>{word}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play button */}
|
||||
<button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
||||
<SvgIcon name='play-fill' className='ml-0.5 h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DailyTracksCard
|
||||
139
packages/web/components/FMCard.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { player } from '@/web/store'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import SvgIcon from './SvgIcon'
|
||||
import ArtistInline from './ArtistsInline'
|
||||
import {
|
||||
State as PlayerState,
|
||||
Mode as PlayerMode,
|
||||
} from '@/web/utils/player'
|
||||
import useCoverColor from '../hooks/useCoverColor'
|
||||
import cx from 'classnames'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const MediaControls = () => {
|
||||
const classes =
|
||||
'btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-1.5 transition duration-200 after:bg-white/10'
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
|
||||
const playOrPause = () => {
|
||||
if (playerSnapshot.mode === PlayerMode.FM) {
|
||||
player.playOrPause()
|
||||
} else {
|
||||
player.playFM()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
key='dislike'
|
||||
className={classes}
|
||||
onClick={() => player.fmTrash()}
|
||||
>
|
||||
<SvgIcon name='dislike' className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button key='play' className={classes} onClick={playOrPause}>
|
||||
<SvgIcon
|
||||
className='h-6 w-6'
|
||||
name={
|
||||
playerSnapshot.mode === PlayerMode.FM &&
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
key='next'
|
||||
className={classes}
|
||||
onClick={() => player.nextTrack(true)}
|
||||
>
|
||||
<SvgIcon name='next' className='h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FMCard = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.fmTrack, [playerSnapshot.fmTrack])
|
||||
const coverUrl = useMemo(
|
||||
() => resizeImage(track?.al?.picUrl ?? '', 'md'),
|
||||
[track?.al?.picUrl]
|
||||
)
|
||||
|
||||
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative flex h-[198px] overflow-hidden rounded-2xl bg-gray-100 p-4 dark:bg-gray-800'
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
|
||||
}}
|
||||
>
|
||||
{coverUrl ? (
|
||||
<img
|
||||
onClick={() => track?.al?.id && navigate(`/album/${track.al.id}`)}
|
||||
className='rounded-lg shadow-2xl'
|
||||
src={coverUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className='aspect-square h-full rounded-lg bg-gray-200 dark:bg-white/5'></div>
|
||||
)}
|
||||
|
||||
<div className='ml-5 flex w-full flex-col justify-between text-white'>
|
||||
{/* Track info */}
|
||||
<div>
|
||||
{track ? (
|
||||
<div className='line-clamp-2 text-xl font-semibold'>
|
||||
{track?.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex'>
|
||||
<div className='bg-gray-200 text-xl text-transparent dark:bg-white/5'>
|
||||
PLACEHOLDER12345
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{track ? (
|
||||
<ArtistInline
|
||||
className='line-clamp-2 opacity-75'
|
||||
artists={track?.ar ?? []}
|
||||
/>
|
||||
) : (
|
||||
<div className='mt-1 flex'>
|
||||
<div className='bg-gray-200 text-transparent dark:bg-white/5'>
|
||||
PLACEHOLDER
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='-mb-1 flex items-center justify-between'>
|
||||
{track ? <MediaControls /> : <div className='h-9'></div>}
|
||||
|
||||
{/* FM logo */}
|
||||
<div
|
||||
className={cx(
|
||||
'right-4 bottom-5 flex opacity-20',
|
||||
track ? 'text-white ' : 'text-gray-700 dark:text-white'
|
||||
)}
|
||||
>
|
||||
<SvgIcon name='fm' className='mr-1 h-6 w-6' />
|
||||
<span className='font-semibold'>私人FM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FMCard
|
||||
32
packages/web/components/IconButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ReactNode } from 'react'
|
||||
import cx from 'classnames'
|
||||
|
||||
const IconButton = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean | undefined
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cx(
|
||||
className,
|
||||
'relative transform cursor-default p-1.5 transition duration-200',
|
||||
!disabled &&
|
||||
'btn-pressed-animation btn-hover-animation after:bg-black/[.06] dark:after:bg-white/10',
|
||||
disabled && 'opacity-30'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconButton
|
||||
115
packages/web/components/Lyric/Lyric.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import useLyric from '@/web/hooks/useLyric'
|
||||
import { player } from '@/web/store'
|
||||
import { motion } from 'framer-motion'
|
||||
import { lyricParser } from '@/web/utils/lyric'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import cx from 'classnames'
|
||||
|
||||
const Lyric = ({ className }: { className?: string }) => {
|
||||
// const ease = [0.5, 0.2, 0.2, 0.8]
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
|
||||
|
||||
const lyric = useMemo(() => {
|
||||
return lyricRaw && lyricParser(lyricRaw)
|
||||
}, [lyricRaw])
|
||||
|
||||
const progress = playerSnapshot.progress + 0.3
|
||||
const currentLine = useMemo(() => {
|
||||
const index =
|
||||
(lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
|
||||
return {
|
||||
index: index < 1 ? 0 : index,
|
||||
time: lyric?.lyric?.[index]?.time ?? 0,
|
||||
}
|
||||
}, [lyric?.lyric, progress])
|
||||
|
||||
const displayLines = useMemo(() => {
|
||||
const index = currentLine.index
|
||||
const lines =
|
||||
lyric?.lyric.slice(index === 0 ? 0 : index - 1, currentLine.index + 7) ??
|
||||
[]
|
||||
if (index === 0) {
|
||||
lines.unshift({
|
||||
time: 0,
|
||||
content: '',
|
||||
rawTime: '[00:00:00]',
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}, [currentLine.index, lyric?.lyric])
|
||||
|
||||
const variants = {
|
||||
initial: { opacity: [0, 0.2], y: ['24%', 0] },
|
||||
current: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
ease: [0.5, 0.2, 0.2, 0.8],
|
||||
duration: 0.7,
|
||||
},
|
||||
},
|
||||
rest: (index: number) => ({
|
||||
opacity: 0.2,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: index * 0.04,
|
||||
ease: [0.5, 0.2, 0.2, 0.8],
|
||||
duration: 0.7,
|
||||
},
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -132,
|
||||
height: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
ease: [0.5, 0.2, 0.2, 0.8],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'max-h-screen cursor-default overflow-hidden font-semibold',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
paddingTop: 'calc(100vh / 7 * 3)',
|
||||
paddingBottom: 'calc(100vh / 7 * 3)',
|
||||
fontSize: 'calc(100vw * 0.0264)',
|
||||
lineHeight: 'calc(100vw * 0.032)',
|
||||
}}
|
||||
>
|
||||
{displayLines.map(({ content, time }, index) => {
|
||||
return (
|
||||
<motion.div
|
||||
key={`${String(index)}-${String(time)}`}
|
||||
custom={index}
|
||||
variants={variants}
|
||||
initial={'initial'}
|
||||
animate={
|
||||
time === currentLine.time
|
||||
? 'current'
|
||||
: time < currentLine.time
|
||||
? 'exit'
|
||||
: 'rest'
|
||||
}
|
||||
layout
|
||||
className={cx('max-w-[78%] py-[calc(100vw_*_0.0111)] text-white')}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Lyric
|
||||
101
packages/web/components/Lyric/Lyric2.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import useLyric from '@/web/hooks/useLyric'
|
||||
import { player } from '@/web/store'
|
||||
import { motion, useMotionValue } from 'framer-motion'
|
||||
import { lyricParser } from '@/web/utils/lyric'
|
||||
import { useWindowSize } from 'react-use'
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import cx from 'classnames'
|
||||
|
||||
const Lyric = ({ className }: { className?: string }) => {
|
||||
// const ease = [0.5, 0.2, 0.2, 0.8]
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const { data: lyricRaw } = useLyric({ id: track?.id ?? 0 })
|
||||
|
||||
const lyric = useMemo(() => {
|
||||
return lyricRaw && lyricParser(lyricRaw)
|
||||
}, [lyricRaw])
|
||||
|
||||
const [progress, setProgress] = useState(0)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setProgress(player.howler.seek() + 0.3)
|
||||
}, 300)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
const currentIndex = useMemo(() => {
|
||||
return (lyric?.lyric.findIndex(({ time }) => time > progress) ?? 1) - 1
|
||||
}, [lyric?.lyric, progress])
|
||||
|
||||
const y = useMotionValue(1000)
|
||||
const { height: windowHight } = useWindowSize()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const top = (
|
||||
document.getElementById('lyrics')?.children?.[currentIndex] as any
|
||||
)?.offsetTop
|
||||
if (top) {
|
||||
y.set((windowHight / 9) * 4 - top)
|
||||
}
|
||||
}, [currentIndex, windowHight, y])
|
||||
|
||||
useEffect(() => {
|
||||
y.set(0)
|
||||
}, [track, y])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'max-h-screen cursor-default select-none overflow-hidden font-semibold',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
paddingTop: 'calc(100vh / 9 * 4)',
|
||||
paddingBottom: 'calc(100vh / 9 * 4)',
|
||||
fontSize: 'calc(100vw * 0.0264)',
|
||||
lineHeight: 'calc(100vw * 0.032)',
|
||||
}}
|
||||
id='lyrics'
|
||||
>
|
||||
{lyric?.lyric.map(({ content, time }, index) => {
|
||||
return (
|
||||
<motion.div
|
||||
id={String(time)}
|
||||
key={`${String(index)}-${String(time)}`}
|
||||
className={cx(
|
||||
'max-w-[78%] py-[calc(100vw_*_0.0111)] text-white duration-700'
|
||||
)}
|
||||
style={{
|
||||
y,
|
||||
opacity:
|
||||
index === currentIndex
|
||||
? 1
|
||||
: index > currentIndex && index < currentIndex + 8
|
||||
? 0.2
|
||||
: 0,
|
||||
transitionProperty:
|
||||
index > currentIndex - 2 && index < currentIndex + 8
|
||||
? 'transform, opacity'
|
||||
: 'none',
|
||||
transitionTimingFunction:
|
||||
index > currentIndex - 2 && index < currentIndex + 8
|
||||
? 'cubic-bezier(0.5, 0.2, 0.2, 0.8)'
|
||||
: 'none',
|
||||
transitionDelay: `${
|
||||
index < currentIndex + 8 && index > currentIndex
|
||||
? 0.04 * (index - currentIndex)
|
||||
: 0
|
||||
}s`,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Lyric
|
||||
68
packages/web/components/Lyric/LyricPanel.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Player from './Player'
|
||||
import { player, state } from '@/web/store'
|
||||
import { getCoverColor } from '@/web/utils/common'
|
||||
import { colord } from 'colord'
|
||||
import IconButton from '../IconButton'
|
||||
import SvgIcon from '../SvgIcon'
|
||||
import Lyric from './Lyric'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Lyric2 from './Lyric2'
|
||||
import useCoverColor from '@/web/hooks/useCoverColor'
|
||||
import cx from 'classnames'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const LyricPanel = () => {
|
||||
const stateSnapshot = useSnapshot(state)
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{stateSnapshot.uiStates.showLyricPanel && (
|
||||
<motion.div
|
||||
initial={{
|
||||
y: '100%',
|
||||
}}
|
||||
animate={{
|
||||
y: 0,
|
||||
transition: {
|
||||
ease: 'easeIn',
|
||||
duration: 0.4,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
y: '100%',
|
||||
transition: {
|
||||
ease: 'easeIn',
|
||||
duration: 0.4,
|
||||
},
|
||||
}}
|
||||
className={cx(
|
||||
'fixed inset-0 z-40 grid grid-cols-[repeat(13,_minmax(0,_1fr))] gap-[8%] bg-gray-800'
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
|
||||
}}
|
||||
>
|
||||
{/* Drag area */}
|
||||
<div className='app-region-drag absolute top-0 right-0 left-0 h-16'></div>
|
||||
|
||||
<Player className='col-span-6' />
|
||||
{/* <Lyric className='col-span-7' /> */}
|
||||
<Lyric2 className='col-span-7' />
|
||||
|
||||
<div className='absolute bottom-3.5 right-7 text-white'>
|
||||
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
|
||||
<SvgIcon className='h-6 w-6' name='lyrics' />
|
||||
</IconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricPanel
|
||||
168
packages/web/components/Lyric/Player.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
||||
import { player, state } from '@/web/store'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
|
||||
import ArtistInline from '../ArtistsInline'
|
||||
import Cover from '../Cover'
|
||||
import IconButton from '../IconButton'
|
||||
import SvgIcon from '../SvgIcon'
|
||||
import {
|
||||
State as PlayerState,
|
||||
Mode as PlayerMode,
|
||||
} from '@/web/utils/player'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import cx from 'classnames'
|
||||
|
||||
const PlayingTrack = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const navigate = useNavigate()
|
||||
|
||||
const toAlbum = () => {
|
||||
const id = track?.al?.id
|
||||
if (!id) return
|
||||
navigate(`/album/${id}`)
|
||||
state.uiStates.showLyricPanel = false
|
||||
}
|
||||
|
||||
const trackListSource = useMemo(
|
||||
() => playerSnapshot.trackListSource,
|
||||
[playerSnapshot.trackListSource]
|
||||
)
|
||||
|
||||
const hasListSource =
|
||||
playerSnapshot.mode !== PlayerMode.FM && trackListSource?.type
|
||||
|
||||
const toTrackListSource = () => {
|
||||
if (!hasListSource) return
|
||||
|
||||
navigate(`/${trackListSource.type}/${trackListSource.id}`)
|
||||
state.uiStates.showLyricPanel = false
|
||||
}
|
||||
|
||||
const toArtist = (id: number) => {
|
||||
navigate(`/artist/${id}`)
|
||||
state.uiStates.showLyricPanel = false
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={toTrackListSource}
|
||||
className={cx(
|
||||
'line-clamp-1 text-[22px] font-semibold text-white',
|
||||
hasListSource && 'hover:underline'
|
||||
)}
|
||||
>
|
||||
{track?.name}
|
||||
</div>
|
||||
<div className='line-clamp-1 -mt-0.5 inline-flex max-h-7 text-white opacity-60'>
|
||||
<ArtistInline artists={track?.ar ?? []} onClick={toArtist} />
|
||||
{!!track?.al?.id && (
|
||||
<span>
|
||||
{' '}
|
||||
-{' '}
|
||||
<span onClick={toAlbum} className='hover:underline'>
|
||||
{track?.al.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LikeButton = ({ track }: { track: Track | undefined | null }) => {
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
return (
|
||||
<div className='mr-1 '>
|
||||
<IconButton
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-6 w-6 text-white'
|
||||
name={
|
||||
track?.id && userLikedSongs?.ids?.includes(track.id)
|
||||
? 'heart'
|
||||
: 'heart-outline'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Controls = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center gap-2 text-white'>
|
||||
{mode === PlayerMode.TrackList && (
|
||||
<IconButton
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
>
|
||||
<SvgIcon className='h-6 w-6' name='previous' />
|
||||
</IconButton>
|
||||
)}
|
||||
{mode === PlayerMode.FM && (
|
||||
<IconButton onClick={() => player.fmTrash()}>
|
||||
<SvgIcon className='h-6 w-6' name='dislike' />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => track && player.playOrPause()}
|
||||
disabled={!track}
|
||||
className='after:rounded-xl'
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-7 w-7'
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||
<SvgIcon className='h-6 w-6' name='next' />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Player = ({ className }: { className?: string }) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div className={cx('flex w-full items-center justify-end', className)}>
|
||||
<div className='relative w-[74%]'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(track?.al.picUrl ?? '', 'lg')}
|
||||
roundedClass='rounded-2xl'
|
||||
alwaysShowShadow={true}
|
||||
/>
|
||||
<div className='absolute -bottom-32 right-0 left-0'>
|
||||
<div className='mt-6 flex cursor-default justify-between'>
|
||||
<PlayingTrack />
|
||||
<LikeButton track={track} />
|
||||
</div>
|
||||
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
||||
3
packages/web/components/Lyric/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import LyricPanel from './LyricPanel'
|
||||
|
||||
export default LyricPanel
|
||||
25
packages/web/components/Main.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Router from './Router'
|
||||
import Topbar from './Topbar'
|
||||
import cx from 'classnames'
|
||||
|
||||
const Main = () => {
|
||||
return (
|
||||
<div
|
||||
id='mainContainer'
|
||||
className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
|
||||
>
|
||||
<Topbar />
|
||||
<main
|
||||
id='main'
|
||||
className={cx(
|
||||
'mb-24 flex-grow px-8',
|
||||
window.env?.isEnableTitlebar && 'mt-8'
|
||||
)}
|
||||
>
|
||||
<Router />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Main
|
||||
244
packages/web/components/Player.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import ArtistInline from './ArtistsInline'
|
||||
import IconButton from './IconButton'
|
||||
import Slider from './Slider'
|
||||
import SvgIcon from './SvgIcon'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
||||
import { player, state } from '@/web/store'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import {
|
||||
State as PlayerState,
|
||||
Mode as PlayerMode,
|
||||
} from '@/web/utils/player'
|
||||
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
|
||||
import cx from 'classnames'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const PlayingTrack = () => {
|
||||
const navigate = useNavigate()
|
||||
const snappedPlayer = useSnapshot(player)
|
||||
const track = useMemo(() => snappedPlayer.track, [snappedPlayer.track])
|
||||
const trackListSource = useMemo(
|
||||
() => snappedPlayer.trackListSource,
|
||||
[snappedPlayer.trackListSource]
|
||||
)
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
const hasTrackListSource =
|
||||
snappedPlayer.mode !== PlayerMode.FM && trackListSource?.type
|
||||
|
||||
const toAlbum = () => {
|
||||
const id = track?.al?.id
|
||||
if (id) navigate(`/album/${id}`)
|
||||
}
|
||||
|
||||
const toTrackListSource = () => {
|
||||
if (hasTrackListSource)
|
||||
navigate(`/${trackListSource.type}/${trackListSource.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{track && (
|
||||
<div className='flex items-center gap-3'>
|
||||
{track?.al?.picUrl && (
|
||||
<img
|
||||
onClick={toAlbum}
|
||||
className='aspect-square h-full rounded-md shadow-md'
|
||||
src={resizeImage(track.al.picUrl, 'xs')}
|
||||
/>
|
||||
)}
|
||||
{!track?.al?.picUrl && (
|
||||
<div
|
||||
onClick={toAlbum}
|
||||
className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm'
|
||||
>
|
||||
<SvgIcon className='h-6 w-6 text-gray-300' name='music-note' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col justify-center leading-tight'>
|
||||
<div
|
||||
onClick={toTrackListSource}
|
||||
className={cx(
|
||||
'line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 dark:text-white dark:decoration-gray-300',
|
||||
hasTrackListSource && 'hover:underline'
|
||||
)}
|
||||
>
|
||||
{track?.name}
|
||||
</div>
|
||||
<div className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
|
||||
<ArtistInline artists={track?.ar ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
name={
|
||||
track?.id && userLikedSongs?.ids?.includes(track.id)
|
||||
? 'heart'
|
||||
: 'heart-outline'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{!track && <div></div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MediaControls = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center gap-2 text-black dark:text-white'>
|
||||
{mode === PlayerMode.TrackList && (
|
||||
<IconButton
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
>
|
||||
<SvgIcon className='h-6 w-6' name='previous' />
|
||||
</IconButton>
|
||||
)}
|
||||
{mode === PlayerMode.FM && (
|
||||
<IconButton onClick={() => player.fmTrash()}>
|
||||
<SvgIcon className='h-6 w-6' name='dislike' />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => track && player.playOrPause()}
|
||||
disabled={!track}
|
||||
className='after:rounded-xl'
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-7 w-7'
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||
<SvgIcon className='h-6 w-6' name='next' />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Others = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
const switchRepeatMode = () => {
|
||||
if (playerSnapshot.repeatMode === PlayerRepeatMode.Off) {
|
||||
player.repeatMode = PlayerRepeatMode.On
|
||||
} else if (playerSnapshot.repeatMode === PlayerRepeatMode.On) {
|
||||
player.repeatMode = PlayerRepeatMode.One
|
||||
} else {
|
||||
player.repeatMode = PlayerRepeatMode.Off
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-2 pr-2 text-black dark:text-white'>
|
||||
<IconButton
|
||||
onClick={() => toast('Work in progress')}
|
||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||
>
|
||||
<SvgIcon className='h-6 w-6' name='playlist' />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={switchRepeatMode}
|
||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||
>
|
||||
<SvgIcon
|
||||
className={cx(
|
||||
'h-6 w-6',
|
||||
playerSnapshot.repeatMode !== PlayerRepeatMode.Off &&
|
||||
'text-brand-500'
|
||||
)}
|
||||
name={
|
||||
playerSnapshot.repeatMode === PlayerRepeatMode.One
|
||||
? 'repeat-1'
|
||||
: 'repeat'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => toast('施工中...')}
|
||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||
>
|
||||
<SvgIcon className='h-6 w-6' name='shuffle' />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => toast('施工中...')}>
|
||||
<SvgIcon className='h-6 w-6' name='volume' />
|
||||
</IconButton>
|
||||
|
||||
{/* Lyric */}
|
||||
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
|
||||
<SvgIcon className='h-6 w-6' name='lyrics' />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Progress = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const progress = useMemo(
|
||||
() => playerSnapshot.progress,
|
||||
[playerSnapshot.progress]
|
||||
)
|
||||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div className='absolute w-screen'>
|
||||
{track && (
|
||||
<Slider
|
||||
min={0}
|
||||
max={(track.dt ?? 0) / 1000}
|
||||
value={
|
||||
state === PlayerState.Playing || state === PlayerState.Paused
|
||||
? progress
|
||||
: 0
|
||||
}
|
||||
onChange={value => {
|
||||
player.progress = value
|
||||
}}
|
||||
onlyCallOnChangeAfterDragEnded={true}
|
||||
/>
|
||||
)}
|
||||
{!track && (
|
||||
<div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Player = () => {
|
||||
return (
|
||||
<div className='fixed bottom-0 left-0 right-0 grid h-16 grid-cols-3 grid-rows-1 bg-white bg-opacity-[.86] py-2.5 px-5 backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'>
|
||||
<Progress />
|
||||
|
||||
<PlayingTrack />
|
||||
<MediaControls />
|
||||
<Others />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
||||
63
packages/web/components/Router.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { RouteObject } from 'react-router-dom'
|
||||
import { useRoutes } from 'react-router-dom'
|
||||
import Album from '@/web/pages/Album'
|
||||
import Home from '@/web/pages/Home'
|
||||
import Login from '@/web/pages/Login'
|
||||
import Playlist from '@/web/pages/Playlist'
|
||||
import Artist from '@/web/pages/Artist'
|
||||
import Search from '@/web/pages/Search'
|
||||
import Library from '@/web/pages/Library'
|
||||
import Podcast from '@/web/pages/Podcast'
|
||||
import Settings from '@/web/pages/Settings'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: '/podcast',
|
||||
element: <Podcast />,
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
element: <Library />,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/search/:keywords',
|
||||
element: <Search />,
|
||||
children: [
|
||||
{
|
||||
path: ':type',
|
||||
element: <Search />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/playlist/:id',
|
||||
element: <Playlist />,
|
||||
},
|
||||
{
|
||||
path: '/album/:id',
|
||||
element: <Album />,
|
||||
},
|
||||
{
|
||||
path: '/artist/:id',
|
||||
element: <Artist />,
|
||||
},
|
||||
]
|
||||
|
||||
const Router = () => {
|
||||
const element = useRoutes(routes)
|
||||
return <>{element}</>
|
||||
}
|
||||
|
||||
export default Router
|
||||
109
packages/web/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import SvgIcon from './SvgIcon'
|
||||
import useUserPlaylists from '@/web/hooks/useUserPlaylists'
|
||||
import { scrollToTop } from '@/web/utils/common'
|
||||
import { prefetchPlaylist } from '@/web/hooks/usePlaylist'
|
||||
import { player } from '@/web/store'
|
||||
import { Mode, TrackListSourceType } from '@/web/utils/player'
|
||||
import cx from 'classnames'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const primaryTabs = [
|
||||
{
|
||||
name: '主页',
|
||||
icon: 'home',
|
||||
route: '/',
|
||||
},
|
||||
{
|
||||
name: '播客',
|
||||
icon: 'podcast',
|
||||
route: '/podcast',
|
||||
},
|
||||
{
|
||||
name: '音乐库',
|
||||
icon: 'music-library',
|
||||
route: '/library',
|
||||
},
|
||||
] as const
|
||||
|
||||
const PrimaryTabs = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={cx(window.env?.isMac && 'app-region-drag', 'h-14')}></div>
|
||||
{primaryTabs.map(tab => (
|
||||
<NavLink
|
||||
onClick={() => scrollToTop()}
|
||||
key={tab.route}
|
||||
to={tab.route}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20',
|
||||
!isActive && 'text-gray-700 dark:text-white',
|
||||
isActive && 'text-brand-500 '
|
||||
)
|
||||
}
|
||||
>
|
||||
<SvgIcon className='mr-3 h-6 w-6' name={tab.icon} />
|
||||
<span className='font-semibold'>{tab.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<div className='mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-10'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Playlists = () => {
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const currentPlaylistID = useMemo(
|
||||
() => playerSnapshot.trackListSource?.id,
|
||||
[playerSnapshot.trackListSource]
|
||||
)
|
||||
const playlistMode = useMemo(
|
||||
() => playerSnapshot.trackListSource?.type,
|
||||
[playerSnapshot.trackListSource]
|
||||
)
|
||||
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
|
||||
|
||||
return (
|
||||
<div className='mb-16 overflow-auto pb-2'>
|
||||
{playlists?.playlist?.map(playlist => (
|
||||
<NavLink
|
||||
onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
|
||||
key={playlist.id}
|
||||
onClick={() => scrollToTop()}
|
||||
to={`/playlist/${playlist.id}`}
|
||||
className={({ isActive }: { isActive: boolean }) =>
|
||||
cx(
|
||||
'btn-hover-animation line-clamp-1 my-px mx-3 flex cursor-default items-center justify-between rounded-lg px-3 py-[0.38rem] text-sm text-black opacity-70 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/20',
|
||||
isActive && 'after:scale-100 after:opacity-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className='line-clamp-1'>{playlist.name}</span>
|
||||
{playlistMode === TrackListSourceType.Playlist &&
|
||||
mode === Mode.TrackList &&
|
||||
currentPlaylistID === playlist.id && (
|
||||
<SvgIcon className='h-5 w-5' name='volume-half' />
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<div
|
||||
id='sidebar'
|
||||
className='grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:border-gray-500/10 dark:bg-gray-900 dark:bg-opacity-80'
|
||||
>
|
||||
<PrimaryTabs />
|
||||
<Playlists />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
23
packages/web/components/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { ReactNode } from 'react'
|
||||
import cx from 'classnames'
|
||||
|
||||
const Skeleton = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'relative animate-pulse bg-gray-100 text-transparent dark:bg-gray-800',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Skeleton
|
||||
158
packages/web/components/Slider.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import cx from 'classnames'
|
||||
|
||||
const Slider = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
onlyCallOnChangeAfterDragEnded = false,
|
||||
orientation = 'horizontal',
|
||||
}: {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
onChange: (value: number) => void
|
||||
onlyCallOnChangeAfterDragEnded?: boolean
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
}) => {
|
||||
const sliderRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [draggingValue, setDraggingValue] = useState(value)
|
||||
const memoedValue = useMemo(
|
||||
() =>
|
||||
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
|
||||
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the value of the slider based on the position of the pointer
|
||||
*/
|
||||
const getNewValue = useCallback(
|
||||
(val: number) => {
|
||||
if (!sliderRef?.current) return 0
|
||||
const sliderWidth = sliderRef.current.getBoundingClientRect().width
|
||||
const newValue = (val / sliderWidth) * max
|
||||
if (newValue < min) return min
|
||||
if (newValue > max) return max
|
||||
return newValue
|
||||
},
|
||||
[sliderRef, max, min]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle slider click event
|
||||
*/
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
onChange(getNewValue(e.clientX))
|
||||
},
|
||||
[getNewValue, onChange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle pointer down event
|
||||
*/
|
||||
const handlePointerDown = () => {
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pointer move events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
||||
if (!isDragging) return
|
||||
const newValue = getNewValue(e.clientX)
|
||||
onlyCallOnChangeAfterDragEnded
|
||||
? setDraggingValue(newValue)
|
||||
: onChange(newValue)
|
||||
}
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
onChange,
|
||||
setDraggingValue,
|
||||
onlyCallOnChangeAfterDragEnded,
|
||||
getNewValue,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handle pointer up events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
if (onlyCallOnChangeAfterDragEnded) {
|
||||
console.log('draggingValue', draggingValue)
|
||||
onChange(draggingValue)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
onlyCallOnChangeAfterDragEnded,
|
||||
draggingValue,
|
||||
onChange,
|
||||
])
|
||||
|
||||
/**
|
||||
* Track and thumb styles
|
||||
*/
|
||||
const usedTrackStyle = useMemo(
|
||||
() => ({ width: `${(memoedValue / max) * 100}%` }),
|
||||
[max, memoedValue]
|
||||
)
|
||||
const thumbStyle = useMemo(
|
||||
() => ({
|
||||
left: `${(memoedValue / max) * 100}%`,
|
||||
transform: `translateX(-10px)`,
|
||||
}),
|
||||
[max, memoedValue]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group flex h-2 -translate-y-[3px] items-center'
|
||||
ref={sliderRef}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Track */}
|
||||
<div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
|
||||
|
||||
{/* Passed track */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute h-[2px] group-hover:bg-brand-500',
|
||||
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-500'
|
||||
)}
|
||||
style={usedTrackStyle}
|
||||
></div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={cx(
|
||||
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity ',
|
||||
isDragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
style={thumbStyle}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
<div className='absolute h-2 w-2 rounded-full bg-brand-500'></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Slider
|
||||
57
packages/web/components/SvgIcon.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export type SvgName =
|
||||
| 'back'
|
||||
| 'dislike'
|
||||
| 'dj'
|
||||
| 'email'
|
||||
| 'explicit'
|
||||
| 'eye-off'
|
||||
| 'eye'
|
||||
| 'fm'
|
||||
| 'forward'
|
||||
| 'heart-outline'
|
||||
| 'heart'
|
||||
| 'home'
|
||||
| 'lock'
|
||||
| 'lyrics'
|
||||
| 'more'
|
||||
| 'music-library'
|
||||
| 'music-note'
|
||||
| 'next'
|
||||
| 'pause'
|
||||
| 'phone'
|
||||
| 'play-fill'
|
||||
| 'play'
|
||||
| 'playlist'
|
||||
| 'podcast'
|
||||
| 'previous'
|
||||
| 'qrcode'
|
||||
| 'repeat'
|
||||
| 'repeat-1'
|
||||
| 'search'
|
||||
| 'settings'
|
||||
| 'shuffle'
|
||||
| 'user'
|
||||
| 'volume-half'
|
||||
| 'volume-mute'
|
||||
| 'volume'
|
||||
| 'windows-close'
|
||||
| 'windows-minimize'
|
||||
| 'windows-maximize'
|
||||
| 'windows-un-maximize'
|
||||
| 'x'
|
||||
const SvgIcon = ({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name: SvgName
|
||||
className?: string
|
||||
}) => {
|
||||
const symbolId = `#icon-${name}`
|
||||
return (
|
||||
<svg aria-hidden='true' className={className}>
|
||||
<use href={symbolId} fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default SvgIcon
|
||||
98
packages/web/components/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { player } from '@/web/store'
|
||||
import SvgIcon from './SvgIcon'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Controls = () => {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
||||
setIsMaximized(value)
|
||||
})
|
||||
|
||||
const minimize = () => {
|
||||
window.ipcRenderer?.send(IpcChannels.Minimize)
|
||||
}
|
||||
|
||||
const maxRestore = () => {
|
||||
window.ipcRenderer?.send(IpcChannels.MaximizeOrUnmaximize)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
window.ipcRenderer?.send(IpcChannels.Close)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='app-region-no-drag flex h-full'>
|
||||
<button
|
||||
onClick={minimize}
|
||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
||||
>
|
||||
<SvgIcon className='h-3 w-3' name='windows-minimize' />
|
||||
</button>
|
||||
<button
|
||||
onClick={maxRestore}
|
||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-3 w-3'
|
||||
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'
|
||||
>
|
||||
<SvgIcon className='h-3 w-3' name='windows-close' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Title = ({ className }: { className?: string }) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div className={cx('text-sm text-gray-500', className)}>
|
||||
{track?.name && (
|
||||
<>
|
||||
<span>{track.name}</span>
|
||||
<span className='mx-2'>-</span>
|
||||
</>
|
||||
)}
|
||||
<span>YesPlayMusic</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Win = () => {
|
||||
return (
|
||||
<div className='flex h-8 w-screen items-center justify-between bg-gray-50'>
|
||||
<Title className='ml-3' />
|
||||
<Controls />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Linux = () => {
|
||||
return (
|
||||
<div className='flex h-8 w-screen items-center justify-between bg-gray-50'>
|
||||
<div></div>
|
||||
<Title className='text-center' />
|
||||
<Controls />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleBar = () => {
|
||||
return (
|
||||
<div className='app-region-drag fixed z-30'>
|
||||
{window.env?.isWin ? <Win /> : <Linux />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleBar
|
||||
117
packages/web/components/Topbar.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import useScroll from '@/web/hooks/useScroll'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Avatar from './Avatar'
|
||||
import cx from 'classnames'
|
||||
|
||||
const NavigationButtons = () => {
|
||||
const navigate = useNavigate()
|
||||
enum ACTION {
|
||||
Back = 'back',
|
||||
Forward = 'forward',
|
||||
}
|
||||
const handleNavigate = (action: ACTION) => {
|
||||
if (action === ACTION.Back) navigate(-1)
|
||||
if (action === ACTION.Forward) navigate(1)
|
||||
}
|
||||
return (
|
||||
<div className='flex gap-1'>
|
||||
{[ACTION.Back, ACTION.Forward].map(action => (
|
||||
<div
|
||||
onClick={() => handleNavigate(action)}
|
||||
key={action}
|
||||
className='app-region-no-drag btn-hover-animation rounded-lg p-2 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
|
||||
>
|
||||
<SvgIcon className='h-5 w-5' name={action} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchBox = () => {
|
||||
const { type } = useParams()
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const toSearch = (e: React.KeyboardEvent) => {
|
||||
if (!keywords) return
|
||||
if (e.key === 'Enter') {
|
||||
navigate(`/search/${keywords}${type ? `/${type}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 pl-2.5 pr-2 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5'>
|
||||
<SvgIcon
|
||||
className='mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200'
|
||||
name='search'
|
||||
/>
|
||||
<input
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onKeyDown={toSearch}
|
||||
type='text'
|
||||
className='flex-grow bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
|
||||
placeholder='搜索'
|
||||
/>
|
||||
<div
|
||||
onClick={() => setKeywords('')}
|
||||
className={cx(
|
||||
'cursor-default rounded-full p-1 text-gray-600 transition hover:bg-gray-400/20 dark:text-white/50 dark:hover:bg-white/20',
|
||||
!keywords && 'hidden'
|
||||
)}
|
||||
>
|
||||
<SvgIcon className='h-4 w-4' name='x' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate('/settings')}
|
||||
className='app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
|
||||
>
|
||||
<SvgIcon className='h-[1.125rem] w-[1.125rem]' name='settings' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Topbar = () => {
|
||||
/**
|
||||
* Show topbar background when scroll down
|
||||
*/
|
||||
const [mainContainer, setMainContainer] = useState<HTMLElement | null>(null)
|
||||
const scroll = useScroll(mainContainer, { throttle: 100 })
|
||||
|
||||
useEffect(() => {
|
||||
setMainContainer(document.getElementById('mainContainer'))
|
||||
}, [setMainContainer])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'sticky z-30 flex h-16 min-h-[4rem] w-full cursor-default items-center justify-between px-8 transition duration-300',
|
||||
window.env?.isMac && 'app-region-drag',
|
||||
window.env?.isEnableTitlebar ? 'top-8' : 'top-0',
|
||||
!scroll.arrivedState.top &&
|
||||
'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'
|
||||
)}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<NavigationButtons />
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Settings />
|
||||
<Avatar />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Topbar
|
||||
265
packages/web/components/TracksAlbum.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { memo, useCallback, useMemo } from 'react'
|
||||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
||||
import { player } from '@/web/store'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import cx from 'classnames'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const PlayOrPauseButtonInTrack = memo(
|
||||
({ isHighlight, trackID }: { isHighlight: boolean; trackID: number }) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const isPlaying = useMemo(
|
||||
() => playerSnapshot.state === PlayerState.Playing,
|
||||
[playerSnapshot.state]
|
||||
)
|
||||
|
||||
const onClick = () => {
|
||||
isHighlight ? player.playOrPause() : player.playTrack(trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'btn-pressed-animation -ml-1 self-center',
|
||||
!isHighlight && 'hidden group-hover:block'
|
||||
)}
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-5 w-5 text-brand-500'
|
||||
name={isPlaying && isHighlight ? 'pause' : 'play'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
PlayOrPauseButtonInTrack.displayName = 'PlayOrPauseButtonInTrack'
|
||||
|
||||
const Track = memo(
|
||||
({
|
||||
track,
|
||||
isLiked = false,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
onClick,
|
||||
}: {
|
||||
track: Track
|
||||
isLiked?: boolean
|
||||
isSkeleton?: boolean
|
||||
isHighlight?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
const subtitle = useMemo(
|
||||
() => track.tns?.at(0) ?? track.alia?.at(0),
|
||||
[track.alia, track.tns]
|
||||
)
|
||||
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={e => onClick(e, track.id)}
|
||||
className={cx(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl',
|
||||
'grid-cols-12 py-2.5 px-4',
|
||||
!isSkeleton && {
|
||||
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10':
|
||||
!isHighlight,
|
||||
'bg-brand-50 dark:bg-gray-800': isHighlight,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{/* Track name and number */}
|
||||
<div className='col-span-6 grid grid-cols-[2rem_auto] pr-8'>
|
||||
{/* Track number */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='h-6.5 w-6.5 -translate-x-1'></Skeleton>
|
||||
) : (
|
||||
!isHighlight && (
|
||||
<div
|
||||
className={cx(
|
||||
'self-center group-hover:hidden',
|
||||
isHighlight && 'text-brand-500',
|
||||
!isHighlight && 'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{track.no}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Play or pause button for playing track */}
|
||||
{!isSkeleton && (
|
||||
<PlayOrPauseButtonInTrack
|
||||
isHighlight={isHighlight}
|
||||
trackID={track.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='text-lg'>
|
||||
PLACEHOLDER123456789012345
|
||||
</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 break-all text-lg font-semibold',
|
||||
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center'>
|
||||
{track.name}
|
||||
{track.mark === 1318912 && (
|
||||
<SvgIcon
|
||||
name='explicit'
|
||||
className='ml-1.5 mt-[2px] h-4 w-4 text-gray-300 dark:text-gray-500'
|
||||
/>
|
||||
)}
|
||||
{subtitle && (
|
||||
<span
|
||||
className={cx(
|
||||
'ml-1',
|
||||
isHighlight ? 'text-brand-500/[.8]' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
({subtitle})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artists */}
|
||||
<div className='col-span-4 flex items-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton>PLACEHOLDER1234</Skeleton>
|
||||
) : (
|
||||
<ArtistInline
|
||||
className={
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
artists={track.ar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions & Track duration */}
|
||||
<div className='col-span-2 flex items-center justify-end'>
|
||||
{/* Like button */}
|
||||
{!isSkeleton && (
|
||||
<button
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
className={cx(
|
||||
'mr-5 cursor-default transition duration-300 hover:scale-[1.2]',
|
||||
isLiked
|
||||
? 'text-brand-500 opacity-100'
|
||||
: 'text-gray-600 opacity-0 dark:text-gray-400',
|
||||
!isSkeleton && 'group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<SvgIcon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Track duration */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton>0:00</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'min-w-[2.5rem] text-right',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Track.displayName = 'Track'
|
||||
|
||||
const TracksAlbum = ({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
}) => {
|
||||
// Fake data when isSkeleton is true
|
||||
const skeletonTracks: Track[] = new Array(1).fill({})
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onTrackDoubleClick?.(trackID)
|
||||
},
|
||||
[onTrackDoubleClick]
|
||||
)
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const playingTrack = useMemo(
|
||||
() => playerSnapshot.track,
|
||||
[playerSnapshot.track]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='grid w-full'>
|
||||
{/* Tracks table header */}
|
||||
<div className='mx-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'>
|
||||
<div className='col-span-6 grid grid-cols-[2rem_auto]'>
|
||||
<div>#</div>
|
||||
<div>标题</div>
|
||||
</div>
|
||||
<div className='col-span-4'>艺人</div>
|
||||
<div className='col-span-2 justify-self-end'>时长</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks */}
|
||||
{isSkeleton
|
||||
? skeletonTracks.map((track, index) => (
|
||||
<Track
|
||||
key={index}
|
||||
track={track}
|
||||
onClick={() => null}
|
||||
isSkeleton={true}
|
||||
/>
|
||||
))
|
||||
: tracks.map(track => (
|
||||
<Track
|
||||
key={track.id}
|
||||
track={track}
|
||||
onClick={handleClick}
|
||||
isLiked={userLikedSongs?.ids?.includes(track.id) ?? false}
|
||||
isSkeleton={false}
|
||||
isHighlight={track.id === playingTrack?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TracksAlbum
|
||||
137
packages/web/components/TracksGrid.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import { player } from '@/web/store'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import SvgIcon from './SvgIcon'
|
||||
import cx from 'classnames'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Track = ({
|
||||
track,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
onClick,
|
||||
}: {
|
||||
track: Track
|
||||
isSkeleton?: boolean
|
||||
isHighlight?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={e => onClick(e, track.id)}
|
||||
className={cx(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ',
|
||||
'grid-cols-1 py-1.5 px-2',
|
||||
!isSkeleton && {
|
||||
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10':
|
||||
!isHighlight,
|
||||
'bg-brand-50 dark:bg-gray-800': isHighlight,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className='grid grid-cols-[3rem_auto] items-center'>
|
||||
{/* Cover */}
|
||||
<div>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='mr-4 h-9 w-9 rounded-md border border-gray-100' />
|
||||
) : (
|
||||
<img
|
||||
src={resizeImage(track.al.picUrl, 'xs')}
|
||||
className='box-content h-9 w-9 rounded-md border border-black border-opacity-[.03]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track name & Artists */}
|
||||
<div className='flex flex-col justify-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='text-base '>PLACEHOLDER12345</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 break-all text-base font-semibold ',
|
||||
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
|
||||
)}
|
||||
>
|
||||
{track.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
|
||||
) : (
|
||||
<span className='flex items-center'>
|
||||
{track.mark === 1318912 && (
|
||||
<SvgIcon
|
||||
name='explicit'
|
||||
className={cx(
|
||||
'mr-1 h-3 w-3',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-300 dark:text-gray-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<ArtistInline
|
||||
artists={track.ar}
|
||||
disableLink={true}
|
||||
className={
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TrackGrid = ({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
cols = 2,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
cols?: number
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onTrackDoubleClick?.(trackID)
|
||||
}
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const playingTrack = useMemo(
|
||||
() => playerSnapshot.track,
|
||||
[playerSnapshot.track]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='grid gap-x-2'
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{tracks.map((track, index) => (
|
||||
<Track
|
||||
onClick={handleClick}
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSkeleton={isSkeleton}
|
||||
isHighlight={track.id === playingTrack?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackGrid
|
||||
239
packages/web/components/TracksList.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { memo, useMemo } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import ArtistInline from '@/web/components/ArtistsInline'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import { player } from '@/web/store'
|
||||
import cx from 'classnames'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Track = memo(
|
||||
({
|
||||
track,
|
||||
isLiked = false,
|
||||
isSkeleton = false,
|
||||
isHighlight = false,
|
||||
onClick,
|
||||
}: {
|
||||
track: Track
|
||||
isLiked?: boolean
|
||||
isSkeleton?: boolean
|
||||
isHighlight?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
|
||||
}) => {
|
||||
const subtitle = useMemo(
|
||||
() => track.tns?.at(0) ?? track.alia?.at(0),
|
||||
[track.alia, track.tns]
|
||||
)
|
||||
const mutationLikeATrack = useMutationLikeATrack()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={e => onClick(e, track.id)}
|
||||
className={cx(
|
||||
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ',
|
||||
'grid-cols-12 p-2 pr-4',
|
||||
!isSkeleton &&
|
||||
!isHighlight &&
|
||||
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10',
|
||||
!isSkeleton && isHighlight && 'bg-brand-50 dark:bg-gray-800'
|
||||
)}
|
||||
>
|
||||
{/* Track info */}
|
||||
<div className='col-span-6 grid grid-cols-[4.2rem_auto] pr-8'>
|
||||
{/* Cover */}
|
||||
<div>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='mr-4 h-12 w-12 rounded-md border border-gray-100 dark:border-gray-800' />
|
||||
) : (
|
||||
<img
|
||||
src={resizeImage(track.al.picUrl, 'xs')}
|
||||
className='box-content h-12 w-12 rounded-md border border-black border-opacity-[.03]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track name & Artists */}
|
||||
<div className='flex flex-col justify-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='text-lg'>PLACEHOLDER12345</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-1 break-all text-lg font-semibold',
|
||||
isHighlight ? 'text-brand-500' : 'text-black dark:text-white'
|
||||
)}
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
{subtitle && (
|
||||
<span
|
||||
title={subtitle}
|
||||
className={cx(
|
||||
'ml-1',
|
||||
isHighlight ? 'text-brand-500/[.8]' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
({subtitle})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'text-sm',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
|
||||
) : (
|
||||
<span className='inline-flex items-center'>
|
||||
{track.mark === 1318912 && (
|
||||
<SvgIcon
|
||||
name='explicit'
|
||||
className='mr-1 h-3.5 w-3.5 text-gray-300 dark:text-gray-500'
|
||||
/>
|
||||
)}
|
||||
<ArtistInline artists={track.ar} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Album name */}
|
||||
<div className='col-span-4 flex items-center text-gray-600 dark:text-gray-400'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton>PLACEHOLDER1234567890</Skeleton>
|
||||
) : (
|
||||
<>
|
||||
<NavLink
|
||||
to={`/album/${track.al.id}`}
|
||||
className={cx(
|
||||
'hover:underline',
|
||||
isHighlight && 'text-brand-500'
|
||||
)}
|
||||
>
|
||||
{track.al.name}
|
||||
</NavLink>
|
||||
<span className='flex-grow'></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions & Track duration */}
|
||||
<div className='col-span-2 flex items-center justify-end'>
|
||||
{/* Like button */}
|
||||
{!isSkeleton && (
|
||||
<button
|
||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||
className={cx(
|
||||
'mr-5 cursor-default transition duration-300 hover:scale-[1.2]',
|
||||
!isLiked && 'text-gray-600 opacity-0 dark:text-gray-400',
|
||||
isLiked && 'text-brand-500 opacity-100',
|
||||
!isSkeleton && 'group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<SvgIcon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Track duration */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton>0:00</Skeleton>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'min-w-[2.5rem] text-right',
|
||||
isHighlight
|
||||
? 'text-brand-500'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Track.displayName = 'Track'
|
||||
|
||||
const TracksList = memo(
|
||||
({
|
||||
tracks,
|
||||
isSkeleton = false,
|
||||
onTrackDoubleClick,
|
||||
}: {
|
||||
tracks: Track[]
|
||||
isSkeleton?: boolean
|
||||
onTrackDoubleClick?: (trackID: number) => void
|
||||
}) => {
|
||||
// Fake data when isLoading is true
|
||||
const skeletonTracks: Track[] = new Array(12).fill({})
|
||||
|
||||
// Liked songs ids
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (e.detail === 2) onTrackDoubleClick?.(trackID)
|
||||
}
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const playingTrack = useMemo(
|
||||
() => playerSnapshot.track,
|
||||
[playerSnapshot.track]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tracks table header */}
|
||||
<div className='ml-2 mr-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'>
|
||||
<div className='col-span-6 grid grid-cols-[4.2rem_auto]'>
|
||||
<div></div>
|
||||
<div>标题</div>
|
||||
</div>
|
||||
<div className='col-span-4'>专辑</div>
|
||||
<div className='col-span-2 justify-self-end'>时长</div>
|
||||
</div>
|
||||
|
||||
<div className='grid w-full'>
|
||||
{/* Tracks */}
|
||||
{isSkeleton
|
||||
? skeletonTracks.map((track, index) => (
|
||||
<Track
|
||||
key={index}
|
||||
track={track}
|
||||
onClick={() => null}
|
||||
isSkeleton={true}
|
||||
/>
|
||||
))
|
||||
: tracks.map(track => (
|
||||
<Track
|
||||
onClick={handleClick}
|
||||
key={track.id}
|
||||
track={track}
|
||||
isLiked={userLikedSongs?.ids?.includes(track.id) ?? false}
|
||||
isSkeleton={false}
|
||||
isHighlight={track.id === playingTrack?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
TracksList.displayName = 'TracksList'
|
||||
|
||||
export default TracksList
|
||||
38
packages/web/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { IpcChannelsParams, IpcChannelsReturns } from '@/shared/IpcChannels'
|
||||
import { ElectronLog } from 'electron-log'
|
||||
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ipcRenderer?: {
|
||||
sendSync: <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
params?: IpcChannelsParams[T]
|
||||
) => IpcChannelsReturns[T]
|
||||
send: <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
params?: IpcChannelsParams[T]
|
||||
) => void
|
||||
on: <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
listener: (
|
||||
event: Electron.IpcRendererEvent,
|
||||
value: IpcChannelsReturns[T]
|
||||
) => void
|
||||
) => void
|
||||
}
|
||||
env?: {
|
||||
isElectron: boolean
|
||||
isEnableTitlebar: boolean
|
||||
isLinux: boolean
|
||||
isMac: boolean
|
||||
isWin: boolean
|
||||
}
|
||||
log?: ElectronLog
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'valtio' {
|
||||
function useSnapshot<T extends object>(p: T): T
|
||||
}
|
||||
56
packages/web/hooks/useAlbum.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { fetchAlbum } from '@/web/api/album'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
FetchAlbumParams,
|
||||
AlbumApiNames,
|
||||
FetchAlbumResponse,
|
||||
} from '@/shared/api/Album'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
const fetch = async (params: FetchAlbumParams, noCache?: boolean) => {
|
||||
const album = await fetchAlbum(params, !!noCache)
|
||||
if (album?.album?.songs) {
|
||||
album.album.songs = album.songs
|
||||
}
|
||||
return album
|
||||
}
|
||||
|
||||
export default function useAlbum(params: FetchAlbumParams, noCache?: boolean) {
|
||||
return useQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
() => fetch(params, noCache),
|
||||
{
|
||||
enabled: !!params.id,
|
||||
staleTime: 24 * 60 * 60 * 1000, // 24 hours
|
||||
placeholderData: (): FetchAlbumResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Album,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
() => fetch(params),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function prefetchAlbum(params: FetchAlbumParams) {
|
||||
await reactQueryClient.prefetchQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
() => fetch(params),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
}
|
||||
)
|
||||
}
|
||||
30
packages/web/hooks/useArtist.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { fetchArtist } from '@/web/api/artist'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
FetchArtistParams,
|
||||
ArtistApiNames,
|
||||
FetchArtistResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function useArtist(
|
||||
params: FetchArtistParams,
|
||||
noCache?: boolean
|
||||
) {
|
||||
return useQuery(
|
||||
[ArtistApiNames.FetchArtist, params],
|
||||
() => fetchArtist(params, !!noCache),
|
||||
{
|
||||
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
placeholderData: (): FetchArtistResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Artist,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
30
packages/web/hooks/useArtistAlbums.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { fetchArtistAlbums } from '@/web/api/artist'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
FetchArtistAlbumsParams,
|
||||
ArtistApiNames,
|
||||
FetchArtistAlbumsResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function useUserAlbums(params: FetchArtistAlbumsParams) {
|
||||
return useQuery(
|
||||
[ArtistApiNames.FetchArtistAlbums, params],
|
||||
async () => {
|
||||
const data = await fetchArtistAlbums(params)
|
||||
return data
|
||||
},
|
||||
{
|
||||
enabled: !!params.id && params.id !== 0,
|
||||
staleTime: 3600000,
|
||||
placeholderData: (): FetchArtistAlbumsResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.ArtistAlbum,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
17
packages/web/hooks/useCoverColor.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { colord } from 'colord'
|
||||
import { getCoverColor } from '../utils/common'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function useCoverColor(url: string) {
|
||||
const [color, setColor] = useState({ from: '#fff', to: '#fff' })
|
||||
|
||||
useEffect(() => {
|
||||
getCoverColor(url || '').then(color => {
|
||||
if (!color) return
|
||||
const to = colord(color).darken(0.15).rotate(-5).toHex()
|
||||
setColor({ from: color, to })
|
||||
})
|
||||
}, [url])
|
||||
|
||||
return color
|
||||
}
|
||||
13
packages/web/hooks/useIpcRenderer.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { IpcChannelsParams, IpcChannelsReturns } from '@/shared/IpcChannels'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const useIpcRenderer = <T extends keyof IpcChannelsParams>(
|
||||
channcel: T,
|
||||
listener: (event: any, value: IpcChannelsReturns[T]) => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
return window.ipcRenderer?.on(channcel, listener)
|
||||
}, [])
|
||||
}
|
||||
|
||||
export default useIpcRenderer
|
||||
47
packages/web/hooks/useLyric.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { fetchLyric } from '@/web/api/track'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import {
|
||||
FetchLyricParams,
|
||||
FetchLyricResponse,
|
||||
TrackApiNames,
|
||||
} from '@/shared/api/Track'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function useLyric(params: FetchLyricParams) {
|
||||
return useQuery(
|
||||
[TrackApiNames.FetchLyric, params],
|
||||
() => {
|
||||
return fetchLyric(params)
|
||||
},
|
||||
{
|
||||
enabled: !!params.id && params.id !== 0,
|
||||
refetchInterval: false,
|
||||
staleTime: Infinity,
|
||||
initialData: (): FetchLyricResponse | undefined =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Lyric,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchTracksWithReactQuery(params: FetchLyricParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[TrackApiNames.FetchLyric, params],
|
||||
() => {
|
||||
return fetchLyric(params)
|
||||
},
|
||||
{
|
||||
retry: 4,
|
||||
retryDelay: (retryCount: number) => {
|
||||
return retryCount * 500
|
||||
},
|
||||
staleTime: Infinity,
|
||||
}
|
||||
)
|
||||
}
|
||||
18
packages/web/hooks/usePersonalFM.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { fetchPersonalFM, PersonalFMApiNames } from '@/web/api/personalFM'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
|
||||
export function fetchPersonalFMWithReactQuery() {
|
||||
return reactQueryClient.fetchQuery(
|
||||
PersonalFMApiNames.FetchPersonalFm,
|
||||
async () => {
|
||||
const data = await fetchPersonalFM()
|
||||
if (!data.data?.length) {
|
||||
throw new Error('No data')
|
||||
}
|
||||
return data
|
||||
},
|
||||
{
|
||||
retry: 3,
|
||||
}
|
||||
)
|
||||
}
|
||||
55
packages/web/hooks/usePlaylist.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { fetchPlaylist } from '@/web/api/playlist'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
FetchPlaylistParams,
|
||||
PlaylistApiNames,
|
||||
FetchPlaylistResponse,
|
||||
} from '@/shared/api/Playlists'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
const fetch = (params: FetchPlaylistParams, noCache?: boolean) => {
|
||||
return fetchPlaylist(params, !!noCache)
|
||||
}
|
||||
|
||||
export default function usePlaylist(
|
||||
params: FetchPlaylistParams,
|
||||
noCache?: boolean
|
||||
) {
|
||||
return useQuery(
|
||||
[PlaylistApiNames.FetchPlaylist, params],
|
||||
() => fetch(params, noCache),
|
||||
{
|
||||
enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))),
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: (): FetchPlaylistResponse | undefined =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Playlist,
|
||||
query: {
|
||||
id: params.id,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchPlaylistWithReactQuery(params: FetchPlaylistParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[PlaylistApiNames.FetchPlaylist, params],
|
||||
() => fetch(params),
|
||||
{
|
||||
staleTime: 3600000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function prefetchPlaylist(params: FetchPlaylistParams) {
|
||||
await reactQueryClient.prefetchQuery(
|
||||
[PlaylistApiNames.FetchPlaylist, params],
|
||||
() => fetch(params),
|
||||
{
|
||||
staleTime: 3600000,
|
||||
}
|
||||
)
|
||||
}
|
||||
74
packages/web/hooks/useScroll.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Inspired by https://github.com/vueuse/vueuse
|
||||
import { throttle as lodashThrottle } from 'lodash-es'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface ArrivedState {
|
||||
top: boolean
|
||||
bottom: boolean
|
||||
left: boolean
|
||||
right: boolean
|
||||
}
|
||||
|
||||
interface Offset {
|
||||
top?: number
|
||||
bottom?: number
|
||||
left?: number
|
||||
right?: number
|
||||
}
|
||||
|
||||
const useScroll = (
|
||||
ref: React.RefObject<HTMLDivElement> | HTMLElement | null,
|
||||
{ offset, throttle }: { offset?: Offset; throttle?: number } = {}
|
||||
) => {
|
||||
const [scroll, setScroll] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
arrivedState: ArrivedState
|
||||
}>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
arrivedState: {
|
||||
top: true,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref) return
|
||||
const handleScroll = (e: Event) => {
|
||||
if (!e.target) return
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
const arrivedState: ArrivedState = {
|
||||
left: target.scrollLeft <= 0 + (offset?.left || 0),
|
||||
right:
|
||||
target.scrollLeft + target.clientWidth >=
|
||||
target.scrollWidth - (offset?.right || 0),
|
||||
top: target.scrollTop <= 0 + (offset?.top || 0),
|
||||
bottom:
|
||||
target.scrollTop + target.clientHeight >=
|
||||
target.scrollHeight - (offset?.bottom || 0),
|
||||
}
|
||||
|
||||
setScroll({
|
||||
x: target.scrollLeft,
|
||||
y: target.scrollTop,
|
||||
arrivedState,
|
||||
})
|
||||
}
|
||||
|
||||
const readHandleScroll = throttle
|
||||
? lodashThrottle(handleScroll, throttle)
|
||||
: handleScroll
|
||||
|
||||
const element = 'current' in ref ? ref?.current : ref
|
||||
element?.addEventListener('scroll', readHandleScroll)
|
||||
return () => element?.removeEventListener('scroll', readHandleScroll)
|
||||
}, [offset?.bottom, offset?.left, offset?.right, offset?.top, ref, throttle])
|
||||
|
||||
return scroll
|
||||
}
|
||||
|
||||
export default useScroll
|
||||
62
packages/web/hooks/useTracks.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { fetchAudioSource, fetchTracks } from '@/web/api/track'
|
||||
import type {} from '@/web/api/track'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import {
|
||||
FetchAudioSourceParams,
|
||||
FetchTracksParams,
|
||||
FetchTracksResponse,
|
||||
TrackApiNames,
|
||||
} from '@/shared/api/Track'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function useTracks(params: FetchTracksParams) {
|
||||
return useQuery(
|
||||
[TrackApiNames.FetchTracks, params],
|
||||
() => {
|
||||
return fetchTracks(params)
|
||||
},
|
||||
{
|
||||
enabled: params.ids.length !== 0,
|
||||
refetchInterval: false,
|
||||
staleTime: Infinity,
|
||||
initialData: (): FetchTracksResponse | undefined =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Track,
|
||||
query: {
|
||||
ids: params.ids.join(','),
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchTracksWithReactQuery(params: FetchTracksParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[TrackApiNames.FetchTracks, params],
|
||||
() => {
|
||||
return fetchTracks(params)
|
||||
},
|
||||
{
|
||||
retry: 4,
|
||||
retryDelay: (retryCount: number) => {
|
||||
return retryCount * 500
|
||||
},
|
||||
staleTime: 86400000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAudioSourceWithReactQuery(params: FetchAudioSourceParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[TrackApiNames.FetchAudioSource, params],
|
||||
() => {
|
||||
return fetchAudioSource(params)
|
||||
},
|
||||
{
|
||||
retry: 3,
|
||||
staleTime: 0, // TODO: Web版1小时缓存
|
||||
}
|
||||
)
|
||||
}
|
||||