feat: updates

This commit is contained in:
qier222 2022-07-11 11:06:41 +08:00
parent 0b4baa3eff
commit 222fb02355
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
77 changed files with 654 additions and 551 deletions

View file

@ -1,84 +1,56 @@
import { logger } from '@sentry/utils'
import log from './log'
import axios from 'axios'
// 'https://mvod.itunes.apple.com/itunes-assets/HLSMusic116/v4/de/52/95/de52957b-fcf1-ae96-b114-0445cb8c41d4/P359420813_default.m3u8'
const searchAlbum = async (
keyword: string
): Promise<
| {
id: string
href: string
attributes: {
artistName: string
url: string
name: string
editorialNotes?: {
standard: string
short: string
}
}
}
| undefined
> => {
const r = await axios.get(
`https://amp-api.music.apple.com/v1/catalog/cn/search`,
{
params: {
term: keyword,
l: 'zh-cn',
platform: 'web',
types: 'albums',
limit: 1,
},
headers: {
authorization: 'Bearer xxxxxx', // required token
},
}
)
return r.data?.results?.albums?.data?.[0]
}
const token =
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjQ2NjU1MDgwLCJleHAiOjE2NjIyMDcwODB9.pyOrt2FmP0cHkzYtO8KiEzQL2t1qpRszzxIYbLH7faXSddo6PQei771Ja3aGwGVU4hD99lZAw7bwat60iBcGiQ'
export const getCoverVideo = async ({
name,
artists,
artist,
}: {
name: string
artists: string[]
artist: string
}): Promise<string | undefined> => {
const keyword = `${artists.join(' ')} ${name}`
logger.debug(`[appleMusic] getCoverVideo: ${keyword}`)
const album = await searchAlbum(keyword).catch(e => {
console.log(e)
logger.debug('[appleMusic] Search album error', e)
const keyword = `${artist} ${name}`
log.debug(`[appleMusic] getCoverVideo: ${keyword}`)
const searchResult = await axios({
method: 'GET',
url: 'https://amp-api.music.apple.com/v1/catalog/us/search',
params: {
term: keyword,
types: 'albums',
'fields[albums]': 'artistName,name,editorialVideo',
platform: 'web',
limit: '1',
},
headers: {
Authority: 'amp-api.music.apple.com',
Accept: '*/*',
Authorization: token,
Referer: 'http://localhost:9000/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36',
'Accept-Encoding': 'gzip',
},
}).catch(e => {
log.debug('[appleMusic] Search album error', e)
})
const url = album?.attributes.url
if (!url) {
logger.info('[appleMusic] no url')
const album = searchResult?.data?.results?.albums?.data?.[0]
if (!album) {
log.debug('[appleMusic] No album found on apple music')
return
}
let { data: html } = await axios.get(url)
if (!html) return
const regex =
/<script type="fastboot\/shoebox" id="shoebox-media-api-cache-amp-music">(.*?)<\/script>/
html = html
.match(regex)[0]
.replace(
'<script type="fastboot/shoebox" id="shoebox-media-api-cache-amp-music">',
''
log.debug(
`[appleMusic] Got ${album?.id}: ${album?.attributes?.name} by ${album?.attributes?.artistName}`
)
.replace('</script>', '')
html = JSON.parse(html)
const data = JSON.parse(html[Object.keys(html)[1]])
const m3u8 =
data?.d?.[0]?.attributes?.editorialVideo?.motionSquareVideo1x1?.video
logger.debug(`[appleMusic] ${m3u8}`)
return m3u8
const url = album?.attributes?.editorialVideo?.motionSquareVideo1x1?.video
if (!url) {
log.debug('[appleMusic] Album does not have video cover')
}
return url
}

View file

@ -21,6 +21,7 @@ class Cache {
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
if (!data) return
db.upsert(Tables.AccountData, {

View file

@ -16,7 +16,6 @@ import { createTray, YPMTray } from './tray'
import { IpcChannels } from '@/shared/IpcChannels'
import { createTaskbar, Thumbar } from './windowsTaskbar'
import { createMenu } from './menu'
import { Store as State, initialState } from '@/shared/store'
import { isDev, isWindows, isLinux, isMac } from './utils'
export interface TypedElectronStore {
@ -26,7 +25,7 @@ export interface TypedElectronStore {
x?: number
y?: number
}
settings: State['settings']
// settings: State['settings']
}
class Main {
@ -39,7 +38,7 @@ class Main {
width: 1440,
height: 1024,
},
settings: initialState.settings,
// settings: initialState.settings,
},
})

View file

@ -155,19 +155,17 @@ function initOtherIpcMain() {
})
/**
*
*
*/
on(IpcChannels.SetVideoCover, (event, args) => {
const { id, url } = args
cache.set(APIs.VideoCover, { id, url })
})
handle(IpcChannels.GetVideoCover, async (event, { id, name, artist }) => {
const fromCache = cache.get(APIs.VideoCover, { id })
if (fromCache) {
return fromCache === 'no' ? undefined : fromCache
}
/**
*
*/
on(IpcChannels.GetVideoCover, (event, args) => {
const { id } = args
event.returnValue = cache.get(APIs.VideoCover, { id })
const fromApple = await getCoverVideo({ name, artist })
cache.set(APIs.VideoCover, { id, url: fromApple || 'no' })
return fromApple
})
/**

View file

@ -5,6 +5,7 @@ import {
} from './api/Artist'
import { FetchAlbumResponse } from './api/Album'
import {
FetchListenedRecordsResponse,
FetchUserAccountResponse,
FetchUserAlbumsResponse,
FetchUserArtistsResponse,
@ -37,6 +38,7 @@ export const enum APIs {
UserArtists = 'artist/sublist',
UserPlaylist = 'user/playlist',
SimilarArtist = 'simi/artist',
ListenedRecords = 'user/record',
// not netease api
CoverColor = 'cover_color',
@ -61,6 +63,7 @@ export interface APIsParams {
[APIs.SimilarArtist]: { id: number }
[APIs.CoverColor]: { id: number }
[APIs.VideoCover]: { id: number }
[APIs.ListenedRecords]: { id: number; type: number }
}
export interface APIsResponse {
@ -81,4 +84,5 @@ export interface APIsResponse {
[APIs.SimilarArtist]: FetchSimilarArtistsResponse
[APIs.CoverColor]: string | undefined
[APIs.VideoCover]: string | undefined
[APIs.ListenedRecords]: FetchListenedRecordsResponse
}

View file

@ -1,6 +1,5 @@
import { APIs } from './CacheAPIs'
import { RepeatMode } from './playerDataTypes'
import { Store } from '@/shared/store'
export const enum IpcChannels {
ClearAPICache = 'ClearAPICache',
@ -57,10 +56,10 @@ export interface IpcChannelsParams {
[IpcChannels.Repeat]: {
mode: RepeatMode
}
[IpcChannels.SyncSettings]: Store['settings']
[IpcChannels.SyncSettings]: any
[IpcChannels.GetAudioCacheSize]: void
[IpcChannels.ResetWindowSize]: void
[IpcChannels.GetVideoCover]: { id: number }
[IpcChannels.GetVideoCover]: { id: number; name: string; artist: string }
[IpcChannels.SetVideoCover]: { id: number; url: string }
}

View file

@ -107,3 +107,17 @@ export interface FetchUserArtistsResponse {
count: number
data: Artist[]
}
// 听歌排名
export interface FetchListenedRecordsParams {
uid: number // 用户id
type: number // type=1 时只返回 weekData, type=0 时返回 allData
}
export interface FetchListenedRecordsResponse {
code: number
weekData: {
playCount: number
score: number
song: Track
}[]
}

View file

@ -1,54 +0,0 @@
export interface Store {
uiStates: {
showLyricPanel: boolean
showLoginPanel: boolean
}
persistedUiStates: {
loginPhoneCountryCode: string
loginType: 'phone' | 'email' | 'qrCode'
}
settings: {
showSidebar: boolean
accentColor: string
unm: {
enabled: boolean
sources: Array<
'migu' | 'kuwo' | 'kugou' | 'ytdl' | 'qq' | 'bilibili' | 'joox'
>
searchMode: 'order-first' | 'fast-first'
proxy: null | {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
username?: string
password?: string
}
cookies: {
qq?: string
joox?: string
}
}
}
}
export const initialState: Store = {
uiStates: {
showLyricPanel: false,
showLoginPanel: false,
},
persistedUiStates: {
loginPhoneCountryCode: '+86',
loginType: 'qrCode',
},
settings: {
showSidebar: true,
accentColor: 'blue',
unm: {
enabled: true,
sources: ['migu'],
searchMode: 'order-first',
proxy: null,
cookies: {},
},
},
}

View file

@ -13,7 +13,7 @@ const App = () => {
return (
<ErrorBoundary>
<div className='dark'>
<div>
{window.env?.isEnableTitlebar && <TitleBar />}
{isMobile ? <LayoutMobile /> : <Layout />}
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />

View file

@ -2,7 +2,7 @@ import { IpcChannels } from '@/shared/IpcChannels'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player } from '@/web/store'
import player from '@/web/states/player'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { State as PlayerState } from '@/web/utils/player'
import { useEffect, useRef, useState } from 'react'

View file

@ -1,8 +1,5 @@
import {
fetchListenedRecords,
FetchListenedRecordsParams,
} from '@/web/api/user'
import { UserApiNames } from '@/shared/api/User'
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'
@ -24,10 +21,10 @@ export default function useUserListenedRecords(params: {
{
refetchOnWindowFocus: false,
enabled: !!uid,
// placeholderData: (): FetchUserArtistsResponse =>
// window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
// api: APIs.UserArtists,
// }),
placeholderData: (): FetchListenedRecordsResponse =>
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
api: APIs.UserArtists,
}),
}
)
}

View file

@ -5,6 +5,7 @@ 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'
export default function useUserPlaylists() {
const { data: user } = useUser()

View file

@ -8,6 +8,8 @@ import {
FetchUserAlbumsParams,
FetchUserAlbumsResponse,
FetchUserArtistsResponse,
FetchListenedRecordsParams,
FetchListenedRecordsResponse,
} from '@/shared/api/User'
/**
@ -78,18 +80,7 @@ export function scrobble(params: {
})
}
export interface FetchListenedRecordsParams {
uid: number // 用户id
type: number // type=1 时只返回 weekData, type=0 时返回 allData
}
export interface FetchListenedRecordsResponse {
code: number
weekData: {
playCount: number
score: number
song: Track
}[]
}
// 用户最近听歌排名
export function fetchListenedRecords(
params: FetchListenedRecordsParams
): Promise<FetchListenedRecordsResponse> {

View file

@ -0,0 +1,3 @@
<svg width="47" height="10" viewBox="0 0 47 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.6555 8.15086C28.0962 5.28305 17.9054 5.28305 8.34605 8.15086L3.86283 9.49582C2.27585 9.97192 0.603405 9.07137 0.127311 7.48439C-0.348783 5.89741 0.551768 4.22496 2.13875 3.74887L6.62196 2.4039C17.306 -0.801301 28.6956 -0.801301 39.3796 2.4039L43.8628 3.74887C45.4498 4.22496 46.3504 5.89741 45.8743 7.48439C45.3982 9.07137 43.7257 9.97192 42.1387 9.49582L37.6555 8.15086Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 551 B

View file

@ -1,4 +1,4 @@
import { player } from '@/web/store'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import Icon from './Icon'
import ArtistInline from './ArtistsInline'

View file

@ -1,5 +1,5 @@
import useLyric from '@/web/api/hooks/useLyric'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { motion } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useMemo } from 'react'

View file

@ -1,5 +1,5 @@
import useLyric from '@/web/api/hooks/useLyric'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { motion, useMotionValue } from 'framer-motion'
import { lyricParser } from '@/web/utils/lyric'
import { useWindowSize } from 'react-use'

View file

@ -1,5 +1,5 @@
import Player from './Player'
import { player, state } from '@/web/store'
import player from '@/web/states/player'
import { getCoverColor } from '@/web/utils/common'
import { colord } from 'colord'
import IconButton from '../IconButton'
@ -13,7 +13,7 @@ import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
const LyricPanel = () => {
const stateSnapshot = useSnapshot(state)
const stateSnapshot = useSnapshot(player)
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
@ -55,7 +55,11 @@ const LyricPanel = () => {
<Lyric2 className='col-span-7' />
<div className='absolute bottom-3.5 right-7 text-white'>
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
<IconButton
onClick={() => {
//
}}
>
<Icon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>

View file

@ -1,7 +1,7 @@
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player, state } from '@/web/store'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import ArtistInline from '../ArtistsInline'
@ -23,7 +23,6 @@ const PlayingTrack = () => {
const id = track?.al?.id
if (!id) return
navigate(`/album/${id}`)
state.uiStates.showLyricPanel = false
}
const trackListSource = useMemo(
@ -38,12 +37,10 @@ const PlayingTrack = () => {
if (!hasListSource) return
navigate(`/${trackListSource.type}/${trackListSource.id}`)
state.uiStates.showLyricPanel = false
}
const toArtist = (id: number) => {
navigate(`/artist/${id}`)
state.uiStates.showLyricPanel = false
}
return (

View file

@ -0,0 +1,38 @@
import { resizeImage } from '@/web/utils/common'
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 { ease } from '@/web/utils/const'
const BlurBackground = ({ cover }: { cover?: string }) => {
const isMobile = useIsMobile()
const { hideTopbarBackground } = useSnapshot(uiStates)
return (
<AnimatePresence>
{!isMobile && cover && hideTopbarBackground && (
<motion.img
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease }}
className={cx(
'absolute z-0 object-cover opacity-70',
css`
top: -400px;
left: -370px;
width: 1572px;
height: 528px;
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
/>
)}
</AnimatePresence>
)
}
export default BlurBackground

View file

@ -46,8 +46,6 @@ const CoverWall = ({
sizes[album.large ? 'large' : 'small'][breakpoint]
)}
key={album.id}
alt='Album Cover'
placeholder={null}
className={cx(
'aspect-square h-full w-full rounded-20 lg:rounded-24',
album.large && 'col-span-2 row-span-2'

View file

@ -3,9 +3,10 @@ import Player from '@/web/components/New/Player'
import MenuBar from '@/web/components/New/MenuBar'
import Topbar from '@/web/components/New/Topbar/TopbarDesktop'
import { css, cx } from '@emotion/css'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Login from './Login'
import TrafficLight from './TrafficLight'
const Layout = () => {
const playerSnapshot = useSnapshot(player)
@ -39,6 +40,12 @@ const Layout = () => {
<Main />
<Login />
{showPlayer && <Player />}
{window.env?.isMac && (
<div className='fixed top-6 left-6 z-30 translate-y-0.5'>
<TrafficLight />
</div>
)}
</div>
)
}

View file

@ -1,7 +1,7 @@
import Player from '@/web/components/New/PlayerMobile'
import { css, cx } from '@emotion/css'
import { useMemo } from 'react'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Router from '@/web/components/New/Router'
import MenuBar from './MenuBar'
@ -17,8 +17,8 @@ const LayoutMobile = () => {
const location = useLocation()
return (
<div id='layout' className='bg-white select-none pb-28 dark:bg-black'>
<main className='min-h-screen pb-16 overflow-x-hidden overflow-y-auto'>
<div id='layout' className='select-none bg-white pb-28 dark:bg-black'>
<main className='min-h-screen overflow-y-auto overflow-x-hidden pb-16 '>
{location.pathname === '/' && <Topbar />}
<Router />
</main>
@ -48,7 +48,7 @@ const LayoutMobile = () => {
)}
<MenuBar />
{/* <PlayingNext /> */}
<PlayingNext />
</div>
<Login />

View file

@ -1,13 +1,13 @@
import { cx, css } from '@emotion/css'
import { useEffect, useState } from 'react'
import { state } from '@/web/store'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import Icon from '@/web/components/Icon'
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import LoginWithQRCode from './LoginWithQRCode'
import persistedUiStates from '@/web/states/persistedUiStates'
const OR = ({
children,
@ -37,9 +37,10 @@ const OR = ({
}
const Login = () => {
const { uiStates, persistedUiStates } = useSnapshot(state)
const { loginType } = useSnapshot(persistedUiStates)
const { showLoginPanel } = useSnapshot(uiStates)
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
persistedUiStates.loginType === 'qrCode' ? 'qrCode' : 'phone/email'
loginType === 'qrCode' ? 'qrCode' : 'phone/email'
)
const animateCard = useAnimation()
@ -52,8 +53,7 @@ const Login = () => {
})
setCardType(cardType === 'qrCode' ? 'phone/email' : 'qrCode')
state.persistedUiStates.loginType =
cardType === 'qrCode' ? 'phone' : 'qrCode'
persistedUiStates.loginType = cardType === 'qrCode' ? 'phone' : 'qrCode'
await animateCard.start({
rotateY: 0,
@ -66,7 +66,7 @@ const Login = () => {
<>
{/* Blur bg */}
<AnimatePresence>
{uiStates.showLoginPanel && (
{showLoginPanel && (
<motion.div
className='fixed inset-0 z-30 bg-black/80 backdrop-blur-3xl lg:rounded-24'
initial={{ opacity: 0 }}
@ -79,7 +79,7 @@ const Login = () => {
{/* Content */}
<AnimatePresence>
{uiStates.showLoginPanel && (
{showLoginPanel && (
<div className='fixed inset-0 z-30 flex justify-center rounded-24 pt-56'>
<motion.div
className='flex flex-col items-center'
@ -134,7 +134,7 @@ const Login = () => {
<motion.div
layout='position'
transition={{ ease }}
onClick={() => (state.uiStates.showLoginPanel = false)}
onClick={() => (uiStates.showLoginPanel = false)}
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10'
>
<Icon name='x' className='h-7 w-7 text-white/50' />

View file

@ -1,6 +1,5 @@
import { cx, css } from '@emotion/css'
import { useState } from 'react'
import { state } from '@/web/store'
import { useMutation } from 'react-query'
import { loginWithEmail, loginWithPhone } from '@/web/api/auth'
import md5 from 'md5'
@ -9,17 +8,20 @@ import { setCookies } from '@/web/utils/cookie'
import { AnimatePresence, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
const LoginWithPhoneOrEmail = () => {
const { persistedUiStates } = useSnapshot(state)
const { loginPhoneCountryCode, loginType: persistedLoginType } =
useSnapshot(persistedUiStates)
const [email, setEmail] = useState<string>('')
const [countryCode, setCountryCode] = useState<string>(
persistedUiStates.loginPhoneCountryCode || '+86'
loginPhoneCountryCode || '+86'
)
const [phone, setPhone] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [loginType, setLoginType] = useState<'phone' | 'email'>(
persistedUiStates.loginType === 'email' ? 'email' : 'phone'
persistedLoginType === 'email' ? 'email' : 'phone'
)
const doEmailLogin = useMutation(
@ -36,7 +38,7 @@ const LoginWithPhoneOrEmail = () => {
}
setCookies(result.cookie)
state.uiStates.showLoginPanel = false
uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
@ -80,7 +82,7 @@ const LoginWithPhoneOrEmail = () => {
return
}
setCookies(result.cookie)
state.uiStates.showLoginPanel = false
uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
@ -132,7 +134,7 @@ const LoginWithPhoneOrEmail = () => {
onClick={() => {
const type = loginType === 'phone' ? 'email' : 'phone'
setLoginType(type)
state.persistedUiStates.loginType = type
persistedUiStates.loginType = type
}}
>
Phone
@ -165,7 +167,7 @@ const LoginWithPhoneOrEmail = () => {
<input
onChange={e => {
setCountryCode(e.target.value)
state.persistedUiStates.loginPhoneCountryCode = e.target.value
persistedUiStates.loginPhoneCountryCode = e.target.value
}}
className={cx(
'my-3.5 flex-shrink-0 bg-transparent',
@ -181,7 +183,7 @@ const LoginWithPhoneOrEmail = () => {
onChange={e => setPhone(e.target.value)}
className='my-3.5 flex-grow appearance-none bg-transparent'
placeholder='Phone'
type='number'
type='tel'
value={phone}
/>
</motion.div>
@ -198,10 +200,11 @@ const LoginWithPhoneOrEmail = () => {
initial='hidden'
animate='show'
exit='hidden'
className='w-full'
>
<input
onChange={e => setEmail(e.target.value)}
className='flex-grow appearance-none bg-transparent'
className='w-full flex-grow appearance-none bg-transparent'
placeholder='Email'
type='email'
value={email}
@ -215,7 +218,7 @@ const LoginWithPhoneOrEmail = () => {
<div className='mt-4 flex items-center rounded-12 bg-black/50 p-3 text-16 font-medium text-night-50'>
<input
onChange={e => setPassword(e.target.value)}
className='bg-transparent'
className='w-full bg-transparent'
placeholder='Password'
type='password'
value={password}

View file

@ -1,21 +1,11 @@
import { cx, css } from '@emotion/css'
import { useEffect, useState, useMemo } from 'react'
import qrCode from 'qrcode'
import { state } from '@/web/store'
import { useSnapshot } from 'valtio'
import { useMutation, useQuery } from 'react-query'
import {
checkLoginQrCodeStatus,
fetchLoginQrCodeKey,
loginWithEmail,
loginWithPhone,
} from '@/web/api/auth'
import md5 from 'md5'
import { useQuery } from 'react-query'
import { checkLoginQrCodeStatus, fetchLoginQrCodeKey } from '@/web/api/auth'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
import { AnimatePresence, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import Icon from '@/web/components/Icon'
import uiStates from '@/web/states/uiStates'
const QRCode = ({ className, text }: { className?: string; text: string }) => {
const [image, setImage] = useState<string>('')
@ -90,7 +80,7 @@ const LoginWithQRCode = () => {
break
}
setCookies(status.cookie)
state.uiStates.showLoginPanel = false
uiStates.showLoginPanel = false
break
}
},

View file

@ -1,18 +1,37 @@
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'
const Main = () => {
// Show/hide topbar background
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen } = useIntersectionObserver(observePoint)
useEffect(() => {
uiStates.hideTopbarBackground = onScreen
return () => {
uiStates.hideTopbarBackground = false
}
}, [onScreen])
return (
<main
className={cx(
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
css`
padding-top: 132px;
grid-area: main;
`
)}
>
<div ref={observePoint}></div>
<div
className={css`
margin-top: 132px;
`}
>
<Router />
</div>
</main>
)
}

View file

@ -144,11 +144,6 @@ const MenuBar = ({ className }: { className?: string }) => {
`
)}
>
{window.env?.isMac && (
<div className='fixed top-6 left-6 translate-y-0.5'>
<TrafficLight />
</div>
)}
<Tabs />
{!isMobile && <TabName />}
</div>

View file

@ -2,7 +2,7 @@ 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/store'
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'
@ -17,7 +17,7 @@ const Progress = () => {
const { track, progress } = useSnapshot(player)
return (
<div className='flex flex-col w-full mt-10'>
<div className='mt-10 flex w-full flex-col'>
<Slider
min={0}
max={(track?.dt ?? 100000) / 1000}
@ -28,7 +28,7 @@ const Progress = () => {
onlyCallOnChangeAfterDragEnded={true}
/>
<div className='flex justify-between mt-1 font-bold text-14 text-black/20 dark:text-white/20'>
<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>
@ -117,9 +117,9 @@ const NowPlaying = () => {
<Cover />
{/* Info & Controls */}
<div className='flex flex-col items-center p-5 m-3 font-medium rounded-20 bg-white/60 backdrop-blur-3xl dark:bg-black/70'>
<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='text-lg text-black line-clamp-1 dark:text-white'>
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<ArtistInline
@ -129,13 +129,13 @@ const NowPlaying = () => {
/>
{/* Dividing line */}
<div className='w-2/3 h-px mt-2 bg-black/10 dark:bg-white/10'></div>
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
{/* Progress */}
<Progress />
{/* Controls */}
<div className='flex items-center justify-between w-full mt-4'>
<div className='mt-4 flex w-full items-center justify-between'>
<button>
<Icon
name='hide-list'
@ -149,7 +149,7 @@ const NowPlaying = () => {
disabled={!track}
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon name='previous' className='w-6 h-6 ' />
<Icon name='previous' className='h-6 w-6 ' />
</button>
<button
onClick={() => track && player.playOrPause()}
@ -161,7 +161,7 @@ const NowPlaying = () => {
? 'pause'
: 'play'
}
className='w-6 h-6 '
className='h-6 w-6 '
/>
</button>
<button
@ -169,7 +169,7 @@ const NowPlaying = () => {
disabled={!track}
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon name='next' className='w-6 h-6 ' />
<Icon name='next' className='h-6 w-6 ' />
</button>
</div>

View file

@ -1,7 +1,7 @@
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile'
import scrollPositions from '@/web/store/scrollPositions'
import scrollPositions from '@/web/states/scrollPositions'
import { useLayoutEffect } from 'react'
const PageTransition = ({

View file

@ -12,7 +12,7 @@ const Player = () => {
`
)}
>
<PlayingNext className='mb-3 h-full' />
<PlayingNext className='h-full' />
<div className='pb-6'>
<NowPlaying />
</div>

View file

@ -1,17 +1,18 @@
import { player } from '@/web/store'
import player from '@/web/states/player'
import { css, cx } from '@emotion/css'
import { useSnapshot } from 'valtio'
import Image from '@/web/components/New/Image'
import Icon from '@/web/components/Icon'
import useCoverColor from '@/web/hooks/useCoverColor'
import { resizeImage } from '@/web/utils/common'
import { motion, PanInfo, useMotionValue } from 'framer-motion'
import { motion, PanInfo } from 'framer-motion'
import { useLockBodyScroll } from 'react-use'
import { useState } from 'react'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import PlayingNextMobile from './PlayingNextMobile'
import uiStates from '@/web/states/uiStates'
import { ease } from '@/web/utils/const'
const LikeButton = () => {
const { track } = useSnapshot(player)
@ -23,7 +24,7 @@ const LikeButton = () => {
return (
<button
className='flex items-center h-full'
className='flex h-full items-center'
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
>
<Icon
@ -39,6 +40,7 @@ const PlayerMobile = () => {
const bgColor = useCoverColor(track?.al?.picUrl ?? '')
const [locked, setLocked] = useState(false)
useLockBodyScroll(locked)
const { mobileShowPlayingNext } = useSnapshot(uiStates)
const onDragEnd = (
event: MouseEvent | TouchEvent | PointerEvent,
@ -63,33 +65,56 @@ const PlayerMobile = () => {
`
)}
>
{/* Cover */}
{/* Handler */}
{!mobileShowPlayingNext && (
<motion.div
onClick={() => {
uiStates.mobileShowPlayingNext = true
}}
className={cx(
'absolute right-0 left-0 flex justify-center',
css`
--height: 20px;
height: var(--height);
top: calc(var(--height) * -1);
`
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease, duration: 0.2 }}
>
<Icon name='player-handler' className='h-2.5 text-brand-700' />
</motion.div>
)}
{/* Cover */}
<div className='h-full py-2.5'>
<Image
src={resizeImage(track?.al.picUrl || '', 'sm')}
className='z-10 h-full rounded-lg aspect-square'
className='z-10 aspect-square h-full rounded-lg'
/>
</div>
{/* Track info */}
<div className='relative flex items-center flex-grow h-full px-3 overflow-hidden'>
<div className='relative flex h-full flex-grow items-center overflow-hidden px-3'>
<motion.div
drag='x'
dragConstraints={{ left: 0, right: 0 }}
onDragStart={() => setLocked(true)}
onDragEnd={onDragEnd}
className='flex items-center flex-grow h-full '
dragDirectionLock={true}
className='flex h-full flex-grow items-center '
>
<div className='flex-shrink-0'>
<div className='font-bold text-white line-clamp-1 text-14'>
<div className='line-clamp-1 text-14 font-bold text-white'>
{track?.name}
</div>
<div className='mt-1 font-bold line-clamp-1 text-12 text-white/60'>
<div className='line-clamp-1 mt-1 text-12 font-bold text-white/60'>
{track?.ar?.map(a => a.name).join(', ')}
</div>
</div>
<div className='flex-grow h-full'></div>
<div className='h-full flex-grow'></div>
</motion.div>
<div
@ -120,7 +145,7 @@ const PlayerMobile = () => {
>
<Icon
name={state === 'playing' ? 'pause' : 'play'}
className='w-6 h-6 text-white/80'
className='h-6 w-6 text-white/80'
/>
</button>
</div>

View file

@ -1,27 +1,25 @@
import { resizeImage } from '@/web/utils/common'
import { player } from '@/web/store'
import { isIosPwa, resizeImage } from '@/web/utils/common'
import player from '@/web/states/player'
import { State as PlayerState } from '@/web/utils/player'
import { useSnapshot } from 'valtio'
import useTracks from '@/web/api/hooks/useTracks'
import { css, cx } from '@emotion/css'
import { AnimatePresence, motion } from 'framer-motion'
import Image from './Image'
import Wave from './Wave'
import Icon from '@/web/components/Icon'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
import { useWindowSize } from 'react-use'
import { playerWidth, topbarHeight } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile'
import { Virtuoso } from 'react-virtuoso'
const Header = () => {
return (
<div
className={cx(
'absolute top-0 left-0 z-10 flex w-full items-center justify-between 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-4'
)}
>
<div className='flex'>
<div className='w-1 h-4 mr-2 bg-brand-700'></div>
<div className='mr-2 h-4 w-1 bg-brand-700'></div>
PLAYING NEXT
</div>
<div className='flex'>
@ -48,30 +46,21 @@ const Track = ({
state: PlayerState
}) => {
return (
<motion.div
className='flex items-center justify-between'
// initial={{ opacity: 0 }}
// animate={{ opacity: 1 }}
// exit={{ x: '100%', opacity: 0 }}
// transition={{
// duration: 0.24,
// }}
// layout
<div
className='mb-5 flex items-center justify-between'
onClick={e => {
if (e.detail === 2 && track?.id) player.playTrack(track.id)
}}
>
{/* Cover */}
<Image
<img
alt='Cover'
className='flex-shrink-0 mr-4 aspect-square h-14 w-14 rounded-12'
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
src={resizeImage(track?.al?.picUrl || '', 'sm')}
animation={false}
placeholder={false}
/>
{/* Track info */}
<div className='flex-grow mr-3'>
<div className='mr-3 flex-grow'>
<div
className={cx(
'line-clamp-1 text-16 font-medium ',
@ -82,7 +71,7 @@ const Track = ({
>
{track?.name}
</div>
<div className='mt-1 font-bold line-clamp-1 text-14 text-neutral-200 dark:text-neutral-700'>
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'>
{track?.ar.map(a => a.name).join(', ')}
</div>
</div>
@ -91,11 +80,11 @@ const Track = ({
{playingTrackIndex === index ? (
<Wave playing={state === 'playing'} />
) : (
<div className='font-medium text-16 text-neutral-700 dark:text-neutral-200'>
<div className='text-16 font-medium text-neutral-700 dark:text-neutral-200'>
{String(index + 1).padStart(2, '0')}
</div>
)}
</motion.div>
</div>
)
}
@ -103,69 +92,57 @@ const TrackList = ({ className }: { className?: string }) => {
const { trackList, trackIndex, state } = useSnapshot(player)
const { data: tracksRaw } = useTracks({ ids: trackList })
const tracks = tracksRaw?.songs || []
const parentRef = useRef<HTMLDivElement>(null)
const { height } = useWindowSize()
const isMobile = useIsMobile()
const listHeight = height - topbarHeight - playerWidth - 24 - 20 // 24是封面与底部间距20是list与封面间距
const rowVirtualizer = useVirtualizer({
count: tracks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 76,
overscan: 10,
})
const listHeight = height - topbarHeight - playerWidth - 24 // 24是封面与底部间距
const listHeightMobile = height - 154 - 110 - (isIosPwa ? 34 : 0) // 154是列表距离底部的距离110是顶部的距离
return (
<>
<div
ref={parentRef}
className={css`
mask-image: linear-gradient(
to bottom,
transparent 22px,
black 42px
); // 顶部渐变遮罩
`}
>
<Virtuoso
style={{
height: `${listHeight}px`,
height: `${isMobile ? listHeightMobile : listHeight}px`,
}}
totalCount={tracks.length}
className={cx(
'no-scrollbar relative z-10 w-full overflow-auto',
className,
css`
padding-top: 42px;
mask-image: linear-gradient(
to bottom,
transparent 0,
to top,
transparent 8px,
black 42px
); // 顶部渐变遮罩
); // 底部渐变遮罩
`
)}
>
<div
className='relative w-full'
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
fixedItemHeight={76}
data={tracks}
overscan={10}
components={{
Header: () => <div className='h-8'></div>,
Footer: () => <div className='h-1'></div>,
}}
>
{rowVirtualizer.getVirtualItems().map((row: any) => (
<div
key={row.index}
className='absolute top-0 left-0 w-full'
style={{
height: `${row.size}px`,
transform: `translateY(${row.start}px)`,
}}
>
itemContent={(index, track) => (
<Track
track={tracks?.[row.index]}
index={row.index}
key={index}
track={track}
index={index}
playingTrackIndex={trackIndex}
state={state}
/>
)}
></Virtuoso>
</div>
))}
</div>
</div>
{/* 底部渐变遮罩 */}
<div
className='absolute left-0 right-0 z-20 hidden pointer-events-none h-14 bg-gradient-to-t from-black to-transparent lg:block'
style={{ top: `${listHeight - 56}px` }}
></div>
</>
)
}

View file

@ -10,38 +10,52 @@ 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'
import uiStates from '@/web/states/uiStates'
import Icon from '@/web/components/Icon'
const PlayingNextMobile = () => {
const [display, setDisplay] = useState(false)
const { mobileShowPlayingNext: display } = useSnapshot(uiStates)
const [isDragging, setIsDragging] = useState(false)
useLockBodyScroll(isDragging || display)
useLockBodyScroll(isDragging)
const dragControls = useDragControls()
const y = useMotionValue('82%')
return (
<AnimatePresence>
{display && (
<motion.div
className='fixed inset-0 px-3 bg-black/80 backdrop-blur-3xl'
className='fixed inset-0 bg-black/80 backdrop-blur-3xl'
exit={{
y: '100%',
borderRadius: '24px',
transition: {
duration: 0.6,
ease: 'easeOut',
duration: 0.4,
},
}}
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
style={{
borderRadius: isDragging ? '24px' : '0px',
y,
}}
drag='y'
animate={{ y: 0, borderRadius: 0 }}
initial={{ y: '100%', borderRadius: '24px' }}
transition={{ duration: 0.6, ease }}
dragControls={dragControls}
dragListener={false}
whileDrag={{
borderRadius: '24px',
transition: {
duration: 0.2,
ease: 'linear',
},
}}
dragConstraints={{ top: 0, bottom: 0 }}
dragDirectionLock={true}
onDrag={(event, info) => console.log(info.point.y)}
onDragEnd={(event, info) => {
setIsDragging(false)
const offset = info.offset.y
if (offset > 150) {
uiStates.mobileShowPlayingNext = false
}
}}
drag='y'
>
{/* Indictor */}
<motion.div
@ -49,33 +63,28 @@ const PlayingNextMobile = () => {
setIsDragging(true)
dragControls.start(e)
}}
onDragEnd={() => setIsDragging(false)}
dragConstraints={{ top: 0, bottom: 0 }}
onClick={() => {
uiStates.mobileShowPlayingNext = false
}}
className={cx(
'mx-7 flex justify-center',
'flex flex-col justify-end',
css`
--height: 30px;
bottom: calc(
70px + 64px +
${isIosPwa ? '24px' : 'env(safe-area-inset-bottom)'}
); // 拖动条到导航栏的距离 + 导航栏高度 + safe-area-inset-bottom
height: var(--height);
height: 108px;
`
)}
layout
>
<motion.div
className='mt-3.5 h-1.5 w-10 rounded-full bg-brand-700'
layout
style={{ width: isDragging || display ? '80px' : '40px' }}
></motion.div>
<Icon
name='player-handler'
className='mb-5 h-2.5 rotate-180 text-brand-700'
/>
</motion.div>
{/* List */}
<div className='relative'>
<div className='relative h-full px-7'>
<PlayingNext />
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -1,5 +1,5 @@
import { useLayoutEffect } from 'react'
import scrollPositions from '@/web/store/scrollPositions'
import scrollPositions from '@/web/states/scrollPositions'
import { throttle } from 'lodash-es'
const ScrollRestoration = () => {

View file

@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'
import Icon from '../../Icon'
import { resizeImage } from '@/web/utils/common'
import useUser from '@/web/api/hooks/useUser'
import { state } from '@/web/store'
import uiStates from '@/web/states/uiStates'
const Avatar = ({ className }: { className?: string }) => {
const { data: user } = useUser()
@ -16,7 +16,7 @@ const Avatar = ({ className }: { className?: string }) => {
{avatarUrl ? (
<img
src={avatarUrl}
onClick={() => (state.uiStates.showLoginPanel = true)}
onClick={() => (uiStates.showLoginPanel = true)}
className={cx(
'app-region-no-drag rounded-full',
className || 'h-12 w-12'
@ -24,7 +24,7 @@ const Avatar = ({ className }: { className?: string }) => {
/>
) : (
<div
onClick={() => (state.uiStates.showLoginPanel = true)}
onClick={() => (uiStates.showLoginPanel = true)}
className={cx(
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
className || 'h-12 w-12'

View file

@ -1,32 +1,55 @@
import { css, cx } from '@emotion/css'
import { useLocation } from 'react-router-dom'
import Avatar from './Avatar'
import SearchBox from './SearchBox'
import SettingsButton from './SettingsButton'
import NavigationButtons from './NavigationButtons'
import topbarBackground from '@/web/assets/images/topbar-background.png'
import uiStates from '@/web/states/uiStates'
import { useSnapshot } from 'valtio'
import { AnimatePresence, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
const TopbarDesktop = () => {
const location = useLocation()
const Background = () => {
const { hideTopbarBackground } = useSnapshot(uiStates)
return (
<div
<>
<AnimatePresence>
{!hideTopbarBackground && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease }}
className={cx(
'app-region-drag fixed top-0 right-0 z-20 flex items-center justify-between overflow-hidden rounded-tr-24 bg-contain pt-11 pb-10 pr-6 pl-10',
css`
left: 104px;
`,
!location.pathname.startsWith('/album/') &&
!location.pathname.startsWith('/playlist/') &&
!location.pathname.startsWith('/browse') &&
!location.pathname.startsWith('/artist') &&
'absolute inset-0 z-0 bg-contain bg-repeat-x',
css`
background-image: url(${topbarBackground});
`
)}
></motion.div>
)}
</AnimatePresence>
</>
)
}
const TopbarDesktop = () => {
return (
<div
className={cx(
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between overflow-hidden bg-contain pt-11 pb-10 pr-6',
css`
padding-left: 144px;
`,
window.env?.isElectron && 'rounded-t-24'
)}
>
{/* Background */}
<Background />
{/* Left Part */}
<div className='flex items-center'>
<div className='z-10 flex items-center'>
<NavigationButtons />
{/* Dividing line */}
@ -36,7 +59,7 @@ const TopbarDesktop = () => {
</div>
{/* Right Part */}
<div className='flex'>
<div className='z-10 flex'>
<SettingsButton />
<Avatar className='ml-3 h-12 w-12' />
</div>

View file

@ -1,7 +1,7 @@
import { formatDuration } from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import { useMemo } from 'react'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Wave from './Wave'
import Icon from '@/web/components/Icon'

View file

@ -17,6 +17,7 @@ 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'
injectGlobal`
.plyr__video-wrapper,
@ -86,21 +87,7 @@ const Cover = memo(
</div>
{/* Blur bg */}
{!isMobile && (
<img
className={cx(
'absolute z-0 object-cover opacity-70',
css`
top: -400px;
left: -370px;
width: 1572px;
height: 528px;
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
/>
)}
<BlurBackground cover={cover} />
</>
)
}
@ -111,10 +98,12 @@ const TrackListHeader = ({
album,
playlist,
onPlay,
className,
}: {
album?: Album
playlist?: Playlist
onPlay: () => void
className?: string
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
@ -126,6 +115,7 @@ const TrackListHeader = ({
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 &&

View file

@ -5,7 +5,7 @@ import Icon from './Icon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player, state } from '@/web/store'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
@ -180,7 +180,11 @@ const Others = () => {
</IconButton>
{/* Lyric */}
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
<IconButton
onClick={() => {
//
}}
>
<Icon className='h-6 w-6' name='lyrics' />
</IconButton>
</div>

View file

@ -3,7 +3,7 @@ import Icon from './Icon'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import { scrollToTop } from '@/web/utils/common'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { Mode, TrackListSourceType } from '@/web/utils/player'
import { cx } from '@emotion/css'
import { useMemo } from 'react'

View file

@ -1,4 +1,4 @@
import { player } from '@/web/store'
import player from '@/web/states/player'
import Icon from './Icon'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'

View file

@ -5,7 +5,7 @@ import Icon from '@/web/components/Icon'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { formatDuration } from '@/web/utils/common'
import { State as PlayerState } from '@/web/utils/player'
import { cx } from '@emotion/css'

View file

@ -1,6 +1,6 @@
import ArtistInline from '@/web/components/ArtistsInline'
import Skeleton from '@/web/components/Skeleton'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import Icon from './Icon'
import { cx } from '@emotion/css'

View file

@ -7,7 +7,7 @@ import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import { formatDuration, resizeImage } from '@/web/utils/common'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { cx } from '@emotion/css'
import { useSnapshot } from 'valtio'

View file

@ -0,0 +1,25 @@
import { useState, useEffect, RefObject } from 'react'
const useIntersectionObserver = (
element: RefObject<Element>
): { onScreen: boolean } => {
const [onScreen, setOnScreen] = useState(false)
useEffect(() => {
if (element.current) {
const observer = new IntersectionObserver(([entry]) =>
setOnScreen(entry.isIntersecting)
)
observer.observe(element.current)
return () => {
observer.disconnect()
}
}
}, [element, setOnScreen])
return {
onScreen,
}
}
export default useIntersectionObserver

View file

@ -13,23 +13,21 @@ export default function useVideoCover(props: {
async () => {
if (!id || !name || !artist) return
const fromCache = window.ipcRenderer?.sendSync(
const fromMainProcess = await window.ipcRenderer?.invoke(
IpcChannels.GetVideoCover,
{
id,
name,
artist,
}
)
if (fromCache) {
return fromCache === 'no' ? undefined : fromCache
if (fromMainProcess) {
return fromMainProcess
}
const fromRemote = await axios.get('/yesplaymusic/video-cover', {
params: props,
})
window.ipcRenderer?.send(IpcChannels.SetVideoCover, {
id,
url: fromRemote.data.url || '',
})
if (fromRemote?.data?.url) {
return fromRemote.data.url
}

View file

@ -1,4 +1,4 @@
import { player } from '@/web/store'
import player from '@/web/states/player'
import {
IpcChannels,
IpcChannelsReturns,

View file

@ -14,7 +14,6 @@ import ReactGA from 'react-ga4'
import { ipcRenderer } from './ipcRenderer'
import { QueryClientProvider } from 'react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
import ReactDOM from 'react-dom'
ReactGA.initialize('G-KMJJCFZDKF')
@ -35,23 +34,12 @@ ipcRenderer()
const container = document.getElementById('root') as HTMLElement
const root = ReactDOMClient.createRoot(container)
// root.render(
// <StrictMode>
// <BrowserRouter>
// <QueryClientProvider client={reactQueryClient}>
// <App />
// </QueryClientProvider>
// </BrowserRouter>
// </StrictMode>
// )
ReactDOM.render(
root.render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
document.getElementById('root')
</StrictMode>
)

View file

@ -9,14 +9,15 @@
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:types": "tsc --noEmit --project src/renderer/tsconfig.json",
"test:types": "tsc --noEmit --project ./tsconfig.json",
"lint": "eslint --ext .ts,.js,.tsx,.jsx ./",
"analyze:css": "npx windicss-analysis",
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
"storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook",
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
"api:netease": "npx NeteaseCloudMusicApi@latest"
"api:netease": "npx NeteaseCloudMusicApi@latest",
"format": "prettier --config ../../prettier.config.js --write './**/*.{ts,tsx,js,jsx,css}' --ignore-path ../../.prettierignore"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
@ -25,7 +26,6 @@
"@emotion/css": "^11.9.0",
"@sentry/react": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@tanstack/react-virtual": "3.0.0-beta.2",
"ahooks": "^3.4.1",
"axios": "^0.27.2",
"color.js": "^1.2.0",
@ -48,6 +48,7 @@
"react-query": "^3.38.0",
"react-router-dom": "^6.3.0",
"react-use": "^17.4.0",
"react-virtuoso": "^2.16.1",
"valtio": "^1.6.1"
},
"devDependencies": {

View file

@ -7,7 +7,7 @@ import Icon from '@/web/components/Icon'
import TracksAlbum from '@/web/components/TracksAlbum'
import useAlbum from '@/web/api/hooks/useAlbum'
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
import { player } from '@/web/store'
import player from '@/web/states/player'
import {
Mode as PlayerMode,
State as PlayerState,

View file

@ -9,7 +9,7 @@ import TracksGrid from '@/web/components/TracksGrid'
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
import Skeleton from '@/web/components/Skeleton'
import useTracks from '@/web/api/hooks/useTracks'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { cx } from '@emotion/css'
import { useCallback, useMemo } from 'react'
import toast from 'react-hot-toast'

View file

@ -5,7 +5,7 @@ import useLyric from '@/web/api/hooks/useLyric'
import usePlaylist from '@/web/api/hooks/usePlaylist'
import useUser from '@/web/api/hooks/useUser'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import { sample, chunk } from 'lodash-es'
import useUserArtists from '@/web/api/hooks/useUserArtists'

View file

@ -4,7 +4,7 @@ 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/store'
import player from '@/web/states/player'
import toast from 'react-hot-toast'
import { useSnapshot } from 'valtio'
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
@ -113,9 +113,13 @@ const Album = () => {
return (
<PageTransition>
<TrackListHeader album={album?.album} onPlay={onPlay} />
<TrackListHeader
album={album?.album}
onPlay={onPlay}
className='mt-2.5 lg:mt-0'
/>
<TrackList
tracks={tracks?.songs || album?.songs || album?.album.songs}
tracks={tracks?.songs || album?.album.songs || album?.songs}
className='z-10 mx-2.5 mt-3 lg:mx-0 lg:mt-10'
onPlay={onPlay}
/>

View file

@ -7,7 +7,7 @@ const ArtistInfo = ({ artist }: { artist?: Artist }) => {
<div className='text-28 font-semibold text-night-50 lg:text-32'>
{artist?.name}
</div>
<div className='mt-2.5 text-24 font-medium text-night-400 lg:mt-6'>
<div className='text-white-400 mt-2.5 text-24 font-medium lg:mt-6'>
Artist
</div>
<div className='mt-1 text-12 font-medium text-night-400'>

View file

@ -1,26 +0,0 @@
import { resizeImage } from '@/web/utils/common'
import { cx, css } from '@emotion/css'
import useIsMobile from '@/web/hooks/useIsMobile'
const BlurBackground = ({ cover }: { cover?: string }) => {
const isMobile = useIsMobile()
return isMobile || !cover ? (
<></>
) : (
<img
className={cx(
'absolute z-0 object-cover opacity-70',
css`
top: -400px;
left: -370px;
width: 1572px;
height: 528px;
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
/>
)
}
export default BlurBackground

View file

@ -1,9 +1,8 @@
import { resizeImage } from '@/web/utils/common'
import { cx, css } from '@emotion/css'
import Image from '@/web/components/New/Image'
import { useMemo } from 'react'
import { breakpoint as bp } from '@/web/utils/const'
import BlurBackground from './BlurBackground'
import BlurBackground from '@/web/components/New/BlurBackground'
import ArtistInfo from './ArtistInfo'
import Actions from './Actions'
import LatestRelease from './LatestRelease'

View file

@ -37,11 +37,11 @@ const Album = () => {
`
)}
/>
<div className='ml-2 flex-shrink-1'>
<div className='font-medium line-clamp-1 text-16 text-night-100'>
<div className='flex-shrink-1 ml-2'>
<div className='line-clamp-1 text-16 font-medium text-night-100'>
{album.name}
</div>
<div className='mt-1 font-bold text-14 text-night-500'>
<div className='mt-1 text-14 font-bold text-night-500'>
{album.type}
{album.size > 1 ? `· ${album.size} Tracks` : ''}
</div>
@ -69,8 +69,8 @@ const Video = () => {
`
)}
/>
<div className='ml-2 flex-shrink-1'>
<div className='font-medium line-clamp-2 text-16 text-night-100'>
<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...
</div>
<div className='mt-1.5 text-12 font-medium text-night-500'>
@ -84,7 +84,7 @@ const Video = () => {
const LatestRelease = () => {
return (
<div className='mx-2.5 lg:mx-0'>
<div className='mb-3 font-bold mt-7 text-14 text-neutral-300'>
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
Latest Releases
</div>

View file

@ -1,5 +1,5 @@
import { resizeImage } from '@/web/utils/common'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { State as PlayerState } from '@/web/utils/player'
import useTracks from '@/web/api/hooks/useTracks'
import { css, cx } from '@emotion/css'

View file

@ -82,7 +82,7 @@ const Browse = () => {
tabs={categories}
value={active}
onChange={category => setActive(category)}
className='sticky top-0 z-10 px-2.5 lg:px-0'
className='sticky 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'>

View file

@ -106,7 +106,7 @@ const Discover = () => {
return (
<PageTransition disableEnterAnimation={true}>
<div className='mx-2.5 pb-10 lg:mx-0 lg:pb-16'>
<div className='mx-2.5 mt-2.5 pb-10 lg:mx-0 lg:mt-0 lg:pb-16'>
<CoverWall albums={albums || []} />
</div>
</PageTransition>

View file

@ -5,6 +5,8 @@ import { useMemo, useState } from 'react'
import CoverRow from '@/web/components/New/CoverRow'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
const tabs = [
{
@ -39,7 +41,12 @@ const Playlists = () => {
const Collections = () => {
// const { data: artists } = useUserArtists()
const [selectedTab, setSelectedTab] = useState(tabs[0].id)
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
const setSelectedTab = (
id: 'playlists' | 'albums' | 'artists' | 'videos'
) => {
uiStates.librarySelectedTab = id
}
return (
<div>

View file

@ -1,7 +1,7 @@
import useLyric from '@/web/api/hooks/useLyric'
import usePlaylist from '@/web/api/hooks/usePlaylist'
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { sample, chunk, sampleSize } from 'lodash-es'
import { css, cx } from '@emotion/css'
import { useState, useEffect, useMemo, useCallback, memo } from 'react'

View file

@ -2,13 +2,13 @@ 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/store'
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'
import useScroll from '@/web/hooks/useScroll'
const Playlist = () => {
const params = useParams()
const { data: playlist, isLoading } = usePlaylist({
@ -35,7 +35,11 @@ const Playlist = () => {
return (
<PageTransition>
<TrackListHeader playlist={playlist?.playlist} onPlay={onPlay} />
<TrackListHeader
playlist={playlist?.playlist}
onPlay={onPlay}
className='mt-2.5 lg:mt-0'
/>
<TrackList
tracks={playlist?.playlist?.tracks ?? []}
onPlay={onPlay}

View file

@ -6,7 +6,7 @@ import TracksList from '@/web/components/TracksList'
import usePlaylist from '@/web/api/hooks/usePlaylist'
import useScroll from '@/web/hooks/useScroll'
import useTracksInfinite from '@/web/api/hooks/useTracksInfinite'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { formatDate, resizeImage } from '@/web/utils/common'
import useUserPlaylists, {
useMutationLikeAPlaylist,

View file

@ -1,7 +1,7 @@
import { multiMatchSearch, search } from '@/web/api/search'
import Cover from '@/web/components/Cover'
import TrackGrid from '@/web/components/TracksGrid'
import { player } from '@/web/store'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
import { SearchTypes, SearchApiNames } from '@/shared/api/Search'
import dayjs from 'dayjs'

View file

@ -1,4 +1,4 @@
import { state } from '@/web/store'
import settings from '@/web/states/settings'
import { changeAccentColor } from '@/web/utils/theme'
import { useSnapshot } from 'valtio'
import { cx } from '@emotion/css'
@ -25,11 +25,11 @@ const AccentColor = () => {
}
const changeColor = (color: string) => {
state.settings.accentColor = color
settings.accentColor = color
changeAccentColor(color)
}
const accentColor = useSnapshot(state).settings.accentColor
const accentColor = useSnapshot(settings).accentColor
return (
<div className='mt-4'>
<div className='mb-2 dark:text-white'></div>

View file

@ -0,0 +1,19 @@
import { proxy, subscribe } from 'valtio'
interface PersistedUiStates {
loginPhoneCountryCode: string
loginType: 'phone' | 'email' | 'qrCode'
}
const initPersistedUiStates: PersistedUiStates = {
loginPhoneCountryCode: '+86',
loginType: 'qrCode',
}
const persistedUiStates = proxy<PersistedUiStates>(initPersistedUiStates)
subscribe(persistedUiStates, () => {
localStorage.setItem('persistedUiStates', JSON.stringify(persistedUiStates))
})
export default persistedUiStates

View file

@ -0,0 +1,18 @@
import { Player } from '@/web/utils/player'
import { proxy, subscribe } from 'valtio'
const playerInLocalStorage = localStorage.getItem('player')
const player = proxy(new Player())
player.init((playerInLocalStorage && JSON.parse(playerInLocalStorage)) || {})
subscribe(player, () => {
localStorage.setItem('player', JSON.stringify(player))
})
if (import.meta.env.DEV) {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
;(window as any).player = player
}
export default player

View file

@ -0,0 +1,53 @@
import { IpcChannels } from '@/shared/IpcChannels'
import { merge } from 'lodash-es'
import { proxy, subscribe } from 'valtio'
interface Settings {
accentColor: string
unm: {
enabled: boolean
sources: Array<
'migu' | 'kuwo' | 'kugou' | 'ytdl' | 'qq' | 'bilibili' | 'joox'
>
searchMode: 'order-first' | 'fast-first'
proxy: null | {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
username?: string
password?: string
}
cookies: {
qq?: string
joox?: string
}
}
}
const initSettings: Settings = {
accentColor: 'blue',
unm: {
enabled: true,
sources: ['migu'],
searchMode: 'order-first',
proxy: null,
cookies: {},
},
}
const settingsInLocalStorage = localStorage.getItem('settings')
const settings = proxy<Settings>(
merge(
initSettings,
settingsInLocalStorage ? JSON.parse(settingsInLocalStorage) : {}
)
)
subscribe(settings, () => {
localStorage.setItem('settings', JSON.stringify(settings))
})
subscribe(settings, () => {
window.ipcRenderer?.send(IpcChannels.SyncSettings, settings)
})
export default settings

View file

@ -0,0 +1,19 @@
import { proxy } from 'valtio'
interface UIStates {
showLyricPanel: boolean
showLoginPanel: boolean
hideTopbarBackground: boolean
librarySelectedTab: 'playlists' | 'albums' | 'artists' | 'videos'
mobileShowPlayingNext: boolean
}
const initUIStates: UIStates = {
showLyricPanel: false,
showLoginPanel: false,
hideTopbarBackground: false,
librarySelectedTab: 'playlists',
mobileShowPlayingNext: false,
}
export default proxy<UIStates>(initUIStates)

View file

@ -1,35 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { Player } from '@/web/utils/player'
import { merge } from 'lodash-es'
import { IpcChannels } from '@/shared/IpcChannels'
import { Store, initialState } from '@/shared/store'
const stateInLocalStorage = localStorage.getItem('state')
export const state = proxy<Store>(
merge(
initialState,
stateInLocalStorage ? JSON.parse(stateInLocalStorage) : {},
{
uiStates: initialState.uiStates,
}
)
)
subscribe(state, () => {
localStorage.setItem('state', JSON.stringify(state))
})
subscribe(state.settings, () => {
window.ipcRenderer?.send(IpcChannels.SyncSettings, { ...state.settings })
})
// player
const playerInLocalStorage = localStorage.getItem('player')
export const player = proxy(new Player())
player.init((playerInLocalStorage && JSON.parse(playerInLocalStorage)) || {})
subscribe(player, () => {
localStorage.setItem('player', JSON.stringify(player))
})
if (import.meta.env.DEV) {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
;(window as any).player = player
}

View file

@ -1,8 +1,17 @@
export const changeTheme = (theme: 'light' | 'dark') => {
document.body.setAttribute('class', theme)
if (!window.env?.isElectron) {
document.documentElement.style.background =
theme === 'dark' ? '#000' : '#fff'
}
}
export const changeAccentColor = (color: string) => {
document.body.setAttribute('data-accent-color', color)
}
const stateString = localStorage.getItem('state')
const stateInLocalStorage = stateString ? JSON.parse(stateString) : {}
const settingsInStorage = localStorage.getItem('settings')
const settings = settingsInStorage ? JSON.parse(settingsInStorage) : {}
changeAccentColor(stateInLocalStorage?.settings?.accentColor || 'blue')
changeTheme(settings.theme || 'dark')
changeAccentColor(settings?.accentColor || 'green')

View file

@ -85,7 +85,8 @@ export default defineConfig({
strictPort: IS_ELECTRON ? true : false,
proxy: {
'/netease/': {
target: `http://192.168.2.111:${
target: `http://192.168.50.111:${
// target: `http://127.0.0.1:${
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
}`,
changeOrigin: true,
@ -93,7 +94,6 @@ export default defineConfig({
},
'/yesplaymusic/video-cover': {
target: `http://168.138.40.199:51324`,
// target: `http://127.0.0.1:51324`,
changeOrigin: true,
},
'/yesplaymusic/': {

42
pnpm-lock.yaml generated
View file

@ -117,7 +117,6 @@ importers:
'@storybook/builder-vite': ^0.1.35
'@storybook/react': ^6.5.5
'@storybook/testing-library': ^0.0.11
'@tanstack/react-virtual': 3.0.0-beta.2
'@testing-library/react': ^13.3.0
'@types/howler': ^2.2.7
'@types/js-cookie': ^3.0.2
@ -163,6 +162,7 @@ importers:
react-query: ^3.38.0
react-router-dom: ^6.3.0
react-use: ^17.4.0
react-virtuoso: ^2.16.1
rollup-plugin-visualizer: ^5.6.0
storybook-tailwind-dark-mode: ^1.0.12
tailwindcss: ^3.0.24
@ -176,7 +176,6 @@ importers:
'@emotion/css': 11.9.0
'@sentry/react': 6.19.7_react@18.1.0
'@sentry/tracing': 6.19.7
'@tanstack/react-virtual': 3.0.0-beta.2
ahooks: 3.4.1_react@18.1.0
axios: 0.27.2
color.js: 1.2.0
@ -199,6 +198,7 @@ importers:
react-query: 3.39.1_ef5jwxihqo6n7gxfmzogljlgcm
react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm
react-use: 17.4.0_ef5jwxihqo6n7gxfmzogljlgcm
react-virtuoso: 2.16.1_ef5jwxihqo6n7gxfmzogljlgcm
valtio: 1.6.1_react@18.1.0+vite@2.9.9
devDependencies:
'@storybook/addon-actions': 6.5.5_ef5jwxihqo6n7gxfmzogljlgcm
@ -4539,10 +4539,6 @@ packages:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: true
/@reach/observe-rect/1.2.0:
resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==}
dev: false
/@rollup/plugin-babel/5.3.1_4kojsos35jimftt7mhjohcqk6y:
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'}
@ -6402,13 +6398,6 @@ packages:
defer-to-connect: 2.0.1
dev: true
/@tanstack/react-virtual/3.0.0-beta.2:
resolution: {integrity: sha512-pwA9URTHYXX/2PgIISoMcf1P77hxf5oI3L/IDQ19Q1xuAc76o2R2CwHv6vvl5fDhwVj5klOfBxJvuT61Lhy9/w==}
engines: {node: '>=12'}
dependencies:
'@reach/observe-rect': 1.2.0
dev: false
/@testing-library/dom/8.13.0:
resolution: {integrity: sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==}
engines: {node: '>=12'}
@ -7151,6 +7140,20 @@ packages:
url-toolkit: 2.2.5
dev: false
/@virtuoso.dev/react-urx/0.2.13_react@18.1.0:
resolution: {integrity: sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
dependencies:
'@virtuoso.dev/urx': 0.2.13
react: 18.1.0
dev: false
/@virtuoso.dev/urx/0.2.13:
resolution: {integrity: sha512-iirJNv92A1ZWxoOHHDYW/1KPoi83939o83iUBQHIim0i3tMeSKEh+bxhJdTHQ86Mr4uXx9xGUTq69cp52ZP8Xw==}
dev: false
/@vitejs/plugin-react/1.3.2:
resolution: {integrity: sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==}
engines: {node: '>=12.0.0'}
@ -16373,6 +16376,19 @@ packages:
tslib: 2.4.0
dev: false
/react-virtuoso/2.16.1_ef5jwxihqo6n7gxfmzogljlgcm:
resolution: {integrity: sha512-WpcHZedUe00XYSQ56KcdYmWy/oaiPPuweTYemC9gl8CbjchLTKqLPCJa51Yv32U9oj1XPAEMfxuaXM7NTtjOiw==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16 || >=17 || >= 18'
react-dom: '>=16 || >=17 || >= 18'
dependencies:
'@virtuoso.dev/react-urx': 0.2.13_react@18.1.0
'@virtuoso.dev/urx': 0.2.13
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
dev: false
/react/18.1.0:
resolution: {integrity: sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==}
engines: {node: '>=0.10.0'}