feat: updates

This commit is contained in:
qier222 2022-08-03 23:48:39 +08:00
parent 47e41dea9b
commit ebebf2a733
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
160 changed files with 4148 additions and 2001 deletions

View file

@ -0,0 +1,67 @@
import player from '@/web/states/player'
import { css, cx } from '@emotion/css'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useSnapshot } from 'valtio'
const useAirplayDevices = () => {
return useQuery(['useAirplayDevices'], () =>
window.ipcRenderer?.invoke('airplay-scan-devices')
)
}
const Airplay = () => {
const [showPanel, setShowPanel] = useState(false)
const { data: devices, isLoading } = useAirplayDevices()
const { remoteDevice } = useSnapshot(player)
const selectedAirplayDeviceID =
remoteDevice?.protocol === 'airplay' ? remoteDevice?.id : ''
return (
<div
className={cx(
'fixed z-20',
css`
top: 46px;
right: 256px;
`
)}
>
<div
onClick={() => setShowPanel(!showPanel)}
className='flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-24 text-white'
>
A
</div>
{showPanel && (
<div
className={cx(
'absolute rounded-24 border border-white/10 bg-black/60 p-2 backdrop-blur-xl',
css`
width: 256px;
height: 256px;
`
)}
>
{devices?.devices?.map(device => (
<div
key={device.identifier}
className={cx(
'rounded-12 p-2 hover:bg-white/10',
device.identifier === selectedAirplayDeviceID
? 'text-brand-500'
: 'text-white'
)}
onClick={() => player.switchToAirplayDevice(device.identifier)}
>
{device.name}
</div>
))}
</div>
)}
</div>
)
}
export default Airplay

View file

