diff --git a/LICENSE b/LICENSE index 8fd5175..b6a55a6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 草鞋没号 +Copyright (c) 2022 qier222 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/electron/main/server.ts b/packages/electron/main/server.ts index 7300d08..c161029 100644 --- a/packages/electron/main/server.ts +++ b/packages/electron/main/server.ts @@ -255,7 +255,11 @@ class Server { } 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) return } diff --git a/packages/shared/CacheAPIs.ts b/packages/shared/CacheAPIs.ts index e358f5a..43fd46c 100644 --- a/packages/shared/CacheAPIs.ts +++ b/packages/shared/CacheAPIs.ts @@ -1,4 +1,8 @@ -import { FetchArtistAlbumsResponse, FetchArtistResponse } from './api/Artist' +import { + FetchArtistAlbumsResponse, + FetchArtistResponse, + FetchSimilarArtistsResponse, +} from './api/Artist' import { FetchAlbumResponse } from './api/Album' import { FetchUserAccountResponse, @@ -32,6 +36,7 @@ export const enum APIs { UserAlbums = 'album/sublist', UserArtists = 'artist/sublist', UserPlaylist = 'user/playlist', + SimilarArtist = 'simi/artist', // not netease api CoverColor = 'cover_color', @@ -53,6 +58,7 @@ export interface APIsParams { [APIs.UserAlbums]: void [APIs.UserArtists]: void [APIs.UserPlaylist]: void + [APIs.SimilarArtist]: { id: number } [APIs.CoverColor]: { id: number } [APIs.VideoCover]: { id: number } } @@ -72,6 +78,7 @@ export interface APIsResponse { [APIs.UserAlbums]: FetchUserAlbumsResponse [APIs.UserArtists]: FetchUserArtistsResponse [APIs.UserPlaylist]: FetchUserPlaylistsResponse + [APIs.SimilarArtist]: FetchSimilarArtistsResponse [APIs.CoverColor]: string | undefined [APIs.VideoCover]: string | undefined } diff --git a/packages/shared/api/Artist.ts b/packages/shared/api/Artist.ts index 54a594c..2fd75b2 100644 --- a/packages/shared/api/Artist.ts +++ b/packages/shared/api/Artist.ts @@ -1,6 +1,7 @@ export enum ArtistApiNames { FetchArtist = 'fetchArtist', FetchArtistAlbums = 'fetchArtistAlbums', + FetchSimilarArtists = 'fetchSimilarArtists', } // 歌手详情 @@ -26,3 +27,12 @@ export interface FetchArtistAlbumsResponse { more: boolean artist: Artist } + +// 获取相似歌手 +export interface FetchSimilarArtistsParams { + id: number +} +export interface FetchSimilarArtistsResponse { + code: number + artists: Artist[] +} diff --git a/packages/shared/store.ts b/packages/shared/store.ts index 1e1871f..a3aa387 100644 --- a/packages/shared/store.ts +++ b/packages/shared/store.ts @@ -1,7 +1,11 @@ export interface Store { uiStates: { - loginPhoneCountryCode: string showLyricPanel: boolean + showLoginPanel: boolean + } + persistedUiStates: { + loginPhoneCountryCode: string + loginType: 'phone' | 'email' | 'qrCode' } settings: { showSidebar: boolean @@ -29,8 +33,12 @@ export interface Store { export const initialState: Store = { uiStates: { - loginPhoneCountryCode: '+86', showLyricPanel: false, + showLoginPanel: false, + }, + persistedUiStates: { + loginPhoneCountryCode: '+86', + loginType: 'qrCode', }, settings: { showSidebar: true, diff --git a/packages/web/AppNew.tsx b/packages/web/AppNew.tsx index b9cc2b1..96ed082 100644 --- a/packages/web/AppNew.tsx +++ b/packages/web/AppNew.tsx @@ -6,6 +6,7 @@ import Devtool from '@/web/components/New/Devtool' import ErrorBoundary from '@/web/components/New/ErrorBoundary' import useIsMobile from '@/web/hooks/useIsMobile' import LayoutMobile from '@/web/components/New/LayoutMobile' +import ScrollRestoration from '@/web/components/New/ScrollRestoration' const App = () => { const isMobile = useIsMobile() @@ -16,6 +17,7 @@ const App = () => { {window.env?.isEnableTitlebar && } {isMobile ? : } + diff --git a/packages/web/api/artist.ts b/packages/web/api/artist.ts index e797c94..6ee5919 100644 --- a/packages/web/api/artist.ts +++ b/packages/web/api/artist.ts @@ -4,6 +4,8 @@ import { FetchArtistResponse, FetchArtistAlbumsParams, FetchArtistAlbumsResponse, + FetchSimilarArtistsParams, + FetchSimilarArtistsResponse, } from '@/shared/api/Artist' // 歌手详情 @@ -30,3 +32,13 @@ export function fetchArtistAlbums( params, }) } + +export function fetchSimilarArtists( + params: FetchSimilarArtistsParams +): Promise { + return request({ + url: 'simi/artist', + method: 'get', + params, + }) +} diff --git a/packages/web/api/hooks/useSimilarArtists.ts b/packages/web/api/hooks/useSimilarArtists.ts new file mode 100644 index 0000000..9878fa3 --- /dev/null +++ b/packages/web/api/hooks/useSimilarArtists.ts @@ -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, + }, + }), + } + ) +} diff --git a/packages/web/api/hooks/useUserAlbums.ts b/packages/web/api/hooks/useUserAlbums.ts index d63434a..12819fa 100644 --- a/packages/web/api/hooks/useUserAlbums.ts +++ b/packages/web/api/hooks/useUserAlbums.ts @@ -9,6 +9,7 @@ import { FetchUserAlbumsResponse, } from '@/shared/api/User' import { fetchUserAlbums } from '../user' +import toast from 'react-hot-toast' export default function useUserAlbums(params: FetchUserAlbumsParams = {}) { const { data: user } = useUser() diff --git a/packages/web/components/New/ArtistRow.tsx b/packages/web/components/New/ArtistRow.tsx index 9d322bd..1f955d2 100644 --- a/packages/web/components/New/ArtistRow.tsx +++ b/packages/web/components/New/ArtistRow.tsx @@ -1,13 +1,19 @@ import { resizeImage } from '@/web/utils/common' import { css, cx } from '@emotion/css' import { memo } from 'react' +import { useNavigate } from 'react-router-dom' import Image from './Image' const Artist = ({ artist }: { artist: Artist }) => { + const navigate = useNavigate() + const to = () => { + navigate(`/artist/${artist.id}`) + } + return (
{artist.name} { ` )} /> -
+
{artist.name}
diff --git a/packages/web/components/New/ArtistsInLine.tsx b/packages/web/components/New/ArtistsInLine.tsx new file mode 100644 index 0000000..232eb90 --- /dev/null +++ b/packages/web/components/New/ArtistsInLine.tsx @@ -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
+ + return ( +
+ {artists.map((artist, index) => ( + + handleClick(artist.id)} + className={cx(!!artist.id && !disableLink && hoverClassName)} + > + {artist.name} + + {index < artists.length - 1 ? ', ' : ''}  + + ))} +
+ ) +} + +export default ArtistInline diff --git a/packages/web/components/New/CoverRow.tsx b/packages/web/components/New/CoverRow.tsx index 635af5c..1125769 100644 --- a/packages/web/components/New/CoverRow.tsx +++ b/packages/web/components/New/CoverRow.tsx @@ -9,7 +9,6 @@ import { memo, useCallback } from 'react' const Album = ({ album }: { album: Album }) => { const navigate = useNavigate() const goTo = () => { - console.log('dsada') navigate(`/album/${album.id}`) } const prefetch = () => { diff --git a/packages/web/components/New/LayoutMobile.tsx b/packages/web/components/New/LayoutMobile.tsx index 413fdfa..0a37eea 100644 --- a/packages/web/components/New/LayoutMobile.tsx +++ b/packages/web/components/New/LayoutMobile.tsx @@ -5,23 +5,26 @@ import { player } from '@/web/store' import { useSnapshot } from 'valtio' import Router from '@/web/components/New/Router' import MenuBar from './MenuBar' -import TopbarMobile from './Topbar/TopbarMobile' +import Topbar from './Topbar/TopbarMobile' import { isIOS, isPWA, isSafari } from '@/web/utils/common' import Login from './Login' +import { useLocation } from 'react-router-dom' +import PlayingNext from './PlayingNextMobile' const LayoutMobile = () => { const playerSnapshot = useSnapshot(player) const showPlayer = !!playerSnapshot.track + const location = useLocation() return ( -
+
- + {location.pathname === '/' && }
{ {showPlayer && (
{ )} + {/* */}
diff --git a/packages/web/components/New/Login/Login.tsx b/packages/web/components/New/Login/Login.tsx index 2a1042e..103f719 100644 --- a/packages/web/components/New/Login/Login.tsx +++ b/packages/web/components/New/Login/Login.tsx @@ -1,79 +1,150 @@ import { cx, css } from '@emotion/css' -import { useEffect, useRef, useState, useMemo } from 'react' -import qrCode from 'qrcode' +import { useEffect, useState } from 'react' +import { state } from '@/web/store' +import { useSnapshot } from 'valtio' -const QRCode = ({ className, text }: { className?: string; text: string }) => { - const [image, setImage] = useState('') - - useEffect(() => { - if (text) { - qrCode - .toString(text, { - margin: 0, - color: { light: '#ffffff00' }, - type: 'svg', - }) - .then(image => { - setImage(image) - console.log(image) - }) - } - }, [text]) - - const encodedImage = useMemo(() => encodeURIComponent(image), [image]) +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' +const OR = ({ + children, + onClick, +}: { + children: React.ReactNode + onClick: () => void +}) => { return ( - + <> +
+
+
or
+
+
+ +
+ +
+ ) } const Login = () => { - return
+ 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 ( -
-
-
-
- Log in with NetEase QR -
+ <> + {/* Blur bg */} + + {uiStates.showLoginPanel && ( + + )} + -
- -
+ {/* Content */} + + {uiStates.showLoginPanel && ( +
+ + {/* Login card */} + + + {cardType === 'qrCode' && } + {cardType === 'phone/email' && } -
-
-
or
-
-
+ + {cardType === 'qrCode' + ? 'Use Phone or Email' + : 'Scan QR Code'} + +
+
-
- + {/* Close button */} + + (state.uiStates.showLoginPanel = false)} + className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10' + > + + + +
-
- - {/* Close */} -
-
-
+ )} + + ) } diff --git a/packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx b/packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx new file mode 100644 index 0000000..c17774d --- /dev/null +++ b/packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx @@ -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('') + const [countryCode, setCountryCode] = useState( + persistedUiStates.loginPhoneCountryCode || '+86' + ) + const [phone, setPhone] = useState('') + const [password, setPassword] = useState('') + 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 ( + <> +
+ Log in with{' '} + { + const type = loginType === 'phone' ? 'email' : 'phone' + setLoginType(type) + state.persistedUiStates.loginType = type + }} + > + Phone + {' '} + /{' '} + { + if (loginType !== 'email') setLoginType('email') + }} + > + Email + +
+ + {/* Phone input */} + {loginType === 'phone' && ( +
+ + + { + 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} + /> +
+ setPhone(e.target.value)} + className='my-3.5 flex-grow appearance-none bg-transparent' + placeholder='Phone' + type='number' + value={phone} + /> +
+
+
+ )} + + {/* Email input */} + {loginType === 'email' && ( +
+ + + setEmail(e.target.value)} + className='flex-grow appearance-none bg-transparent' + placeholder='Email' + type='email' + value={email} + /> + + +
+ )} + + {/* Password input */} +
+ setPassword(e.target.value)} + className='bg-transparent' + placeholder='Password' + type='password' + value={password} + /> +
+ + {/* Login button */} +
+ loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin() + } + className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white' + > + LOG IN +
+ + ) +} + +export default LoginWithPhoneOrEmail diff --git a/packages/web/components/New/Login/LoginWithQRCode.tsx b/packages/web/components/New/Login/LoginWithQRCode.tsx new file mode 100644 index 0000000..ffcbecc --- /dev/null +++ b/packages/web/components/New/Login/LoginWithQRCode.tsx @@ -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('') + + 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 ( + + ) +} + +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 ( + <> +
+ Log in with NetEase QR +
+ +
+ +
+ + ) +} + +export default LoginWithQRCode diff --git a/packages/web/components/New/Main.tsx b/packages/web/components/New/Main.tsx index 6168614..e106a3f 100644 --- a/packages/web/components/New/Main.tsx +++ b/packages/web/components/New/Main.tsx @@ -4,7 +4,6 @@ import Router from './Router' const Main = () => { return (
{ scale: { scale: 0.8 }, reset: { scale: 1 }, }} + className={cx( + active === tab.path + ? 'text-brand-600 dark:text-brand-700' + : 'lg:hover:text-black lg:dark:hover:text-white' + )} > @@ -130,12 +132,13 @@ const Tabs = () => { ) } -const MenuBar = () => { +const MenuBar = ({ className }: { className?: string }) => { const isMobile = useIsMobile() return (
{ 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 ( + + ) +} + const NowPlaying = () => { const { state, track } = useSnapshot(player) @@ -100,9 +122,11 @@ const NowPlaying = () => {
{track?.name}
-
- {track?.ar.map(a => a.name).join(', ')} -
+ {/* Dividing line */}
@@ -149,12 +173,7 @@ const NowPlaying = () => {
- +
diff --git a/packages/web/components/New/PageTransition.tsx b/packages/web/components/New/PageTransition.tsx index 0f6f7e5..e8010d0 100644 --- a/packages/web/components/New/PageTransition.tsx +++ b/packages/web/components/New/PageTransition.tsx @@ -1,6 +1,8 @@ import { motion } from 'framer-motion' import { ease } from '@/web/utils/const' import useIsMobile from '@/web/hooks/useIsMobile' +import scrollPositions from '@/web/store/scrollPositions' +import { useLayoutEffect } from 'react' const PageTransition = ({ children, @@ -10,6 +12,15 @@ const PageTransition = ({ disableEnterAnimation?: boolean }) => { 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) { return <>{children} } diff --git a/packages/web/components/New/PlayerMobile.tsx b/packages/web/components/New/PlayerMobile.tsx index 162fc7f..a870d22 100644 --- a/packages/web/components/New/PlayerMobile.tsx +++ b/packages/web/components/New/PlayerMobile.tsx @@ -22,39 +22,55 @@ const PlayerMobile = () => { info: PanInfo ) => { console.log(JSON.stringify(info)) - const x = info.offset.x + const { x, y } = info.offset const offset = 100 - if (x > offset) player.prevTrack() - if (x < -offset) player.nextTrack() + if (y > -100) { + if (x > offset) player.prevTrack() + if (x < -offset) player.nextTrack() + } setLocked(false) } + const y = useMotionValue(0) + return (
-
+ > +
+ + {/* Cover */}
Cover
+ {/* Track info */}
{ >
+ {/* Like */} + {/* Play or pause */} + +
+ +
+ ) +} + +export default Actions diff --git a/packages/web/pages/New/Artist/Header/ArtistInfo.tsx b/packages/web/pages/New/Artist/Header/ArtistInfo.tsx new file mode 100644 index 0000000..5d9d39a --- /dev/null +++ b/packages/web/pages/New/Artist/Header/ArtistInfo.tsx @@ -0,0 +1,28 @@ +import useIsMobile from '@/web/hooks/useIsMobile' + +const ArtistInfo = ({ artist }: { artist?: Artist }) => { + const isMobile = useIsMobile() + return ( +
+
+ {artist?.name} +
+
+ Artist +
+
+ {artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '} + {artist?.mvSize} Videos +
+ + {/* Description */} + {!isMobile && ( +
+ {artist?.briefDesc} +
+ )} +
+ ) +} + +export default ArtistInfo diff --git a/packages/web/pages/New/Artist/Header/BlurBackground.tsx b/packages/web/pages/New/Artist/Header/BlurBackground.tsx new file mode 100644 index 0000000..bbe2395 --- /dev/null +++ b/packages/web/pages/New/Artist/Header/BlurBackground.tsx @@ -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 ? ( + <> + ) : ( + + ) +} + +export default BlurBackground diff --git a/packages/web/pages/New/Artist/Header/Header.tsx b/packages/web/pages/New/Artist/Header/Header.tsx new file mode 100644 index 0000000..f8f1238 --- /dev/null +++ b/packages/web/pages/New/Artist/Header/Header.tsx @@ -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 ( +
+ + + + +
+
+ + +
+ + +
+
+ ) +} + +export default Header diff --git a/packages/web/pages/New/Artist/Header/LatestRelease.tsx b/packages/web/pages/New/Artist/Header/LatestRelease.tsx new file mode 100644 index 0000000..f8bd3f4 --- /dev/null +++ b/packages/web/pages/New/Artist/Header/LatestRelease.tsx @@ -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 ( +
navigate(`/album/${album.id}`)} + className='flex rounded-24 bg-white/10 p-2.5' + > + +
+
+ {album.name} +
+
+ {album.type} + {album.size > 1 ? `· ${album.size} Tracks` : ''} +
+
+ {dayjs(album?.publishTime || 0).format('MMM DD, YYYY')} +
+
+
+ ) +} + +const Video = () => { + return ( +
+ +
+
+ Swedish House Mafia & The Weeknd Live at C... +
+
+ {dayjs().format('MMM DD, YYYY')} +
+
+
+ ) +} + +const LatestRelease = () => { + return ( +
+
+ Latest Releases +
+ + +
+ ) +} + +export default LatestRelease diff --git a/packages/web/pages/New/Artist/Header/index.tsx b/packages/web/pages/New/Artist/Header/index.tsx new file mode 100644 index 0000000..7cd29d7 --- /dev/null +++ b/packages/web/pages/New/Artist/Header/index.tsx @@ -0,0 +1,3 @@ +import Header from './Header' + +export default Header diff --git a/packages/web/pages/New/Artist/Popular.tsx b/packages/web/pages/New/Artist/Popular.tsx new file mode 100644 index 0000000..e9a476d --- /dev/null +++ b/packages/web/pages/New/Artist/Popular.tsx @@ -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 ( +
{ + if (e.detail === 2 && track?.id) onPlay(track.id) + }} + > + {/* Cover */} + + + {/* Track info */} +
+
+ {track?.name} +
+
+ {track?.ar.map(a => a.name).join(', ')} +
+
+
+ ) +} + +const Popular = ({ tracks }: { tracks?: Track[] }) => { + const onPlay = (id: number) => { + if (!tracks) return + player.playAList( + tracks.map(t => t.id), + id + ) + } + + return ( +
+
+ Popular +
+ +
+ {tracks?.slice(0, 9)?.map(t => ( + + ))} +
+
+ ) +} + +export default Popular diff --git a/packages/web/pages/New/Artist/index.tsx b/packages/web/pages/New/Artist/index.tsx new file mode 100644 index 0000000..c4a048c --- /dev/null +++ b/packages/web/pages/New/Artist/index.tsx @@ -0,0 +1,3 @@ +import Artist from './Artist' + +export default Artist diff --git a/packages/web/pages/New/Browse.tsx b/packages/web/pages/New/Browse.tsx index 8e55658..67a47c0 100644 --- a/packages/web/pages/New/Browse.tsx +++ b/packages/web/pages/New/Browse.tsx @@ -16,6 +16,7 @@ import topbarBackground from '@/web/assets/images/topbar-background.png' const reactQueryOptions = { refetchOnWindowFocus: false, refetchInterval: 1000 * 60 * 60, // 1 hour + refetchOnMount: false, } const Recommend = () => { diff --git a/packages/web/pages/New/Discover.tsx b/packages/web/pages/New/Discover.tsx index 62034c8..90c1748 100644 --- a/packages/web/pages/New/Discover.tsx +++ b/packages/web/pages/New/Discover.tsx @@ -8,6 +8,7 @@ import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks' import { useEffect, useState } from 'react' import { sampleSize } from 'lodash-es' import { FetchPlaylistResponse } from '@/shared/api/Playlists' +import { useQuery } from 'react-query' interface DiscoverAlbum { id: number @@ -82,28 +83,31 @@ const getAlbumsFromAPI = async () => { } const Discover = () => { - const [albums, setAlbums] = useState([]) - - useEffect(() => { - const get = async () => { + const { data: albums } = useQuery( + ['DiscoveryAlbums'], + async () => { const albumsInLocalStorageTime = localStorage.getItem('discoverAlbumsTime') if ( !albumsInLocalStorageTime || Date.now() - Number(albumsInLocalStorageTime) > 1000 * 60 * 60 * 2 // 2小时刷新一次 ) { - setAlbums(await getAlbumsFromAPI()) + return await getAlbumsFromAPI() } 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 (
- +
) diff --git a/packages/web/store.ts b/packages/web/store/index.ts similarity index 86% rename from packages/web/store.ts rename to packages/web/store/index.ts index d1f5e93..db2ca0e 100644 --- a/packages/web/store.ts +++ b/packages/web/store/index.ts @@ -6,14 +6,13 @@ import { Store, initialState } from '@/shared/store' const stateInLocalStorage = localStorage.getItem('state') export const state = proxy( - merge(initialState, [ + merge( + initialState, stateInLocalStorage ? JSON.parse(stateInLocalStorage) : {}, { - uiStates: { - showLyricPanel: false, - }, - }, - ]) + uiStates: initialState.uiStates, + } + ) ) subscribe(state, () => { localStorage.setItem('state', JSON.stringify(state)) @@ -31,5 +30,6 @@ subscribe(player, () => { }) if (import.meta.env.DEV) { + // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(window as any).player = player } diff --git a/packages/web/store/scrollPositions.ts b/packages/web/store/scrollPositions.ts new file mode 100644 index 0000000..62ef36e --- /dev/null +++ b/packages/web/store/scrollPositions.ts @@ -0,0 +1,61 @@ +class ScrollPositions { + private _nestedPaths: string[] = ['/artist', '/album', '/playlist', '/search'] + private _positions: Record = {} + private _generalPositions: Record = {} + + 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 diff --git a/packages/web/styles/global.css b/packages/web/styles/global.css index c090227..7d85367 100644 --- a/packages/web/styles/global.css +++ b/packages/web/styles/global.css @@ -18,24 +18,6 @@ -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 { display: -webkit-box; -webkit-box-orient: vertical; @@ -48,7 +30,6 @@ display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; - word-break: break-all; -webkit-line-clamp: 2; } @@ -56,7 +37,6 @@ display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; - word-break: break-all; -webkit-line-clamp: 3; } @@ -64,7 +44,13 @@ display: -webkit-box; -webkit-box-orient: vertical; 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; } } @@ -115,11 +101,11 @@ input { } body { - overscroll-behavior: contain; + @apply overscroll-contain; } html { - background-color: black; + @apply bg-black lg:bg-transparent; min-height: calc(100% + env(safe-area-inset-top)); } @@ -130,7 +116,7 @@ input { a, button { - cursor: default; + @apply cursor-default; } img, @@ -144,5 +130,11 @@ a { } .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; }