mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 13:48:02 +00:00
feat: updates
This commit is contained in:
parent
7ce516877e
commit
ccebe0a67a
74 changed files with 56065 additions and 2810 deletions
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(','),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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`)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
100
packages/web/components/DescriptionViewer.tsx
Normal file
100
packages/web/components/DescriptionViewer.tsx
Normal 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
|
||||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
5
packages/web/components/Tooltip.tsx
Normal file
5
packages/web/components/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
function Tooltip() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -112,8 +112,14 @@ input {
|
|||
@apply outline-none;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
@apply bg-brand-500 text-neutral-800;
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
button,
|
||||
p,
|
||||
div {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue