mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 05:38:04 +00:00
feat: updates
This commit is contained in:
parent
47e41dea9b
commit
ebebf2a733
160 changed files with 4148 additions and 2001 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { Toaster } from 'react-hot-toast'
|
||||
import { QueryClientProvider } from 'react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from 'react-query/devtools'
|
||||
import Player from '@/web/components/Player'
|
||||
import Sidebar from '@/web/components/Sidebar'
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ import {
|
|||
|
||||
// 专辑详情
|
||||
export function fetchAlbum(
|
||||
params: FetchAlbumParams,
|
||||
noCache: boolean
|
||||
params: FetchAlbumParams
|
||||
): Promise<FetchAlbumResponse> {
|
||||
const otherParams: { timestamp?: number } = {}
|
||||
if (noCache) otherParams.timestamp = new Date().getTime()
|
||||
return request({
|
||||
url: '/album',
|
||||
method: 'get',
|
||||
params: { ...params, ...otherParams },
|
||||
params: {
|
||||
...params,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,20 @@ import {
|
|||
FetchArtistAlbumsResponse,
|
||||
FetchSimilarArtistsParams,
|
||||
FetchSimilarArtistsResponse,
|
||||
FetchArtistMVParams,
|
||||
FetchArtistMVResponse,
|
||||
LikeAArtistParams,
|
||||
LikeAArtistResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
|
||||
// 歌手详情
|
||||
export function fetchArtist(
|
||||
params: FetchArtistParams,
|
||||
noCache: boolean
|
||||
params: FetchArtistParams
|
||||
): Promise<FetchArtistResponse> {
|
||||
const otherParams: { timestamp?: number } = {}
|
||||
if (noCache) otherParams.timestamp = new Date().getTime()
|
||||
return request({
|
||||
url: '/artists',
|
||||
method: 'get',
|
||||
params: { ...params, ...otherParams },
|
||||
params: { ...params, timestamp: new Date().getTime() },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ export function fetchArtistAlbums(
|
|||
})
|
||||
}
|
||||
|
||||
// 获取相似歌手
|
||||
export function fetchSimilarArtists(
|
||||
params: FetchSimilarArtistsParams
|
||||
): Promise<FetchSimilarArtistsResponse> {
|
||||
|
|
@ -42,3 +44,28 @@ export function fetchSimilarArtists(
|
|||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取歌手MV
|
||||
export function fetchArtistMV(
|
||||
params: FetchArtistMVParams
|
||||
): Promise<FetchArtistMVResponse> {
|
||||
return request({
|
||||
url: '/artist/mv',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 收藏歌手
|
||||
export function likeAArtist(
|
||||
params: LikeAArtistParams
|
||||
): Promise<LikeAArtistResponse> {
|
||||
return request({
|
||||
url: 'artist/sub',
|
||||
method: 'get',
|
||||
params: {
|
||||
id: params.id,
|
||||
t: Number(params.like),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export interface LoginWithEmailParams {
|
|||
password?: string
|
||||
md5_password?: string
|
||||
}
|
||||
export interface loginWithEmailResponse extends FetchUserAccountResponse {
|
||||
export interface LoginWithEmailResponse extends FetchUserAccountResponse {
|
||||
code: number
|
||||
cookie: string
|
||||
loginType: number
|
||||
|
|
@ -49,7 +49,7 @@ export interface loginWithEmailResponse extends FetchUserAccountResponse {
|
|||
}
|
||||
export function loginWithEmail(
|
||||
params: LoginWithEmailParams
|
||||
): Promise<loginWithEmailResponse> {
|
||||
): Promise<LoginWithEmailResponse> {
|
||||
return request({
|
||||
url: '/login',
|
||||
method: 'post',
|
||||
|
|
@ -58,14 +58,14 @@ export function loginWithEmail(
|
|||
}
|
||||
|
||||
// 生成二维码key
|
||||
export interface fetchLoginQrCodeKeyResponse {
|
||||
export interface FetchLoginQrCodeKeyResponse {
|
||||
code: number
|
||||
data: {
|
||||
code: number
|
||||
unikey: string
|
||||
}
|
||||
}
|
||||
export function fetchLoginQrCodeKey(): Promise<fetchLoginQrCodeKeyResponse> {
|
||||
export function fetchLoginQrCodeKey(): Promise<FetchLoginQrCodeKeyResponse> {
|
||||
return request({
|
||||
url: '/login/qr/key',
|
||||
method: 'get',
|
||||
|
|
|
|||
|
|
@ -7,37 +7,33 @@ import {
|
|||
AlbumApiNames,
|
||||
FetchAlbumResponse,
|
||||
} from '@/shared/api/Album'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
const fetch = async (params: FetchAlbumParams, noCache?: boolean) => {
|
||||
const album = await fetchAlbum(params, !!noCache)
|
||||
const fetch = async (params: FetchAlbumParams) => {
|
||||
const album = await fetchAlbum(params)
|
||||
if (album?.album?.songs) {
|
||||
album.album.songs = album.songs
|
||||
}
|
||||
return album
|
||||
}
|
||||
|
||||
const fetchFromCache = (id: number): FetchAlbumResponse =>
|
||||
const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Album,
|
||||
query: { id },
|
||||
query: params,
|
||||
})
|
||||
|
||||
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: () => fetchFromCache(params.id),
|
||||
}
|
||||
)
|
||||
export default function useAlbum(params: FetchAlbumParams) {
|
||||
return useQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
|
||||
enabled: !!params.id,
|
||||
staleTime: 24 * 60 * 60 * 1000, // 24 hours
|
||||
placeholderData: () => fetchFromCache(params),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
[AlbumApiNames.FetchAlbum, params],
|
||||
() => fetch(params),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
|
|
@ -46,9 +42,9 @@ export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
|
|||
}
|
||||
|
||||
export async function prefetchAlbum(params: FetchAlbumParams) {
|
||||
if (fetchFromCache(params.id)) return
|
||||
if (fetchFromCache(params)) return
|
||||
await reactQueryClient.prefetchQuery(
|
||||
[AlbumApiNames.FetchAlbum, params.id],
|
||||
[AlbumApiNames.FetchAlbum, params],
|
||||
() => fetch(params),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
|
|
|
|||
|
|
@ -6,25 +6,46 @@ import {
|
|||
ArtistApiNames,
|
||||
FetchArtistResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
|
||||
export default function useArtist(
|
||||
params: FetchArtistParams,
|
||||
noCache?: boolean
|
||||
) {
|
||||
const fetchFromCache = (id: number): FetchArtistResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Artist,
|
||||
query: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
|
||||
export default function useArtist(params: FetchArtistParams) {
|
||||
return useQuery(
|
||||
[ArtistApiNames.FetchArtist, params],
|
||||
() => fetchArtist(params, !!noCache),
|
||||
() => fetchArtist(params),
|
||||
{
|
||||
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,
|
||||
},
|
||||
}),
|
||||
placeholderData: () => fetchFromCache(params.id),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchArtistWithReactQuery(params: FetchArtistParams) {
|
||||
return reactQueryClient.fetchQuery(
|
||||
[ArtistApiNames.FetchArtist, params],
|
||||
() => fetchArtist(params),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function prefetchArtist(params: FetchArtistParams) {
|
||||
if (fetchFromCache(params.id)) return
|
||||
await reactQueryClient.prefetchQuery(
|
||||
[ArtistApiNames.FetchArtist, params],
|
||||
() => fetchArtist(params),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import {
|
|||
ArtistApiNames,
|
||||
FetchArtistAlbumsResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useUserAlbums(params: FetchArtistAlbumsParams) {
|
||||
export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
|
||||
return useQuery(
|
||||
[ArtistApiNames.FetchArtistAlbums, params],
|
||||
async () => {
|
||||
|
|
|
|||
30
packages/web/api/hooks/useArtistMV.ts
Normal file
30
packages/web/api/hooks/useArtistMV.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { fetchArtistMV } from '@/web/api/artist'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
FetchArtistMVParams,
|
||||
ArtistApiNames,
|
||||
FetchArtistMVResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useArtistMV(params: FetchArtistMVParams) {
|
||||
return useQuery(
|
||||
[ArtistApiNames.FetchArtistMV, params],
|
||||
async () => {
|
||||
const data = await fetchArtistMV(params)
|
||||
return data
|
||||
},
|
||||
{
|
||||
enabled: !!params.id && params.id !== 0,
|
||||
staleTime: 3600000,
|
||||
// placeholderData: (): FetchArtistMVResponse =>
|
||||
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
// api: APIs.ArtistAlbum,
|
||||
// query: {
|
||||
// id: params.id,
|
||||
// },
|
||||
// }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
ArtistApiNames,
|
||||
FetchArtistResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useArtists(ids: number[]) {
|
||||
return useQuery(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '@/shared/api/Track'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useLyric(params: FetchLyricParams) {
|
||||
return useQuery(
|
||||
|
|
|
|||
31
packages/web/api/hooks/useMV.ts
Normal file
31
packages/web/api/hooks/useMV.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { fetchMV, fetchMVUrl } from '@/web/api/mv'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import {
|
||||
MVApiNames,
|
||||
FetchMVParams,
|
||||
FetchMVResponse,
|
||||
FetchMVUrlParams,
|
||||
} from '@/shared/api/MV'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useMV(params: FetchMVParams) {
|
||||
return useQuery([MVApiNames.FetchMV, params], () => fetchMV(params), {
|
||||
enabled: !!params.mvid && params.mvid > 0 && !isNaN(Number(params.mvid)),
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
// placeholderData: (): FetchMVResponse =>
|
||||
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
// api: APIs.SimilarArtist,
|
||||
// query: {
|
||||
// id: params.id,
|
||||
// },
|
||||
// }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMVUrl(params: FetchMVUrlParams) {
|
||||
return useQuery([MVApiNames.FetchMVUrl, params], () => fetchMVUrl(params), {
|
||||
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
||||
staleTime: 60 * 60 * 1000, // 60 mins
|
||||
})
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import reactQueryClient from '@/web/utils/reactQueryClient'
|
|||
|
||||
export function fetchPersonalFMWithReactQuery() {
|
||||
return reactQueryClient.fetchQuery(
|
||||
PersonalFMApiNames.FetchPersonalFm,
|
||||
[PersonalFMApiNames.FetchPersonalFm],
|
||||
async () => {
|
||||
const data = await fetchPersonalFM()
|
||||
if (!data.data?.length) {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import {
|
|||
PlaylistApiNames,
|
||||
FetchPlaylistResponse,
|
||||
} from '@/shared/api/Playlists'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
const fetch = (params: FetchPlaylistParams, noCache?: boolean) => {
|
||||
return fetchPlaylist(params, !!noCache)
|
||||
const fetch = (params: FetchPlaylistParams) => {
|
||||
return fetchPlaylist(params)
|
||||
}
|
||||
|
||||
export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
|
||||
|
|
@ -19,13 +19,10 @@ export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined =>
|
|||
query: { id },
|
||||
})
|
||||
|
||||
export default function usePlaylist(
|
||||
params: FetchPlaylistParams,
|
||||
noCache?: boolean
|
||||
) {
|
||||
export default function usePlaylist(params: FetchPlaylistParams) {
|
||||
return useQuery(
|
||||
[PlaylistApiNames.FetchPlaylist, params],
|
||||
() => fetch(params, noCache),
|
||||
() => fetch(params),
|
||||
{
|
||||
enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))),
|
||||
refetchOnWindowFocus: true,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
ArtistApiNames,
|
||||
FetchSimilarArtistsResponse,
|
||||
} from '@/shared/api/Artist'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
|
||||
return useQuery(
|
||||
|
|
@ -15,6 +15,7 @@ export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
|
|||
{
|
||||
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
retry: 0,
|
||||
placeholderData: (): FetchSimilarArtistsResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.SimilarArtist,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
TrackApiNames,
|
||||
} from '@/shared/api/Track'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useTracks(params: FetchTracksParams) {
|
||||
return useQuery(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FetchTracksParams, TrackApiNames } from '@/shared/api/Track'
|
||||
import { useInfiniteQuery } from 'react-query'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { fetchTracks } from '../track'
|
||||
|
||||
// 100 tracks each page
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { fetchUserAccount } from '@/web/api/user'
|
|||
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useUser() {
|
||||
return useQuery(UserApiNames.FetchUserAccount, fetchUserAccount, {
|
||||
return useQuery([UserApiNames.FetchUserAccount], fetchUserAccount, {
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: (): FetchUserAccountResponse | undefined =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { likeAAlbum } from '@/web/api/album'
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import useUser from './useUser'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
|
|
@ -10,11 +10,15 @@ import {
|
|||
} from '@/shared/api/User'
|
||||
import { fetchUserAlbums } from '../user'
|
||||
import toast from 'react-hot-toast'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { AlbumApiNames, FetchAlbumResponse } from '@/shared/api/Album'
|
||||
|
||||
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
||||
const { data: user } = useUser()
|
||||
const uid = user?.profile?.userId ?? 0
|
||||
return useQuery(
|
||||
[UserApiNames.FetchUserAlbums, user?.profile?.userId ?? 0],
|
||||
[UserApiNames.FetchUserAlbums, uid],
|
||||
() => fetchUserAlbums(params),
|
||||
{
|
||||
refetchOnWindowFocus: true,
|
||||
|
|
@ -28,53 +32,91 @@ export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
|||
}
|
||||
|
||||
export const useMutationLikeAAlbum = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user } = useUser()
|
||||
const { data: userAlbums } = useUserAlbums({ limit: 2000 })
|
||||
const uid = user?.account?.id ?? 0
|
||||
const uid = user?.profile?.userId ?? 0
|
||||
const key = [UserApiNames.FetchUserAlbums, uid]
|
||||
|
||||
return useMutation(
|
||||
async (album: Album) => {
|
||||
if (!album.id || userAlbums?.data === undefined) {
|
||||
async (albumID: number) => {
|
||||
if (!albumID || userAlbums?.data === undefined) {
|
||||
throw new Error('album id is required or userAlbums is undefined')
|
||||
}
|
||||
const response = await likeAAlbum({
|
||||
id: album.id,
|
||||
t: userAlbums?.data.findIndex(a => a.id === album.id) > -1 ? 2 : 1,
|
||||
id: albumID,
|
||||
t: userAlbums?.data.findIndex(a => a.id === albumID) > -1 ? 2 : 1,
|
||||
})
|
||||
if (response.code !== 200) throw new Error((response as any).msg)
|
||||
return response
|
||||
},
|
||||
{
|
||||
onMutate: async album => {
|
||||
onMutate: async albumID => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries(key)
|
||||
await reactQueryClient.cancelQueries(key)
|
||||
|
||||
console.log(reactQueryClient.getQueryData(key))
|
||||
|
||||
// 如果还未获取用户收藏的专辑列表,则获取一次
|
||||
if (!reactQueryClient.getQueryData(key)) {
|
||||
await reactQueryClient.fetchQuery(key)
|
||||
}
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData(key)
|
||||
const previousData = reactQueryClient.getQueryData(
|
||||
key
|
||||
) as FetchUserAlbumsResponse
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(key, old => {
|
||||
const userAlbums = old as FetchUserAlbumsResponse
|
||||
const albums = userAlbums.data
|
||||
const newAlbums =
|
||||
albums.findIndex(a => a.id === album.id) > -1
|
||||
? albums.filter(a => a.id !== album.id)
|
||||
: [...albums, album]
|
||||
return {
|
||||
...userAlbums,
|
||||
data: newAlbums,
|
||||
const isLiked = !!previousData?.data.find(a => a.id === albumID)
|
||||
const newAlbums = cloneDeep(previousData!)
|
||||
|
||||
console.log({ isLiked })
|
||||
|
||||
if (isLiked) {
|
||||
newAlbums.data = previousData.data.filter(a => a.id !== albumID)
|
||||
} else {
|
||||
// 从react-query缓存获取专辑
|
||||
|
||||
console.log({ albumID })
|
||||
|
||||
const albumFromCache: FetchAlbumResponse | undefined =
|
||||
reactQueryClient.getQueryData([
|
||||
AlbumApiNames.FetchAlbum,
|
||||
{ id: albumID },
|
||||
])
|
||||
|
||||
console.log({ albumFromCache })
|
||||
|
||||
// 从api获取专辑
|
||||
const album: FetchAlbumResponse | undefined = albumFromCache
|
||||
? albumFromCache
|
||||
: await reactQueryClient.fetchQuery([
|
||||
AlbumApiNames.FetchAlbum,
|
||||
{ id: albumID },
|
||||
])
|
||||
|
||||
if (!album?.album) {
|
||||
toast.error('Failed to like album: unable to fetch album info')
|
||||
throw new Error('unable to fetch album info')
|
||||
}
|
||||
})
|
||||
newAlbums.data.unshift(album.album)
|
||||
|
||||
// Optimistically update to the new value
|
||||
reactQueryClient.setQueriesData(key, newAlbums)
|
||||
}
|
||||
|
||||
reactQueryClient.setQueriesData(key, newAlbums)
|
||||
|
||||
console.log({ newAlbums })
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousData }
|
||||
},
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
onError: (err, trackID, context) => {
|
||||
queryClient.setQueryData(key, (context as any).previousData)
|
||||
toast((err as any).toString())
|
||||
onSettled: (data, error, albumID, context) => {
|
||||
if (data?.code !== 200) {
|
||||
reactQueryClient.setQueryData(key, (context as any).previousData)
|
||||
toast((error as any).toString())
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@ import { fetchUserArtists } from '@/web/api/user'
|
|||
import { UserApiNames, FetchUserArtistsResponse } from '@/shared/api/User'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { likeAArtist } from '../artist'
|
||||
import { ArtistApiNames, FetchArtistResponse } from '@/shared/api/Artist'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
const KEYS = {
|
||||
useUserArtists: [UserApiNames.FetchUserArtists],
|
||||
}
|
||||
|
||||
export default function useUserArtists() {
|
||||
return useQuery([UserApiNames.FetchUserArtist], fetchUserArtists, {
|
||||
return useQuery(KEYS.useUserArtists, fetchUserArtists, {
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: (): FetchUserArtistsResponse =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
|
|
@ -13,3 +22,85 @@ export default function useUserArtists() {
|
|||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const useMutationLikeAArtist = () => {
|
||||
const { data: userLikedArtists } = useUserArtists()
|
||||
|
||||
return useMutation(
|
||||
async (artistID: number) => {
|
||||
if (!artistID || !userLikedArtists?.data) {
|
||||
throw new Error('artistID is required or userLikedArtists is undefined')
|
||||
}
|
||||
const response = await likeAArtist({
|
||||
id: artistID,
|
||||
like: !userLikedArtists.data.find(a => a.id === artistID),
|
||||
})
|
||||
if (response.code !== 200) throw new Error((response as any).msg)
|
||||
return response
|
||||
},
|
||||
{
|
||||
onMutate: async artistID => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await reactQueryClient.cancelQueries(KEYS.useUserArtists)
|
||||
|
||||
// 如果还未获取用户收藏的歌手列表,则获取一次
|
||||
if (!reactQueryClient.getQueryData(KEYS.useUserArtists)) {
|
||||
await reactQueryClient.fetchQuery(KEYS.useUserArtists)
|
||||
}
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = reactQueryClient.getQueryData(
|
||||
KEYS.useUserArtists
|
||||
) as FetchUserArtistsResponse
|
||||
|
||||
const isLiked = !!previousData?.data.find(a => a.id === artistID)
|
||||
const newLikedArtists = cloneDeep(previousData!)
|
||||
|
||||
if (isLiked) {
|
||||
newLikedArtists.data = previousData.data.filter(
|
||||
a => a.id !== artistID
|
||||
)
|
||||
} else {
|
||||
// 从react-query缓存获取歌手信息
|
||||
const artistFromCache: FetchArtistResponse | undefined =
|
||||
reactQueryClient.getQueryData([
|
||||
ArtistApiNames.FetchArtist,
|
||||
{ id: artistID },
|
||||
])
|
||||
|
||||
// 从api获取歌手信息
|
||||
const artist: FetchArtistResponse | undefined = artistFromCache
|
||||
? artistFromCache
|
||||
: await reactQueryClient.fetchQuery([
|
||||
ArtistApiNames.FetchArtist,
|
||||
{ id: artistID },
|
||||
])
|
||||
|
||||
if (!artist?.artist) {
|
||||
toast.error('Failed to like artist: unable to fetch artist info')
|
||||
throw new Error('unable to fetch artist info')
|
||||
}
|
||||
newLikedArtists.data.unshift(artist.artist)
|
||||
|
||||
// Optimistically update to the new value
|
||||
reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists)
|
||||
}
|
||||
|
||||
reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists)
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousData }
|
||||
},
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
onSettled: (data, error, artistID, context) => {
|
||||
if (data?.code !== 200) {
|
||||
reactQueryClient.setQueryData(
|
||||
KEYS.useUserArtists,
|
||||
(context as any).previousData
|
||||
)
|
||||
toast((error as any).toString())
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { likeATrack } from '@/web/api/track'
|
||||
import useUser from './useUser'
|
||||
import { useMutation, useQueryClient } from 'react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { fetchUserLikedTracksIDs } from '../user'
|
||||
|
|
@ -8,8 +8,9 @@ import {
|
|||
FetchUserLikedTracksIDsResponse,
|
||||
UserApiNames,
|
||||
} from '@/shared/api/User'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
|
||||
export default function useUserLikedTracksIDs() {
|
||||
const { data: user } = useUser()
|
||||
|
|
@ -33,7 +34,6 @@ export default function useUserLikedTracksIDs() {
|
|||
}
|
||||
|
||||
export const useMutationLikeATrack = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user } = useUser()
|
||||
const { data: userLikedSongs } = useUserLikedTracksIDs()
|
||||
const uid = user?.account?.id ?? 0
|
||||
|
|
@ -54,13 +54,13 @@ export const useMutationLikeATrack = () => {
|
|||
{
|
||||
onMutate: async trackID => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries(key)
|
||||
await reactQueryClient.cancelQueries(key)
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData(key)
|
||||
const previousData = reactQueryClient.getQueryData(key)
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(key, old => {
|
||||
reactQueryClient.setQueryData(key, old => {
|
||||
const likedSongs = old as FetchUserLikedTracksIDsResponse
|
||||
const ids = likedSongs.ids
|
||||
const newIds = ids.includes(trackID)
|
||||
|
|
@ -77,7 +77,7 @@ export const useMutationLikeATrack = () => {
|
|||
},
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
onError: (err, trackID, context) => {
|
||||
queryClient.setQueryData(key, (context as any).previousData)
|
||||
reactQueryClient.setQueryData(key, (context as any).previousData)
|
||||
toast((err as any).toString())
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { fetchListenedRecords } from '@/web/api/user'
|
|||
import { UserApiNames, FetchListenedRecordsResponse } from '@/shared/api/User'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import useUser from './useUser'
|
||||
|
||||
export default function useUserListenedRecords(params: {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { likeAPlaylist } from '@/web/api/playlist'
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import useUser from './useUser'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { fetchUserPlaylists } from '@/web/api/user'
|
||||
import { FetchUserPlaylistsResponse, UserApiNames } from '@/shared/api/User'
|
||||
import toast from 'react-hot-toast'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { FetchPlaylistResponse, PlaylistApiNames } from '@/shared/api/Playlists'
|
||||
|
||||
export default function useUserPlaylists() {
|
||||
const { data: user } = useUser()
|
||||
|
|
@ -45,21 +48,20 @@ export default function useUserPlaylists() {
|
|||
}
|
||||
|
||||
export const useMutationLikeAPlaylist = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user } = useUser()
|
||||
const { data: userPlaylists } = useUserPlaylists()
|
||||
const uid = user?.account?.id ?? 0
|
||||
const key = [UserApiNames.FetchUserPlaylists, uid]
|
||||
|
||||
return useMutation(
|
||||
async (playlist: Playlist) => {
|
||||
if (!playlist.id || userPlaylists?.playlist === undefined) {
|
||||
async (playlistID: number) => {
|
||||
if (!playlistID || userPlaylists?.playlist === undefined) {
|
||||
throw new Error('playlist id is required or userPlaylists is undefined')
|
||||
}
|
||||
const response = await likeAPlaylist({
|
||||
id: playlist.id,
|
||||
id: playlistID,
|
||||
t:
|
||||
userPlaylists.playlist.findIndex(p => p.id === playlist.id) > -1
|
||||
userPlaylists.playlist.findIndex(p => p.id === playlistID) > -1
|
||||
? 2
|
||||
: 1,
|
||||
})
|
||||
|
|
@ -67,34 +69,73 @@ export const useMutationLikeAPlaylist = () => {
|
|||
return response
|
||||
},
|
||||
{
|
||||
onMutate: async playlist => {
|
||||
onMutate: async playlistID => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries(key)
|
||||
await reactQueryClient.cancelQueries(key)
|
||||
|
||||
console.log(reactQueryClient.getQueryData(key))
|
||||
|
||||
// 如果还未获取用户收藏的专辑列表,则获取一次
|
||||
if (!reactQueryClient.getQueryData(key)) {
|
||||
await reactQueryClient.fetchQuery(key)
|
||||
}
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData(key)
|
||||
const previousData = reactQueryClient.getQueryData(
|
||||
key
|
||||
) as FetchUserPlaylistsResponse
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(key, old => {
|
||||
const userPlaylists = old as FetchUserPlaylistsResponse
|
||||
const playlists = userPlaylists.playlist
|
||||
const newPlaylists =
|
||||
playlists.findIndex(p => p.id === playlist.id) > -1
|
||||
? playlists.filter(p => p.id !== playlist.id)
|
||||
: [...playlists, playlist]
|
||||
return {
|
||||
...userPlaylists,
|
||||
playlist: newPlaylists,
|
||||
const isLiked = !!previousData?.playlist.find(p => p.id === playlistID)
|
||||
const newPlaylists = cloneDeep(previousData!)
|
||||
|
||||
console.log({ isLiked })
|
||||
|
||||
if (isLiked) {
|
||||
newPlaylists.playlist = previousData.playlist.filter(
|
||||
p => p.id !== playlistID
|
||||
)
|
||||
} else {
|
||||
// 从react-query缓存获取歌单信息
|
||||
|
||||
const playlistFromCache: FetchPlaylistResponse | undefined =
|
||||
reactQueryClient.getQueryData([
|
||||
PlaylistApiNames.FetchPlaylist,
|
||||
{ id: playlistID },
|
||||
])
|
||||
|
||||
// 从api获取歌单信息
|
||||
const playlist: FetchPlaylistResponse | undefined = playlistFromCache
|
||||
? playlistFromCache
|
||||
: await reactQueryClient.fetchQuery([
|
||||
PlaylistApiNames.FetchPlaylist,
|
||||
{ id: playlistID },
|
||||
])
|
||||
|
||||
if (!playlist?.playlist) {
|
||||
toast.error(
|
||||
'Failed to like playlist: unable to fetch playlist info'
|
||||
)
|
||||
throw new Error('unable to fetch playlist info')
|
||||
}
|
||||
})
|
||||
newPlaylists.playlist.splice(1, 0, playlist.playlist)
|
||||
|
||||
// Optimistically update to the new value
|
||||
reactQueryClient.setQueriesData(key, newPlaylists)
|
||||
}
|
||||
|
||||
reactQueryClient.setQueriesData(key, newPlaylists)
|
||||
|
||||
console.log({ newPlaylists })
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousData }
|
||||
},
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
onError: (err, trackID, context) => {
|
||||
queryClient.setQueryData(key, (context as any).previousData)
|
||||
toast((err as any).toString())
|
||||
onSettled: (data, error, playlistID, context) => {
|
||||
if (data?.code !== 200) {
|
||||
reactQueryClient.setQueryData(key, (context as any).previousData)
|
||||
toast((error as any).toString())
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
62
packages/web/api/mv.ts
Normal file
62
packages/web/api/mv.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
FetchMVResponse,
|
||||
FetchMVParams,
|
||||
FetchMVUrlParams,
|
||||
FetchMVUrlResponse,
|
||||
} from '@/shared/api/MV'
|
||||
import request from '@/web/utils/request'
|
||||
|
||||
// 获取 mv 数据
|
||||
export function fetchMV(params: FetchMVParams): Promise<FetchMVResponse> {
|
||||
return request({
|
||||
url: '/mv/detail',
|
||||
method: 'get',
|
||||
params: {
|
||||
mvid: params.mvid,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mv 地址
|
||||
export function fetchMVUrl(
|
||||
params: FetchMVUrlParams
|
||||
): Promise<FetchMVUrlResponse> {
|
||||
return request({
|
||||
url: '/mv/url',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 相似 mv
|
||||
* 说明 : 调用此接口 , 传入 mvid 可获取相似 mv
|
||||
* @param {number} mvid
|
||||
*/
|
||||
export function simiMv(mvid) {
|
||||
return request({
|
||||
url: '/simi/mv',
|
||||
method: 'get',
|
||||
params: { mvid },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏/取消收藏 MV
|
||||
* 说明 : 调用此接口,可收藏/取消收藏 MV
|
||||
* - mvid: mv id
|
||||
* - t: 1 为收藏,其他为取消收藏
|
||||
* @param {Object} params
|
||||
* @param {number} params.mvid
|
||||
* @param {number=} params.t
|
||||
*/
|
||||
|
||||
export function likeAMV(params) {
|
||||
params.timestamp = new Date().getTime()
|
||||
return request({
|
||||
url: '/mv/sub',
|
||||
method: 'post',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
|
@ -11,18 +11,15 @@ import {
|
|||
|
||||
// 歌单详情
|
||||
export function fetchPlaylist(
|
||||
params: FetchPlaylistParams,
|
||||
noCache: boolean
|
||||
params: FetchPlaylistParams
|
||||
): 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,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
67
packages/web/components/New/Airplay.tsx
Normal file
67
packages/web/components/New/Airplay.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import player from '@/web/states/player'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const useAirplayDevices = () => {
|
||||
return useQuery(['useAirplayDevices'], () =>
|
||||
window.ipcRenderer?.invoke('airplay-scan-devices')
|
||||
)
|
||||
}
|
||||
|
||||
const Airplay = () => {
|
||||
const [showPanel, setShowPanel] = useState(false)
|
||||
const { data: devices, isLoading } = useAirplayDevices()
|
||||
const { remoteDevice } = useSnapshot(player)
|
||||
const selectedAirplayDeviceID =
|
||||
remoteDevice?.protocol === 'airplay' ? remoteDevice?.id : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed z-20',
|
||||
css`
|
||||
top: 46px;
|
||||
right: 256px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onClick={() => setShowPanel(!showPanel)}
|
||||
className='flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-24 text-white'
|
||||
>
|
||||
A
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute rounded-24 border border-white/10 bg-black/60 p-2 backdrop-blur-xl',
|
||||
css`
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{devices?.devices?.map(device => (
|
||||
<div
|
||||
key={device.identifier}
|
||||
className={cx(
|
||||
'rounded-12 p-2 hover:bg-white/10',
|
||||
device.identifier === selectedAirplayDeviceID
|
||||
? 'text-brand-500'
|
||||
: 'text-white'
|
||||
)}
|
||||
onClick={() => player.switchToAirplayDevice(device.identifier)}
|
||||
>
|
||||
{device.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Airplay
|
||||
|
|
@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css'
|
|||
import { memo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Image from './Image'
|
||||
import { prefetchArtist } from '@/web/api/hooks/useArtist'
|
||||
|
||||
const Artist = ({ artist }: { artist: Artist }) => {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -11,7 +12,10 @@ const Artist = ({ artist }: { artist: Artist }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<div
|
||||
className='text-center'
|
||||
onMouseOver={() => prefetchArtist({ id: artist.id })}
|
||||
>
|
||||
<Image
|
||||
onClick={to}
|
||||
src={resizeImage(artist.img1v1Url, 'md')}
|
||||
|
|
@ -72,19 +76,14 @@ const ArtistRow = ({
|
|||
<div className={className}>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h4
|
||||
className={cx(
|
||||
'text-12 font-medium uppercase dark:text-neutral-300 lg:text-14',
|
||||
'mx-2.5 mb-6 lg:mx-0 lg:font-bold'
|
||||
)}
|
||||
>
|
||||
<h4 className='mx-2.5 mb-6 text-12 font-medium uppercase dark:text-neutral-300 lg:mx-0 lg:text-14 lg:font-bold'>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{/* Artists */}
|
||||
{artists && (
|
||||
<div className='no-scrollbar flex snap-x overflow-x-scroll lg:grid lg:w-auto lg:grid-cols-5 lg:gap-10'>
|
||||
<div className='no-scrollbar flex snap-x overflow-x-scroll lg:grid lg:w-auto lg:grid-cols-5 lg:gap-x-10 lg:gap-y-8'>
|
||||
{artists.map(artist => (
|
||||
<div className='snap-start px-2.5 lg:px-0' key={artist.id}>
|
||||
<Artist artist={artist} key={artist.id} />
|
||||
|
|
|
|||
|
|
@ -3,21 +3,34 @@ import { cx, css } from '@emotion/css'
|
|||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const BlurBackground = ({ cover }: { cover?: string }) => {
|
||||
const BlurBackground = () => {
|
||||
const isMobile = useIsMobile()
|
||||
const { hideTopbarBackground } = useSnapshot(uiStates)
|
||||
const { hideTopbarBackground, blurBackgroundImage } = useSnapshot(uiStates)
|
||||
const location = useLocation()
|
||||
const animate = useAnimation()
|
||||
|
||||
useEffect(() => {
|
||||
uiStates.blurBackgroundImage = null
|
||||
}, [location.pathname])
|
||||
|
||||
const onLoad = async () => {
|
||||
animate.start({ opacity: 1 })
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!isMobile && cover && hideTopbarBackground && (
|
||||
{!isMobile && blurBackgroundImage && hideTopbarBackground && (
|
||||
<motion.img
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
animate={animate}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ease }}
|
||||
onLoad={onLoad}
|
||||
className={cx(
|
||||
'absolute z-0 object-cover opacity-70',
|
||||
css`
|
||||
|
|
@ -28,7 +41,7 @@ const BlurBackground = ({ cover }: { cover?: string }) => {
|
|||
filter: blur(256px) saturate(1.2);
|
||||
`
|
||||
)}
|
||||
src={resizeImage(cover, 'sm')}
|
||||
src={resizeImage(blurBackgroundImage, 'sm')}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Image from './Image'
|
|||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
import { memo, useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const Album = ({ album }: { album: Album }) => {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -16,13 +17,21 @@ const Album = ({ album }: { album: Album }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
onClick={goTo}
|
||||
key={album.id}
|
||||
src={resizeImage(album?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
<div>
|
||||
<Image
|
||||
onClick={goTo}
|
||||
key={album.id}
|
||||
src={resizeImage(album?.picUrl || '', 'md')}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
|
||||
{album.name}
|
||||
</div>
|
||||
<div className='mt-1 text-14 font-medium text-neutral-700'>
|
||||
{dayjs(album.publishTime || 0).year()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Image from './Image'
|
||||
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { CSSProperties, useRef } from 'react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
const CoverRow = ({
|
||||
albums,
|
||||
playlists,
|
||||
title,
|
||||
className,
|
||||
containerClassName,
|
||||
containerStyle,
|
||||
}: {
|
||||
title?: string
|
||||
className?: string
|
||||
|
|
@ -34,9 +31,6 @@ const CoverRow = ({
|
|||
if (playlists) prefetchPlaylist({ id })
|
||||
}
|
||||
|
||||
// The scrollable element for your list
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
type Item = Album | Playlist
|
||||
const items: Item[] = albums || playlists || []
|
||||
const rows = items.reduce((rows: Item[][], item: Item, index: number) => {
|
||||
|
|
@ -49,29 +43,6 @@ const CoverRow = ({
|
|||
return rows
|
||||
}, [])
|
||||
|
||||
// The virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 5,
|
||||
estimateSize: () => {
|
||||
const containerWidth = parentRef.current?.clientWidth
|
||||
console.log(parentRef.current?.clientWidth)
|
||||
if (!containerWidth) {
|
||||
return 192
|
||||
}
|
||||
const gridGapY = 24
|
||||
const gridGapX = 40
|
||||
const gridColumns = 4
|
||||
console.log(
|
||||
(containerWidth - (gridColumns - 1) * gridGapX) / gridColumns + gridGapY
|
||||
)
|
||||
return (
|
||||
(containerWidth - (gridColumns - 1) * gridGapX) / gridColumns + gridGapY
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Title */}
|
||||
|
|
@ -81,46 +52,43 @@ const CoverRow = ({
|
|||
</h4>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={cx('w-full overflow-auto', containerClassName)}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div
|
||||
className='relative w-full'
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((row: any) => (
|
||||
<div
|
||||
key={row.index}
|
||||
className='absolute top-0 left-0 grid w-full grid-cols-4 gap-4 lg:gap-10'
|
||||
style={{
|
||||
height: `${row.size}px`,
|
||||
transform: `translateY(${row.start}px)`,
|
||||
}}
|
||||
>
|
||||
{rows[row.index].map((item: Item) => (
|
||||
<img
|
||||
onClick={() => goTo(item.id)}
|
||||
key={item.id}
|
||||
alt={item.name}
|
||||
src={resizeImage(
|
||||
<Virtuoso
|
||||
className='no-scrollbar'
|
||||
style={{
|
||||
height: 'calc(100vh - 132px)',
|
||||
}}
|
||||
data={rows}
|
||||
overscan={5}
|
||||
itemSize={el => el.getBoundingClientRect().height + 24}
|
||||
totalCount={rows.length}
|
||||
components={{
|
||||
Header: () => <div className='h-16'></div>,
|
||||
Footer: () => <div className='h-16'></div>,
|
||||
}}
|
||||
itemContent={(index, row) => (
|
||||
<div
|
||||
key={index}
|
||||
className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'
|
||||
>
|
||||
{row.map((item: Item) => (
|
||||
<img
|
||||
onClick={() => goTo(item.id)}
|
||||
key={item.id}
|
||||
alt={item.name}
|
||||
src={resizeImage(
|
||||
item?.picUrl ||
|
||||
(item as Playlist)?.coverImgUrl ||
|
||||
item?.picUrl ||
|
||||
(item as Playlist)?.coverImgUrl ||
|
||||
item?.picUrl ||
|
||||
'',
|
||||
'md'
|
||||
)}
|
||||
className='aspect-square rounded-24'
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
'',
|
||||
'md'
|
||||
)}
|
||||
className='rounded-24'
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import useBreakpoint from '@/web/hooks/useBreakpoint'
|
||||
import { ReactQueryDevtools } from 'react-query/devtools'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
|
||||
const Devtool = () => {
|
||||
|
|
@ -10,10 +9,19 @@ const Devtool = () => {
|
|||
toggleButtonProps={{
|
||||
style: {
|
||||
position: 'fixed',
|
||||
bottom: isMobile ? 'auto' : 0,
|
||||
right: 0,
|
||||
top: isMobile ? 0 : 'auto',
|
||||
left: 'auto',
|
||||
...(isMobile
|
||||
? {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 'auto',
|
||||
left: 'atuo',
|
||||
}
|
||||
: {
|
||||
top: 36,
|
||||
right: 148,
|
||||
bottom: 'atuo',
|
||||
left: 'auto',
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,42 +1,34 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { ErrorBoundary as ErrorBoundaryRaw } from 'react-error-boundary'
|
||||
|
||||
function ErrorFallback({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role='alert'
|
||||
className='app-region-drag flex h-screen w-screen items-center justify-center bg-white dark:bg-black'
|
||||
>
|
||||
<div className='app-region-no-drag'>
|
||||
<p>Something went wrong:</p>
|
||||
<pre>{error.message}</pre>
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
const ErrorBoundary = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<ErrorBoundaryRaw
|
||||
FallbackComponent={ErrorFallback}
|
||||
onReset={() => {
|
||||
// reset the state of your app so the error doesn't happen again
|
||||
}}
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={({ error, componentStack }) => (
|
||||
<div
|
||||
role='alert'
|
||||
className='app-region-drag flex h-screen w-screen items-center justify-center rounded-24 bg-white dark:bg-black'
|
||||
>
|
||||
<div className='app-region-no-drag'>
|
||||
<p>Something went wrong:</p>
|
||||
<pre className='mb-2 text-18 dark:text-white'>
|
||||
{error.toString()}
|
||||
</pre>
|
||||
<div className='max-h-72 max-w-2xl overflow-scroll whitespace-pre-line rounded-12 border border-white/10 px-3 py-2 dark:text-white/50'>
|
||||
{componentStack?.trim()}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => location.reload()}
|
||||
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundaryRaw>
|
||||
</Sentry.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ const ImageDesktop = ({
|
|||
{placeholder && (
|
||||
<motion.div
|
||||
{...placeholderMotionProps}
|
||||
className='absolute inset-0 h-full w-full bg-white dark:bg-neutral-800'
|
||||
className='absolute inset-0 h-full w-full bg-white dark:bg-white/10'
|
||||
></motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ import player from '@/web/states/player'
|
|||
import { useSnapshot } from 'valtio'
|
||||
import Login from './Login'
|
||||
import TrafficLight from './TrafficLight'
|
||||
import BlurBackground from './BlurBackground'
|
||||
import Airplay from './Airplay'
|
||||
import TitleBar from './TitleBar'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const Layout = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const { fullscreen } = useSnapshot(uiStates)
|
||||
const showPlayer = !!playerSnapshot.track
|
||||
|
||||
return (
|
||||
|
|
@ -17,24 +22,10 @@ const Layout = () => {
|
|||
id='layout'
|
||||
className={cx(
|
||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
|
||||
window.env?.isElectron && 'rounded-24',
|
||||
css`
|
||||
grid-template-columns: 6.5rem auto 358px;
|
||||
grid-template-rows: 132px auto;
|
||||
`,
|
||||
showPlayer
|
||||
? css`
|
||||
grid-template-areas:
|
||||
'menubar main -'
|
||||
'menubar main player';
|
||||
`
|
||||
: css`
|
||||
grid-template-areas:
|
||||
'menubar main main'
|
||||
'menubar main main';
|
||||
`
|
||||
window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||
)}
|
||||
>
|
||||
<BlurBackground />
|
||||
<MenuBar />
|
||||
<Topbar />
|
||||
<Main />
|
||||
|
|
@ -46,6 +37,10 @@ const Layout = () => {
|
|||
<TrafficLight />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{window.env?.isWindows && <TitleBar />}
|
||||
|
||||
{/* {window.env?.isElectron && <Airplay />} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import Icon from '@/web/components/Icon'
|
|||
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
|
||||
import LoginWithQRCode from './LoginWithQRCode'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
|
||||
const OR = ({
|
||||
children,
|
||||
|
|
@ -26,7 +27,7 @@ const OR = ({
|
|||
|
||||
<div className='mt-4 flex justify-center'>
|
||||
<button
|
||||
className='text-16 font-medium text-night-50 transition-colors duration-300 hover:text-white'
|
||||
className='text-16 font-medium text-night-50 transition-colors duration-400 hover:text-white'
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -37,12 +38,20 @@ const OR = ({
|
|||
}
|
||||
|
||||
const Login = () => {
|
||||
const { data: user, isLoading: isLoadingUser } = useUser()
|
||||
const { loginType } = useSnapshot(persistedUiStates)
|
||||
const { showLoginPanel } = useSnapshot(uiStates)
|
||||
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
|
||||
loginType === 'qrCode' ? 'qrCode' : 'phone/email'
|
||||
)
|
||||
|
||||
// Show login panel when user first loads the page and not logged in
|
||||
useEffect(() => {
|
||||
if (!user?.account && !isLoadingUser) {
|
||||
uiStates.showLoginPanel = true
|
||||
}
|
||||
}, [user?.account, isLoadingUser])
|
||||
|
||||
const animateCard = useAnimation()
|
||||
const handleSwitchCard = async () => {
|
||||
const transition = { duration: 0.36, ease }
|
||||
|
|
@ -80,7 +89,7 @@ const Login = () => {
|
|||
{/* Content */}
|
||||
<AnimatePresence>
|
||||
{showLoginPanel && (
|
||||
<div className='fixed inset-0 z-30 flex justify-center rounded-24 pt-56'>
|
||||
<div className='fixed inset-0 z-30 flex items-center justify-center rounded-24 pt-24'>
|
||||
<motion.div
|
||||
className='flex flex-col items-center'
|
||||
variants={{
|
||||
|
|
@ -135,9 +144,9 @@ const Login = () => {
|
|||
layout='position'
|
||||
transition={{ ease }}
|
||||
onClick={() => (uiStates.showLoginPanel = false)}
|
||||
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10'
|
||||
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
|
||||
>
|
||||
<Icon name='x' className='h-7 w-7 text-white/50' />
|
||||
<Icon name='x' className='h-7 w-7 ' />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { cx, css } from '@emotion/css'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from 'react-query'
|
||||
import { loginWithEmail, loginWithPhone } from '@/web/api/auth'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
loginWithEmail,
|
||||
LoginWithEmailResponse,
|
||||
loginWithPhone,
|
||||
LoginWithPhoneResponse,
|
||||
} from '@/web/api/auth'
|
||||
import md5 from 'md5'
|
||||
import toast from 'react-hot-toast'
|
||||
import { setCookies } from '@/web/utils/cookie'
|
||||
|
|
@ -10,6 +15,8 @@ import { ease } from '@/web/utils/const'
|
|||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { UserApiNames } from '@/shared/api/User'
|
||||
|
||||
const LoginWithPhoneOrEmail = () => {
|
||||
const { loginPhoneCountryCode, loginType: persistedLoginType } =
|
||||
|
|
@ -24,26 +31,36 @@ const LoginWithPhoneOrEmail = () => {
|
|||
persistedLoginType === 'email' ? 'email' : 'phone'
|
||||
)
|
||||
|
||||
const handleAfterLogin = (
|
||||
result: LoginWithEmailResponse | LoginWithPhoneResponse
|
||||
) => {
|
||||
if (result?.code !== 200) return
|
||||
setCookies(result.cookie)
|
||||
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
|
||||
uiStates.showLoginPanel = false
|
||||
}
|
||||
|
||||
const handleError = (data: any, error: any) => {
|
||||
if (data?.code === 200) return
|
||||
toast(
|
||||
`Login failed: ${
|
||||
data?.message ||
|
||||
data?.msg ||
|
||||
data?.error ||
|
||||
error?.response?.data?.message ||
|
||||
error?.response?.data?.msg ||
|
||||
error
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
const doEmailLogin = useMutation(
|
||||
() =>
|
||||
loginWithEmail({
|
||||
email: email.trim(),
|
||||
md5_password: md5(password.trim()),
|
||||
}),
|
||||
{
|
||||
onSuccess: result => {
|
||||
if (result?.code !== 200) {
|
||||
toast(`Login failed: ${result.code}`)
|
||||
return
|
||||
}
|
||||
setCookies(result.cookie)
|
||||
|
||||
uiStates.showLoginPanel = false
|
||||
},
|
||||
onError: error => {
|
||||
toast(`Login failed: ${error}`)
|
||||
},
|
||||
}
|
||||
{ onSuccess: handleAfterLogin, onSettled: handleError }
|
||||
)
|
||||
|
||||
const handleEmailLogin = () => {
|
||||
|
|
@ -75,19 +92,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
md5_password: md5(password.trim()),
|
||||
})
|
||||
},
|
||||
{
|
||||
onSuccess: result => {
|
||||
if (result?.code !== 200) {
|
||||
toast(`Login failed: ${result.code}`)
|
||||
return
|
||||
}
|
||||
setCookies(result.cookie)
|
||||
uiStates.showLoginPanel = false
|
||||
},
|
||||
onError: error => {
|
||||
toast(`Login failed: ${error}`)
|
||||
},
|
||||
}
|
||||
{ onSuccess: handleAfterLogin, onSettled: handleError }
|
||||
)
|
||||
|
||||
const handlePhoneLogin = () => {
|
||||
|
|
@ -124,12 +129,12 @@ const LoginWithPhoneOrEmail = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className='text-center text-18 font-medium text-night-600'>
|
||||
<div className='text-center text-18 font-medium text-white/20'>
|
||||
Log in with{' '}
|
||||
<span
|
||||
className={cx(
|
||||
'transition-colors duration-300',
|
||||
loginType === 'phone' ? 'text-brand-600' : 'hover:text-night-50'
|
||||
loginType === 'phone' ? 'text-brand-600' : 'hover:text-white/70'
|
||||
)}
|
||||
onClick={() => {
|
||||
const type = loginType === 'phone' ? 'email' : 'phone'
|
||||
|
|
@ -143,7 +148,7 @@ const LoginWithPhoneOrEmail = () => {
|
|||
<span
|
||||
className={cx(
|
||||
'transition-colors duration-300',
|
||||
loginType === 'email' ? 'text-brand-600' : 'hover:text-night-50'
|
||||
loginType === 'email' ? 'text-brand-600' : 'hover:text-white/70'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (loginType !== 'email') setLoginType('email')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { cx, css } from '@emotion/css'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import qrCode from 'qrcode'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { checkLoginQrCodeStatus, fetchLoginQrCodeKey } from '@/web/api/auth'
|
||||
import toast from 'react-hot-toast'
|
||||
import { setCookies } from '@/web/utils/cookie'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import { UserApiNames } from '@/shared/api/User'
|
||||
|
||||
const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
||||
const [image, setImage] = useState<string>('')
|
||||
|
|
@ -40,7 +42,7 @@ const LoginWithQRCode = () => {
|
|||
status: keyStatus,
|
||||
refetch: refetchKey,
|
||||
} = useQuery(
|
||||
'qrCodeKey',
|
||||
['qrCodeKey'],
|
||||
async () => {
|
||||
const result = await fetchLoginQrCodeKey()
|
||||
if (result.data.code !== 200) {
|
||||
|
|
@ -58,7 +60,7 @@ const LoginWithQRCode = () => {
|
|||
)
|
||||
|
||||
const { data: status } = useQuery(
|
||||
'qrCodeStatus',
|
||||
['qrCodeStatus'],
|
||||
async () => checkLoginQrCodeStatus({ key: key?.data?.unikey || '' }),
|
||||
{
|
||||
refetchInterval: 1000,
|
||||
|
|
@ -80,6 +82,7 @@ const LoginWithQRCode = () => {
|
|||
break
|
||||
}
|
||||
setCookies(status.cookie)
|
||||
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
|
||||
uiStates.showLoginPanel = false
|
||||
break
|
||||
}
|
||||
|
|
@ -97,7 +100,7 @@ const LoginWithQRCode = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className='text-center text-18 font-medium text-night-600'>
|
||||
<div className='text-center text-18 font-medium text-white/20'>
|
||||
Log in with NetEase QR
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,17 @@ import { css, cx } from '@emotion/css'
|
|||
import Router from './Router'
|
||||
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { breakpoint as bp, ease } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { sleep } from '@/web/utils/common'
|
||||
import player from '@/web/states/player'
|
||||
|
||||
const Main = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
// Show/hide topbar background
|
||||
const observePoint = useRef<HTMLDivElement | null>(null)
|
||||
const { onScreen } = useIntersectionObserver(observePoint)
|
||||
|
|
@ -15,12 +23,33 @@ const Main = () => {
|
|||
}
|
||||
}, [onScreen])
|
||||
|
||||
// Change width when player is minimized
|
||||
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
|
||||
const controlsMain = useAnimation()
|
||||
useEffect(() => {
|
||||
const animate = async () => {
|
||||
await controlsMain.start({ opacity: 0 })
|
||||
await sleep(100)
|
||||
setIsMaxWidth(minimizePlayer)
|
||||
await controlsMain.start({ opacity: 1 })
|
||||
}
|
||||
if (minimizePlayer !== isMaxWidth) animate()
|
||||
}, [controlsMain, isMaxWidth, minimizePlayer])
|
||||
|
||||
return (
|
||||
<main
|
||||
<motion.main
|
||||
id='main'
|
||||
animate={controlsMain}
|
||||
transition={{ ease, duration: 0.4 }}
|
||||
className={cx(
|
||||
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
|
||||
'no-scrollbar z-10 h-screen overflow-y-auto',
|
||||
css`
|
||||
grid-area: main;
|
||||
${bp.lg} {
|
||||
margin-left: 144px;
|
||||
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
|
|
@ -32,7 +61,7 @@ const Main = () => {
|
|||
>
|
||||
<Router />
|
||||
</div>
|
||||
</main>
|
||||
</motion.main>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import Icon from '../Icon'
|
|||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAnimation, motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import TrafficLight from './TrafficLight'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
|
@ -132,15 +135,17 @@ const Tabs = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const MenuBar = ({ className }: { className?: string }) => {
|
||||
const MenuBar = () => {
|
||||
const isMobile = useIsMobile()
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-drag relative flex h-full w-full flex-col justify-center',
|
||||
className,
|
||||
'lg:fixed lg:left-0 lg:top-0 lg:bottom-0',
|
||||
css`
|
||||
grid-area: menubar;
|
||||
${bp.lg} {
|
||||
width: 104px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||
import Slider from './Slider'
|
||||
import { animate, motion, useAnimation } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import ArtistInline from './ArtistsInLine'
|
||||
|
||||
const Progress = () => {
|
||||
const { track, progress } = useSnapshot(player)
|
||||
|
||||
return (
|
||||
<div className='mt-10 flex w-full flex-col'>
|
||||
<Slider
|
||||
min={0}
|
||||
max={(track?.dt ?? 100000) / 1000}
|
||||
value={progress}
|
||||
onChange={value => {
|
||||
player.progress = value
|
||||
}}
|
||||
onlyCallOnChangeAfterDragEnded={true}
|
||||
/>
|
||||
|
||||
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
|
||||
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
|
||||
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Cover = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const [cover, setCover] = useState('')
|
||||
const animationStartTime = useRef(0)
|
||||
const controls = useAnimation()
|
||||
const duration = 150 // ms
|
||||
|
||||
useEffect(() => {
|
||||
const resizedCover = resizeImage(
|
||||
playerSnapshot.track?.al.picUrl || '',
|
||||
'lg'
|
||||
)
|
||||
const animate = async () => {
|
||||
animationStartTime.current = Date.now()
|
||||
await controls.start({ opacity: 0 })
|
||||
setCover(resizedCover)
|
||||
}
|
||||
animate()
|
||||
}, [controls, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||
useEffect(() => {
|
||||
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
|
||||
if (cover !== realCover) setCover(realCover)
|
||||
}, [cover, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
const onLoad = () => {
|
||||
const passedTime = Date.now() - animationStartTime.current
|
||||
controls.start({
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.img
|
||||
animate={controls}
|
||||
transition={{ duration: duration / 1000, ease }}
|
||||
className={cx('absolute inset-0 w-full')}
|
||||
src={cover}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const LikeButton = () => {
|
||||
const { track } = useSnapshot(player)
|
||||
const { data: likedIDs } = useUserLikedTracksIDs()
|
||||
|
||||
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
|
||||
|
||||
const likeATrack = useMutationLikeATrack()
|
||||
|
||||
return (
|
||||
<button onClick={() => track?.id && likeATrack.mutateAsync(track.id)}>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const NowPlaying = () => {
|
||||
const { state, track } = useSnapshot(player)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
|
||||
css`
|
||||
border-color: hsl(0, 100%, 100%, 0.08);
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Cover */}
|
||||
<Cover />
|
||||
|
||||
{/* Info & Controls */}
|
||||
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
|
||||
{/* Track Info */}
|
||||
<div className='line-clamp-1 text-lg text-black dark:text-white'>
|
||||
{track?.name}
|
||||
</div>
|
||||
<ArtistInline
|
||||
artists={track?.ar || []}
|
||||
className='text-black/30 dark:text-white/30'
|
||||
hoverClassName='hover:text-black/50 dark:hover:text-white/50 transition-colors duration-500'
|
||||
/>
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
|
||||
|
||||
{/* Progress */}
|
||||
<Progress />
|
||||
|
||||
{/* Controls */}
|
||||
<div className='mt-4 flex w-full items-center justify-between'>
|
||||
<button>
|
||||
<Icon
|
||||
name='hide-list'
|
||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className='text-black/95 dark:text-white/80'>
|
||||
<button
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||
>
|
||||
<Icon name='previous' className='h-6 w-6 ' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => track && player.playOrPause()}
|
||||
className='mx-2 rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
className='h-6 w-6 '
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => track && player.nextTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||
>
|
||||
<Icon name='next' className='h-6 w-6 ' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LikeButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NowPlaying
|
||||
114
packages/web/components/New/NowPlaying/Controls.tsx
Normal file
114
packages/web/components/New/NowPlaying/Controls.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import player from '@/web/states/player'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import { MotionConfig, motion } from 'framer-motion'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Icon from '../../Icon'
|
||||
import { State as PlayerState } from '@/web/utils/player'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
|
||||
const LikeButton = () => {
|
||||
const { track } = useSnapshot(player)
|
||||
const { data: likedIDs } = useUserLikedTracksIDs()
|
||||
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
|
||||
const likeATrack = useMutationLikeATrack()
|
||||
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
|
||||
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
|
||||
>
|
||||
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
const Controls = () => {
|
||||
const { state, track } = useSnapshot(player)
|
||||
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<MotionConfig transition={{ ease, duration: 0.6 }}>
|
||||
<motion.div
|
||||
className={cx(
|
||||
'fixed bottom-0 right-0 flex',
|
||||
mini
|
||||
? 'flex-col items-center justify-between'
|
||||
: 'items-center justify-between',
|
||||
mini
|
||||
? css`
|
||||
right: 24px;
|
||||
bottom: 18px;
|
||||
width: 44px;
|
||||
height: 254px;
|
||||
`
|
||||
: css`
|
||||
bottom: 56px;
|
||||
right: 56px;
|
||||
width: 254px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Minimize */}
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
|
||||
onClick={() => {
|
||||
persistedUiStates.minimizePlayer = !mini
|
||||
}}
|
||||
>
|
||||
<Icon name='hide-list' className='h-7 w-7 ' />
|
||||
</motion.button>
|
||||
|
||||
{/* Media controls */}
|
||||
<div className='flex flex-wrap gap-2 text-black/95 dark:text-white/80'>
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track && player.prevTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||
>
|
||||
<Icon name='previous' className='h-6 w-6' />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track && player.playOrPause()}
|
||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
className='h-6 w-6 '
|
||||
/>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
layout='position'
|
||||
animate={{ rotate: mini ? 90 : 0 }}
|
||||
onClick={() => track && player.nextTrack()}
|
||||
disabled={!track}
|
||||
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
|
||||
>
|
||||
<Icon name='next' className='h-6 w-6 ' />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Like */}
|
||||
<LikeButton />
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
||||
export default Controls
|
||||
62
packages/web/components/New/NowPlaying/Cover.tsx
Normal file
62
packages/web/components/New/NowPlaying/Cover.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import player from '@/web/states/player'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useAnimation, motion } from 'framer-motion'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const Cover = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const [cover, setCover] = useState('')
|
||||
const animationStartTime = useRef(0)
|
||||
const controls = useAnimation()
|
||||
const duration = 150 // ms
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const resizedCover = resizeImage(
|
||||
playerSnapshot.track?.al.picUrl || '',
|
||||
'lg'
|
||||
)
|
||||
const animate = async () => {
|
||||
animationStartTime.current = Date.now()
|
||||
await controls.start({ opacity: 0 })
|
||||
setCover(resizedCover)
|
||||
}
|
||||
animate()
|
||||
}, [controls, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||
useEffect(() => {
|
||||
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
|
||||
if (cover !== realCover) setCover(realCover)
|
||||
}, [cover, playerSnapshot.track?.al.picUrl])
|
||||
|
||||
const onLoad = () => {
|
||||
const passedTime = Date.now() - animationStartTime.current
|
||||
controls.start({
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.img
|
||||
animate={controls}
|
||||
transition={{ duration: duration / 1000, ease }}
|
||||
className={cx('absolute inset-0 w-full')}
|
||||
src={cover}
|
||||
onLoad={onLoad}
|
||||
onClick={() => {
|
||||
const id = playerSnapshot.track?.al.id
|
||||
if (id) navigate(`/album/${id}`)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import NowPlaying from './NowPlaying'
|
||||
import tracks from '../../.storybook/mock/tracks'
|
||||
import tracks from '@/web/.storybook/mock/tracks'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
export default {
|
||||
65
packages/web/components/New/NowPlaying/NowPlaying.tsx
Normal file
65
packages/web/components/New/NowPlaying/NowPlaying.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import ArtistInline from '@/web/components/New/ArtistsInLine'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import Controls from './Controls'
|
||||
import Cover from './Cover'
|
||||
import Progress from './Progress'
|
||||
|
||||
const NowPlaying = () => {
|
||||
const { track } = useSnapshot(player)
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Now Playing */}
|
||||
<AnimatePresence>
|
||||
{!minimizePlayer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cx(
|
||||
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
|
||||
css`
|
||||
border-color: hsl(0, 100%, 100%, 0.08);
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Cover */}
|
||||
<Cover />
|
||||
|
||||
{/* Info & Controls */}
|
||||
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
|
||||
{/* Track Info */}
|
||||
<div className='line-clamp-1 text-lg text-black dark:text-white'>
|
||||
{track?.name}
|
||||
</div>
|
||||
<ArtistInline
|
||||
artists={track?.ar || []}
|
||||
className='text-black/30 dark:text-white/30'
|
||||
hoverClassName='hover:text-black/50 dark:hover:text-white/70 transition-colors duration-400'
|
||||
/>
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mt-2 h-px w-2/5 bg-black/10 dark:bg-white/10'></div>
|
||||
|
||||
{/* Progress */}
|
||||
<Progress />
|
||||
|
||||
{/* Controls placeholder */}
|
||||
<div className='h-11'></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Controls (for Animation) */}
|
||||
<Controls />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NowPlaying
|
||||
29
packages/web/components/New/NowPlaying/Progress.tsx
Normal file
29
packages/web/components/New/NowPlaying/Progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import player from '@/web/states/player'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Slider from '../Slider'
|
||||
|
||||
const Progress = () => {
|
||||
const { track, progress } = useSnapshot(player)
|
||||
|
||||
return (
|
||||
<div className='mt-9 mb-4 flex w-full flex-col'>
|
||||
<Slider
|
||||
min={0}
|
||||
max={(track?.dt ?? 100000) / 1000}
|
||||
value={progress}
|
||||
onChange={value => {
|
||||
player.progress = value
|
||||
}}
|
||||
onlyCallOnChangeAfterDragEnded={true}
|
||||
/>
|
||||
|
||||
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
|
||||
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
|
||||
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Progress
|
||||
3
packages/web/components/New/NowPlaying/index.tsx
Normal file
3
packages/web/components/New/NowPlaying/index.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import NowPlaying from './NowPlaying'
|
||||
|
||||
export default NowPlaying
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import { motion, MotionConfig } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import scrollPositions from '@/web/states/scrollPositions'
|
||||
|
|
@ -26,14 +26,16 @@ const PageTransition = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: disableEnterAnimation ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
<MotionConfig transition={{ ease }}>
|
||||
<motion.div
|
||||
initial={{ opacity: disableEnterAnimation ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,39 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import persistedUiStates from '@/web/states/persistedUiStates'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import NowPlaying from './NowPlaying'
|
||||
import PlayingNext from './PlayingNext'
|
||||
import { AnimatePresence, motion, MotionConfig } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
|
||||
const Player = () => {
|
||||
const { minimizePlayer } = useSnapshot(persistedUiStates)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex w-full flex-col justify-between overflow-hidden pr-6 pl-4',
|
||||
css`
|
||||
grid-area: player;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<PlayingNext className='h-full' />
|
||||
<div className='pb-6'>
|
||||
<MotionConfig transition={{ duration: 0.6 }}>
|
||||
<div
|
||||
className={cx(
|
||||
'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden',
|
||||
css`
|
||||
width: 318px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!minimizePlayer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<PlayingNext />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<NowPlaying />
|
||||
</div>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const Header = () => {
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300 lg:px-4'
|
||||
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300 lg:px-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex'>
|
||||
|
|
@ -26,7 +26,7 @@ const Header = () => {
|
|||
<div className='mr-2'>
|
||||
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
<div className='mr-1'>
|
||||
<div>
|
||||
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,11 +147,11 @@ const TrackList = ({ className }: { className?: string }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const PlayingNext = ({ className }: { className?: string }) => {
|
||||
const PlayingNext = () => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<TrackList className={className} />
|
||||
<TrackList />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useDragControls,
|
||||
AnimatePresence,
|
||||
} from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, useDragControls, AnimatePresence } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { useLockBodyScroll } from 'react-use'
|
||||
import { isIosPwa } from '@/web/utils/common'
|
||||
import PlayingNext from './PlayingNext'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
|
|||
const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
||||
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
|
||||
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
|
||||
const MV = React.lazy(() => import('@/web/pages/New/MV'))
|
||||
|
||||
const lazy = (component: ReactNode) => {
|
||||
return <Suspense>{component}</Suspense>
|
||||
|
|
@ -27,6 +28,7 @@ const Router = () => {
|
|||
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
||||
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
||||
<Route path='/mv/:id' element={lazy(<MV />)} />
|
||||
<Route path='/settings' element={lazy(<Settings />)} />
|
||||
<Route path='/search/:keywords' element={lazy(<Search />)}>
|
||||
<Route path=':type' element={lazy(<Search />)} />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const ScrollRestoration = () => {
|
|||
const main = document.querySelector('main')
|
||||
const handleScroll = throttle(() => {
|
||||
scrollPositions.set(window.location.pathname, main?.scrollTop ?? 0)
|
||||
}, 100)
|
||||
}, 200)
|
||||
main?.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
main?.removeEventListener('scroll', handleScroll)
|
||||
|
|
|
|||
75
packages/web/components/New/TitleBar.tsx
Normal file
75
packages/web/components/New/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import player from '@/web/states/player'
|
||||
import Icon from '../Icon'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css, cx } from '@emotion/css'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const classNames = cx(
|
||||
'flex items-center justify-center text-white/80 hover:text-white hover:bg-white/20 transition duration-400',
|
||||
css`
|
||||
height: 28px;
|
||||
width: 48px;
|
||||
border-radius: 4px;
|
||||
`
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='app-region-no-drag flex h-full items-center'>
|
||||
<button onClick={minimize} className={classNames}>
|
||||
<Icon className='h-3 w-3' name='windows-minimize' />
|
||||
</button>
|
||||
<button onClick={maxRestore} className={classNames}>
|
||||
<Icon
|
||||
className='h-3 w-3'
|
||||
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className={cx(
|
||||
classNames,
|
||||
css`
|
||||
margin-right: 5px;
|
||||
border-radius: 4px 20px 4px 4px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Icon className='h-3 w-3' name='windows-close' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleBar = () => {
|
||||
return (
|
||||
<div className='app-region-drag fixed z-30'>
|
||||
<div className='flex h-9 w-screen items-center justify-between'>
|
||||
<div></div>
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleBar
|
||||
|
|
@ -4,6 +4,9 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { ease } from '@/web/utils/const'
|
||||
import Icon from '../../Icon'
|
||||
|
||||
const buttonClassNames =
|
||||
'app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl transition-colors duration-400 hover:bg-white/20 hover:text-white/60'
|
||||
|
||||
const NavigationButtons = () => {
|
||||
const navigate = useNavigate()
|
||||
const controlsBack = useAnimation()
|
||||
|
|
@ -18,10 +21,10 @@ const NavigationButtons = () => {
|
|||
await controlsBack.start({ x: -5 })
|
||||
await controlsBack.start({ x: 0 })
|
||||
}}
|
||||
className='app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl'
|
||||
className={buttonClassNames}
|
||||
>
|
||||
<motion.div animate={controlsBack} transition={transition}>
|
||||
<Icon name='back' className='h-7 w-7 ' />
|
||||
<Icon name='back' className='h-7 w-7' />
|
||||
</motion.div>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -32,7 +35,7 @@ const NavigationButtons = () => {
|
|||
await controlsForward.start({ x: 5 })
|
||||
await controlsForward.start({ x: 0 })
|
||||
}}
|
||||
className='app-region-no-drag ml-2.5 rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl'
|
||||
className={cx('ml-2.5', buttonClassNames)}
|
||||
>
|
||||
<motion.div animate={controlsForward} transition={transition}>
|
||||
<Icon name='forward' className='h-7 w-7' />
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
isIOS,
|
||||
isSafari,
|
||||
resizeImage,
|
||||
} from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import dayjs from 'dayjs'
|
||||
import Image from './Image'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { memo, useEffect, useMemo, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { injectGlobal } from '@emotion/css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import BlurBackground from '@/web/components/New/BlurBackground'
|
||||
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
|
||||
import { AppleMusicAlbum } from '@/shared/AppleMusic'
|
||||
|
||||
injectGlobal`
|
||||
.plyr__video-wrapper,
|
||||
.plyr--video {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
`
|
||||
|
||||
const VideoCover = ({ source }: { source?: string }) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>(new Hls())
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported()) {
|
||||
const video = document.querySelector('#video-cover') as HTMLVideoElement
|
||||
hls.current.loadSource(source)
|
||||
hls.current.attachMedia(video)
|
||||
}
|
||||
}, [source])
|
||||
|
||||
return (
|
||||
<div className='z-10 aspect-square overflow-hidden rounded-24'>
|
||||
<video id='video-cover' ref={ref} autoPlay muted loop />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Cover = memo(
|
||||
({ album, playlist }: { album?: Album; playlist?: Playlist }) => {
|
||||
const { data: albumFromApple } = useAppleMusicAlbum({
|
||||
id: album?.id,
|
||||
name: album?.name,
|
||||
artist: album?.artist.name,
|
||||
})
|
||||
const { data: videoCoverFromRemote } = useVideoCover({
|
||||
id: album?.id,
|
||||
name: album?.name,
|
||||
artist: album?.artist.name,
|
||||
enabled: !window.env?.isElectron,
|
||||
})
|
||||
const videoCover =
|
||||
albumFromApple?.attributes?.editorialVideo?.motionSquareVideo1x1?.video ||
|
||||
videoCoverFromRemote
|
||||
const cover = album?.picUrl || playlist?.coverImgUrl || ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative z-10 aspect-square w-full overflow-auto rounded-24 '>
|
||||
<Image
|
||||
className='absolute inset-0 h-full w-full'
|
||||
src={resizeImage(cover, 'lg')}
|
||||
/>
|
||||
|
||||
{videoCover && (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className='absolute inset-0 h-full w-full'
|
||||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
src={videoCover}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
></video>
|
||||
) : (
|
||||
<VideoCover source={videoCover} />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blur bg */}
|
||||
<BlurBackground cover={cover} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Cover.displayName = 'Cover'
|
||||
|
||||
const TrackListHeader = ({
|
||||
album,
|
||||
playlist,
|
||||
onPlay,
|
||||
className,
|
||||
}: {
|
||||
album?: Album
|
||||
playlist?: Playlist
|
||||
onPlay: () => void
|
||||
className?: string
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useIsMobile()
|
||||
const albumDuration = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||
}, [album?.songs])
|
||||
const { data: albumFromApple, isLoading: isLoadingAlbumFromApple } =
|
||||
useAppleMusicAlbum({
|
||||
id: album?.id,
|
||||
name: album?.name,
|
||||
artist: album?.artist.name,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
className,
|
||||
'z-10 mx-2.5 rounded-48 p-8 dark:bg-white/10',
|
||||
'lg:mx-0 lg:grid lg:grid-rows-1 lg:gap-10 lg:rounded-none lg:p-0 lg:dark:bg-transparent',
|
||||
!isMobile &&
|
||||
css`
|
||||
grid-template-columns: 318px auto;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Cover {...{ album, playlist }} />
|
||||
|
||||
<div className='flex flex-col justify-between'>
|
||||
<div>
|
||||
{/* Name */}
|
||||
<div className='mt-2.5 text-28 font-semibold dark:text-night-50 lg:mt-0 lg:text-36 lg:font-medium'>
|
||||
{album?.name || playlist?.name}
|
||||
</div>
|
||||
|
||||
{/* Creator */}
|
||||
<div className='mt-2.5 lg:mt-6'>
|
||||
<span
|
||||
onClick={() => {
|
||||
if (album?.artist?.id) navigate(`/artist/${album?.artist?.id}`)
|
||||
}}
|
||||
className='text-24 font-medium transition-colors duration-300 dark:text-night-400 hover:dark:text-neutral-100 '
|
||||
>
|
||||
{album?.artist.name || playlist?.creator.nickname}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Extra info */}
|
||||
<div className='mt-1 flex items-center text-12 font-medium dark:text-night-400 lg:text-14 lg:font-bold'>
|
||||
{/* Album info */}
|
||||
{!!album && (
|
||||
<>
|
||||
{album?.mark === 1056768 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5 '
|
||||
/>
|
||||
)}{' '}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length}{' '}
|
||||
tracks, {albumDuration}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Playlist info */}
|
||||
{!!playlist && (
|
||||
<>
|
||||
Updated at {formatDate(playlist?.updateTime || 0, 'en')} ·{' '}
|
||||
{playlist.trackCount} tracks
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-night-400 '
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: !isLoadingAlbumFromApple
|
||||
? albumFromApple?.attributes?.editorialNotes?.standard ||
|
||||
album?.description ||
|
||||
playlist?.description ||
|
||||
''
|
||||
: '',
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='mt-11 flex items-end justify-between lg:z-10 lg:mt-4 lg:justify-start'>
|
||||
<div className='flex items-end'>
|
||||
<button className='mr-2.5 h-14 w-14 rounded-full dark:bg-white/10 lg:mr-6 lg:h-[72px] lg:w-[72px]'></button>
|
||||
<button className='h-14 w-14 rounded-full dark:bg-white/10 lg:mr-6 lg:h-[72px] lg:w-[72px]'></button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPlay()}
|
||||
className='h-14 w-[125px] rounded-full dark:bg-brand-700 lg:h-[72px] lg:w-[170px]'
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackListHeader
|
||||
42
packages/web/components/New/TrackListHeader/Actions.tsx
Normal file
42
packages/web/components/New/TrackListHeader/Actions.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import Icon from '../../Icon'
|
||||
|
||||
const Actions = ({
|
||||
onPlay,
|
||||
onLike,
|
||||
isLiked,
|
||||
}: {
|
||||
isLiked?: boolean
|
||||
onPlay: () => void
|
||||
onLike?: () => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
|
||||
<div className='flex items-end'>
|
||||
{/* Menu */}
|
||||
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
|
||||
<Icon name='more' className='h-7 w-7' />
|
||||
</button>
|
||||
{/* Like */}
|
||||
{onLike && (
|
||||
<button
|
||||
onClick={() => onLike()}
|
||||
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30 lg:mr-2.5'
|
||||
>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-7 w-7'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPlay()}
|
||||
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Actions
|
||||
33
packages/web/components/New/TrackListHeader/Cover.tsx
Normal file
33
packages/web/components/New/TrackListHeader/Cover.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import { memo, useEffect } from 'react'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from './VideoCover'
|
||||
|
||||
const Cover = memo(
|
||||
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative aspect-square w-full overflow-hidden rounded-24 '>
|
||||
<Image
|
||||
className='absolute inset-0'
|
||||
src={resizeImage(cover || '', 'lg')}
|
||||
/>
|
||||
|
||||
{videoCover && <VideoCover videoCover={videoCover} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Cover.displayName = 'Cover'
|
||||
|
||||
export default Cover
|
||||
59
packages/web/components/New/TrackListHeader/Info.tsx
Normal file
59
packages/web/components/New/TrackListHeader/Info.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { formatDate } from '@/web/utils/common'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import dayjs from 'dayjs'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const Info = ({
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
description,
|
||||
extraInfo,
|
||||
}: {
|
||||
title?: string
|
||||
creatorName?: string
|
||||
creatorLink?: string
|
||||
description?: string
|
||||
extraInfo?: string | ReactNode
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Title */}
|
||||
<div className='mt-2.5 text-28 font-semibold dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Creator */}
|
||||
<div className='mt-2.5 lg:mt-6'>
|
||||
<span
|
||||
onClick={() => creatorLink && navigate(creatorLink)}
|
||||
className='text-24 font-medium transition-colors duration-300 dark:text-white/40 hover:dark:text-neutral-100 '
|
||||
>
|
||||
{creatorName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Extra info */}
|
||||
<div className='mt-1 flex items-center text-12 font-medium dark:text-white/40 lg:text-14 lg:font-bold'>
|
||||
{extraInfo}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: description || '',
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Info
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Cover from './Cover'
|
||||
import Actions from './Actions'
|
||||
import Info from './Info'
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
title?: string
|
||||
creatorName?: string
|
||||
creatorLink?: string
|
||||
description?: string
|
||||
extraInfo?: string | React.ReactNode
|
||||
cover?: string
|
||||
videoCover?: string
|
||||
isLiked: boolean
|
||||
onPlay: () => void
|
||||
onLike?: () => void
|
||||
}
|
||||
|
||||
const TrackListHeader = ({
|
||||
className,
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
description,
|
||||
extraInfo,
|
||||
cover,
|
||||
videoCover,
|
||||
isLiked,
|
||||
onPlay,
|
||||
onLike,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
className,
|
||||
'mx-2.5 rounded-48 p-8 dark:bg-white/10',
|
||||
'lg:mx-0 lg:grid lg:grid-rows-1 lg:gap-10 lg:rounded-none lg:p-0 lg:dark:bg-transparent',
|
||||
css`
|
||||
grid-template-columns: 318px auto;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Cover {...{ cover, videoCover }} />
|
||||
|
||||
<div className='flex flex-col justify-between'>
|
||||
<Info
|
||||
{...{ title, creatorName, creatorLink, description, extraInfo }}
|
||||
/>
|
||||
<Actions {...{ onPlay, onLike, isLiked }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const memoized = React.memo(TrackListHeader)
|
||||
memoized.displayName = 'TrackListHeader'
|
||||
|
||||
export default memoized
|
||||
51
packages/web/components/New/TrackListHeader/VideoCover.tsx
Normal file
51
packages/web/components/New/TrackListHeader/VideoCover.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { injectGlobal } from '@emotion/css'
|
||||
import { isIOS, isSafari } from '@/web/utils/common'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
injectGlobal`
|
||||
.plyr__video-wrapper,
|
||||
.plyr--video {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
`
|
||||
|
||||
const VideoCover = ({ videoCover }: { videoCover?: string }) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>(new Hls())
|
||||
|
||||
useEffect(() => {
|
||||
if (videoCover && Hls.isSupported()) {
|
||||
const video = document.querySelector('#video-cover') as HTMLVideoElement
|
||||
hls.current.loadSource(videoCover)
|
||||
hls.current.attachMedia(video)
|
||||
}
|
||||
}, [videoCover])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className='absolute inset-0'
|
||||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
src={videoCover}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
></video>
|
||||
) : (
|
||||
<div className='aspect-square'>
|
||||
<video id='video-cover' ref={ref} autoPlay muted loop />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoCover
|
||||
3
packages/web/components/New/TrackListHeader/index.tsx
Normal file
3
packages/web/components/New/TrackListHeader/index.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import TrackListHeader from './TrackListHeader'
|
||||
|
||||
export default TrackListHeader
|
||||
|
|
@ -1,15 +1,10 @@
|
|||
import { useState } from 'react'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const TrafficLight = () => {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const { fullscreen } = useSnapshot(uiStates)
|
||||
|
||||
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
||||
setIsMaximized(value)
|
||||
})
|
||||
|
||||
if (isMaximized) {
|
||||
if (fullscreen) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useAppleMusicAlbum(props: {
|
||||
id?: number
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { AppleMusicArtist } from '@/shared/AppleMusic'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useAppleMusicArtist(props: {
|
||||
id?: number
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import axios from 'axios'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function useVideoCover(props: {
|
||||
id?: number
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IpcChannelsReturns,
|
||||
IpcChannelsParams,
|
||||
} from '@/shared/IpcChannels'
|
||||
import uiStates from './states/uiStates'
|
||||
|
||||
const on = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
|
|
@ -36,4 +37,8 @@ export function ipcRenderer() {
|
|||
on(IpcChannels.Repeat, (e, mode) => {
|
||||
player.repeatMode = mode
|
||||
})
|
||||
|
||||
on(IpcChannels.FullscreenStateChange, (e, isFullscreen) => {
|
||||
uiStates.fullscreen = isFullscreen
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import './utils/initLog'
|
|||
import './utils/theme'
|
||||
import { StrictMode } from 'react'
|
||||
import * as ReactDOMClient from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import {
|
||||
Routes,
|
||||
BrowserRouter,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
} from 'react-router-dom'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { BrowserTracing } from '@sentry/tracing'
|
||||
import 'virtual:svg-icons-register'
|
||||
|
|
@ -12,14 +19,25 @@ import App from './AppNew'
|
|||
import pkg from '../../package.json'
|
||||
import ReactGA from 'react-ga4'
|
||||
import { ipcRenderer } from './ipcRenderer'
|
||||
import { QueryClientProvider } from 'react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import React from 'react'
|
||||
|
||||
ReactGA.initialize('G-KMJJCFZDKF')
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://7cc7879b42ba4bed9f66fb6752558475@o436528.ingest.sentry.io/6274630',
|
||||
integrations: [new BrowserTracing()],
|
||||
integrations: [
|
||||
new BrowserTracing({
|
||||
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
|
||||
React.useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes
|
||||
),
|
||||
}),
|
||||
],
|
||||
release: `yesplaymusic@${pkg.version}`,
|
||||
environment: import.meta.env.MODE,
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.9.0",
|
||||
"@sentry/react": "^6.19.7",
|
||||
"@sentry/tracing": "^6.19.7",
|
||||
"@sentry/react": "^7.8.0",
|
||||
"@sentry/tracing": "^7.8.0",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"@tanstack/react-query-devtools": "^4.0.10",
|
||||
"ahooks": "^3.4.1",
|
||||
"axios": "^0.27.2",
|
||||
"color.js": "^1.2.0",
|
||||
|
|
@ -37,18 +39,15 @@
|
|||
"js-cookie": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"plyr": "^3.7.2",
|
||||
"plyr-react": "^5.0.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-ga4": "^1.4.1",
|
||||
"react-hot-toast": "^2.2.0",
|
||||
"react-query": "^3.38.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtuoso": "^2.16.1",
|
||||
"react-virtuoso": "^2.16.5",
|
||||
"valtio": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ import FMCard from '@/web/components/FMCard'
|
|||
import { PlaylistApiNames } from '@/shared/api/Playlists'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
data: dailyRecommendPlaylists,
|
||||
isLoading: isLoadingDailyRecommendPlaylists,
|
||||
} = useQuery(
|
||||
PlaylistApiNames.FetchDailyRecommendPlaylists,
|
||||
[PlaylistApiNames.FetchDailyRecommendPlaylists],
|
||||
fetchDailyRecommendPlaylists,
|
||||
{
|
||||
retry: false,
|
||||
|
|
@ -30,7 +30,7 @@ export default function Home() {
|
|||
data: recommendedPlaylists,
|
||||
isLoading: isLoadingRecommendedPlaylists,
|
||||
} = useQuery(
|
||||
PlaylistApiNames.FetchRecommendedPlaylists,
|
||||
[PlaylistApiNames.FetchRecommendedPlaylists],
|
||||
() => {
|
||||
return fetchRecommendedPlaylists({})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useInterval } from 'react-use'
|
|||
import { cx } from '@emotion/css'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useMutation, useQuery } from 'react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
|
|
@ -327,7 +327,7 @@ const LoginWithQRCode = () => {
|
|||
status: keyStatus,
|
||||
refetch: refetchKey,
|
||||
} = useQuery(
|
||||
'qrCodeKey',
|
||||
['qrCodeKey'],
|
||||
async () => {
|
||||
const result = await fetchLoginQrCodeKey()
|
||||
if (result.data.code !== 200) {
|
||||
|
|
|
|||
54
packages/web/pages/New/Album/Album.tsx
Normal file
54
packages/web/pages/New/Album/Album.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import MoreByArtist from './MoreByArtist'
|
||||
import Header from './Header'
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const { data: album } = useAlbum({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
const { data: tracks } = useTracks({
|
||||
ids: album?.songs?.map(track => track.id) ?? [],
|
||||
})
|
||||
|
||||
const onPlay = useCallback(
|
||||
async (trackID: number | null = null) => {
|
||||
if (!album?.album?.id) {
|
||||
toast('无法播放专辑,该专辑不存在')
|
||||
return
|
||||
}
|
||||
player.playAlbum(album.album.id, trackID)
|
||||
},
|
||||
[album?.album?.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<Header />
|
||||
<TrackList
|
||||
tracks={tracks?.songs || album?.album.songs || album?.songs}
|
||||
className='z-10 mx-2.5 mt-3 lg:mx-0 lg:mt-10'
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
<MoreByArtist album={album?.album} />
|
||||
|
||||
{/* Page padding */}
|
||||
<div className='h-16'></div>
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
||||
export default Album
|
||||
107
packages/web/pages/New/Album/Header.tsx
Normal file
107
packages/web/pages/New/Album/Header.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useUserAlbums, {
|
||||
useMutationLikeAAlbum,
|
||||
} from '@/web/api/hooks/useUserAlbums'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDuration } from '@/web/utils/common'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Header = () => {
|
||||
const params = useParams()
|
||||
const { data: userLikedAlbums } = useUserAlbums()
|
||||
|
||||
const { data: albumRaw } = useAlbum({
|
||||
id: Number(params.id),
|
||||
})
|
||||
const album = useMemo(() => albumRaw?.album, [albumRaw])
|
||||
|
||||
const { data: albumFromApple, isLoading: isLoadingAlbumFromApple } =
|
||||
useAppleMusicAlbum({
|
||||
id: album?.id,
|
||||
name: album?.name,
|
||||
artist: album?.artist.name,
|
||||
})
|
||||
|
||||
const { data: videoCoverFromRemote } = useVideoCover({
|
||||
id: album?.id,
|
||||
name: album?.name,
|
||||
artist: album?.artist.name,
|
||||
enabled: !window.env?.isElectron,
|
||||
})
|
||||
|
||||
// For <Cover />
|
||||
const cover = album?.picUrl
|
||||
const videoCover =
|
||||
albumFromApple?.attributes?.editorialVideo?.motionSquareVideo1x1?.video ||
|
||||
videoCoverFromRemote?.video
|
||||
|
||||
// For <Info />
|
||||
const title = album?.name
|
||||
const creatorName = album?.artist.name
|
||||
const creatorLink = `/artist/${album?.artist.id}`
|
||||
const description = isLoadingAlbumFromApple
|
||||
? ''
|
||||
: albumFromApple?.attributes?.editorialNotes?.standard || album?.description
|
||||
const extraInfo = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
const albumDuration = formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||
return (
|
||||
<>
|
||||
{album?.mark === 1056768 && (
|
||||
<Icon
|
||||
name='explicit'
|
||||
className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5'
|
||||
/>
|
||||
)}{' '}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length} tracks,{' '}
|
||||
{albumDuration}
|
||||
</>
|
||||
)
|
||||
}, [album])
|
||||
|
||||
// For <Actions />
|
||||
const isLiked = useMemo(() => {
|
||||
const id = Number(params.id)
|
||||
if (!id) return false
|
||||
return !!userLikedAlbums?.data.find(item => item.id === id)
|
||||
}, [params.id, userLikedAlbums?.data])
|
||||
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
if (!album?.id) {
|
||||
toast('无法播放专辑,该专辑不存在')
|
||||
return
|
||||
}
|
||||
player.playAlbum(album.id, trackID)
|
||||
}
|
||||
|
||||
const likeAAlbum = useMutationLikeAAlbum()
|
||||
const onLike = async () => {
|
||||
likeAAlbum.mutateAsync(album?.id || Number(params.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackListHeader
|
||||
{...{
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
description,
|
||||
extraInfo,
|
||||
cover,
|
||||
videoCover,
|
||||
isLiked,
|
||||
onLike,
|
||||
onPlay,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
|
@ -11,7 +11,6 @@ import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
|||
import { css, cx } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import { useMemo } from 'react'
|
||||
import 'plyr-react/plyr.css'
|
||||
|
||||
const MoreByArtist = ({ album }: { album?: Album }) => {
|
||||
const { data: albums } = useArtistAlbums({
|
||||
|
|
@ -84,48 +83,4 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const { data: album } = useAlbum({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
const { data: tracks } = useTracks({
|
||||
ids: album?.songs?.map(track => track.id) ?? [],
|
||||
})
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
if (!album?.album.id) {
|
||||
toast('无法播放专辑,该专辑不存在')
|
||||
return
|
||||
}
|
||||
if (
|
||||
playerSnapshot.trackListSource?.type === 'album' &&
|
||||
playerSnapshot.trackListSource?.id === album.album.id
|
||||
) {
|
||||
await player.playTrack(trackID ?? album.songs[0].id)
|
||||
return
|
||||
}
|
||||
await player.playAlbum(album.album.id, trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<TrackListHeader
|
||||
album={album?.album}
|
||||
onPlay={onPlay}
|
||||
className='mt-2.5 lg:mt-0'
|
||||
/>
|
||||
<TrackList
|
||||
tracks={tracks?.songs || album?.album.songs || album?.songs}
|
||||
className='z-10 mx-2.5 mt-3 lg:mx-0 lg:mt-10'
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
<MoreByArtist album={album?.album} />
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
||||
export default Album
|
||||
export default MoreByArtist
|
||||
2
packages/web/pages/New/Album/index.tsx
Normal file
2
packages/web/pages/New/Album/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Album from './Album'
|
||||
export default Album
|
||||
|
|
@ -5,6 +5,7 @@ import Header from './Header'
|
|||
import Popular from './Popular'
|
||||
import ArtistAlbum from './ArtistAlbums'
|
||||
import FansAlsoLike from './FansAlsoLike'
|
||||
import ArtistMVs from './ArtistMVs'
|
||||
|
||||
const Artist = () => {
|
||||
const params = useParams()
|
||||
|
|
@ -17,13 +18,16 @@ const Artist = () => {
|
|||
<div>
|
||||
<Header artist={artist?.artist} />
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div>
|
||||
|
||||
<Popular tracks={artist?.hotSongs} />
|
||||
|
||||
<ArtistAlbum />
|
||||
|
||||
<ArtistMVs />
|
||||
<FansAlsoLike />
|
||||
|
||||
{/* Page padding */}
|
||||
<div className='h-16'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
|
|
@ -11,7 +12,18 @@ const ArtistAlbum = () => {
|
|||
limit: 1000,
|
||||
})
|
||||
|
||||
const albums = useMemo(() => albumsRaw?.hotAlbums, [albumsRaw?.hotAlbums])
|
||||
const pages = useMemo(() => {
|
||||
const pages: Album[][] = []
|
||||
albumsRaw?.hotAlbums.forEach((album, index) => {
|
||||
const pageNo = Math.floor(index / 12)
|
||||
if (!pages[pageNo]) {
|
||||
pages[pageNo] = [album]
|
||||
} else {
|
||||
pages[pageNo].push(album)
|
||||
}
|
||||
})
|
||||
return pages
|
||||
}, [albumsRaw?.hotAlbums])
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -19,9 +31,19 @@ const ArtistAlbum = () => {
|
|||
Albums
|
||||
</div>
|
||||
|
||||
<CoverRow albums={albums?.slice(0, 12)} />
|
||||
<div className='no-scrollbar flex gap-6 overflow-y-hidden overflow-x-scroll'>
|
||||
{pages.map((page, index) => (
|
||||
<CoverRow
|
||||
key={index}
|
||||
albums={page}
|
||||
className='h-full w-full flex-shrink-0'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistAlbum
|
||||
const memoized = React.memo(ArtistAlbum)
|
||||
memoized.displayName = 'ArtistAlbum'
|
||||
export default memoized
|
||||
|
|
|
|||
32
packages/web/pages/New/Artist/ArtistMVs.tsx
Normal file
32
packages/web/pages/New/Artist/ArtistMVs.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import useArtistMV from '@/web/api/hooks/useArtistMV'
|
||||
|
||||
const ArtistMVs = () => {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'>
|
||||
MV
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-6'>
|
||||
{videos?.mvs?.slice(0, 6)?.map(video => (
|
||||
<div key={video.id} onClick={() => navigate(`/mv/${video.id}`)}>
|
||||
<img
|
||||
src={video.imgurl16v9}
|
||||
className='aspect-video w-full rounded-24 object-contain'
|
||||
/>
|
||||
<div className='mt-2 text-12 font-medium text-neutral-600'>
|
||||
{video.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistMVs
|
||||
|
|
@ -4,16 +4,22 @@ import { useParams } from 'react-router-dom'
|
|||
|
||||
const FansAlsoLike = () => {
|
||||
const params = useParams()
|
||||
const { data: artists } = useSimilarArtists({ id: Number(params.id) || 0 })
|
||||
const { data: artists, isLoading } = useSimilarArtists({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'>
|
||||
Fans Also Like
|
||||
</div>
|
||||
<>
|
||||
{(isLoading || artists?.artists) && (
|
||||
<div>
|
||||
<div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'>
|
||||
Fans Also Like
|
||||
</div>
|
||||
|
||||
<ArtistRow artists={artists?.artists?.slice(0, 5)} />
|
||||
</div>
|
||||
<ArtistRow artists={artists?.artists?.slice(0, 5)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,44 @@
|
|||
import useUserArtists, {
|
||||
useMutationLikeAArtist,
|
||||
} from '@/web/api/hooks/useUserArtists'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import player from '@/web/states/player'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Actions = () => {
|
||||
const { data: likedArtists } = useUserArtists()
|
||||
const params = useParams()
|
||||
const id = Number(params.id) || 0
|
||||
const isLiked = !!likedArtists?.data?.find(artist => artist.id === id)
|
||||
const likeArtist = useMutationLikeAArtist()
|
||||
|
||||
return (
|
||||
<div className='mt-11 flex items-end justify-between lg:z-10 lg:mt-6'>
|
||||
<div className='flex items-end'>
|
||||
<button className='mr-2.5 h-14 w-14 rounded-full dark:bg-white/10'></button>
|
||||
<button className='h-14 w-14 rounded-full dark:bg-white/10'></button>
|
||||
{/* Menu */}
|
||||
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
|
||||
<Icon name='more' className='h-7 w-7' />
|
||||
</button>
|
||||
|
||||
{/* Like */}
|
||||
<button
|
||||
onClick={() => likeArtist.mutateAsync(id)}
|
||||
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'
|
||||
>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-7 w-7'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button className='h-14 w-[125px] rounded-full dark:bg-brand-700 lg:w-[170px]'></button>
|
||||
|
||||
{/* Listen */}
|
||||
<button
|
||||
onClick={() => player.playArtistPopularTracks(id)}
|
||||
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
|
||||
>
|
||||
Listen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
import { cx, css } from '@emotion/css'
|
||||
|
||||
const ArtistInfo = ({ artist }: { artist?: Artist }) => {
|
||||
const isMobile = useIsMobile()
|
||||
|
|
@ -11,20 +12,27 @@ const ArtistInfo = ({ artist }: { artist?: Artist }) => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className='text-28 font-semibold text-night-50 lg:text-32'>
|
||||
<div className='text-28 font-semibold text-white/70 lg:text-32'>
|
||||
{artist?.name}
|
||||
</div>
|
||||
<div className='mt-2.5 text-24 font-medium text-night-400 lg:mt-6'>
|
||||
<div className='mt-2.5 text-24 font-medium text-white/40 lg:mt-6'>
|
||||
Artist
|
||||
</div>
|
||||
<div className='mt-1 text-12 font-medium text-night-400'>
|
||||
<div className='mt-1 text-12 font-medium text-white/40'>
|
||||
{artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '}
|
||||
{artist?.mvSize} Videos
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{!isMobile && !isLoadingArtistFromApple && (
|
||||
<div className='line-clamp-5 mt-6 text-14 font-bold text-night-400'>
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-5 mt-6 text-14 font-bold text-white/40',
|
||||
css`
|
||||
height: 86px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{artistFromApple?.attributes?.artistBio || artist?.briefDesc}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
101
packages/web/pages/New/Artist/Header/Cover.tsx
Normal file
101
packages/web/pages/New/Artist/Header/Cover.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { motion } from 'framer-motion'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const VideoCover = ({ source }: { source?: string }) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>(new Hls())
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported()) {
|
||||
const video = document.querySelector('#video-cover') as HTMLVideoElement
|
||||
hls.current.loadSource(source)
|
||||
hls.current.attachMedia(video)
|
||||
}
|
||||
}, [source])
|
||||
|
||||
return (
|
||||
<div className='z-10 aspect-square overflow-hidden rounded-24'>
|
||||
<video
|
||||
id='video-cover'
|
||||
className='h-full w-full'
|
||||
ref={ref}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Cover = ({ artist }: { artist?: Artist }) => {
|
||||
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
|
||||
useAppleMusicArtist({
|
||||
id: artist?.id,
|
||||
name: artist?.name,
|
||||
})
|
||||
|
||||
const video =
|
||||
artistFromApple?.attributes?.editorialVideo?.motionArtistSquare1x1?.video
|
||||
const cover = isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url
|
||||
|
||||
useEffect(() => {
|
||||
if (cover) uiStates.blurBackgroundImage = cover
|
||||
}, [cover])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
'relative',
|
||||
css`
|
||||
grid-area: cover;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
className='aspect-square h-full w-full lg:z-10 lg:rounded-24'
|
||||
src={resizeImage(
|
||||
isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url ||
|
||||
artist?.img1v1Url ||
|
||||
'',
|
||||
'lg'
|
||||
)}
|
||||
/>
|
||||
|
||||
{video && (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className='absolute inset-0 z-10 h-full w-full'
|
||||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
src={video}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
></video>
|
||||
) : (
|
||||
<VideoCover source={video} />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cover
|
||||
|
|
@ -1,19 +1,10 @@
|
|||
import { resizeImage } from '@/web/utils/common'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import BlurBackground from '@/web/components/New/BlurBackground'
|
||||
import ArtistInfo from './ArtistInfo'
|
||||
import Actions from './Actions'
|
||||
import LatestRelease from './LatestRelease'
|
||||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
|
||||
import Cover from "./Cover"
|
||||
const Header = ({ artist }: { artist?: Artist }) => {
|
||||
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
|
||||
useAppleMusicArtist({
|
||||
id: artist?.id,
|
||||
name: artist?.name,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -27,30 +18,7 @@ const Header = ({ artist }: { artist?: Artist }) => {
|
|||
`
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
className={cx(
|
||||
'aspect-square lg:z-10 lg:rounded-24',
|
||||
css`
|
||||
grid-area: cover;
|
||||
`
|
||||
)}
|
||||
src={resizeImage(
|
||||
isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url ||
|
||||
artist?.img1v1Url ||
|
||||
'',
|
||||
'lg'
|
||||
)}
|
||||
/>
|
||||
|
||||
<BlurBackground
|
||||
cover={
|
||||
isLoadingArtistFromApple
|
||||
? ''
|
||||
: artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url
|
||||
}
|
||||
/>
|
||||
<Cover artist={artist} />
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||
import Image from '@/web/components/New/Image'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { useMemo } from 'react'
|
||||
import useArtistMV from '@/web/api/hooks/useArtistMV'
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
|
|
@ -54,30 +55,40 @@ const Album = () => {
|
|||
}
|
||||
|
||||
const Video = () => {
|
||||
const params = useParams()
|
||||
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
|
||||
const video = videos?.mvs?.[0]
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className='mt-4 flex rounded-24 bg-white/10 p-2.5'>
|
||||
<Image
|
||||
src={resizeImage(
|
||||
'https://p1.music.126.net/am47BH30IGQit_L2vYaArg==/109951167502760845.jpg',
|
||||
'sm'
|
||||
)}
|
||||
className={cx(
|
||||
css`
|
||||
height: 60px;
|
||||
width: 106px;
|
||||
border-radius: 16px;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
<div className='flex-shrink-1 ml-2'>
|
||||
<div className='line-clamp-2 text-16 font-medium text-night-100'>
|
||||
Swedish House Mafia & The Weeknd Live at C...
|
||||
<>
|
||||
{video && (
|
||||
<div
|
||||
className='mt-4 flex rounded-24 bg-white/10 p-2.5'
|
||||
onClick={() => navigate(`/mv/${video.id}`)}
|
||||
>
|
||||
<img
|
||||
src={video.imgurl16v9}
|
||||
className={cx(
|
||||
'object-contain',
|
||||
css`
|
||||
height: 60px;
|
||||
border-radius: 16px;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
<div className='flex-shrink-1 ml-2'>
|
||||
<div className='line-clamp-1 text-16 font-medium text-night-100'>
|
||||
{video.name}
|
||||
</div>
|
||||
<div className='mt-1 text-14 font-bold text-night-500'>MV</div>
|
||||
<div className='mt-1.5 text-12 font-medium text-night-500'>
|
||||
{dayjs(video.publishTime).format('MMM DD, YYYY')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-1.5 text-12 font-medium text-night-500'>
|
||||
{dayjs().format('MMM DD, YYYY')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from '@/web/api/playlist'
|
||||
import { PlaylistApiNames } from '@/shared/api/Playlists'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import CoverRowVirtual from '@/web/components/New/CoverRowVirtual'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import { playerWidth, topbarHeight } from '@/web/utils/const'
|
||||
|
|
@ -20,31 +20,27 @@ const reactQueryOptions = {
|
|||
}
|
||||
|
||||
const Recommend = () => {
|
||||
const { data: dailyRecommendPlaylists } = useQuery(
|
||||
PlaylistApiNames.FetchDailyRecommendPlaylists,
|
||||
const { data: dailyRecommendPlaylists, isLoading: isLoadingDaily } = useQuery(
|
||||
[PlaylistApiNames.FetchDailyRecommendPlaylists],
|
||||
() => fetchDailyRecommendPlaylists(),
|
||||
reactQueryOptions
|
||||
)
|
||||
const { data: recommendedPlaylists } = useQuery(
|
||||
const { data: recommendedPlaylists, isLoading: isLoading } = useQuery(
|
||||
[PlaylistApiNames.FetchRecommendedPlaylists, { limit: 200 }],
|
||||
() => fetchRecommendedPlaylists({ limit: 200 }),
|
||||
reactQueryOptions
|
||||
)
|
||||
const playlists = [
|
||||
...(dailyRecommendPlaylists?.recommend || []),
|
||||
...(recommendedPlaylists?.result || []),
|
||||
]
|
||||
const playlists =
|
||||
isLoadingDaily || isLoading
|
||||
? []
|
||||
: [
|
||||
...(dailyRecommendPlaylists?.recommend || []),
|
||||
...(recommendedPlaylists?.result || []),
|
||||
]
|
||||
|
||||
// return (
|
||||
// <CoverRowVirtual
|
||||
// playlists={playlists}
|
||||
// containerStyle={{
|
||||
// height: `${document.body.clientHeight - topbarHeight - 44}px`,
|
||||
// }}
|
||||
// />
|
||||
// )
|
||||
return <CoverRowVirtual playlists={playlists} />
|
||||
|
||||
return <CoverRow playlists={playlists} />
|
||||
// return <CoverRow playlists={playlists} />
|
||||
}
|
||||
|
||||
const All = () => {
|
||||
|
|
@ -66,27 +62,29 @@ const Browse = () => {
|
|||
|
||||
return (
|
||||
<PageTransition>
|
||||
{/* Topbar background */}
|
||||
<div
|
||||
className={cx(
|
||||
'pointer-events-none fixed top-0 left-0 z-10 hidden lg:block',
|
||||
css`
|
||||
height: 230px;
|
||||
right: ${playerWidth + 32}px;
|
||||
background-image: url(${topbarBackground});
|
||||
`
|
||||
)}
|
||||
></div>
|
||||
<div className='relative'>
|
||||
{/* Topbar background */}
|
||||
<div
|
||||
className={cx(
|
||||
'pointer-events-none fixed top-0 left-10 z-10 hidden lg:block',
|
||||
css`
|
||||
height: 230px;
|
||||
right: ${playerWidth + 32}px;
|
||||
background-image: url(${topbarBackground});
|
||||
`
|
||||
)}
|
||||
></div>
|
||||
|
||||
<Tabs
|
||||
tabs={categories}
|
||||
value={active}
|
||||
onChange={category => setActive(category)}
|
||||
className='sticky top-0 z-10 mt-2.5 px-2.5 lg:mt-0 lg:px-0'
|
||||
/>
|
||||
<Tabs
|
||||
tabs={categories}
|
||||
value={active}
|
||||
onChange={category => setActive(category)}
|
||||
className='absolute top-0 z-10 mt-2.5 px-2.5 lg:mt-0 lg:px-0'
|
||||
/>
|
||||
|
||||
<div className='relative mx-2.5 mt-5 lg:mx-0'>
|
||||
{categories.find(c => c.id === active)?.component}
|
||||
<div className='absolute inset-0 mx-2.5 mt-5 lg:mx-0 lg:mt-0'>
|
||||
{categories.find(c => c.id === active)?.component}
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
|||
import { useEffect, useState } from 'react'
|
||||
import { sampleSize } from 'lodash-es'
|
||||
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
interface DiscoverAlbum {
|
||||
id: number
|
||||
|
|
|
|||
62
packages/web/pages/New/MV.tsx
Normal file
62
packages/web/pages/New/MV.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import useMV, { useMVUrl } from '@/web/api/hooks/useMV'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import Plyr, { PlyrOptions, PlyrSource } from 'plyr-react'
|
||||
import 'plyr-react/plyr.css'
|
||||
import { useMemo } from 'react'
|
||||
import { css, cx } from '@emotion/css'
|
||||
|
||||
const plyrStyle = css`
|
||||
--plyr-color-main: rgb(152 208 11);
|
||||
--plyr-video-control-background-hover: rgba(255, 255, 255, 0.3);
|
||||
--plyr-control-radius: 8px;
|
||||
--plyr-range-fill-background: white;
|
||||
button[data-plyr='play']:not(.plyr__controls__item) {
|
||||
--plyr-video-control-background-hover: var(--plyr-color-main);
|
||||
}
|
||||
`
|
||||
|
||||
const plyrOptions: PlyrOptions = {
|
||||
settings: [],
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'fullscreen',
|
||||
],
|
||||
resetOnEnd: true,
|
||||
ratio: '16:9',
|
||||
}
|
||||
|
||||
const MV = () => {
|
||||
const params = useParams()
|
||||
const { data: mv } = useMV({ mvid: Number(params.id) || 0 })
|
||||
const { data: mvUrl } = useMVUrl({ id: Number(params.id) || 0 })
|
||||
const source: PlyrSource = useMemo(
|
||||
() => ({
|
||||
type: 'video',
|
||||
sources: [
|
||||
{
|
||||
src: mvUrl?.data?.url || '',
|
||||
},
|
||||
],
|
||||
poster: mv?.data.cover,
|
||||
title: mv?.data.name,
|
||||
}),
|
||||
[mv?.data.cover, mv?.data.name, mvUrl?.data?.url]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className='text-white'>{mv?.data.name}</div>
|
||||
<div className={cx('aspect-video overflow-hidden rounded-24', plyrStyle)}>
|
||||
{mvUrl && <Plyr options={plyrOptions} source={source} />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
||||
export default MV
|
||||
|
|
@ -7,6 +7,7 @@ import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
|||
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
|
@ -30,17 +31,21 @@ const tabs = [
|
|||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums()
|
||||
|
||||
return <CoverRow albums={albums?.data} className='mt-6 px-2.5 lg:px-0' />
|
||||
return <CoverRow albums={albums?.data} />
|
||||
}
|
||||
|
||||
const Playlists = () => {
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
const p = useMemo(() => playlists?.playlist?.slice(1), [playlists])
|
||||
return <CoverRow playlists={p} className='mt-6 px-2.5 lg:px-0' />
|
||||
return <CoverRow playlists={p} />
|
||||
}
|
||||
|
||||
const Artists = () => {
|
||||
const { data: artists } = useUserArtists()
|
||||
return <ArtistRow artists={artists?.data || []} />
|
||||
}
|
||||
|
||||
const Collections = () => {
|
||||
// const { data: artists } = useUserArtists()
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
const setSelectedTab = (
|
||||
id: 'playlists' | 'albums' | 'artists' | 'videos'
|
||||
|
|
@ -56,8 +61,11 @@ const Collections = () => {
|
|||
onChange={(id: string) => setSelectedTab(id)}
|
||||
className='px-2.5 lg:px-0'
|
||||
/>
|
||||
{selectedTab === 'albums' && <Albums />}
|
||||
{selectedTab === 'playlists' && <Playlists />}
|
||||
<div className='mt-6 px-2.5 lg:px-0'>
|
||||
{selectedTab === 'albums' && <Albums />}
|
||||
{selectedTab === 'playlists' && <Playlists />}
|
||||
{selectedTab === 'artists' && <Artists />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import { lyricParser } from '@/web/utils/lyric'
|
|||
import Image from '@/web/components/New/Image'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
|
||||
const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
||||
const [id, setId] = useState(0)
|
||||
const { data: user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (id === 0) {
|
||||
|
|
@ -37,7 +39,7 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-4 text-18 font-medium text-white/20 lg:text-21',
|
||||
'line-clamp-5',
|
||||
css`
|
||||
height: 86px;
|
||||
${bp.lg} {
|
||||
|
|
@ -46,8 +48,16 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => {
|
|||
`
|
||||
)}
|
||||
>
|
||||
<div className='mb-3.5 text-18 font-medium text-white/70'>
|
||||
{user?.profile?.nickname}'S LIKED TRACKS
|
||||
</div>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
className='text-18 font-medium text-white/20'
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -136,7 +146,7 @@ const PlayLikedSongsCard = () => {
|
|||
navigate(`/playlist/${likedSongsPlaylist?.playlist.id}`)
|
||||
}
|
||||
className={cx(
|
||||
'flex items-center justify-center rounded-full bg-white/10 text-night-400',
|
||||
'flex items-center justify-center rounded-full bg-white/10 text-night-400 transition duration-400 hover:bg-white/20 hover:text-neutral-300',
|
||||
css`
|
||||
padding: 15.5px;
|
||||
`
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import useTracksInfinite from '@/web/api/hooks/useTracksInfinite'
|
||||
|
||||
const Playlist = () => {
|
||||
const params = useParams()
|
||||
const { data: playlist, isLoading } = usePlaylist({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
if (!playlist?.playlist.id) {
|
||||
toast('无法播放歌单,该歌单不存在')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
playerSnapshot.trackListSource?.type === 'playlist' &&
|
||||
playerSnapshot.trackListSource?.id === playlist.playlist.id &&
|
||||
playlist?.playlist?.trackIds?.[0].id
|
||||
) {
|
||||
await player.playTrack(trackID ?? playlist.playlist.trackIds[0].id)
|
||||
return
|
||||
}
|
||||
await player.playPlaylist(playlist.playlist.id, trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<TrackListHeader
|
||||
playlist={playlist?.playlist}
|
||||
onPlay={onPlay}
|
||||
className='mt-2.5 lg:mt-0'
|
||||
/>
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlist
|
||||
75
packages/web/pages/New/Playlist/Header.tsx
Normal file
75
packages/web/pages/New/Playlist/Header.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import useUser from '@/web/api/hooks/useUser'
|
||||
import useUserPlaylists, {
|
||||
useMutationLikeAPlaylist,
|
||||
} from '@/web/api/hooks/useUserPlaylists'
|
||||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import player from '@/web/states/player'
|
||||
import { formatDate } from '@/web/utils/common'
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Header = () => {
|
||||
const params = useParams()
|
||||
|
||||
const { data: playlistRaw, isLoading } = usePlaylist({
|
||||
id: Number(params.id),
|
||||
})
|
||||
const { data: user } = useUser()
|
||||
|
||||
const { data: userLikedPlaylists } = useUserPlaylists()
|
||||
|
||||
const playlist = playlistRaw?.playlist
|
||||
|
||||
// For <Cover />
|
||||
const cover = playlist?.coverImgUrl || playlist?.picUrl
|
||||
|
||||
// For <Info />
|
||||
const title = playlist?.name
|
||||
const creatorName = playlist?.creator?.nickname
|
||||
const creatorLink = undefined // TODO: 链接到用户页面
|
||||
const description = playlist?.description || ''
|
||||
const extraInfo = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
Updated at {formatDate(playlist?.updateTime || 0, 'en')} ·{' '}
|
||||
{playlist?.trackCount} tracks
|
||||
</>
|
||||
)
|
||||
}, [playlist])
|
||||
|
||||
// For <Actions />
|
||||
const isLiked = useMemo(() => {
|
||||
const id = Number(params.id)
|
||||
if (!id) return false
|
||||
return !!userLikedPlaylists?.playlist.find(p => p.id === id)
|
||||
}, [params.id, userLikedPlaylists?.playlist])
|
||||
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
await player.playPlaylist(playlist?.id, trackID)
|
||||
}
|
||||
|
||||
const likeAPlaylist = useMutationLikeAPlaylist()
|
||||
const onLike = async () => {
|
||||
likeAPlaylist.mutateAsync(playlist?.id || Number(params.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackListHeader
|
||||
{...{
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
description,
|
||||
extraInfo,
|
||||
cover,
|
||||
isLiked,
|
||||
onLike:
|
||||
user?.account?.id === playlist?.creator?.userId ? undefined : onLike,
|
||||
onPlay,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
30
packages/web/pages/New/Playlist/Playlist.tsx
Normal file
30
packages/web/pages/New/Playlist/Playlist.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||
import Header from './Header'
|
||||
|
||||
const Playlist = () => {
|
||||
const params = useParams()
|
||||
const { data: playlist } = usePlaylist({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
const onPlay = async (trackID: number | null = null) => {
|
||||
await player.playPlaylist(playlist?.playlist?.id, trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<Header />
|
||||
<TrackList
|
||||
tracks={playlist?.playlist?.tracks ?? []}
|
||||
onPlay={onPlay}
|
||||
className='z-10 mt-10'
|
||||
/>
|
||||
</PageTransition>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlist
|
||||
3
packages/web/pages/New/Playlist/index.tsx
Normal file
3
packages/web/pages/New/Playlist/index.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Playlist from './Playlist'
|
||||
|
||||
export default Playlist
|
||||
|
|
@ -7,7 +7,7 @@ import { SearchTypes, SearchApiNames } from '@/shared/api/Search'
|
|||
import dayjs from 'dayjs'
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
const Artists = ({ artists }: { artists: Artist[] }) => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
import { merge } from 'lodash-es'
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
|
||||
interface PersistedUiStates {
|
||||
loginPhoneCountryCode: string
|
||||
loginType: 'phone' | 'email' | 'qrCode'
|
||||
minimizePlayer: boolean
|
||||
}
|
||||
|
||||
const initPersistedUiStates: PersistedUiStates = {
|
||||
loginPhoneCountryCode: '+86',
|
||||
loginType: 'qrCode',
|
||||
minimizePlayer: false,
|
||||
}
|
||||
|
||||
const persistedUiStates = proxy<PersistedUiStates>(initPersistedUiStates)
|
||||
const STORAGE_KEY = 'persistedUiStates'
|
||||
const statesInStorage = localStorage.getItem(STORAGE_KEY)
|
||||
let sates = {}
|
||||
if (statesInStorage) {
|
||||
try {
|
||||
sates = JSON.parse(statesInStorage)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const persistedUiStates = proxy<PersistedUiStates>(
|
||||
merge(initPersistedUiStates, sates)
|
||||
)
|
||||
|
||||
subscribe(persistedUiStates, () => {
|
||||
localStorage.setItem('persistedUiStates', JSON.stringify(persistedUiStates))
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(persistedUiStates))
|
||||
})
|
||||
|
||||
export default persistedUiStates
|
||||
|
|
|
|||
|
|
@ -35,19 +35,22 @@ const initSettings: Settings = {
|
|||
},
|
||||
}
|
||||
|
||||
const settingsInLocalStorage = localStorage.getItem('settings')
|
||||
const settings = proxy<Settings>(
|
||||
merge(
|
||||
initSettings,
|
||||
settingsInLocalStorage ? JSON.parse(settingsInLocalStorage) : {}
|
||||
)
|
||||
)
|
||||
const STORAGE_KEY = 'settings'
|
||||
const statesInStorageString = localStorage.getItem(STORAGE_KEY)
|
||||
let statesInStorage = {}
|
||||
if (statesInStorageString) {
|
||||
try {
|
||||
statesInStorage = JSON.parse(statesInStorageString)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const settings = proxy<Settings>(merge(initSettings, statesInStorage))
|
||||
|
||||
subscribe(settings, () => {
|
||||
localStorage.setItem('settings', JSON.stringify(settings))
|
||||
})
|
||||
subscribe(settings, () => {
|
||||
window.ipcRenderer?.send(IpcChannels.SyncSettings, settings)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
})
|
||||
|
||||
export default settings
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
interface UIStates {
|
||||
|
|
@ -6,6 +7,8 @@ interface UIStates {
|
|||
hideTopbarBackground: boolean
|
||||
librarySelectedTab: 'playlists' | 'albums' | 'artists' | 'videos'
|
||||
mobileShowPlayingNext: boolean
|
||||
blurBackgroundImage: string | null
|
||||
fullscreen: boolean
|
||||
}
|
||||
|
||||
const initUIStates: UIStates = {
|
||||
|
|
@ -14,6 +17,8 @@ const initUIStates: UIStates = {
|
|||
hideTopbarBackground: false,
|
||||
librarySelectedTab: 'playlists',
|
||||
mobileShowPlayingNext: false,
|
||||
blurBackgroundImage: null,
|
||||
fullscreen: window.ipcRenderer?.sendSync(IpcChannels.IsMaximized) || false,
|
||||
}
|
||||
|
||||
export default proxy<UIStates>(initUIStates)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ module.exports = {
|
|||
margin: {
|
||||
7.5: '1.875rem',
|
||||
},
|
||||
transitionDuration: {
|
||||
400: '400ms',
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {},
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@
|
|||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "./",
|
||||
"baseUrl": "../",
|
||||
"paths": {
|
||||
"@/*": ["../*"]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../shared/**/*.ts"]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { average } from 'color.js'
|
|||
import { colord } from 'colord'
|
||||
|
||||
/**
|
||||
* @description 调整网易云封面图片大小
|
||||
* @description 调整网易云和苹果音乐封面图片大小
|
||||
* @param {string} url 封面图片URL
|
||||
* @param {'xs'|'sm'|'md'|'lg'} size - 大小,值对应为 128px | 256px | 512px | 1024px
|
||||
*/
|
||||
|
|
@ -173,3 +173,5 @@ export const isPWA =
|
|||
(navigator as any).standalone ||
|
||||
window.matchMedia('(display-mode: standalone)').matches
|
||||
export const isIosPwa = isIOS && isPWA && isSafari
|
||||
|
||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ export const breakpoint = {
|
|||
'2xl': '@media (min-width: 1536px)',
|
||||
}
|
||||
|
||||
export const titleBarHeight = 32 // 桌面端标题栏高度 (px)
|
||||
export const topbarHeight = 132 // 桌面端顶栏高度 (px)
|
||||
export const playerWidth = 318 // 桌面端播放器宽度 (px)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue