feat: updates

This commit is contained in:
qier222 2023-01-28 11:54:57 +08:00
parent 7ce516877e
commit ccebe0a67a
No known key found for this signature in database
74 changed files with 56065 additions and 2810 deletions

View file

@ -1,12 +1,8 @@
import { fetchAlbum } from '@/web/api/album'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchAlbumParams,
AlbumApiNames,
FetchAlbumResponse,
} from '@/shared/api/Album'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchAlbumParams, AlbumApiNames, FetchAlbumResponse } from '@/shared/api/Album'
import { useQuery } from '@tanstack/react-query'
const fetch = async (params: FetchAlbumParams) => {
@ -17,11 +13,9 @@ const fetch = async (params: FetchAlbumParams) => {
return album
}
const fetchFromCache = async (
params: FetchAlbumParams
): Promise<FetchAlbumResponse | undefined> =>
const fetchFromCache = async (params: FetchAlbumParams): Promise<FetchAlbumResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Album,
api: CacheAPIs.Album,
query: params,
})
@ -48,22 +42,14 @@ export default function useAlbum(params: FetchAlbumParams) {
}
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
return reactQueryClient.fetchQuery(
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),
{
staleTime: Infinity,
}
)
return reactQueryClient.fetchQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
staleTime: Infinity,
})
}
export async function prefetchAlbum(params: FetchAlbumParams) {
if (await fetchFromCache(params)) return
await reactQueryClient.prefetchQuery(
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),
{
staleTime: Infinity,
}
)
await reactQueryClient.prefetchQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
staleTime: Infinity,
})
}

View file

@ -1,11 +1,7 @@
import { fetchArtist } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchArtistParams,
ArtistApiNames,
FetchArtistResponse,
} from '@/shared/api/Artist'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchArtistParams, ArtistApiNames, FetchArtistResponse } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -13,7 +9,7 @@ const fetchFromCache = async (
params: FetchArtistParams
): Promise<FetchArtistResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Artist,
api: CacheAPIs.Artist,
query: params,
})

View file

@ -1,6 +1,6 @@
import { fetchArtistAlbums } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchArtistAlbumsParams, ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -13,7 +13,7 @@ export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
// fetch from cache as placeholder
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.ArtistAlbum,
api: CacheAPIs.ArtistAlbum,
query: {
id: params.id,
},

View file

@ -1,11 +1,7 @@
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 { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchArtistMVParams, ArtistApiNames, FetchArtistMVResponse } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
export default function useArtistMV(params: FetchArtistMVParams) {

View file

@ -1,6 +1,6 @@
import { fetchArtist } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -11,21 +11,15 @@ export default function useArtists(ids: number[]) {
() =>
Promise.all(
ids.map(async id => {
const queryData = reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id },
])
const queryData = reactQueryClient.getQueryData([ArtistApiNames.FetchArtist, { id }])
if (queryData) return queryData
const cache = await window.ipcRenderer?.invoke(
IpcChannels.GetApiCache,
{
api: APIs.Artist,
query: {
id,
},
}
)
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: CacheAPIs.Artist,
query: {
id,
},
})
if (cache) return cache
return fetchArtist({ id })

View file