@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css'
import { memo } from 'react'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
import { prefetchArtist } from '@/web/api/hooks/useArtist'
const Artist = ({ artist }: { artist: Artist }) => {
const navigate = useNavigate()
@ -11,7 +12,10 @@ const Artist = ({ artist }: { artist: Artist }) => {
}
return (
<div className='text-center'>
<div
className='text-center'
onMouseOver={() => prefetchArtist({ id: artist.id })}
>
<Image
onClick={to}
src={resizeImage(artist.img1v1Url, 'md')}
@ -72,19 +76,14 @@ const ArtistRow = ({
<div className={className}>
{/* Title */}
{title && (
<h4
className={cx(
'text-12 font-medium uppercase dark:text-neutral-300 lg:text-14',
'mx-2.5 mb-6 lg:mx-0 lg:font-bold'
)}
>
<h4 className='mx-2.5 mb-6 text-12 font-medium uppercase dark:text-neutral-300 lg:mx-0 lg:text-14 lg:font-bold'>
{title}
</h4>
)}
{/* Artists */}
{artists && (
<div className='no-scrollbar flex snap-x overflow-x-scroll lg:grid lg:w-auto lg:grid-cols-5 lg:gap-10'>
<div className='no-scrollbar flex snap-x overflow-x-scroll lg:grid lg:w-auto lg:grid-cols-5 lg:gap-x-10 lg:gap-y-8'>
{artists.map(artist => (
<div className='snap-start px-2.5 lg:px-0' key={artist.id}>
<Artist artist={artist} key={artist.id} />

View file

@ -3,21 +3,34 @@ import { cx, css } from '@emotion/css'
import useIsMobile from '@/web/hooks/useIsMobile'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import { AnimatePresence, motion } from 'framer-motion'
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { useLocation } from 'react-router-dom'
import { useEffect } from 'react'
const BlurBackground = ({ cover }: { cover?: string }) => {
const BlurBackground = () => {
const isMobile = useIsMobile()
const { hideTopbarBackground } = useSnapshot(uiStates)
const { hideTopbarBackground, blurBackgroundImage } = useSnapshot(uiStates)
const location = useLocation()
const animate = useAnimation()
useEffect(() => {
uiStates.blurBackgroundImage = null
}, [location.pathname])
const onLoad = async () => {
animate.start({ opacity: 1 })
}
return (
<AnimatePresence>
{!isMobile && cover && hideTopbarBackground && (
{!isMobile && blurBackgroundImage && hideTopbarBackground && (
<motion.img
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
animate={animate}
exit={{ opacity: 0 }}
transition={{ ease }}
onLoad={onLoad}
className={cx(
'absolute z-0 object-cover opacity-70',
css`
@ -28,7 +41,7 @@ const BlurBackground = ({ cover }: { cover?: string }) => {
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
src={resizeImage(blurBackgroundImage, 'sm')}
/>
)}
</AnimatePresence>

View file

@ -5,6 +5,7 @@ import Image from './Image'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { memo, useCallback } from 'react'
import dayjs from 'dayjs'
const Album = ({ album }: { album: Album }) => {
const navigate = useNavigate()
@ -16,13 +17,21 @@ const Album = ({ album }: { album: Album }) => {
}
return (
<Image
onClick={goTo}
key={album.id}
src={resizeImage(album?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
<div>
<Image
onClick={goTo}
key={album.id}
src={resizeImage(album?.picUrl || '', 'md')}
className='aspect-square rounded-24'
onMouseOver={prefetch}
/>
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{album.name}
</div>
<div className='mt-1 text-14 font-medium text-neutral-700'>
{dayjs(album.publishTime || 0).year()}
</div>
</div>
)
}

View file

@ -1,19 +1,16 @@
import { resizeImage } from '@/web/utils/common'
import { cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { useVirtualizer } from '@tanstack/react-virtual'
import { CSSProperties, useRef } from 'react'
import { Virtuoso } from 'react-virtuoso'
import { CSSProperties } from 'react'
const CoverRow = ({
albums,
playlists,
title,
className,
containerClassName,
containerStyle,
}: {
title?: string
className?: string
@ -34,9 +31,6 @@ const CoverRow = ({
if (playlists) prefetchPlaylist({ id })
}
// The scrollable element for your list
const parentRef = useRef<HTMLDivElement>(null)
type Item = Album | Playlist
const items: Item[] = albums || playlists || []
const rows = items.reduce((rows: Item[][], item: Item, index: number) => {
@ -49,29 +43,6 @@ const CoverRow = ({
return rows
}, [])
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
overscan: 5,
estimateSize: () => {
const containerWidth = parentRef.current?.clientWidth
console.log(parentRef.current?.clientWidth)
if (!containerWidth) {
return 192
}
const gridGapY = 24
const gridGapX = 40
const gridColumns = 4
console.log(
(containerWidth - (gridColumns - 1) * gridGapX) / gridColumns + gridGapY
)
return (
(containerWidth - (gridColumns - 1) * gridGapX) / gridColumns + gridGapY
)
},
})
return (
<div className={className}>
{/* Title */}
@ -81,46 +52,43 @@ const CoverRow = ({
</h4>
)}
<div
ref={parentRef}
className={cx('w-full overflow-auto', containerClassName)}
style={containerStyle}
>
<div
className='relative w-full'
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((row: any) => (
<div
key={row.index}
className='absolute top-0 left-0 grid w-full grid-cols-4 gap-4 lg:gap-10'
style={{
height: `${row.size}px`,
transform: `translateY(${row.start}px)`,
}}
>
{rows[row.index].map((item: Item) => (
<img
onClick={() => goTo(item.id)}
key={item.id}
alt={item.name}
src={resizeImage(
<Virtuoso
className='no-scrollbar'
style={{
height: 'calc(100vh - 132px)',
}}
data={rows}
overscan={5}
itemSize={el => el.getBoundingClientRect().height + 24}
totalCount={rows.length}
components={{
Header: () => <div className='h-16'></div>,
Footer: () => <div className='h-16'></div>,
}}
itemContent={(index, row) => (
<div
key={index}
className='grid w-full grid-cols-4 gap-4 lg:mb-6 lg:gap-6'
>
{row.map((item: Item) => (
<img
onClick={() => goTo(item.id)}
key={item.id}
alt={item.name}
src={resizeImage(
item?.picUrl ||
(item as Playlist)?.coverImgUrl ||
item?.picUrl ||
(item as Playlist)?.coverImgUrl ||
item?.picUrl ||
'',
'md'
)}
className='aspect-square rounded-24'
onMouseOver={() => prefetch(item.id)}
/>
))}
</div>
))}
</div>
</div>
'',
'md'
)}
className='rounded-24'
onMouseOver={() => prefetch(item.id)}
/>
))}
</div>
)}
/>
</div>
)
}

View file

@ -1,5 +1,4 @@
import useBreakpoint from '@/web/hooks/useBreakpoint'
import { ReactQueryDevtools } from 'react-query/devtools'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import useIsMobile from '@/web/hooks/useIsMobile'
const Devtool = () => {
@ -10,10 +9,19 @@ const Devtool = () => {
toggleButtonProps={{
style: {
position: 'fixed',
bottom: isMobile ? 'auto' : 0,
right: 0,
top: isMobile ? 0 : 'auto',
left: 'auto',
...(isMobile
? {
top: 0,
right: 0,
bottom: 'auto',
left: 'atuo',
}
: {
top: 36,
right: 148,
bottom: 'atuo',
left: 'auto',
}),
},
}}
/>

View file

@ -1,42 +1,34 @@
import { ReactNode } from 'react'
import { ErrorBoundary as ErrorBoundaryRaw } from 'react-error-boundary'
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error
resetErrorBoundary: () => void
}) {
return (
<div
role='alert'
className='app-region-drag flex h-screen w-screen items-center justify-center bg-white dark:bg-black'
>
<div className='app-region-no-drag'>
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button
onClick={resetErrorBoundary}
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
>
Try Again
</button>
</div>
</div>
)
}
import * as Sentry from '@sentry/react'
const ErrorBoundary = ({ children }: { children: ReactNode }) => {
return (
<ErrorBoundaryRaw
FallbackComponent={ErrorFallback}
onReset={() => {
// reset the state of your app so the error doesn't happen again
}}
<Sentry.ErrorBoundary
fallback={({ error, componentStack }) => (
<div
role='alert'
className='app-region-drag flex h-screen w-screen items-center justify-center rounded-24 bg-white dark:bg-black'
>
<div className='app-region-no-drag'>
<p>Something went wrong:</p>
<pre className='mb-2 text-18 dark:text-white'>
{error.toString()}
</pre>
<div className='max-h-72 max-w-2xl overflow-scroll whitespace-pre-line rounded-12 border border-white/10 px-3 py-2 dark:text-white/50'>
{componentStack?.trim()}
</div>
<button
onClick={() => location.reload()}
className='mt-4 rounded-full bg-brand-600 px-6 py-5 text-16 font-medium'
>
Reload
</button>
</div>
</div>
)}
>
{children}
</ErrorBoundaryRaw>
</Sentry.ErrorBoundary>
)
}

View file

@ -92,7 +92,7 @@ const ImageDesktop = ({
{placeholder && (
<motion.div
{...placeholderMotionProps}
className='absolute inset-0 h-full w-full bg-white dark:bg-neutral-800'
className='absolute inset-0 h-full w-full bg-white dark:bg-white/10'
></motion.div>
)}
</AnimatePresence>

View file

@ -7,9 +7,14 @@ import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Login from './Login'
import TrafficLight from './TrafficLight'
import BlurBackground from './BlurBackground'
import Airplay from './Airplay'
import TitleBar from './TitleBar'
import uiStates from '@/web/states/uiStates'
const Layout = () => {
const playerSnapshot = useSnapshot(player)
const { fullscreen } = useSnapshot(uiStates)
const showPlayer = !!playerSnapshot.track
return (
@ -17,24 +22,10 @@ const Layout = () => {
id='layout'
className={cx(
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
window.env?.isElectron && 'rounded-24',
css`
grid-template-columns: 6.5rem auto 358px;
grid-template-rows: 132px auto;
`,
showPlayer
? css`
grid-template-areas:
'menubar main -'
'menubar main player';
`
: css`
grid-template-areas:
'menubar main main'
'menubar main main';
`
window.env?.isElectron && !fullscreen && 'rounded-24'
)}
>
<BlurBackground />
<MenuBar />
<Topbar />
<Main />
@ -46,6 +37,10 @@ const Layout = () => {
<TrafficLight />
</div>
)}
{window.env?.isWindows && <TitleBar />}
{/* {window.env?.isElectron && <Airplay />} */}
</div>
)
}

View file

@ -8,6 +8,7 @@ import Icon from '@/web/components/Icon'
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import LoginWithQRCode from './LoginWithQRCode'
import persistedUiStates from '@/web/states/persistedUiStates'
import useUser from '@/web/api/hooks/useUser'
const OR = ({
children,
@ -26,7 +27,7 @@ const OR = ({
<div className='mt-4 flex justify-center'>
<button
className='text-16 font-medium text-night-50 transition-colors duration-300 hover:text-white'
className='text-16 font-medium text-night-50 transition-colors duration-400 hover:text-white'
onClick={onClick}
>
{children}
@ -37,12 +38,20 @@ const OR = ({
}
const Login = () => {
const { data: user, isLoading: isLoadingUser } = useUser()
const { loginType } = useSnapshot(persistedUiStates)
const { showLoginPanel } = useSnapshot(uiStates)
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
loginType === 'qrCode' ? 'qrCode' : 'phone/email'
)
// Show login panel when user first loads the page and not logged in
useEffect(() => {
if (!user?.account && !isLoadingUser) {
uiStates.showLoginPanel = true
}
}, [user?.account, isLoadingUser])
const animateCard = useAnimation()
const handleSwitchCard = async () => {
const transition = { duration: 0.36, ease }
@ -80,7 +89,7 @@ const Login = () => {
{/* Content */}
<AnimatePresence>
{showLoginPanel && (
<div className='fixed inset-0 z-30 flex justify-center rounded-24 pt-56'>
<div className='fixed inset-0 z-30 flex items-center justify-center rounded-24 pt-24'>
<motion.div
className='flex flex-col items-center'
variants={{
@ -135,9 +144,9 @@ const Login = () => {
layout='position'
transition={{ ease }}
onClick={() => (uiStates.showLoginPanel = false)}
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10'
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
>
<Icon name='x' className='h-7 w-7 text-white/50' />
<Icon name='x' className='h-7 w-7 ' />
</motion.div>
</AnimatePresence>
</motion.div>

View file

@ -1,7 +1,12 @@
import { cx, css } from '@emotion/css'
import { useState } from 'react'
import { useMutation } from 'react-query'
import { loginWithEmail, loginWithPhone } from '@/web/api/auth'
import { useMutation } from '@tanstack/react-query'
import {
loginWithEmail,
LoginWithEmailResponse,
loginWithPhone,
LoginWithPhoneResponse,
} from '@/web/api/auth'
import md5 from 'md5'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
@ -10,6 +15,8 @@ import { ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { UserApiNames } from '@/shared/api/User'
const LoginWithPhoneOrEmail = () => {
const { loginPhoneCountryCode, loginType: persistedLoginType } =
@ -24,26 +31,36 @@ const LoginWithPhoneOrEmail = () => {
persistedLoginType === 'email' ? 'email' : 'phone'
)
const handleAfterLogin = (
result: LoginWithEmailResponse | LoginWithPhoneResponse
) => {
if (result?.code !== 200) return
setCookies(result.cookie)
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
uiStates.showLoginPanel = false
}
const handleError = (data: any, error: any) => {
if (data?.code === 200) return
toast(
`Login failed: ${
data?.message ||
data?.msg ||
data?.error ||
error?.response?.data?.message ||
error?.response?.data?.msg ||
error
}`
)
}
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)
uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
},
}
{ onSuccess: handleAfterLogin, onSettled: handleError }
)
const handleEmailLogin = () => {
@ -75,19 +92,7 @@ const LoginWithPhoneOrEmail = () => {
md5_password: md5(password.trim()),
})
},
{
onSuccess: result => {
if (result?.code !== 200) {
toast(`Login failed: ${result.code}`)
return
}
setCookies(result.cookie)
uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
},
}
{ onSuccess: handleAfterLogin, onSettled: handleError }
)
const handlePhoneLogin = () => {
@ -124,12 +129,12 @@ const LoginWithPhoneOrEmail = () => {
return (
<>
<div className='text-center text-18 font-medium text-night-600'>
<div className='text-center text-18 font-medium text-white/20'>
Log in with{' '}
<span
className={cx(
'transition-colors duration-300',
loginType === 'phone' ? 'text-brand-600' : 'hover:text-night-50'
loginType === 'phone' ? 'text-brand-600' : 'hover:text-white/70'
)}
onClick={() => {
const type = loginType === 'phone' ? 'email' : 'phone'
@ -143,7 +148,7 @@ const LoginWithPhoneOrEmail = () => {
<span
className={cx(
'transition-colors duration-300',
loginType === 'email' ? 'text-brand-600' : 'hover:text-night-50'
loginType === 'email' ? 'text-brand-600' : 'hover:text-white/70'
)}
onClick={() => {
if (loginType !== 'email') setLoginType('email')

View file

@ -1,11 +1,13 @@
import { cx, css } from '@emotion/css'
import { useEffect, useState, useMemo } from 'react'
import qrCode from 'qrcode'
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
import { checkLoginQrCodeStatus, fetchLoginQrCodeKey } from '@/web/api/auth'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
import uiStates from '@/web/states/uiStates'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { UserApiNames } from '@/shared/api/User'
const QRCode = ({ className, text }: { className?: string; text: string }) => {
const [image, setImage] = useState<string>('')
@ -40,7 +42,7 @@ const LoginWithQRCode = () => {
status: keyStatus,
refetch: refetchKey,
} = useQuery(
'qrCodeKey',
['qrCodeKey'],
async () => {
const result = await fetchLoginQrCodeKey()
if (result.data.code !== 200) {
@ -58,7 +60,7 @@ const LoginWithQRCode = () => {
)
const { data: status } = useQuery(
'qrCodeStatus',
['qrCodeStatus'],
async () => checkLoginQrCodeStatus({ key: key?.data?.unikey || '' }),
{
refetchInterval: 1000,
@ -80,6 +82,7 @@ const LoginWithQRCode = () => {
break
}
setCookies(status.cookie)
reactQueryClient.refetchQueries([UserApiNames.FetchUserAccount])
uiStates.showLoginPanel = false
break
}
@ -97,7 +100,7 @@ const LoginWithQRCode = () => {
return (
<>
<div className='text-center text-18 font-medium text-night-600'>
<div className='text-center text-18 font-medium text-white/20'>
Log in with NetEase QR
</div>

View file

@ -2,9 +2,17 @@ import { css, cx } from '@emotion/css'
import Router from './Router'
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
import uiStates from '@/web/states/uiStates'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { breakpoint as bp, ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import persistedUiStates from '@/web/states/persistedUiStates'
import { motion, useAnimation } from 'framer-motion'
import { sleep } from '@/web/utils/common'
import player from '@/web/states/player'
const Main = () => {
const playerSnapshot = useSnapshot(player)
// Show/hide topbar background
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen } = useIntersectionObserver(observePoint)
@ -15,12 +23,33 @@ const Main = () => {
}
}, [onScreen])
// Change width when player is minimized
const { minimizePlayer } = useSnapshot(persistedUiStates)
const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer)
const controlsMain = useAnimation()
useEffect(() => {
const animate = async () => {
await controlsMain.start({ opacity: 0 })
await sleep(100)
setIsMaxWidth(minimizePlayer)
await controlsMain.start({ opacity: 1 })
}
if (minimizePlayer !== isMaxWidth) animate()
}, [controlsMain, isMaxWidth, minimizePlayer])
return (
<main
<motion.main
id='main'
animate={controlsMain}
transition={{ ease, duration: 0.4 }}
className={cx(
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
'no-scrollbar z-10 h-screen overflow-y-auto',
css`
grid-area: main;
${bp.lg} {
margin-left: 144px;
margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px;
}
`
)}
>
@ -32,7 +61,7 @@ const Main = () => {
>
<Router />
</div>
</main>
</motion.main>
)
}

View file

@ -4,8 +4,11 @@ import Icon from '../Icon'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAnimation, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import TrafficLight from './TrafficLight'
import useIsMobile from '@/web/hooks/useIsMobile'
import { breakpoint as bp } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
import persistedUiStates from '@/web/states/persistedUiStates'
const tabs = [
{
@ -132,15 +135,17 @@ const Tabs = () => {
)
}
const MenuBar = ({ className }: { className?: string }) => {
const MenuBar = () => {
const isMobile = useIsMobile()
return (
<div
className={cx(
'app-region-drag relative flex h-full w-full flex-col justify-center',
className,
'lg:fixed lg:left-0 lg:top-0 lg:bottom-0',
css`
grid-area: menubar;
${bp.lg} {
width: 104px;
}
`
)}
>

View file

@ -1,183 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { css, cx } from '@emotion/css'
import Icon from '../Icon'
import { formatDuration, resizeImage } from '@/web/utils/common'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import Slider from './Slider'
import { animate, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import ArtistInline from './ArtistsInLine'
const Progress = () => {
const { track, progress } = useSnapshot(player)
return (
<div className='mt-10 flex w-full flex-col'>
<Slider
min={0}
max={(track?.dt ?? 100000) / 1000}
value={progress}
onChange={value => {
player.progress = value
}}
onlyCallOnChangeAfterDragEnded={true}
/>
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
</div>
</div>
)
}
const Cover = () => {
const playerSnapshot = useSnapshot(player)
const [cover, setCover] = useState('')
const animationStartTime = useRef(0)
const controls = useAnimation()
const duration = 150 // ms
useEffect(() => {
const resizedCover = resizeImage(
playerSnapshot.track?.al.picUrl || '',
'lg'
)
const animate = async () => {
animationStartTime.current = Date.now()
await controls.start({ opacity: 0 })
setCover(resizedCover)
}
animate()
}, [controls, playerSnapshot.track?.al.picUrl])
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
useEffect(() => {
const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg')
if (cover !== realCover) setCover(realCover)
}, [cover, playerSnapshot.track?.al.picUrl])
const onLoad = () => {
const passedTime = Date.now() - animationStartTime.current
controls.start({
opacity: 1,
transition: {
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
},
})
}
return (
<motion.img
animate={controls}
transition={{ duration: duration / 1000, ease }}
className={cx('absolute inset-0 w-full')}
src={cover}
onLoad={onLoad}
/>
)
}
const LikeButton = () => {
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 { state, track } = useSnapshot(player)
return (
<div
className={cx(
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
css`
border-color: hsl(0, 100%, 100%, 0.08);
`
)}
>
{/* Cover */}
<Cover />
{/* Info & Controls */}
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
{/* Track Info */}
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<ArtistInline
artists={track?.ar || []}
className='text-black/30 dark:text-white/30'
hoverClassName='hover:text-black/50 dark:hover:text-white/50 transition-colors duration-500'
/>
{/* Dividing line */}
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
{/* Progress */}
<Progress />
{/* Controls */}
<div className='mt-4 flex w-full items-center justify-between'>
<button>
<Icon
name='hide-list'
className='h-7 w-7 text-black/90 dark:text-white/40'
/>
</button>
<div className='text-black/95 dark:text-white/80'>
<button
onClick={() => track && player.prevTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon name='previous' className='h-6 w-6 ' />
</button>
<button
onClick={() => track && player.playOrPause()}
className='mx-2 rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
className='h-6 w-6 '
/>
</button>
<button
onClick={() => track && player.nextTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
>
<Icon name='next' className='h-6 w-6 ' />
</button>
</div>
<LikeButton />
</div>
</div>
</div>
)
}
export default NowPlaying

View file

@ -0,0 +1,114 @@
import persistedUiStates from '@/web/states/persistedUiStates'
import player from '@/web/states/player'
import { ease } from '@/web/utils/const'
import { cx, css } from '@emotion/css'
import { MotionConfig, motion } from 'framer-motion'
import { useSnapshot } from 'valtio'
import Icon from '../../Icon'
import { State as PlayerState } from '@/web/utils/player'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
const LikeButton = () => {
const { track } = useSnapshot(player)
const { data: likedIDs } = useUserLikedTracksIDs()
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
const likeATrack = useMutationLikeATrack()
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
return (
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track?.id && likeATrack.mutateAsync(track.id)}
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
>
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
</motion.button>
)
}
const Controls = () => {
const { state, track } = useSnapshot(player)
const { minimizePlayer: mini } = useSnapshot(persistedUiStates)
return (
<MotionConfig transition={{ ease, duration: 0.6 }}>
<motion.div
className={cx(
'fixed bottom-0 right-0 flex',
mini
? 'flex-col items-center justify-between'
: 'items-center justify-between',
mini
? css`
right: 24px;
bottom: 18px;
width: 44px;
height: 254px;
`
: css`
bottom: 56px;
right: 56px;
width: 254px;
`
)}
>
{/* Minimize */}
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
className='text-black/90 transition-colors duration-400 dark:text-white/40 hover:dark:text-white/90'
onClick={() => {
persistedUiStates.minimizePlayer = !mini
}}
>
<Icon name='hide-list' className='h-7 w-7 ' />
</motion.button>
{/* Media controls */}
<div className='flex flex-wrap gap-2 text-black/95 dark:text-white/80'>
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track && player.prevTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
>
<Icon name='previous' className='h-6 w-6' />
</motion.button>
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track && player.playOrPause()}
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
>
<Icon
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
className='h-6 w-6 '
/>
</motion.button>
<motion.button
layout='position'
animate={{ rotate: mini ? 90 : 0 }}
onClick={() => track && player.nextTrack()}
disabled={!track}
className='rounded-full bg-black/10 p-2.5 transition-colors duration-400 dark:bg-white/10 hover:dark:bg-white/20'
>
<Icon name='next' className='h-6 w-6 ' />
</motion.button>
</div>
{/* Like */}
<LikeButton />
</motion.div>
</MotionConfig>
)
}
export default Controls

View file

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

View file

@ -1,7 +1,7 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react'
import NowPlaying from './NowPlaying'
import tracks from '../../.storybook/mock/tracks'
import tracks from '@/web/.storybook/mock/tracks'
import { sample } from 'lodash-es'
export default {

View file

@ -0,0 +1,65 @@
import { css, cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import { AnimatePresence, motion } from 'framer-motion'
import ArtistInline from '@/web/components/New/ArtistsInLine'
import persistedUiStates from '@/web/states/persistedUiStates'
import Controls from './Controls'
import Cover from './Cover'
import Progress from './Progress'
const NowPlaying = () => {
const { track } = useSnapshot(player)
const { minimizePlayer } = useSnapshot(persistedUiStates)
return (
<>
{/* Now Playing */}
<AnimatePresence>
{!minimizePlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cx(
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
css`
border-color: hsl(0, 100%, 100%, 0.08);
`
)}
>
{/* Cover */}
<Cover />
{/* Info & Controls */}
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
{/* Track Info */}
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<ArtistInline
artists={track?.ar || []}
className='text-black/30 dark:text-white/30'
hoverClassName='hover:text-black/50 dark:hover:text-white/70 transition-colors duration-400'
/>
{/* Dividing line */}
<div className='mt-2 h-px w-2/5 bg-black/10 dark:bg-white/10'></div>
{/* Progress */}
<Progress />
{/* Controls placeholder */}
<div className='h-11'></div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Controls (for Animation) */}
<Controls />
</>
)
}
export default NowPlaying

View file

@ -0,0 +1,29 @@
import player from '@/web/states/player'
import { formatDuration } from '@/web/utils/common'
import { useSnapshot } from 'valtio'
import Slider from '../Slider'
const Progress = () => {
const { track, progress } = useSnapshot(player)
return (
<div className='mt-9 mb-4 flex w-full flex-col'>
<Slider
min={0}
max={(track?.dt ?? 100000) / 1000}
value={progress}
onChange={value => {
player.progress = value
}}
onlyCallOnChangeAfterDragEnded={true}
/>
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
</div>
</div>
)
}
export default Progress

View file

@ -0,0 +1,3 @@
import NowPlaying from './NowPlaying'
export default NowPlaying

View file

@ -1,4 +1,4 @@
import { motion } from 'framer-motion'
import { motion, MotionConfig } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile'
import scrollPositions from '@/web/states/scrollPositions'
@ -26,14 +26,16 @@ const PageTransition = ({
}
return (
<motion.div
initial={{ opacity: disableEnterAnimation ? 1 : 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease }}
>
{children}
</motion.div>
<MotionConfig transition={{ ease }}>
<motion.div
initial={{ opacity: disableEnterAnimation ? 1 : 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
{children}
</motion.div>
</MotionConfig>
)
}

View file

@ -1,22 +1,39 @@
import { css, cx } from '@emotion/css'
import persistedUiStates from '@/web/states/persistedUiStates'
import { useSnapshot } from 'valtio'
import NowPlaying from './NowPlaying'
import PlayingNext from './PlayingNext'
import { AnimatePresence, motion, MotionConfig } from 'framer-motion'
import { ease } from '@/web/utils/const'
const Player = () => {
const { minimizePlayer } = useSnapshot(persistedUiStates)
return (
<div
className={cx(
'relative flex w-full flex-col justify-between overflow-hidden pr-6 pl-4',
css`
grid-area: player;
`
)}
>
<PlayingNext className='h-full' />
<div className='pb-6'>
<MotionConfig transition={{ duration: 0.6 }}>
<div
className={cx(
'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden',
css`
width: 318px;
`
)}
>
<AnimatePresence>
{!minimizePlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<PlayingNext />
</motion.div>
)}
</AnimatePresence>
<NowPlaying />
</div>
</div>
</MotionConfig>
)
}

View file

@ -15,7 +15,7 @@ const Header = () => {
return (
<div
className={cx(
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300 lg:px-4'
'absolute top-0 left-0 z-20 flex w-full items-center justify-between bg-contain bg-repeat-x px-7 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300 lg:px-0'
)}
>
<div className='flex'>
@ -26,7 +26,7 @@ const Header = () => {
<div className='mr-2'>
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
</div>
<div className='mr-1'>
<div>
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
</div>
</div>
@ -147,11 +147,11 @@ const TrackList = ({ className }: { className?: string }) => {
)
}
const PlayingNext = ({ className }: { className?: string }) => {
const PlayingNext = () => {
return (
<>
<Header />
<TrackList className={className} />
<TrackList />
</>
)
}

View file

@ -1,13 +1,7 @@
import { css, cx } from '@emotion/css'
import {
motion,
useMotionValue,
useDragControls,
AnimatePresence,
} from 'framer-motion'
import { useEffect, useState } from 'react'
import { motion, useDragControls, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { useLockBodyScroll } from 'react-use'
import { isIosPwa } from '@/web/utils/common'
import PlayingNext from './PlayingNext'
import { ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'

View file

@ -10,6 +10,7 @@ const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
const Album = React.lazy(() => import('@/web/pages/New/Album'))
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
const MV = React.lazy(() => import('@/web/pages/New/MV'))
const lazy = (component: ReactNode) => {
return <Suspense>{component}</Suspense>
@ -27,6 +28,7 @@ const Router = () => {
<Route path='/album/:id' element={lazy(<Album />)} />
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
<Route path='/artist/:id' element={lazy(<Artist />)} />
<Route path='/mv/:id' element={lazy(<MV />)} />
<Route path='/settings' element={lazy(<Settings />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}>
<Route path=':type' element={lazy(<Search />)} />

View file

@ -7,7 +7,7 @@ const ScrollRestoration = () => {
const main = document.querySelector('main')
const handleScroll = throttle(() => {
scrollPositions.set(window.location.pathname, main?.scrollTop ?? 0)
}, 100)
}, 200)
main?.addEventListener('scroll', handleScroll)
return () => {
main?.removeEventListener('scroll', handleScroll)

View file

@ -0,0 +1,75 @@
import player from '@/web/states/player'
import Icon from '../Icon'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useState, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { css, cx } from '@emotion/css'
const Controls = () => {
const [isMaximized, setIsMaximized] = useState(false)
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
setIsMaximized(value)
})
const minimize = () => {
window.ipcRenderer?.send(IpcChannels.Minimize)
}
const maxRestore = () => {
window.ipcRenderer?.send(IpcChannels.MaximizeOrUnmaximize)
}
const close = () => {
window.ipcRenderer?.send(IpcChannels.Close)
}
const classNames = cx(
'flex items-center justify-center text-white/80 hover:text-white hover:bg-white/20 transition duration-400',
css`
height: 28px;
width: 48px;
border-radius: 4px;
`
)
return (
<div className='app-region-no-drag flex h-full items-center'>
<button onClick={minimize} className={classNames}>
<Icon className='h-3 w-3' name='windows-minimize' />
</button>
<button onClick={maxRestore} className={classNames}>
<Icon
className='h-3 w-3'
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
/>
</button>
<button
onClick={close}
className={cx(
classNames,
css`
margin-right: 5px;
border-radius: 4px 20px 4px 4px;
`
)}
>
<Icon className='h-3 w-3' name='windows-close' />
</button>
</div>
)
}
const TitleBar = () => {
return (
<div className='app-region-drag fixed z-30'>
<div className='flex h-9 w-screen items-center justify-between'>
<div></div>
<Controls />
</div>
</div>
)
}
export default TitleBar

View file

@ -4,6 +4,9 @@ import { useNavigate } from 'react-router-dom'
import { ease } from '@/web/utils/const'
import Icon from '../../Icon'
const buttonClassNames =
'app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl transition-colors duration-400 hover:bg-white/20 hover:text-white/60'
const NavigationButtons = () => {
const navigate = useNavigate()
const controlsBack = useAnimation()
@ -18,10 +21,10 @@ const NavigationButtons = () => {
await controlsBack.start({ x: -5 })
await controlsBack.start({ x: 0 })
}}
className='app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl'
className={buttonClassNames}
>
<motion.div animate={controlsBack} transition={transition}>
<Icon name='back' className='h-7 w-7 ' />
<Icon name='back' className='h-7 w-7' />
</motion.div>
</button>
<button
@ -32,7 +35,7 @@ const NavigationButtons = () => {
await controlsForward.start({ x: 5 })
await controlsForward.start({ x: 0 })
}}
className='app-region-no-drag ml-2.5 rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl'
className={cx('ml-2.5', buttonClassNames)}
>
<motion.div animate={controlsForward} transition={transition}>
<Icon name='forward' className='h-7 w-7' />

View file

@ -1,221 +0,0 @@
import {
formatDate,
formatDuration,
isIOS,
isSafari,
resizeImage,
} from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import Icon from '@/web/components/Icon'
import dayjs from 'dayjs'
import Image from './Image'
import useIsMobile from '@/web/hooks/useIsMobile'
import { memo, useEffect, useMemo, useRef } from 'react'
import Hls from 'hls.js'
import useVideoCover from '@/web/hooks/useVideoCover'
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { injectGlobal } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import BlurBackground from '@/web/components/New/BlurBackground'
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
import { AppleMusicAlbum } from '@/shared/AppleMusic'
injectGlobal`
.plyr__video-wrapper,
.plyr--video {
background-color: transparent !important;
}
`
const VideoCover = ({ source }: { source?: string }) => {
const ref = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>(new Hls())
useEffect(() => {
if (source && Hls.isSupported()) {
const video = document.querySelector('#video-cover') as HTMLVideoElement
hls.current.loadSource(source)
hls.current.attachMedia(video)
}
}, [source])
return (
<div className='z-10 aspect-square overflow-hidden rounded-24'>
<video id='video-cover' ref={ref} autoPlay muted loop />
</div>
)
}
const Cover = memo(
({ album, playlist }: { album?: Album; playlist?: Playlist }) => {
const { data: albumFromApple } = useAppleMusicAlbum({
id: album?.id,
name: album?.name,
artist: album?.artist.name,
})
const { data: videoCoverFromRemote } = useVideoCover({
id: album?.id,
name: album?.name,
artist: album?.artist.name,
enabled: !window.env?.isElectron,
})
const videoCover =
albumFromApple?.attributes?.editorialVideo?.motionSquareVideo1x1?.video ||
videoCoverFromRemote
const cover = album?.picUrl || playlist?.coverImgUrl || ''
return (
<>
<div className='relative z-10 aspect-square w-full overflow-auto rounded-24 '>
<Image
className='absolute inset-0 h-full w-full'
src={resizeImage(cover, 'lg')}
/>
{videoCover && (
<motion.div
initial={{ opacity: isIOS ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease }}
className='absolute inset-0 h-full w-full'
>
{isSafari ? (
<video
src={videoCover}
className='h-full w-full'
autoPlay
loop
muted
playsInline
></video>
) : (
<VideoCover source={videoCover} />
)}
</motion.div>
)}
</div>
{/* Blur bg */}
<BlurBackground cover={cover} />
</>
)
}
)
Cover.displayName = 'Cover'
const TrackListHeader = ({
album,
playlist,
onPlay,
className,
}: {
album?: Album
playlist?: Playlist
onPlay: () => void
className?: string
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const albumDuration = useMemo(() => {
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
}, [album?.songs])
const { data: albumFromApple, isLoading: isLoadingAlbumFromApple } =
useAppleMusicAlbum({
id: album?.id,
name: album?.name,
artist: album?.artist.name,
})
return (
<div
className={cx(
className,
'z-10 mx-2.5 rounded-48 p-8 dark:bg-white/10',
'lg:mx-0 lg:grid lg:grid-rows-1 lg:gap-10 lg:rounded-none lg:p-0 lg:dark:bg-transparent',
!isMobile &&
css`
grid-template-columns: 318px auto;
`
)}
>
<Cover {...{ album, playlist }} />
<div className='flex flex-col justify-between'>
<div>
{/* Name */}
<div className='mt-2.5 text-28 font-semibold dark:text-night-50 lg:mt-0 lg:text-36 lg:font-medium'>
{album?.name || playlist?.name}
</div>
{/* Creator */}
<div className='mt-2.5 lg:mt-6'>
<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>
{/* Extra info */}
<div className='mt-1 flex items-center text-12 font-medium dark:text-night-400 lg:text-14 lg:font-bold'>
{/* Album info */}
{!!album && (
<>
{album?.mark === 1056768 && (
<Icon
name='explicit'
className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5 '
/>
)}{' '}
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length}{' '}
tracks, {albumDuration}
</>
)}
{/* Playlist info */}
{!!playlist && (
<>
Updated at {formatDate(playlist?.updateTime || 0, 'en')} ·{' '}
{playlist.trackCount} tracks
</>
)}
</div>
{/* Description */}
{!isMobile && (
<div
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-night-400 '
dangerouslySetInnerHTML={{
__html: !isLoadingAlbumFromApple
? albumFromApple?.attributes?.editorialNotes?.standard ||
album?.description ||
playlist?.description ||
''
: '',
}}
></div>
)}
</div>
{/* Actions */}
<div className='mt-11 flex items-end justify-between lg:z-10 lg:mt-4 lg:justify-start'>
<div className='flex items-end'>
<button className='mr-2.5 h-14 w-14 rounded-full dark:bg-white/10 lg:mr-6 lg:h-[72px] lg:w-[72px]'></button>
<button className='h-14 w-14 rounded-full dark:bg-white/10 lg:mr-6 lg:h-[72px] lg:w-[72px]'></button>
</div>
<button
onClick={() => onPlay()}
className='h-14 w-[125px] rounded-full dark:bg-brand-700 lg:h-[72px] lg:w-[170px]'
></button>
</div>
</div>
</div>
)
}
export default TrackListHeader

View file

@ -0,0 +1,42 @@
import Icon from '../../Icon'
const Actions = ({
onPlay,
onLike,
isLiked,
}: {
isLiked?: boolean
onPlay: () => void
onLike?: () => void
}) => {
return (
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
<div className='flex items-end'>
{/* Menu */}
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
<Icon name='more' className='h-7 w-7' />
</button>
{/* Like */}
{onLike && (
<button
onClick={() => onLike()}
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30 lg:mr-2.5'
>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7'
/>
</button>
)}
</div>
<button
onClick={() => onPlay()}
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
>
Play
</button>
</div>
)
}
export default Actions

View file

@ -0,0 +1,33 @@
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
import Image from '@/web/components/New/Image'
import { memo, useEffect } from 'react'
import useVideoCover from '@/web/hooks/useVideoCover'
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
import uiStates from '@/web/states/uiStates'
import VideoCover from './VideoCover'
const Cover = memo(
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
useEffect(() => {
if (cover) uiStates.blurBackgroundImage = cover
}, [cover])
return (
<>
<div className='relative aspect-square w-full overflow-hidden rounded-24 '>
<Image
className='absolute inset-0'
src={resizeImage(cover || '', 'lg')}
/>
{videoCover && <VideoCover videoCover={videoCover} />}
</div>
</>
)
}
)
Cover.displayName = 'Cover'
export default Cover

View file

@ -0,0 +1,59 @@
import { formatDate } from '@/web/utils/common'
import Icon from '@/web/components/Icon'
import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom'
import useIsMobile from '@/web/hooks/useIsMobile'
import { ReactNode } from 'react'
const Info = ({
title,
creatorName,
creatorLink,
description,
extraInfo,
}: {
title?: string
creatorName?: string
creatorLink?: string
description?: string
extraInfo?: string | ReactNode
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
return (
<div>
{/* Title */}
<div className='mt-2.5 text-28 font-semibold dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
{title}
</div>
{/* Creator */}
<div className='mt-2.5 lg:mt-6'>
<span
onClick={() => creatorLink && navigate(creatorLink)}
className='text-24 font-medium transition-colors duration-300 dark:text-white/40 hover:dark:text-neutral-100 '
>
{creatorName}
</span>
</div>
{/* Extra info */}
<div className='mt-1 flex items-center text-12 font-medium dark:text-white/40 lg:text-14 lg:font-bold'>
{extraInfo}
</div>
{/* Description */}
{!isMobile && (
<div
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
dangerouslySetInnerHTML={{
__html: description || '',
}}
></div>
)}
</div>
)
}
export default Info

View file

@ -0,0 +1,60 @@
import { css, cx } from '@emotion/css'
import Cover from './Cover'
import Actions from './Actions'
import Info from './Info'
import React from 'react'
interface Props {
className?: string
title?: string
creatorName?: string
creatorLink?: string
description?: string
extraInfo?: string | React.ReactNode
cover?: string
videoCover?: string
isLiked: boolean
onPlay: () => void
onLike?: () => void
}
const TrackListHeader = ({
className,
title,
creatorName,
creatorLink,
description,
extraInfo,
cover,
videoCover,
isLiked,
onPlay,
onLike,
}: Props) => {
return (
<div
className={cx(
className,
'mx-2.5 rounded-48 p-8 dark:bg-white/10',
'lg:mx-0 lg:grid lg:grid-rows-1 lg:gap-10 lg:rounded-none lg:p-0 lg:dark:bg-transparent',
css`
grid-template-columns: 318px auto;
`
)}
>
<Cover {...{ cover, videoCover }} />
<div className='flex flex-col justify-between'>
<Info
{...{ title, creatorName, creatorLink, description, extraInfo }}
/>
<Actions {...{ onPlay, onLike, isLiked }} />
</div>
</div>
)
}
const memoized = React.memo(TrackListHeader)
memoized.displayName = 'TrackListHeader'
export default memoized

View file

@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react'
import Hls from 'hls.js'
import { injectGlobal } from '@emotion/css'
import { isIOS, isSafari } from '@/web/utils/common'
import { motion } from 'framer-motion'
injectGlobal`
.plyr__video-wrapper,
.plyr--video {
background-color: transparent !important;
}
`
const VideoCover = ({ videoCover }: { videoCover?: string }) => {
const ref = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>(new Hls())
useEffect(() => {
if (videoCover && Hls.isSupported()) {
const video = document.querySelector('#video-cover') as HTMLVideoElement
hls.current.loadSource(videoCover)
hls.current.attachMedia(video)
}
}, [videoCover])
return (
<motion.div
initial={{ opacity: isIOS ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className='absolute inset-0'
>
{isSafari ? (
<video
src={videoCover}
className='h-full w-full'
autoPlay
loop
muted
playsInline
></video>
) : (
<div className='aspect-square'>
<video id='video-cover' ref={ref} autoPlay muted loop />
</div>
)}
</motion.div>
)
}
export default VideoCover

View file

@ -0,0 +1,3 @@
import TrackListHeader from './TrackListHeader'
export default TrackListHeader

View file

@ -1,15 +1,10 @@
import { useState } from 'react'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useSnapshot } from 'valtio'
import uiStates from '@/web/states/uiStates'
const TrafficLight = () => {
const [isMaximized, setIsMaximized] = useState(false)
const { fullscreen } = useSnapshot(uiStates)
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
setIsMaximized(value)
})
if (isMaximized) {
if (fullscreen) {
return <></>
}