feat: monorepo

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

45
packages/web/App.tsx Normal file
View 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

View 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
View 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(),
},
})
}

View 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
View 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',
})
}

View 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(),
},
})
}

View 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(),
},
})
}

View 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
View 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
View 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,
// },
// })
// }

View 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,
},
})
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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 ? ', ' : ''}&nbsp;
</span>
))}
</div>
)
}
export default ArtistInline

View 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

View 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

View 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

View 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

View file

@ -0,0 +1,13 @@
@keyframes move {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animation {
animation: move 38s infinite;
animation-direction: alternate;
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
}

View 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,
}
)
}

View 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,
},
}),
}
)
}

View 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,
},
}),
}
)
}

View 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
}

View 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

View 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,
}
)
}

View 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,
}
)
}

View 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,
}
)
}

View 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

View 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小时缓存
}
)
}

Some files were not shown because too many files have changed in this diff Show more