@ -1,7 +1,7 @@
import { fetchLyric } from '@/web/api/track'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { FetchLyricParams, TrackApiNames } from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
@ -12,7 +12,7 @@ export default function useLyric(params: FetchLyricParams) {
async () => {
// fetch from cache as initial data
const cache = window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Lyric,
api: CacheAPIs.Lyric,
query: {
id: params.id,
},

View file

@ -1,12 +1,6 @@
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 { MVApiNames, FetchMVParams, FetchMVResponse, FetchMVUrlParams } from '@/shared/api/MV'
import { useQuery } from '@tanstack/react-query'
export default function useMV(params: FetchMVParams) {

View file

@ -1,7 +1,7 @@
import { fetchPlaylist } from '@/web/api/playlist'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import {
FetchPlaylistParams,
PlaylistApiNames,
@ -17,7 +17,7 @@ export const fetchFromCache = async (
params: FetchPlaylistParams
): Promise<FetchPlaylistResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Playlist,
api: CacheAPIs.Playlist,
query: params,
})

View file

@ -1,6 +1,6 @@
import { fetchSimilarArtists } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchSimilarArtistsParams, ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -12,7 +12,7 @@ export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
() => {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.SimilarArtist,
api: CacheAPIs.SimilarArtist,
query: {
id: params.id,
},

View file

@ -8,7 +8,7 @@ import {
FetchTracksResponse,
TrackApiNames,
} from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { useQuery } from '@tanstack/react-query'
export default function useTracks(params: FetchTracksParams) {
@ -17,7 +17,7 @@ export default function useTracks(params: FetchTracksParams) {
async () => {
// fetch from cache as initial data
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Track,
api: CacheAPIs.Track,
query: {
ids: params.ids.join(','),
},
@ -40,7 +40,7 @@ export function fetchTracksWithReactQuery(params: FetchTracksParams) {
[TrackApiNames.FetchTracks, params],
async () => {
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Track,
api: CacheAPIs.Track,
query: {
ids: params.ids.join(','),
},

View file

@ -1,6 +1,6 @@
import { fetchUserAccount } from '@/web/api/user'
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery } from '@tanstack/react-query'
import { logout } from '../auth'
@ -16,7 +16,7 @@ export default function useUser() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserAccount,
api: CacheAPIs.UserAccount,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)

View file

@ -2,12 +2,8 @@ import { likeAAlbum } from '@/web/api/album'
import { useMutation, useQuery } from '@tanstack/react-query'
import useUser from './useUser'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchUserAlbumsParams,
UserApiNames,
FetchUserAlbumsResponse,
} from '@/shared/api/User'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchUserAlbumsParams, UserApiNames, FetchUserAlbumsResponse } from '@/shared/api/User'
import { fetchUserAlbums } from '../user'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -25,7 +21,7 @@ export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserAlbums,
api: CacheAPIs.UserAlbums,
query: params,
})
.then(cache => {
@ -72,9 +68,7 @@ export const useMutationLikeAAlbum = () => {
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserAlbumsResponse
const previousData = reactQueryClient.getQueryData(key) as FetchUserAlbumsResponse
const isLiked = !!previousData?.data.find(a => a.id === albumID)
const newAlbums = cloneDeep(previousData!)
@ -88,21 +82,17 @@ export const useMutationLikeAAlbum = () => {
console.log({ albumID })
const albumFromCache: FetchAlbumResponse | undefined =
reactQueryClient.getQueryData([
AlbumApiNames.FetchAlbum,
{ id: 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 },
])
: await reactQueryClient.fetchQuery([AlbumApiNames.FetchAlbum, { id: albumID }])
if (!album?.album) {
toast.error('Failed to like album: unable to fetch album info')

View file

@ -1,6 +1,6 @@
import { fetchUserArtists } from '@/web/api/user'
import { UserApiNames, FetchUserArtistsResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@ -18,7 +18,7 @@ export default function useUserArtists() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserArtists,
api: CacheAPIs.UserArtists,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
@ -59,32 +59,24 @@ export const useMutationLikeAArtist = () => {
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserArtistsResponse
const previousData = reactQueryClient.getQueryData(key) 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
)
newLikedArtists.data = previousData.data.filter(a => a.id !== artistID)
} else {
// 从react-query缓存获取歌手信息
const artistFromCache: FetchArtistResponse | undefined =
reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
const artistFromCache: FetchArtistResponse | undefined = reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
// 从api获取歌手信息
const artist: FetchArtistResponse | undefined = artistFromCache
? artistFromCache
: await reactQueryClient.fetchQuery([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
: await reactQueryClient.fetchQuery([ArtistApiNames.FetchArtist, { id: artistID }])
if (!artist?.artist) {
toast.error('Failed to like artist: unable to fetch artist info')

View file

@ -2,12 +2,9 @@ import { likeATrack } from '@/web/api/track'
import useUser from './useUser'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { fetchUserLikedTracksIDs } from '../user'
import {
FetchUserLikedTracksIDsResponse,
UserApiNames,
} from '@/shared/api/User'
import { FetchUserLikedTracksIDsResponse, UserApiNames } from '@/shared/api/User'
import { useQuery } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -24,7 +21,7 @@ export default function useUserLikedTracksIDs() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.Likelist,
api: CacheAPIs.Likelist,
query: {
uid,
},

View file

@ -1,6 +1,6 @@
import { fetchListenedRecords } from '@/web/api/user'
import { UserApiNames, FetchListenedRecordsResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
import useUser from './useUser'
@ -18,7 +18,7 @@ export default function useUserListenedRecords(params: { type: 'week' | 'all' })
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.ListenedRecords,
api: CacheAPIs.ListenedRecords,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)

View file

@ -2,7 +2,7 @@ import { likeAPlaylist } from '@/web/api/playlist'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import useUser from './useUser'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { fetchUserPlaylists } from '@/web/api/user'
import { FetchUserPlaylistsResponse, UserApiNames } from '@/shared/api/User'
import toast from 'react-hot-toast'
@ -33,7 +33,7 @@ export default function useUserPlaylists() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserPlaylist,
api: CacheAPIs.UserPlaylist,
query: {
uid: params.uid,
},
@ -46,11 +46,7 @@ export default function useUserPlaylists() {
return fetchUserPlaylists(params)
},
{
enabled: !!(
!!params.uid &&
params.uid !== 0 &&
params.offset !== undefined
),
enabled: !!(!!params.uid && params.uid !== 0 && params.offset !== undefined),
refetchOnWindowFocus: true,
}
)
@ -69,10 +65,7 @@ export const useMutationLikeAPlaylist = () => {
}
const response = await likeAPlaylist({
id: playlistID,
t:
userPlaylists.playlist.findIndex(p => p.id === playlistID) > -1
? 2
: 1,
t: userPlaylists.playlist.findIndex(p => p.id === playlistID) > -1 ? 2 : 1,
})
if (response.code !== 200) throw new Error((response as any).msg)
return response
@ -90,9 +83,7 @@ export const useMutationLikeAPlaylist = () => {
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserPlaylistsResponse
const previousData = reactQueryClient.getQueryData(key) as FetchUserPlaylistsResponse
const isLiked = !!previousData?.playlist.find(p => p.id === playlistID)
const newPlaylists = cloneDeep(previousData!)
@ -100,17 +91,12 @@ export const useMutationLikeAPlaylist = () => {
console.log({ isLiked })
if (isLiked) {
newPlaylists.playlist = previousData.playlist.filter(
p => p.id !== playlistID
)
newPlaylists.playlist = previousData.playlist.filter(p => p.id !== playlistID)
} else {
// 从react-query缓存获取歌单信息
const playlistFromCache: FetchPlaylistResponse | undefined =
reactQueryClient.getQueryData([
PlaylistApiNames.FetchPlaylist,
{ id: playlistID },
])
reactQueryClient.getQueryData([PlaylistApiNames.FetchPlaylist, { id: playlistID }])
// 从api获取歌单信息
const playlist: FetchPlaylistResponse | undefined = playlistFromCache
@ -121,9 +107,7 @@ export const useMutationLikeAPlaylist = () => {
])
if (!playlist?.playlist) {
toast.error(
'Failed to like playlist: unable to fetch playlist info'
)
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)

View file

@ -3,7 +3,7 @@ import {
FetchListenedRecordsResponse,
FetchUserVideosResponse,
} from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery } from '@tanstack/react-query'
import useUser from './useUser'
@ -25,7 +25,7 @@ export default function useUserVideos() {
// if (!existsQueryData) {
// window.ipcRenderer
// ?.invoke(IpcChannels.GetApiCache, {
// api: APIs.Likelist,
// api: CacheAPIs.Likelist,
// query: {
// uid,
// },

View file

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

View file

@ -87,7 +87,10 @@ const AlbumContextMenu = () => {
type: 'item',
label: t`context-menu.copy-r3play-link`,
onClick: () => {
copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
const baseUrl = window.env?.isElectron
? 'https://r3play.app'
: window.location.origin
copyToClipboard(`${baseUrl}/album/${dataSourceID}`)
toast.success(t`toasts.copied`)
},
},

View file

@ -0,0 +1,100 @@
import { css, cx } from '@emotion/css'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import uiStates from '../states/uiStates'
import { ease } from '../utils/const'
import Icon from './Icon'
function DescriptionViewer({
description,
title,
isOpen,
onClose,
}: {
description: string
title: string
isOpen: boolean
onClose: () => void
}) {
useEffect(() => {
uiStates.isPauseVideos = isOpen
}, [isOpen])
return createPortal(
<>
{/* Blur bg */}
<AnimatePresence>
{isOpen && (
<motion.div
className='fixed inset-0 z-30 bg-black/70 backdrop-blur-3xl lg:rounded-24'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3, delay: 0.3 } }}
transition={{ ease }}
></motion.div>
)}
</AnimatePresence>
{/* Content */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.3, delay: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
transition={{ ease }}
className={cx('fixed inset-0 z-30 flex flex-col items-center justify-center')}
>
<div className='relative'>
{/* Title */}
<div className='line-clamp-1 absolute -top-8 mx-44 max-w-2xl select-none text-32 font-extrabold text-neutral-100'>
{title}
</div>
{/* Description */}
<div
className={css`
mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩
`}
>
<div
className={cx(
'no-scrollbar relative mx-44 max-w-2xl overflow-scroll',
css`
max-height: 60vh;
mask-image: linear-gradient(
to bottom,
transparent 12px,
black 32px
); // 顶部渐变遮罩
`
)}
>
<p
dangerouslySetInnerHTML={{ __html: description + description }}
className='mt-8 whitespace-pre-wrap pb-8 text-16 font-bold leading-6 text-neutral-200'
></p>
</div>
</div>
{/* Close button */}
<div className='absolute -bottom-24 flex w-full justify-center'>
<div
onClick={onClose}
className='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-6 w-6' />
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>,
document.body
)
}
export default DescriptionViewer

View file

@ -6,48 +6,41 @@ import { useAnimation, motion } from 'framer-motion'
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import { subscribeKey } from 'valtio/utils'
const Cover = () => {
const { track } = useSnapshot(player)
const [cover, setCover] = useState(track?.al.picUrl)
const animationStartTime = useRef(0)
const controls = useAnimation()
const duration = 150 // ms
const navigate = useNavigate()
useEffect(() => {
const resizedCover = resizeImage(track?.al.picUrl || '', 'lg')
const animate = async () => {
animationStartTime.current = Date.now()
await controls.start({ opacity: 0 })
setCover(resizedCover)
}
animate()
}, [controls, track?.al.picUrl])
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
useEffect(() => {
const realCover = resizeImage(track?.al.picUrl ?? '', 'lg')
if (cover !== realCover) setCover(realCover)
}, [cover, track?.al.picUrl])
const onLoad = () => {
const passedTime = Date.now() - animationStartTime.current
controls.start({
opacity: 1,
transition: {
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
},
const unsubscribe = subscribeKey(player, 'track', async () => {
const coverUrl = player.track?.al.picUrl
await controls.start({
opacity: 0,
transition: { duration: 0.2 },
})
setCover(coverUrl)
if (!coverUrl) return
const img = new Image()
img.onload = () => {
controls.start({
opacity: 1,
transition: { duration: 0.2 },
})
}
img.src = coverUrl
})
}
return unsubscribe
}, [])
return (
<motion.img
animate={controls}
transition={{ duration: duration / 1000, ease }}
className={cx('absolute inset-0 w-full')}
transition={{ ease }}
className='absolute inset-0 w-full'
src={cover}
onLoad={onLoad}
onClick={() => {
const id = track?.al.id
if (id) navigate(`/album/${id}`)

View file

@ -23,7 +23,7 @@ const Header = () => {
)}
>
<div className='flex'>
<div className='mr-2 h-4 w-1 bg-brand-700'></div>
<div className='mr-2 h-4 w-1 rounded-full bg-brand-700'></div>
{t`player.queue`}
</div>
<div className='flex'>
@ -117,11 +117,7 @@ const TrackList = ({ className }: { className?: string }) => {
<>
<div
className={css`
mask-image: linear-gradient(
to bottom,
transparent 22px,
black 42px
); // 顶部渐变遮罩
mask-image: linear-gradient(to bottom, transparent 22px, black 42px); // 顶部渐变遮罩
`}
>
<Virtuoso
@ -133,11 +129,7 @@ const TrackList = ({ className }: { className?: string }) => {
'no-scrollbar relative z-10 w-full overflow-auto',
className,
css`
mask-image: linear-gradient(
to top,
transparent 8px,
black 42px
); // 底部渐变遮罩
mask-image: linear-gradient(to top, transparent 8px, black 42px); // 底部渐变遮罩
`
)}
fixedItemHeight={76}

View file

@ -0,0 +1,5 @@
function Tooltip() {
return <></>
}
export default Tooltip

View file

@ -128,13 +128,15 @@ const SearchBox = () => {
const [searchText, setSearchText] = useState('')
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className='relative'>
{/* Input */}
<div
onClick={() => inputRef.current?.focus()}
className={cx(
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
'app-region-no-drag flex cursor-text items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
css`
${bp.lg} {
min-width: 284px;
@ -144,6 +146,7 @@ const SearchBox = () => {
>
<Icon name='search' className='mr-2.5 h-7 w-7' />
<input
ref={inputRef}
placeholder={t`search.search`}
className={cx(
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',

View file

@ -3,8 +3,9 @@ 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'
import { AnimatePresence, motion } from 'framer-motion'
import { ReactNode, useState } from 'react'
import { motion } from 'framer-motion'
import DescriptionViewer from '../DescriptionViewer'
const Info = ({
title,
@ -23,6 +24,7 @@ const Info = ({
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const [isOpenDescription, setIsOpenDescription] = useState(false)
return (
<div>
@ -72,12 +74,20 @@ const Info = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold transition-colors duration-300 dark:text-white/40 dark:hover:text-white/60'
dangerouslySetInnerHTML={{
__html: description,
}}
onClick={() => setIsOpenDescription(true)}
></motion.div>
)}
<DescriptionViewer
description={description || ''}
isOpen={isOpenDescription}
onClose={() => setIsOpenDescription(false)}
title={title || ''}
/>
</div>
)
}

View file

@ -22,14 +22,14 @@ const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }
}, [source])
// Pause video cover when playing another video
const { playingVideoID } = useSnapshot(uiStates)
const { playingVideoID, isPauseVideos } = useSnapshot(uiStates)
useEffect(() => {
if (playingVideoID) {
if (playingVideoID || isPauseVideos) {
videoRef?.current?.pause()
} else {
videoRef?.current?.play()
}
}, [playingVideoID])
}, [playingVideoID, isPauseVideos])
return (
<motion.div

View file

@ -3,6 +3,8 @@ import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist'
import { cx, css } from '@emotion/css'
import { useTranslation } from 'react-i18next'
import i18next from 'i18next'
import { useState } from 'react'
import DescriptionViewer from '@/web/components/DescriptionViewer'
const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean }) => {
const { t, i18n } = useTranslation()
@ -12,6 +14,10 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
artist?.id || 0
)
const [isOpenDescription, setIsOpenDescription] = useState(false)
const description =
artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] || artist?.briefDesc
return (
<div>
{/* Name */}
@ -61,15 +67,23 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
) : (
<div
className={cx(
'line-clamp-5 mt-6 text-14 font-bold text-white/40',
css`
height: 86px;
`
'line-clamp-5 mt-6 overflow-hidden whitespace-pre-wrap text-14 font-bold text-white/40 transition-colors duration-500 hover:text-white/60'
// css`
// height: 86px;
// `
)}
onClick={() => setIsOpenDescription(true)}
>
{artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] || artist?.briefDesc}
{description}
</div>
))}
<DescriptionViewer
description={description || ''}
isOpen={isOpenDescription}
onClose={() => setIsOpenDescription(false)}
title={artist?.name || ''}
/>
</div>
)
}

View file

@ -79,7 +79,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cx(
'pointer-events-none fixed top-0 right-0 left-10 z-10 hidden lg:block',
'pointer-events-none fixed top-0 right-0 left-10 z-10',
css`
height: 230px;
background-repeat: repeat;

View file

@ -10,6 +10,7 @@ interface UIStates {
blurBackgroundImage: string | null
fullscreen: boolean
playingVideoID: number | null
isPauseVideos: boolean
}
const initUIStates: UIStates = {
@ -21,6 +22,7 @@ const initUIStates: UIStates = {
blurBackgroundImage: null,
fullscreen: false,
playingVideoID: null,
isPauseVideos: false,
}
window.ipcRenderer

View file

@ -112,8 +112,14 @@ input {
@apply outline-none;
}
*::selection {
@apply bg-brand-500 text-neutral-800;
}
a,
button {
button,
p,
div {
@apply cursor-default;
}

View file

@ -9,7 +9,7 @@ import {
storage,
} from '@/web/utils/common'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
test('resizeImage', () => {
expect(resizeImage('https://test.com/test.jpg', 'xs')).toBe(
@ -50,12 +50,8 @@ test('formatDuration', () => {
expect(formatDuration(3600000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时')
expect(formatDuration(3600000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時')
expect(formatDuration(3700000, 'en', 'hh[hr] mm[min]')).toBe('1 hr 1 min')
expect(formatDuration(3700000, 'zh-CN', 'hh[hr] mm[min]')).toBe(
'1 小时 1 分钟'
)
expect(formatDuration(3700000, 'zh-TW', 'hh[hr] mm[min]')).toBe(
'1 小時 1 分鐘'
)
expect(formatDuration(3700000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时 1 分钟')
expect(formatDuration(3700000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時 1 分鐘')
expect(formatDuration(0)).toBe('0:00')
expect(formatDuration(0, 'en', 'hh[hr] mm[min]')).toBe('0 min')
@ -67,7 +63,7 @@ describe('cacheCoverColor', () => {
vi.stubGlobal('ipcRenderer', {
send: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.CacheCoverColor)
expect(args[0].api).toBe(APIs.CoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({
id: '109951165911363',
color: '#fff',
@ -169,7 +165,7 @@ describe('getCoverColor', () => {
vi.stubGlobal('ipcRenderer', {
sendSync: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.GetApiCache)
expect(args[0].api).toBe(APIs.CoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({
id: '109951165911363',
})

View file

@ -1,7 +1,7 @@
import { IpcChannels } from '@/shared/IpcChannels'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { average } from 'color.js'
import { colord } from 'colord'
import { supportedLanguages } from '../i18n/i18n'
@ -11,10 +11,7 @@ import { supportedLanguages } from '../i18n/i18n'
* @param {string} url URL
* @param {'xs'|'sm'|'md'|'lg'} size - 128px | 256px | 512px | 1024px
*/
export function resizeImage(
url: string,
size: 'xs' | 'sm' | 'md' | 'lg'
): string {
export function resizeImage(url: string, size: 'xs' | 'sm' | 'md' | 'lg'): string {
if (!url) return ''
const sizeMap = {
@ -87,9 +84,7 @@ export function formatDuration(
const seconds = time.seconds().toString().padStart(2, '0')
if (format === 'hh:mm:ss') {
return hours !== '0'
? `${hours}:${mins.padStart(2, '0')}:${seconds}`
: `${mins}:${seconds}`
return hours !== '0' ? `${hours}:${mins.padStart(2, '0')}:${seconds}` : `${mins}:${seconds}`
} else {
const units = {
'en-US': {
@ -107,23 +102,17 @@ export function formatDuration(
} as const
return hours !== '0'
? `${hours} ${units[locale].hours}${
mins === '0' ? '' : ` ${mins} ${units[locale].mins}`
}`
? `${hours} ${units[locale].hours}${mins === '0' ? '' : ` ${mins} ${units[locale].mins}`}`
: `${mins} ${units[locale].mins}`
}
}
export function scrollToTop(smooth = false) {
document
.querySelector('#main')
?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
document.querySelector('#main')?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
}
export function scrollToBottom(smooth = false) {
document
.querySelector('#main')
?.scrollTo({ top: 100000, behavior: smooth ? 'smooth' : 'auto' })
document.querySelector('#main')?.scrollTo({ top: 100000, behavior: smooth ? 'smooth' : 'auto' })
}
export async function getCoverColor(coverUrl: string) {
@ -137,7 +126,7 @@ export async function getCoverColor(coverUrl: string) {
const colorFromCache: string | undefined = await window.ipcRenderer?.invoke(
IpcChannels.GetApiCache,
{
api: APIs.CoverColor,
api: CacheAPIs.CoverColor,
query: {
id,
},
@ -177,12 +166,9 @@ export async function calcCoverColor(coverUrl: string) {
}
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
)
export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
export const isPWA =
(navigator as any).standalone ||
window.matchMedia('(display-mode: standalone)').matches
(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))

View file

@ -28,10 +28,7 @@ interface TrackListSource {
type: TrackListSourceType
id: number
}
interface RemoteDevice {
protocol: 'airplay' | 'chromecast'
id: string
}
export enum Mode {
TrackList = 'trackList',
FM = 'fm',
@ -62,7 +59,6 @@ export class Player {
fmTrackList: TrackID[] = []
shuffle: boolean = false
fmTrack: Track | null = null
remoteDevice: RemoteDevice | null = null
init(params: { [key: string]: any }) {
if (params._track) this._track = params._track
@ -173,10 +169,6 @@ export class Player {
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
}
get _isAirplay() {
return this.remoteDevice?.protocol === 'airplay'
}
private async _initFM() {
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
@ -194,27 +186,9 @@ export class Player {
}
private async _setupProgressInterval() {
if (this.remoteDevice === null) {
// Howler
this._progressInterval = setInterval(() => {
if (this.state === State.Playing) this._progress = _howler.seek()
}, 1000)
} else if (this._isAirplay) {
// Airplay
// let isFetchAirplayPlayingInfo = false
// this._progressInterval = setInterval(async () => {
// if (isFetchAirplayPlayingInfo) return
// isFetchAirplayPlayingInfo = true
// const playingInfo = await window?.ipcRenderer?.invoke(
// 'airplay-get-playing',
// { deviceID: this.remoteDevice?.id }
// )
// if (playingInfo) {
// this._progress = playingInfo.position || 0
// }
// isFetchAirplayPlayingInfo = false
// }, 1000)
}
this._progressInterval = setInterval(() => {
if (this.state === State.Playing) this._progress = _howler.seek()
}, 1000)
}
private async _scrobble() {
@ -285,23 +259,12 @@ export class Player {
return
}
if (this.trackID !== id) return
if (this._isAirplay) {
this._playAudioViaAirplay(audio)
return
} else {
this._playAudioViaHowler(audio, id, autoplay)
}
this._playAudioViaHowler(audio, id, autoplay)
}
private async _playAudioViaHowler(
audio: string,
id: number,
autoplay: boolean = true
) {
private async _playAudioViaHowler(audio: string, id: number, autoplay: boolean = true) {
Howler.unload()
const url = audio.includes('?')
? `${audio}&dash-id=${id}`
: `${audio}?dash-id=${id}`
const url = audio.includes('?') ? `${audio}&dash-id=${id}` : `${audio}?dash-id=${id}`
const howler = new Howl({
src: [url],
format: ['mp3', 'flac', 'webm'],
@ -325,18 +288,6 @@ export class Player {
}
}
private async _playAudioViaAirplay(audio: string) {
// if (!this._isAirplay) {
// console.log('No airplay device selected')
// return
// }
// const result = await window.ipcRenderer?.invoke('airplay-play-url', {
// deviceID: this.remoteDevice?.id,
// url: audio,
// })
// console.log(result)
}
private _howlerOnEndCallback() {
if (this.mode !== Mode.FM && this.repeatMode === RepeatMode.One) {
_howler.seek(0)
@ -367,18 +318,14 @@ export class Player {
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
this._playTrack()
this.fmTrackList.length <= 1
? await this._loadMoreFMTracks()
: this._loadMoreFMTracks()
this.fmTrackList.length <= 1 ? await this._loadMoreFMTracks() : this._loadMoreFMTracks()
prefetchNextTrack()
}
private async _loadMoreFMTracks() {
if (this.fmTrackList.length <= 5) {
const response = await fetchPersonalFMWithReactQuery()
const ids = (response?.data?.map(r => r.id) ?? []).filter(
r => !this.fmTrackList.includes(r)
)
const ids = (response?.data?.map(r => r.id) ?? []).filter(r => !this.fmTrackList.includes(r))
this.fmTrackList.push(...ids)
}
}
@ -476,9 +423,7 @@ export class Player {
this._setStateToLoading()
this.mode = Mode.TrackList
this.trackList = list
this._trackIndex = autoPlayTrackID
? list.findIndex(t => t === autoPlayTrackID)
: 0
this._trackIndex = autoPlayTrackID ? list.findIndex(t => t === autoPlayTrackID) : 0
this._playTrack()
}
@ -552,10 +497,7 @@ export class Player {
async playFM() {
this._setStateToLoading()
this.mode = Mode.FM
if (
this.fmTrackList.length > 0 &&
this.fmTrack?.id === this.fmTrackList[0]
) {
if (this.fmTrackList.length > 0 && this.fmTrack?.id === this.fmTrackList[0]) {
this._track = this.fmTrack
this._playAudio()
} else {
@ -584,29 +526,12 @@ export class Player {
this._playTrack()
}
async switchToThisComputer() {
this.remoteDevice = null
clearInterval(this._progressInterval)
this._setupProgressInterval()
}
async switchToAirplayDevice(deviceID: string) {
this.remoteDevice = {
protocol: 'airplay',
id: deviceID,
}
clearInterval(this._progressInterval)
this._setupProgressInterval()
}
private async _initMediaSession() {
console.log('init')
if ('mediaSession' in navigator === false) return
navigator.mediaSession.setActionHandler('play', () => this.play())
navigator.mediaSession.setActionHandler('pause', () => this.pause())
navigator.mediaSession.setActionHandler('previoustrack', () =>
this.prevTrack()
)
navigator.mediaSession.setActionHandler('previoustrack', () => this.prevTrack())
navigator.mediaSession.setActionHandler('nexttrack', () => this.nextTrack())
navigator.mediaSession.setActionHandler('seekto', event => {
if (event.seekTime) this.progress = event.seekTime