mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 13:17:46 +00:00
feat: updates
This commit is contained in:
parent
f340a90117
commit
cec4c5909d
50 changed files with 1304 additions and 207 deletions
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 草鞋没号
|
Copyright (c) 2022 qier222
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,11 @@ class Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromNetease = await getFromNetease(req)
|
const fromNetease = await getFromNetease(req)
|
||||||
if (fromNetease?.code === 200 && !fromNetease?.data?.[0].freeTrialInfo) {
|
if (
|
||||||
|
fromNetease?.code === 200 &&
|
||||||
|
!fromNetease?.data?.[0]?.freeTrialInfo &&
|
||||||
|
fromNetease?.data?.[0]?.url
|
||||||
|
) {
|
||||||
res.status(200).send(fromNetease)
|
res.status(200).send(fromNetease)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { FetchArtistAlbumsResponse, FetchArtistResponse } from './api/Artist'
|
import {
|
||||||
|
FetchArtistAlbumsResponse,
|
||||||
|
FetchArtistResponse,
|
||||||
|
FetchSimilarArtistsResponse,
|
||||||
|
} from './api/Artist'
|
||||||
import { FetchAlbumResponse } from './api/Album'
|
import { FetchAlbumResponse } from './api/Album'
|
||||||
import {
|
import {
|
||||||
FetchUserAccountResponse,
|
FetchUserAccountResponse,
|
||||||
|
|
@ -32,6 +36,7 @@ export const enum APIs {
|
||||||
UserAlbums = 'album/sublist',
|
UserAlbums = 'album/sublist',
|
||||||
UserArtists = 'artist/sublist',
|
UserArtists = 'artist/sublist',
|
||||||
UserPlaylist = 'user/playlist',
|
UserPlaylist = 'user/playlist',
|
||||||
|
SimilarArtist = 'simi/artist',
|
||||||
|
|
||||||
// not netease api
|
// not netease api
|
||||||
CoverColor = 'cover_color',
|
CoverColor = 'cover_color',
|
||||||
|
|
@ -53,6 +58,7 @@ export interface APIsParams {
|
||||||
[APIs.UserAlbums]: void
|
[APIs.UserAlbums]: void
|
||||||
[APIs.UserArtists]: void
|
[APIs.UserArtists]: void
|
||||||
[APIs.UserPlaylist]: void
|
[APIs.UserPlaylist]: void
|
||||||
|
[APIs.SimilarArtist]: { id: number }
|
||||||
[APIs.CoverColor]: { id: number }
|
[APIs.CoverColor]: { id: number }
|
||||||
[APIs.VideoCover]: { id: number }
|
[APIs.VideoCover]: { id: number }
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +78,7 @@ export interface APIsResponse {
|
||||||
[APIs.UserAlbums]: FetchUserAlbumsResponse
|
[APIs.UserAlbums]: FetchUserAlbumsResponse
|
||||||
[APIs.UserArtists]: FetchUserArtistsResponse
|
[APIs.UserArtists]: FetchUserArtistsResponse
|
||||||
[APIs.UserPlaylist]: FetchUserPlaylistsResponse
|
[APIs.UserPlaylist]: FetchUserPlaylistsResponse
|
||||||
|
[APIs.SimilarArtist]: FetchSimilarArtistsResponse
|
||||||
[APIs.CoverColor]: string | undefined
|
[APIs.CoverColor]: string | undefined
|
||||||
[APIs.VideoCover]: string | undefined
|
[APIs.VideoCover]: string | undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export enum ArtistApiNames {
|
export enum ArtistApiNames {
|
||||||
FetchArtist = 'fetchArtist',
|
FetchArtist = 'fetchArtist',
|
||||||
FetchArtistAlbums = 'fetchArtistAlbums',
|
FetchArtistAlbums = 'fetchArtistAlbums',
|
||||||
|
FetchSimilarArtists = 'fetchSimilarArtists',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 歌手详情
|
// 歌手详情
|
||||||
|
|
@ -26,3 +27,12 @@ export interface FetchArtistAlbumsResponse {
|
||||||
more: boolean
|
more: boolean
|
||||||
artist: Artist
|
artist: Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取相似歌手
|
||||||
|
export interface FetchSimilarArtistsParams {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
export interface FetchSimilarArtistsResponse {
|
||||||
|
code: number
|
||||||
|
artists: Artist[]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
export interface Store {
|
export interface Store {
|
||||||
uiStates: {
|
uiStates: {
|
||||||
loginPhoneCountryCode: string
|
|
||||||
showLyricPanel: boolean
|
showLyricPanel: boolean
|
||||||
|
showLoginPanel: boolean
|
||||||
|
}
|
||||||
|
persistedUiStates: {
|
||||||
|
loginPhoneCountryCode: string
|
||||||
|
loginType: 'phone' | 'email' | 'qrCode'
|
||||||
}
|
}
|
||||||
settings: {
|
settings: {
|
||||||
showSidebar: boolean
|
showSidebar: boolean
|
||||||
|
|
@ -29,8 +33,12 @@ export interface Store {
|
||||||
|
|
||||||
export const initialState: Store = {
|
export const initialState: Store = {
|
||||||
uiStates: {
|
uiStates: {
|
||||||
loginPhoneCountryCode: '+86',
|
|
||||||
showLyricPanel: false,
|
showLyricPanel: false,
|
||||||
|
showLoginPanel: false,
|
||||||
|
},
|
||||||
|
persistedUiStates: {
|
||||||
|
loginPhoneCountryCode: '+86',
|
||||||
|
loginType: 'qrCode',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
showSidebar: true,
|
showSidebar: true,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Devtool from '@/web/components/New/Devtool'
|
||||||
import ErrorBoundary from '@/web/components/New/ErrorBoundary'
|
import ErrorBoundary from '@/web/components/New/ErrorBoundary'
|
||||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||||
import LayoutMobile from '@/web/components/New/LayoutMobile'
|
import LayoutMobile from '@/web/components/New/LayoutMobile'
|
||||||
|
import ScrollRestoration from '@/web/components/New/ScrollRestoration'
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
@ -16,6 +17,7 @@ const App = () => {
|
||||||
{window.env?.isEnableTitlebar && <TitleBar />}
|
{window.env?.isEnableTitlebar && <TitleBar />}
|
||||||
{isMobile ? <LayoutMobile /> : <Layout />}
|
{isMobile ? <LayoutMobile /> : <Layout />}
|
||||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||||
|
<ScrollRestoration />
|
||||||
<IpcRendererReact />
|
<IpcRendererReact />
|
||||||
<Devtool />
|
<Devtool />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
FetchArtistResponse,
|
FetchArtistResponse,
|
||||||
FetchArtistAlbumsParams,
|
FetchArtistAlbumsParams,
|
||||||
FetchArtistAlbumsResponse,
|
FetchArtistAlbumsResponse,
|
||||||
|
FetchSimilarArtistsParams,
|
||||||
|
FetchSimilarArtistsResponse,
|
||||||
} from '@/shared/api/Artist'
|
} from '@/shared/api/Artist'
|
||||||
|
|
||||||
// 歌手详情
|
// 歌手详情
|
||||||
|
|
@ -30,3 +32,13 @@ export function fetchArtistAlbums(
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchSimilarArtists(
|
||||||
|
params: FetchSimilarArtistsParams
|
||||||
|
): Promise<FetchSimilarArtistsResponse> {
|
||||||
|
return request({
|
||||||
|
url: 'simi/artist',
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
27
packages/web/api/hooks/useSimilarArtists.ts
Normal file
27
packages/web/api/hooks/useSimilarArtists.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { fetchSimilarArtists } from '@/web/api/artist'
|
||||||
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
|
import {
|
||||||
|
FetchSimilarArtistsParams,
|
||||||
|
ArtistApiNames,
|
||||||
|
FetchSimilarArtistsResponse,
|
||||||
|
} from '@/shared/api/Artist'
|
||||||
|
import { useQuery } from 'react-query'
|
||||||
|
|
||||||
|
export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
|
||||||
|
return useQuery(
|
||||||
|
[ArtistApiNames.FetchSimilarArtists, params],
|
||||||
|
() => fetchSimilarArtists(params),
|
||||||
|
{
|
||||||
|
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 mins
|
||||||
|
placeholderData: (): FetchSimilarArtistsResponse =>
|
||||||
|
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||||
|
api: APIs.SimilarArtist,
|
||||||
|
query: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
FetchUserAlbumsResponse,
|
FetchUserAlbumsResponse,
|
||||||
} from '@/shared/api/User'
|
} from '@/shared/api/User'
|
||||||
import { fetchUserAlbums } from '../user'
|
import { fetchUserAlbums } from '../user'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
||||||
const { data: user } = useUser()
|
const { data: user } = useUser()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Image from './Image'
|
import Image from './Image'
|
||||||
|
|
||||||
const Artist = ({ artist }: { artist: Artist }) => {
|
const Artist = ({ artist }: { artist: Artist }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const to = () => {
|
||||||
|
navigate(`/artist/${artist.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<Image
|
<Image
|
||||||
alt={artist.name}
|
onClick={to}
|
||||||
src={resizeImage(artist.img1v1Url, 'md')}
|
src={resizeImage(artist.img1v1Url, 'md')}
|
||||||
className={cx(
|
className={cx(
|
||||||
'aspect-square rounded-full',
|
'aspect-square rounded-full',
|
||||||
|
|
@ -17,7 +23,10 @@ const Artist = ({ artist }: { artist: Artist }) => {
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className='line-clamp-1 mt-2.5 text-12 font-medium text-neutral-700 dark:text-neutral-600 lg:text-14 lg:font-bold'>
|
<div
|
||||||
|
onClick={to}
|
||||||
|
className='line-clamp-1 mt-2.5 text-12 font-medium text-neutral-700 dark:text-neutral-600 lg:text-14 lg:font-bold'
|
||||||
|
>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
51
packages/web/components/New/ArtistsInLine.tsx
Normal file
51
packages/web/components/New/ArtistsInLine.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
|
const ArtistInline = ({
|
||||||
|
artists,
|
||||||
|
className,
|
||||||
|
disableLink,
|
||||||
|
onClick,
|
||||||
|
hoverClassName,
|
||||||
|
}: {
|
||||||
|
artists: Artist[]
|
||||||
|
className?: string
|
||||||
|
hoverClassName?: string
|
||||||
|
disableLink?: boolean
|
||||||
|
onClick?: (artistId: number) => void
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const handleClick = (id: number) => {
|
||||||
|
if (id === 0 || disableLink) return
|
||||||
|
if (!onClick) {
|
||||||
|
navigate(`/artist/${id}`)
|
||||||
|
} else {
|
||||||
|
onClick(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artists) return <div></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
!className?.includes('line-clamp') && 'line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{artists.map((artist, index) => (
|
||||||
|
<span key={`${artist.id}-${artist.name}`}>
|
||||||
|
<span
|
||||||
|
onClick={() => handleClick(artist.id)}
|
||||||
|
className={cx(!!artist.id && !disableLink && hoverClassName)}
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</span>
|
||||||
|
{index < artists.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtistInline
|
||||||
|
|
@ -9,7 +9,6 @@ import { memo, useCallback } from 'react'
|
||||||
const Album = ({ album }: { album: Album }) => {
|
const Album = ({ album }: { album: Album }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const goTo = () => {
|
const goTo = () => {
|
||||||
console.log('dsada')
|
|
||||||
navigate(`/album/${album.id}`)
|
navigate(`/album/${album.id}`)
|
||||||
}
|
}
|
||||||
const prefetch = () => {
|
const prefetch = () => {
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,26 @@ import { player } from '@/web/store'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import Router from '@/web/components/New/Router'
|
import Router from '@/web/components/New/Router'
|
||||||
import MenuBar from './MenuBar'
|
import MenuBar from './MenuBar'
|
||||||
import TopbarMobile from './Topbar/TopbarMobile'
|
import Topbar from './Topbar/TopbarMobile'
|
||||||
import { isIOS, isPWA, isSafari } from '@/web/utils/common'
|
import { isIOS, isPWA, isSafari } from '@/web/utils/common'
|
||||||
import Login from './Login'
|
import Login from './Login'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import PlayingNext from './PlayingNextMobile'
|
||||||
|
|
||||||
const LayoutMobile = () => {
|
const LayoutMobile = () => {
|
||||||
const playerSnapshot = useSnapshot(player)
|
const playerSnapshot = useSnapshot(player)
|
||||||
const showPlayer = !!playerSnapshot.track
|
const showPlayer = !!playerSnapshot.track
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id='layout' className='select-none bg-white pb-28 pt-3 dark:bg-black'>
|
<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'>
|
<main className='min-h-screen overflow-y-auto overflow-x-hidden pb-16'>
|
||||||
<TopbarMobile />
|
{location.pathname === '/' && <Topbar />}
|
||||||
<Router />
|
<Router />
|
||||||
</main>
|
</main>
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'fixed bottom-0 left-0 right-0 pt-3 dark:bg-black',
|
'fixed bottom-0 left-0 right-0 z-20 pt-3 dark:bg-black',
|
||||||
css`
|
css`
|
||||||
padding-bottom: calc(
|
padding-bottom: calc(
|
||||||
${isIOS && isSafari && isPWA
|
${isIOS && isSafari && isPWA
|
||||||
|
|
@ -34,7 +37,7 @@ const LayoutMobile = () => {
|
||||||
{showPlayer && (
|
{showPlayer && (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute left-7 right-7',
|
'absolute left-7 right-7 z-20',
|
||||||
css`
|
css`
|
||||||
top: calc(
|
top: calc(
|
||||||
-100% - 6px + ${isIOS && isSafari && isPWA ? '24px' : 'env(safe-area-inset-bottom)'}
|
-100% - 6px + ${isIOS && isSafari && isPWA ? '24px' : 'env(safe-area-inset-bottom)'}
|
||||||
|
|
@ -47,6 +50,7 @@ const LayoutMobile = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MenuBar />
|
<MenuBar />
|
||||||
|
{/* <PlayingNext /> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Login />
|
<Login />
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,150 @@
|
||||||
import { cx, css } from '@emotion/css'
|
import { cx, css } from '@emotion/css'
|
||||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import qrCode from 'qrcode'
|
import { state } from '@/web/store'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
|
||||||
const [image, setImage] = useState<string>('')
|
import { ease } from '@/web/utils/const'
|
||||||
|
import Icon from '@/web/components/Icon'
|
||||||
useEffect(() => {
|
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
|
||||||
if (text) {
|
import LoginWithQRCode from './LoginWithQRCode'
|
||||||
qrCode
|
|
||||||
.toString(text, {
|
|
||||||
margin: 0,
|
|
||||||
color: { light: '#ffffff00' },
|
|
||||||
type: 'svg',
|
|
||||||
})
|
|
||||||
.then(image => {
|
|
||||||
setImage(image)
|
|
||||||
console.log(image)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [text])
|
|
||||||
|
|
||||||
const encodedImage = useMemo(() => encodeURIComponent(image), [image])
|
|
||||||
|
|
||||||
|
const OR = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<img
|
<>
|
||||||
className={cx('', className)}
|
<div className='mt-4 flex items-center'>
|
||||||
src={`data:image/svg+xml;utf8,${encodedImage}`}
|
<div className='h-px flex-grow bg-white/20'></div>
|
||||||
></img>
|
<div className='mx-2 text-16 font-medium text-white'>or</div>
|
||||||
|
<div className='h-px flex-grow bg-white/20'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 flex justify-center'>
|
||||||
|
<button
|
||||||
|
className='text-16 font-medium text-night-50 transition-colors duration-300 hover:text-white'
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
return <div></div>
|
const { uiStates, persistedUiStates } = useSnapshot(state)
|
||||||
|
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
|
||||||
|
persistedUiStates.loginType === 'qrCode' ? 'qrCode' : 'phone/email'
|
||||||
|
)
|
||||||
|
|
||||||
|
const animateCard = useAnimation()
|
||||||
|
const handleSwitchCard = async () => {
|
||||||
|
const transition = { duration: 0.36, ease }
|
||||||
|
await animateCard.start({
|
||||||
|
rotateY: 90,
|
||||||
|
opacity: 0,
|
||||||
|
transition,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCardType(cardType === 'qrCode' ? 'phone/email' : 'qrCode')
|
||||||
|
state.persistedUiStates.loginType =
|
||||||
|
cardType === 'qrCode' ? 'phone' : 'qrCode'
|
||||||
|
|
||||||
|
await animateCard.start({
|
||||||
|
rotateY: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='fixed inset-0 z-30 flex justify-center rounded-24 bg-black/80 pt-56 backdrop-blur-3xl'>
|
<>
|
||||||
<div className='flex flex-col items-center'>
|
{/* Blur bg */}
|
||||||
<div
|
<AnimatePresence>
|
||||||
className={cx(
|
{uiStates.showLoginPanel && (
|
||||||
'rounded-48 bg-white/10 p-9',
|
<motion.div
|
||||||
css`
|
className='fixed inset-0 z-30 bg-black/80 backdrop-blur-3xl lg:rounded-24'
|
||||||
width: 392px;
|
initial={{ opacity: 0 }}
|
||||||
height: fit-content;
|
animate={{ opacity: 1 }}
|
||||||
`
|
exit={{ opacity: 0 }}
|
||||||
)}
|
transition={{ duration: 0.5, ease }}
|
||||||
>
|
></motion.div>
|
||||||
<div className='text-center text-18 font-medium text-night-600'>
|
)}
|
||||||
Log in with NetEase QR
|
</AnimatePresence>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-4 rounded-24 bg-white p-2.5'>
|
{/* Content */}
|
||||||
<QRCode
|
<AnimatePresence>
|
||||||
text='tetesoahfoahdodaoshdoaish'
|
{uiStates.showLoginPanel && (
|
||||||
className={css`
|
<div className='fixed inset-0 z-30 flex justify-center rounded-24 pt-56'>
|
||||||
border-radius: 17px;
|
<motion.div
|
||||||
`}
|
className='flex flex-col items-center'
|
||||||
/>
|
variants={{
|
||||||
</div>
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease,
|
||||||
|
delay: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hide: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 100,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
initial='hide'
|
||||||
|
animate='show'
|
||||||
|
exit='hide'
|
||||||
|
>
|
||||||
|
{/* Login card */}
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
animate={animateCard}
|
||||||
|
className={cx(
|
||||||
|
'relative rounded-48 bg-white/10 p-9',
|
||||||
|
css`
|
||||||
|
width: 392px;
|
||||||
|
height: fit-content;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cardType === 'qrCode' && <LoginWithQRCode />}
|
||||||
|
{cardType === 'phone/email' && <LoginWithPhoneOrEmail />}
|
||||||
|
|
||||||
<div className='mt-4 flex items-center'>
|
<OR onClick={handleSwitchCard}>
|
||||||
<div className='h-px flex-grow bg-white/20'></div>
|
{cardType === 'qrCode'
|
||||||
<div className='mx-2 text-16 font-medium text-white'>or</div>
|
? 'Use Phone or Email'
|
||||||
<div className='h-px flex-grow bg-white/20'></div>
|
: 'Scan QR Code'}
|
||||||
</div>
|
</OR>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className='mt-4 flex justify-center'>
|
{/* Close button */}
|
||||||
<button className='text-16 font-medium text-night-50'>
|
<AnimatePresence>
|
||||||
Use Phone or Email
|
<motion.div
|
||||||
</button>
|
layout='position'
|
||||||
|
transition={{ ease }}
|
||||||
|
onClick={() => (state.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' />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
{/* Close */}
|
</>
|
||||||
<div className='mt-10 h-14 w-14 rounded-full bg-white/10'></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
238
packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx
Normal file
238
packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
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'
|
||||||
|
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 { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
const LoginWithPhoneOrEmail = () => {
|
||||||
|
const { persistedUiStates } = useSnapshot(state)
|
||||||
|
const [email, setEmail] = useState<string>('')
|
||||||
|
const [countryCode, setCountryCode] = useState<string>(
|
||||||
|
persistedUiStates.loginPhoneCountryCode || '+86'
|
||||||
|
)
|
||||||
|
const [phone, setPhone] = useState<string>('')
|
||||||
|
const [password, setPassword] = useState<string>('')
|
||||||
|
const [loginType, setLoginType] = useState<'phone' | 'email'>(
|
||||||
|
persistedUiStates.loginType === 'email' ? 'email' : 'phone'
|
||||||
|
)
|
||||||
|
|
||||||
|
const doEmailLogin = useMutation(
|
||||||
|
() =>
|
||||||
|
loginWithEmail({
|
||||||
|
email: email.trim(),
|
||||||
|
md5_password: md5(password.trim()),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onSuccess: result => {
|
||||||
|
if (result?.code !== 200) {
|
||||||
|
toast(`Login failed: ${result.code}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCookies(result.cookie)
|
||||||
|
|
||||||
|
state.uiStates.showLoginPanel = false
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
toast(`Login failed: ${error}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEmailLogin = () => {
|
||||||
|
if (!email) {
|
||||||
|
toast.error('Please enter email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
toast.error('Please enter password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
email.match(
|
||||||
|
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
|
||||||
|
) == null
|
||||||
|
) {
|
||||||
|
toast.error('Please use netease email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doEmailLogin.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const doPhoneLogin = useMutation(
|
||||||
|
() => {
|
||||||
|
return loginWithPhone({
|
||||||
|
countrycode: Number(countryCode.replace('+', '').trim()) || 86,
|
||||||
|
phone: phone.trim(),
|
||||||
|
md5_password: md5(password.trim()),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: result => {
|
||||||
|
if (result?.code !== 200) {
|
||||||
|
toast(`Login failed: ${result.code}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCookies(result.cookie)
|
||||||
|
state.uiStates.showLoginPanel = false
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
toast(`Login failed: ${error}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePhoneLogin = () => {
|
||||||
|
if (!countryCode || !Number(countryCode.replace('+', '').trim())) {
|
||||||
|
toast.error('Please enter country code')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!phone) {
|
||||||
|
toast.error('Please enter phone number')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
toast.error('Please enter password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doPhoneLogin.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition = {
|
||||||
|
duration: 0.5,
|
||||||
|
ease,
|
||||||
|
}
|
||||||
|
const variants = {
|
||||||
|
hidden: {
|
||||||
|
opacity: 0,
|
||||||
|
transition,
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='text-center text-18 font-medium text-night-600'>
|
||||||
|
Log in with{' '}
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'transition-colors duration-300',
|
||||||
|
loginType === 'phone' ? 'text-brand-600' : 'hover:text-night-50'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
const type = loginType === 'phone' ? 'email' : 'phone'
|
||||||
|
setLoginType(type)
|
||||||
|
state.persistedUiStates.loginType = type
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Phone
|
||||||
|
</span>{' '}
|
||||||
|
/{' '}
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'transition-colors duration-300',
|
||||||
|
loginType === 'email' ? 'text-brand-600' : 'hover:text-night-50'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (loginType !== 'email') setLoginType('email')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone input */}
|
||||||
|
{loginType === 'phone' && (
|
||||||
|
<div className='mt-4 rounded-12 bg-black/50 px-3 text-16 font-medium text-night-50'>
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={variants}
|
||||||
|
initial='hidden'
|
||||||
|
animate='show'
|
||||||
|
exit='hidden'
|
||||||
|
className='flex items-center'
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
onChange={e => {
|
||||||
|
setCountryCode(e.target.value)
|
||||||
|
state.persistedUiStates.loginPhoneCountryCode = e.target.value
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
'my-3.5 flex-shrink-0 bg-transparent',
|
||||||
|
css`
|
||||||
|
width: 28px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
placeholder='+86'
|
||||||
|
value={countryCode}
|
||||||
|
/>
|
||||||
|
<div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div>
|
||||||
|
<input
|
||||||
|
onChange={e => setPhone(e.target.value)}
|
||||||
|
className='my-3.5 flex-grow appearance-none bg-transparent'
|
||||||
|
placeholder='Phone'
|
||||||
|
type='number'
|
||||||
|
value={phone}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email input */}
|
||||||
|
{loginType === 'email' && (
|
||||||
|
<div className='mt-4 flex items-center rounded-12 bg-black/50 px-3 py-3.5 text-16 font-medium text-night-50'>
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={variants}
|
||||||
|
initial='hidden'
|
||||||
|
animate='show'
|
||||||
|
exit='hidden'
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className='flex-grow appearance-none bg-transparent'
|
||||||
|
placeholder='Email'
|
||||||
|
type='email'
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password input */}
|
||||||
|
<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'
|
||||||
|
placeholder='Password'
|
||||||
|
type='password'
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login button */}
|
||||||
|
<div
|
||||||
|
onClick={() =>
|
||||||
|
loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin()
|
||||||
|
}
|
||||||
|
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
|
||||||
|
>
|
||||||
|
LOG IN
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginWithPhoneOrEmail
|
||||||
129
packages/web/components/New/Login/LoginWithQRCode.tsx
Normal file
129
packages/web/components/New/Login/LoginWithQRCode.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
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 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'
|
||||||
|
|
||||||
|
const QRCode = ({ className, text }: { className?: string; text: string }) => {
|
||||||
|
const [image, setImage] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (text) {
|
||||||
|
qrCode
|
||||||
|
.toString(text, {
|
||||||
|
margin: 0,
|
||||||
|
color: { light: '#ffffff00' },
|
||||||
|
type: 'svg',
|
||||||
|
})
|
||||||
|
.then(image => {
|
||||||
|
setImage(image)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
const encodedImage = useMemo(() => encodeURIComponent(image), [image])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={cx('aspect-square', className)}
|
||||||
|
src={text ? `data:image/svg+xml;utf8,${encodedImage}` : undefined}
|
||||||
|
></img>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginWithQRCode = () => {
|
||||||
|
const {
|
||||||
|
data: key,
|
||||||
|
status: keyStatus,
|
||||||
|
refetch: refetchKey,
|
||||||
|
} = useQuery(
|
||||||
|
'qrCodeKey',
|
||||||
|
async () => {
|
||||||
|
const result = await fetchLoginQrCodeKey()
|
||||||
|
if (result.data.code !== 200) {
|
||||||
|
throw Error(`Failed to fetch QR code key: ${result.data.code}`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cacheTime: 0,
|
||||||
|
retry: true,
|
||||||
|
retryDelay: 500,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: 1000 * 60 * 5, // 5 min
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: status } = useQuery(
|
||||||
|
'qrCodeStatus',
|
||||||
|
async () => checkLoginQrCodeStatus({ key: key?.data?.unikey || '' }),
|
||||||
|
{
|
||||||
|
refetchInterval: 1000,
|
||||||
|
enabled: !!key?.data?.unikey,
|
||||||
|
onSuccess: status => {
|
||||||
|
switch (status.code) {
|
||||||
|
case 800:
|
||||||
|
refetchKey()
|
||||||
|
break
|
||||||
|
case 801:
|
||||||
|
// setQrCodeMessage('打开网易云音乐,扫码登录')
|
||||||
|
break
|
||||||
|
case 802:
|
||||||
|
// setQrCodeMessage('等待确认')
|
||||||
|
break
|
||||||
|
case 803:
|
||||||
|
if (!status.cookie) {
|
||||||
|
toast('checkLoginQrCodeStatus returned 803 without cookie')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setCookies(status.cookie)
|
||||||
|
state.uiStates.showLoginPanel = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const text = useMemo(
|
||||||
|
() =>
|
||||||
|
key?.data?.unikey
|
||||||
|
? `https://music.163.com/login?codekey=${key.data.unikey}`
|
||||||
|
: '',
|
||||||
|
[key?.data?.unikey]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='text-center text-18 font-medium text-night-600'>
|
||||||
|
Log in with NetEase QR
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 rounded-24 bg-white p-2.5'>
|
||||||
|
<QRCode
|
||||||
|
text={text}
|
||||||
|
className={cx(
|
||||||
|
'w-full',
|
||||||
|
css`
|
||||||
|
border-radius: 17px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginWithQRCode
|
||||||
|
|
@ -4,7 +4,6 @@ import Router from './Router'
|
||||||
const Main = () => {
|
const Main = () => {
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
id='main'
|
|
||||||
className={cx(
|
className={cx(
|
||||||
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
|
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
|
||||||
css`
|
css`
|
||||||
|
|
|
||||||
|
|
@ -114,14 +114,16 @@ const Tabs = () => {
|
||||||
scale: { scale: 0.8 },
|
scale: { scale: 0.8 },
|
||||||
reset: { scale: 1 },
|
reset: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
|
className={cx(
|
||||||
|
active === tab.path
|
||||||
|
? 'text-brand-600 dark:text-brand-700'
|
||||||
|
: 'lg:hover:text-black lg:dark:hover:text-white'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name={tab.icon}
|
name={tab.icon}
|
||||||
className={cx(
|
className={cx(
|
||||||
'app-region-no-drag h-10 w-10 transition-colors duration-500',
|
'app-region-no-drag h-10 w-10 transition-colors duration-500'
|
||||||
active === tab.path
|
|
||||||
? 'text-brand-600 dark:text-brand-700'
|
|
||||||
: 'lg:hover:text-black lg:dark:hover:text-white'
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -130,12 +132,13 @@ const Tabs = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuBar = () => {
|
const MenuBar = ({ className }: { className?: string }) => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'app-region-drag relative flex h-full w-full flex-col justify-center',
|
'app-region-drag relative flex h-full w-full flex-col justify-center',
|
||||||
|
className,
|
||||||
css`
|
css`
|
||||||
grid-area: menubar;
|
grid-area: menubar;
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||||
import Slider from './Slider'
|
import Slider from './Slider'
|
||||||
import { animate, motion, useAnimation } from 'framer-motion'
|
import { animate, motion, useAnimation } from 'framer-motion'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
|
import useUserLikedTracksIDs, {
|
||||||
|
useMutationLikeATrack,
|
||||||
|
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
|
import ArtistInline from './ArtistsInLine'
|
||||||
|
|
||||||
const Progress = () => {
|
const Progress = () => {
|
||||||
const { track, progress } = useSnapshot(player)
|
const { track, progress } = useSnapshot(player)
|
||||||
|
|
@ -79,6 +83,24 @@ const Cover = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HeartButton = () => {
|
||||||
|
const { track } = useSnapshot(player)
|
||||||
|
const { data: likedIDs } = useUserLikedTracksIDs()
|
||||||
|
|
||||||
|
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
|
||||||
|
|
||||||
|
const likeATrack = useMutationLikeATrack()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => track?.id && likeATrack.mutateAsync(track.id)}>
|
||||||
|
<Icon
|
||||||
|
name={isLiked ? 'heart' : 'heart-outline'}
|
||||||
|
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const NowPlaying = () => {
|
const NowPlaying = () => {
|
||||||
const { state, track } = useSnapshot(player)
|
const { state, track } = useSnapshot(player)
|
||||||
|
|
||||||
|
|
@ -100,9 +122,11 @@ const NowPlaying = () => {
|
||||||
<div className='line-clamp-1 text-lg text-black dark:text-white'>
|
<div className='line-clamp-1 text-lg text-black dark:text-white'>
|
||||||
{track?.name}
|
{track?.name}
|
||||||
</div>
|
</div>
|
||||||
<div className='line-clamp-1 text-base text-black/30 dark:text-white/30'>
|
<ArtistInline
|
||||||
{track?.ar.map(a => a.name).join(', ')}
|
artists={track?.ar || []}
|
||||||
</div>
|
className='text-black/30 dark:text-white/30'
|
||||||
|
hoverClassName='hover:text-black/50 dark:hover:text-white/50 transition-colors duration-500'
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dividing line */}
|
{/* Dividing line */}
|
||||||
<div className='mt-2 h-px w-2/3 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>
|
||||||
|
|
@ -149,12 +173,7 @@ const NowPlaying = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button>
|
<HeartButton />
|
||||||
<Icon
|
|
||||||
name='heart'
|
|
||||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||||
|
import scrollPositions from '@/web/store/scrollPositions'
|
||||||
|
import { useLayoutEffect } from 'react'
|
||||||
|
|
||||||
const PageTransition = ({
|
const PageTransition = ({
|
||||||
children,
|
children,
|
||||||
|
|
@ -10,6 +12,15 @@ const PageTransition = ({
|
||||||
disableEnterAnimation?: boolean
|
disableEnterAnimation?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
// To restore scroll position
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const main = document.querySelector('main')
|
||||||
|
if (main) {
|
||||||
|
main.scrollTop = scrollPositions.get(window.location.pathname) ?? 0
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,39 +22,55 @@ const PlayerMobile = () => {
|
||||||
info: PanInfo
|
info: PanInfo
|
||||||
) => {
|
) => {
|
||||||
console.log(JSON.stringify(info))
|
console.log(JSON.stringify(info))
|
||||||
const x = info.offset.x
|
const { x, y } = info.offset
|
||||||
const offset = 100
|
const offset = 100
|
||||||
if (x > offset) player.prevTrack()
|
if (y > -100) {
|
||||||
if (x < -offset) player.nextTrack()
|
if (x > offset) player.prevTrack()
|
||||||
|
if (x < -offset) player.nextTrack()
|
||||||
|
}
|
||||||
setLocked(false)
|
setLocked(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const y = useMotionValue(0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'relative flex h-16 w-full items-center rounded-20 px-3',
|
'relative z-20 flex h-16 w-full items-center rounded-20 px-3',
|
||||||
css`
|
css`
|
||||||
background-color: ${bgColor.to};
|
background-color: ${bgColor.to};
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
{/* Indictor */}
|
||||||
|
<motion.div
|
||||||
|
drag='y'
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
style={{ y: y.get() * 2 }}
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute -top-2.5 h-1.5 w-10 rounded-full bg-brand-700',
|
'absolute flex items-center justify-center',
|
||||||
css`
|
css`
|
||||||
left: calc((100% - 40px) / 2);
|
--width: 60px;
|
||||||
|
--height: 26px;
|
||||||
|
left: calc((100% - var(--width)) / 2);
|
||||||
|
top: calc(var(--height) * -1);
|
||||||
|
height: var(--height);
|
||||||
|
width: var(--width);
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
></div>
|
>
|
||||||
|
<div className='h-1.5 w-10 rounded-full bg-brand-700'></div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Cover */}
|
||||||
<div className='h-full py-2.5'>
|
<div className='h-full py-2.5'>
|
||||||
<Image
|
<Image
|
||||||
src={resizeImage(track?.al.picUrl || '', 'sm')}
|
src={resizeImage(track?.al.picUrl || '', 'sm')}
|
||||||
alt='Cover'
|
|
||||||
className='z-10 aspect-square h-full rounded-lg'
|
className='z-10 aspect-square h-full rounded-lg'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
<div className='relative flex h-full flex-grow items-center overflow-hidden px-3'>
|
<div className='relative flex h-full flex-grow items-center overflow-hidden px-3'>
|
||||||
<motion.div
|
<motion.div
|
||||||
drag='x'
|
drag='x'
|
||||||
|
|
@ -93,10 +109,12 @@ const PlayerMobile = () => {
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Like */}
|
||||||
<button>
|
<button>
|
||||||
<Icon name='heart' className='h-7 w-7 text-white/10' />
|
<Icon name='heart' className='h-7 w-7 text-white/10' />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Play or pause */}
|
||||||
<button
|
<button
|
||||||
onClick={() => player.playOrPause()}
|
onClick={() => player.playOrPause()}
|
||||||
className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5'
|
className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5'
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ const TrackList = ({ className }: { className?: string }) => {
|
||||||
|
|
||||||
{/* 底部渐变遮罩 */}
|
{/* 底部渐变遮罩 */}
|
||||||
<div
|
<div
|
||||||
className='pointer-events-none absolute right-0 left-0 z-20 h-14 bg-gradient-to-t from-black to-transparent'
|
className='pointer-events-none absolute right-0 left-0 z-20 hidden h-14 bg-gradient-to-t from-black to-transparent lg:block'
|
||||||
style={{ top: `${listHeight - 56}px` }}
|
style={{ top: `${listHeight - 56}px` }}
|
||||||
></div>
|
></div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
16
packages/web/components/New/PlayingNextMobile.tsx
Normal file
16
packages/web/components/New/PlayingNextMobile.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useLockBodyScroll } from 'react-use'
|
||||||
|
import PlayingNext from './PlayingNext'
|
||||||
|
|
||||||
|
const PlayingNextMobile = () => {
|
||||||
|
useLockBodyScroll(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='fixed inset-0 z-10 bg-black/80 backdrop-blur-3xl'>
|
||||||
|
<div className='px-7'>
|
||||||
|
<PlayingNext />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlayingNextMobile
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { Route, RouteObject, Routes, useLocation } from 'react-router-dom'
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import Login from '@/web/pages/Login'
|
|
||||||
import Artist from '@/web/pages/Artist'
|
|
||||||
import Search from '@/web/pages/Search'
|
import Search from '@/web/pages/Search'
|
||||||
import Library from '@/web/pages/Library'
|
|
||||||
import Settings from '@/web/pages/Settings'
|
import Settings from '@/web/pages/Settings'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
import { AnimatePresence } from 'framer-motion'
|
||||||
import React, { ReactNode, Suspense } from 'react'
|
import React, { ReactNode, Suspense } from 'react'
|
||||||
|
|
@ -12,54 +9,10 @@ const Discover = React.lazy(() => import('@/web/pages/New/Discover'))
|
||||||
const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
|
const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
|
||||||
const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
||||||
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
|
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
|
||||||
|
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
|
||||||
|
|
||||||
const routes: RouteObject[] = [
|
const lazy = (component: ReactNode) => {
|
||||||
{
|
return <Suspense>{component}</Suspense>
|
||||||
path: '/',
|
|
||||||
element: <My />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/discover',
|
|
||||||
element: <Discover />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/library',
|
|
||||||
element: <Library />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings',
|
|
||||||
element: <Settings />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
element: <Login />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/search/:keywords',
|
|
||||||
element: <Search />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: ':type',
|
|
||||||
element: <Search />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/playlist/:id',
|
|
||||||
element: <Playlist />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/album/:id',
|
|
||||||
element: <Album />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/artist/:id',
|
|
||||||
element: <Artist />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const lazy = (components: ReactNode) => {
|
|
||||||
return <Suspense>{components}</Suspense>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Router = () => {
|
const Router = () => {
|
||||||
|
|
@ -71,9 +24,13 @@ const Router = () => {
|
||||||
<Route path='/' element={lazy(<My />)} />
|
<Route path='/' element={lazy(<My />)} />
|
||||||
<Route path='/discover' element={lazy(<Discover />)} />
|
<Route path='/discover' element={lazy(<Discover />)} />
|
||||||
<Route path='/browse' element={lazy(<Browse />)} />
|
<Route path='/browse' element={lazy(<Browse />)} />
|
||||||
<Route path='/login' element={lazy(<Login />)} />
|
|
||||||
<Route path='/album/:id' element={lazy(<Album />)} />
|
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
||||||
|
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
||||||
|
<Route path='/settings' element={lazy(<Settings />)} />
|
||||||
|
<Route path='/search/:keywords' element={lazy(<Search />)}>
|
||||||
|
<Route path=':type' element={lazy(<Search />)} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
20
packages/web/components/New/ScrollRestoration.tsx
Normal file
20
packages/web/components/New/ScrollRestoration.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useLayoutEffect } from 'react'
|
||||||
|
import scrollPositions from '@/web/store/scrollPositions'
|
||||||
|
import { throttle } from 'lodash-es'
|
||||||
|
|
||||||
|
const ScrollRestoration = () => {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const main = document.querySelector('main')
|
||||||
|
const handleScroll = throttle(() => {
|
||||||
|
scrollPositions.set(window.location.pathname, main?.scrollTop ?? 0)
|
||||||
|
}, 100)
|
||||||
|
main?.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
|
main?.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScrollRestoration
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import Icon from '../../Icon'
|
import Icon from '../../Icon'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import useUser from '@/web/api/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
|
import { state } from '@/web/store'
|
||||||
|
|
||||||
const Avatar = ({ className }: { className?: string }) => {
|
const Avatar = ({ className }: { className?: string }) => {
|
||||||
const navigate = useNavigate()
|
|
||||||
const { data: user } = useUser()
|
const { data: user } = useUser()
|
||||||
|
|
||||||
const avatarUrl = user?.profile?.avatarUrl
|
const avatarUrl = user?.profile?.avatarUrl
|
||||||
|
|
@ -17,7 +16,7 @@ const Avatar = ({ className }: { className?: string }) => {
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
onClick={() => navigate('/login')}
|
onClick={() => (state.uiStates.showLoginPanel = true)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'app-region-no-drag rounded-full',
|
'app-region-no-drag rounded-full',
|
||||||
className || 'h-12 w-12'
|
className || 'h-12 w-12'
|
||||||
|
|
@ -25,7 +24,7 @@ const Avatar = ({ className }: { className?: string }) => {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate('/login')}
|
onClick={() => (state.uiStates.showLoginPanel = true)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||||
className || 'h-12 w-12'
|
className || 'h-12 w-12'
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { css, cx } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import Icon from '../../Icon'
|
import Icon from '../../Icon'
|
||||||
import { breakpoint as bp } from '@/web/utils/const'
|
import { breakpoint as bp } from '@/web/utils/const'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
const SearchBox = () => {
|
const SearchBox = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
|
|
@ -25,6 +30,13 @@ const SearchBox = () => {
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
navigate(`/search/${searchText}`)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const TopbarDesktop = () => {
|
||||||
!location.pathname.startsWith('/album/') &&
|
!location.pathname.startsWith('/album/') &&
|
||||||
!location.pathname.startsWith('/playlist/') &&
|
!location.pathname.startsWith('/playlist/') &&
|
||||||
!location.pathname.startsWith('/browse') &&
|
!location.pathname.startsWith('/browse') &&
|
||||||
|
!location.pathname.startsWith('/artist') &&
|
||||||
css`
|
css`
|
||||||
background-image: url(${topbarBackground});
|
background-image: url(${topbarBackground});
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { css } from '@emotion/css'
|
import { css, cx } from '@emotion/css'
|
||||||
import Avatar from './Avatar'
|
import Avatar from './Avatar'
|
||||||
import SearchBox from './SearchBox'
|
import SearchBox from './SearchBox'
|
||||||
import SettingsButton from './SettingsButton'
|
import SettingsButton from './SettingsButton'
|
||||||
|
|
||||||
const TopbarMobile = () => {
|
const TopbarMobile = () => {
|
||||||
return (
|
return (
|
||||||
<div className='mb-5 mt-7 flex px-2.5'>
|
<div className='mb-5 mt-10 flex px-2.5'>
|
||||||
<div className='flex-grow'>
|
<div className='flex-grow'>
|
||||||
<SearchBox />
|
<SearchBox />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import useVideoCover from '@/web/hooks/useVideoCover'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ease } from '@/web/utils/const'
|
import { ease } from '@/web/utils/const'
|
||||||
import { injectGlobal } from '@emotion/css'
|
import { injectGlobal } from '@emotion/css'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
injectGlobal`
|
injectGlobal`
|
||||||
.plyr__video-wrapper,
|
.plyr__video-wrapper,
|
||||||
|
|
@ -115,6 +116,7 @@ const TrackListHeader = ({
|
||||||
playlist?: Playlist
|
playlist?: Playlist
|
||||||
onPlay: () => void
|
onPlay: () => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const albumDuration = useMemo(() => {
|
const albumDuration = useMemo(() => {
|
||||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||||
|
|
@ -142,8 +144,15 @@ const TrackListHeader = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Creator */}
|
{/* Creator */}
|
||||||
<div className='mt-2.5 text-24 font-medium dark:text-night-400 lg:mt-6'>
|
<div className='mt-2.5 lg:mt-6'>
|
||||||
{album?.artist.name || playlist?.creator.nickname}
|
<span
|
||||||
|
onClick={() => {
|
||||||
|
if (album?.artist?.id) navigate(`/artist/${album?.artist?.id}`)
|
||||||
|
}}
|
||||||
|
className='text-24 font-medium transition-colors duration-300 dark:text-night-400 hover:dark:text-neutral-100 '
|
||||||
|
>
|
||||||
|
{album?.artist.name || playlist?.creator.nickname}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Extra info */}
|
{/* Extra info */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,18 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
|
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||||
|
|
||||||
const TrafficLight = () => {
|
const TrafficLight = () => {
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
||||||
|
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
|
||||||
|
setIsMaximized(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isMaximized) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
const className =
|
const className =
|
||||||
'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
|
'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getCoverColor } from '../utils/common'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export default function useCoverColor(url: string) {
|
export default function useCoverColor(url: string) {
|
||||||
const [color, setColor] = useState({ from: '#fff', to: '#fff' })
|
const [color, setColor] = useState({ from: '#000', to: '#000' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCoverColor(url || '').then(color => {
|
getCoverColor(url || '').then(color => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { IpcChannelsParams, IpcChannelsReturns } from '@/shared/IpcChannels'
|
import { IpcChannels, IpcChannelsReturns } from '@/shared/IpcChannels'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
const useIpcRenderer = <T extends keyof IpcChannelsParams>(
|
const useIpcRenderer = <T extends IpcChannels>(
|
||||||
channcel: T,
|
channel: T,
|
||||||
listener: (event: any, value: IpcChannelsReturns[T]) => void
|
listener: (event: any, value: IpcChannelsReturns[T]) => void
|
||||||
) => {
|
) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.ipcRenderer?.on(channcel, listener)
|
return window.ipcRenderer?.on(channel, listener)
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"storybook": "start-storybook -p 6006",
|
"storybook": "start-storybook -p 6006",
|
||||||
"storybook:build": "build-storybook",
|
"storybook:build": "build-storybook",
|
||||||
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
|
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
|
||||||
"api:netease": "npx NeteaseCloudMusicApi"
|
"api:netease": "npx NeteaseCloudMusicApi@latest"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.13.1 || >=16.0.0"
|
"node": "^14.13.1 || >=16.0.0"
|
||||||
|
|
|
||||||
31
packages/web/pages/New/Artist/Artist.tsx
Normal file
31
packages/web/pages/New/Artist/Artist.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import useArtist from '@/web/api/hooks/useArtist'
|
||||||
|
import { cx, css } from '@emotion/css'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import Header from './Header'
|
||||||
|
import Popular from './Popular'
|
||||||
|
import ArtistAlbum from './ArtistAlbums'
|
||||||
|
import FansAlsoLike from './FansAlsoLike'
|
||||||
|
|
||||||
|
const Artist = () => {
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
const { data: artist, isLoading: isLoadingArtist } = useArtist({
|
||||||
|
id: Number(params.id) || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header artist={artist?.artist} />
|
||||||
|
|
||||||
|
<div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div>
|
||||||
|
|
||||||
|
<Popular tracks={artist?.hotSongs} />
|
||||||
|
|
||||||
|
<ArtistAlbum />
|
||||||
|
|
||||||
|
<FansAlsoLike />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Artist
|
||||||
27
packages/web/pages/New/Artist/ArtistAlbums.tsx
Normal file
27
packages/web/pages/New/Artist/ArtistAlbums.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||||
|
import CoverRow from '@/web/components/New/CoverRow'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
const ArtistAlbum = () => {
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||||
|
id: Number(params.id) || 0,
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const albums = useMemo(() => albumsRaw?.hotAlbums, [albumsRaw?.hotAlbums])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='mb-4 mt-11 text-12 font-medium uppercase text-neutral-300'>
|
||||||
|
Albums
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoverRow albums={albums?.slice(0, 12)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtistAlbum
|
||||||
20
packages/web/pages/New/Artist/FansAlsoLike.tsx
Normal file
20
packages/web/pages/New/Artist/FansAlsoLike.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||||
|
import useSimilarArtists from '@/web/api/hooks/useSimilarArtists'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
const FansAlsoLike = () => {
|
||||||
|
const params = useParams()
|
||||||
|
const { data: artists } = useSimilarArtists({ id: Number(params.id) || 0 })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'>
|
||||||
|
Fans Also Like
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArtistRow artists={artists?.artists?.slice(0, 5)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FansAlsoLike
|
||||||
13
packages/web/pages/New/Artist/Header/Actions.tsx
Normal file
13
packages/web/pages/New/Artist/Header/Actions.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
const Actions = () => {
|
||||||
|
return (
|
||||||
|
<div className='mt-11 flex items-end justify-between lg:z-10 lg:mt-6'>
|
||||||
|
<div className='flex items-end'>
|
||||||
|
<button className='mr-2.5 h-14 w-14 rounded-full dark:bg-white/10'></button>
|
||||||
|
<button className='h-14 w-14 rounded-full dark:bg-white/10'></button>
|
||||||
|
</div>
|
||||||
|
<button className='h-14 w-[125px] rounded-full dark:bg-brand-700 lg:w-[170px]'></button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Actions
|
||||||
28
packages/web/pages/New/Artist/Header/ArtistInfo.tsx
Normal file
28
packages/web/pages/New/Artist/Header/ArtistInfo.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||||
|
|
||||||
|
const ArtistInfo = ({ artist }: { artist?: Artist }) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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'>
|
||||||
|
Artist
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-12 font-medium text-night-400'>
|
||||||
|
{artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '}
|
||||||
|
{artist?.mvSize} Videos
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className='line-clamp-5 mt-6 text-14 font-bold text-night-400'>
|
||||||
|
{artist?.briefDesc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtistInfo
|
||||||
26
packages/web/pages/New/Artist/Header/BlurBackground.tsx
Normal file
26
packages/web/pages/New/Artist/Header/BlurBackground.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
||||||
65
packages/web/pages/New/Artist/Header/Header.tsx
Normal file
65
packages/web/pages/New/Artist/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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 ArtistInfo from './ArtistInfo'
|
||||||
|
import Actions from './Actions'
|
||||||
|
import LatestRelease from './LatestRelease'
|
||||||
|
|
||||||
|
const Header = ({ artist }: { artist?: Artist }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'lg:grid lg:gap-10',
|
||||||
|
css`
|
||||||
|
grid-template-columns: auto 558px;
|
||||||
|
grid-template-areas:
|
||||||
|
'info cover'
|
||||||
|
'info cover';
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className={cx(
|
||||||
|
'z-10 aspect-square lg:rounded-24',
|
||||||
|
css`
|
||||||
|
grid-area: cover;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
src={resizeImage(artist?.img1v1Url || '', 'lg')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BlurBackground cover={artist?.img1v1Url} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'lg:flex lg:flex-col lg:justify-between',
|
||||||
|
css`
|
||||||
|
grid-area: info;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'mx-2.5 rounded-48 bg-white/10 p-8 backdrop-blur-3xl lg:mx-0 lg:bg-transparent lg:p-0 lg:backdrop-blur-none',
|
||||||
|
css`
|
||||||
|
margin-top: -60px;
|
||||||
|
${bp.lg} {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArtistInfo artist={artist} />
|
||||||
|
<Actions />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LatestRelease />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
97
packages/web/pages/New/Artist/Header/LatestRelease.tsx
Normal file
97
packages/web/pages/New/Artist/Header/LatestRelease.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { cx, css } from '@emotion/css'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import Image from '@/web/components/New/Image'
|
||||||
|
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
const Album = () => {
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||||
|
id: Number(params.id) || 0,
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const album = useMemo(() => albumsRaw?.hotAlbums?.[0], [albumsRaw?.hotAlbums])
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate(`/album/${album.id}`)}
|
||||||
|
className='flex rounded-24 bg-white/10 p-2.5'
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={resizeImage(album.picUrl, 'sm')}
|
||||||
|
className={cx(
|
||||||
|
'aspect-square',
|
||||||
|
css`
|
||||||
|
height: 60px;
|
||||||
|
width: 60px;
|
||||||
|
border-radius: 16px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<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 text-14 font-bold text-night-500'>
|
||||||
|
{album.type}
|
||||||
|
{album.size > 1 ? `· ${album.size} Tracks` : ''}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1.5 text-12 font-medium text-night-500'>
|
||||||
|
{dayjs(album?.publishTime || 0).format('MMM DD, YYYY')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Video = () => {
|
||||||
|
return (
|
||||||
|
<div className='mt-4 flex rounded-24 bg-white/10 p-2.5'>
|
||||||
|
<Image
|
||||||
|
src={resizeImage(
|
||||||
|
'https://p1.music.126.net/am47BH30IGQit_L2vYaArg==/109951167502760845.jpg',
|
||||||
|
'sm'
|
||||||
|
)}
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
height: 60px;
|
||||||
|
width: 106px;
|
||||||
|
border-radius: 16px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className='flex-shrink-1 ml-2'>
|
||||||
|
<div className='line-clamp-2 text-16 font-medium text-night-100'>
|
||||||
|
Swedish House Mafia & The Weeknd Live at C...
|
||||||
|
</div>
|
||||||
|
<div className='mt-1.5 text-12 font-medium text-night-500'>
|
||||||
|
{dayjs().format('MMM DD, YYYY')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LatestRelease = () => {
|
||||||
|
return (
|
||||||
|
<div className='mx-2.5 lg:mx-0'>
|
||||||
|
<div className='mt-7 mb-3 text-14 font-bold text-neutral-300'>
|
||||||
|
Latest Releases
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Album />
|
||||||
|
<Video />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LatestRelease
|
||||||
3
packages/web/pages/New/Artist/Header/index.tsx
Normal file
3
packages/web/pages/New/Artist/Header/index.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Header from './Header'
|
||||||
|
|
||||||
|
export default Header
|
||||||
76
packages/web/pages/New/Artist/Popular.tsx
Normal file
76
packages/web/pages/New/Artist/Popular.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { State as PlayerState } from '@/web/utils/player'
|
||||||
|
import useTracks from '@/web/api/hooks/useTracks'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import Image from '@/web/components/New/Image'
|
||||||
|
|
||||||
|
const Track = ({
|
||||||
|
track,
|
||||||
|
isPlaying,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
track?: Track
|
||||||
|
isPlaying?: boolean
|
||||||
|
onPlay: (id: number) => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='flex items-center justify-between'
|
||||||
|
onClick={e => {
|
||||||
|
if (e.detail === 2 && track?.id) onPlay(track.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cover */}
|
||||||
|
<Image
|
||||||
|
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='mr-3 flex-grow'>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'line-clamp-1 text-16 font-medium ',
|
||||||
|
isPlaying
|
||||||
|
? 'text-brand-700'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{track?.name}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Popular = ({ tracks }: { tracks?: Track[] }) => {
|
||||||
|
const onPlay = (id: number) => {
|
||||||
|
if (!tracks) return
|
||||||
|
player.playAList(
|
||||||
|
tracks.map(t => t.id),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='mb-4 text-12 font-medium uppercase text-neutral-300'>
|
||||||
|
Popular
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'>
|
||||||
|
{tracks?.slice(0, 9)?.map(t => (
|
||||||
|
<Track key={t.id} track={t} onPlay={onPlay} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Popular
|
||||||
3
packages/web/pages/New/Artist/index.tsx
Normal file
3
packages/web/pages/New/Artist/index.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Artist from './Artist'
|
||||||
|
|
||||||
|
export default Artist
|
||||||
|
|
@ -16,6 +16,7 @@ import topbarBackground from '@/web/assets/images/topbar-background.png'
|
||||||
const reactQueryOptions = {
|
const reactQueryOptions = {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchInterval: 1000 * 60 * 60, // 1 hour
|
refetchInterval: 1000 * 60 * 60, // 1 hour
|
||||||
|
refetchOnMount: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Recommend = () => {
|
const Recommend = () => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { sampleSize } from 'lodash-es'
|
import { sampleSize } from 'lodash-es'
|
||||||
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
||||||
|
import { useQuery } from 'react-query'
|
||||||
|
|
||||||
interface DiscoverAlbum {
|
interface DiscoverAlbum {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -82,28 +83,31 @@ const getAlbumsFromAPI = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Discover = () => {
|
const Discover = () => {
|
||||||
const [albums, setAlbums] = useState<DiscoverAlbum[]>([])
|
const { data: albums } = useQuery(
|
||||||
|
['DiscoveryAlbums'],
|
||||||
useEffect(() => {
|
async () => {
|
||||||
const get = async () => {
|
|
||||||
const albumsInLocalStorageTime =
|
const albumsInLocalStorageTime =
|
||||||
localStorage.getItem('discoverAlbumsTime')
|
localStorage.getItem('discoverAlbumsTime')
|
||||||
if (
|
if (
|
||||||
!albumsInLocalStorageTime ||
|
!albumsInLocalStorageTime ||
|
||||||
Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次
|
Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次
|
||||||
) {
|
) {
|
||||||
setAlbums(await getAlbumsFromAPI())
|
return await getAlbumsFromAPI()
|
||||||
} else {
|
} else {
|
||||||
setAlbums(JSON.parse(localStorage.getItem('discoverAlbums') || '[]'))
|
return JSON.parse(localStorage.getItem('discoverAlbums') || '[]')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 1000 * 60 * 60 * 2, // 2hr
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: 1000 * 60 * 60 * 2, // 2hr
|
||||||
}
|
}
|
||||||
get()
|
)
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransition disableEnterAnimation={true}>
|
<PageTransition disableEnterAnimation={true}>
|
||||||
<div className='mx-2.5 pb-10 lg:mx-0 lg:pb-16'>
|
<div className='mx-2.5 pb-10 lg:mx-0 lg:pb-16'>
|
||||||
<CoverWall albums={albums} />
|
<CoverWall albums={albums || []} />
|
||||||
</div>
|
</div>
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@ import { Store, initialState } from '@/shared/store'
|
||||||
|
|
||||||
const stateInLocalStorage = localStorage.getItem('state')
|
const stateInLocalStorage = localStorage.getItem('state')
|
||||||
export const state = proxy<Store>(
|
export const state = proxy<Store>(
|
||||||
merge(initialState, [
|
merge(
|
||||||
|
initialState,
|
||||||
stateInLocalStorage ? JSON.parse(stateInLocalStorage) : {},
|
stateInLocalStorage ? JSON.parse(stateInLocalStorage) : {},
|
||||||
{
|
{
|
||||||
uiStates: {
|
uiStates: initialState.uiStates,
|
||||||
showLyricPanel: false,
|
}
|
||||||
},
|
)
|
||||||
},
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
subscribe(state, () => {
|
subscribe(state, () => {
|
||||||
localStorage.setItem('state', JSON.stringify(state))
|
localStorage.setItem('state', JSON.stringify(state))
|
||||||
|
|
@ -31,5 +30,6 @@ subscribe(player, () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extra-semi
|
||||||
;(window as any).player = player
|
;(window as any).player = player
|
||||||
}
|
}
|
||||||
61
packages/web/store/scrollPositions.ts
Normal file
61
packages/web/store/scrollPositions.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
class ScrollPositions {
|
||||||
|
private _nestedPaths: string[] = ['/artist', '/album', '/playlist', '/search']
|
||||||
|
private _positions: Record<string, { path: string; top: number }[]> = {}
|
||||||
|
private _generalPositions: Record<string, number> = {}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._nestedPaths.forEach(path => (this._positions[path] = []))
|
||||||
|
}
|
||||||
|
|
||||||
|
get(pathname: string) {
|
||||||
|
const nestedPath = `/${pathname.split('/')[1]}`
|
||||||
|
const restPath = pathname.split('/').slice(2).join('/')
|
||||||
|
if (this._nestedPaths.includes(nestedPath)) {
|
||||||
|
return this._positions?.[nestedPath]?.find(
|
||||||
|
({ path }) => path === restPath
|
||||||
|
)?.top
|
||||||
|
} else {
|
||||||
|
return this._generalPositions?.[pathname]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(pathname: string, top: number) {
|
||||||
|
console.log('set', pathname, top)
|
||||||
|
const nestedPath = `/${pathname.split('/')[1]}`
|
||||||
|
const restPath = pathname.split('/').slice(2).join('/')
|
||||||
|
|
||||||
|
// set general position
|
||||||
|
if (!this._nestedPaths.includes(nestedPath)) {
|
||||||
|
this._generalPositions[pathname] = top
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// set nested position
|
||||||
|
const existsPath = this._positions[nestedPath].find(
|
||||||
|
p => p.path === restPath
|
||||||
|
)
|
||||||
|
if (existsPath) {
|
||||||
|
existsPath.top = top
|
||||||
|
this._positions[nestedPath] = this._positions[nestedPath].filter(
|
||||||
|
p => p.path !== restPath
|
||||||
|
)
|
||||||
|
this._positions[nestedPath].push(existsPath)
|
||||||
|
} else {
|
||||||
|
this._positions[nestedPath].push({ path: restPath, top })
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete oldest position when there are more than 10
|
||||||
|
if (this._positions[nestedPath].length > 10) {
|
||||||
|
this._positions[nestedPath].shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPositions = new ScrollPositions()
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extra-semi
|
||||||
|
;(window as any).scrollPositions = scrollPositions
|
||||||
|
}
|
||||||
|
|
||||||
|
export default scrollPositions
|
||||||
|
|
@ -18,24 +18,6 @@
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-pressed-animation {
|
|
||||||
@apply transition-transform duration-300;
|
|
||||||
}
|
|
||||||
.btn-pressed-animation:active {
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-hover-animation {
|
|
||||||
@apply relative transform;
|
|
||||||
}
|
|
||||||
.btn-hover-animation:after {
|
|
||||||
@apply absolute top-0 left-0 z-[-1] h-full w-full scale-[.92] rounded-lg opacity-0 transition-all duration-300;
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
.btn-hover-animation:hover::after {
|
|
||||||
@apply scale-100 opacity-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-clamp-1 {
|
.line-clamp-1 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|
@ -48,7 +30,6 @@
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
word-break: break-all;
|
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +37,6 @@
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
word-break: break-all;
|
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +44,13 @@
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
word-break: break-all;
|
-webkit-line-clamp: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-5 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,11 +101,11 @@ input {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overscroll-behavior: contain;
|
@apply overscroll-contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background-color: black;
|
@apply bg-black lg:bg-transparent;
|
||||||
min-height: calc(100% + env(safe-area-inset-top));
|
min-height: calc(100% + env(safe-area-inset-top));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +116,7 @@ input {
|
||||||
|
|
||||||
a,
|
a,
|
||||||
button {
|
button {
|
||||||
cursor: default;
|
@apply cursor-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
|
|
@ -144,5 +130,11 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
@apply m-0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue