ghp / packages /client /src /components /Player /AudioPlayer.tsx
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
8.88 kB
import { useContainerPortrait } from '@/hooks/useContainerPortrait'
import { useCoverWidth } from '@/hooks/useCoverWidth'
import { useVote } from '@/hooks/useVote'
import { getProxiedCoverUrl } from '@/lib/cover'
import { cn } from '@/lib/utils'
import { usePlayerStore } from '@/stores/playerStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { BackgroundRender } from '@applemusic-like-lyrics/react'
import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { VoteBanner } from '../Vote/VoteBanner'
import { LyricDisplay } from './LyricDisplay'
import { NowPlaying } from './NowPlaying'
import { PlayerControls } from './PlayerControls'
import { SongInfoBar } from './SongInfoBar'
const FULL_SIZE_STYLE = { width: '100%', height: '100%' } as const
const MOBILE_LYRIC_AUTO_EXPAND_DELAY_MS = 900
const SMOOTH_EASE = [0.22, 1, 0.36, 1] as const
const LYRIC_MASK_STYLE = {
maskImage: 'linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)',
} as const
interface AudioPlayerProps {
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onNext: () => void
onPrev: () => void
onOpenChat: () => void
onOpenQueue: () => void
chatUnreadCount: number
}
export function AudioPlayer({
onPlay,
onPause,
onSeek,
onNext,
onPrev,
onOpenChat,
onOpenQueue,
chatUnreadCount,
}: AudioPlayerProps) {
const currentTrack = usePlayerStore((s) => s.currentTrack)
const { activeVote, castVote, startVote } = useVote()
const bgFps = useSettingsStore((s) => s.bgFps)
const bgFlowSpeed = useSettingsStore((s) => s.bgFlowSpeed)
const bgRenderScale = useSettingsStore((s) => s.bgRenderScale)
const { ref: playerRef, isPortrait } = useContainerPortrait()
// 封面 URL 代理:解决 QQ 音乐 / 酷狗等 CDN 的 CORS 限制
const proxiedCover = currentTrack?.cover ? getProxiedCoverUrl(currentTrack.cover) : undefined
// Suppress the document referrer for the renderer's own image request.
const backgroundCover = useMemo(() => {
if (!proxiedCover || typeof Image === 'undefined') return undefined
const image = new Image()
image.crossOrigin = 'anonymous'
image.referrerPolicy = 'no-referrer'
image.src = proxiedCover
return image
}, [proxiedCover])
// Mobile: toggle between cover view and lyric view
const [lyricExpanded, setLyricExpanded] = useState(false)
const hasAutoExpandedLyrics = useRef(false)
// Let the cover render first, then automatically enter the mobile lyric view
// through the same shared-layout transition used by a cover tap.
useEffect(() => {
if (!isPortrait || hasAutoExpandedLyrics.current) return
const timer = window.setTimeout(() => {
hasAutoExpandedLyrics.current = true
setLyricExpanded(true)
}, MOBILE_LYRIC_AUTO_EXPAND_DELAY_MS)
return () => window.clearTimeout(timer)
}, [isPortrait])
// Measure cover area to constrain info/controls width (paused during lyric mode)
const { ref: coverAreaRef, coverWidth } = useCoverWidth(lyricExpanded)
const toggleLyricView = useCallback(() => setLyricExpanded((v) => !v), [])
// Derived styles to constrain info/controls to cover width
const coverMaxStyle = coverWidth ? { maxWidth: coverWidth } : undefined
const coverMaxStyleUnlessExpanded = lyricExpanded ? undefined : coverMaxStyle
const playerControlsProps = {
onPlay,
onPause,
onSeek,
onNext,
onPrev,
onOpenQueue,
onStartVote: startVote,
} as const
const songInfoProps = {
onOpenChat,
chatUnreadCount,
} as const
return (
<div className="relative flex h-full flex-col overflow-hidden">
{/* AMLL fluid dynamic background powered by pixi.js */}
{backgroundCover && (
<div className="pointer-events-none absolute inset-0 z-0 opacity-80 saturate-[1.3]">
<BackgroundRender
album={backgroundCover}
playing
fps={bgFps}
flowSpeed={bgFlowSpeed}
renderScale={bgRenderScale}
style={FULL_SIZE_STYLE}
/>
</div>
)}
{/* Content with padding */}
<div className="relative z-10 h-full p-5 md:p-[5%] lg:p-[5%]">
<div
ref={playerRef}
className={cn('flex h-full', isPortrait ? 'flex-col' : 'flex-row gap-[clamp(24px,3vw,48px)]')}
>
{/* ----------------------------------------------------------------- */}
{/* Mobile layout: dual-mode (cover view / lyric view) */}
{/* ----------------------------------------------------------------- */}
{isPortrait ? (
<LayoutGroup>
<div className="relative mx-auto flex h-full w-full max-w-md flex-col items-center gap-[clamp(12px,3vh,32px)]">
{/* 1. Cover — fills remaining space in cover mode, centered within */}
<div
ref={coverAreaRef}
className={cn('w-full', !lyricExpanded && 'flex-1 min-h-0 flex items-center justify-center')}
style={!lyricExpanded ? ({ containerType: 'size' } as React.CSSProperties) : undefined}
>
<NowPlaying compact={lyricExpanded} onCoverClick={toggleLyricView} />
</div>
{/* Lyrics — popLayout so exiting lyrics don't occupy flex space */}
<AnimatePresence mode="popLayout">
{lyricExpanded && (
<motion.div
key="lyrics"
initial={{ opacity: 0, y: 32 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.65, ease: SMOOTH_EASE, delay: 0.08 }}
className="min-h-0 w-full flex-1 overflow-hidden"
style={LYRIC_MASK_STYLE}
>
<LyricDisplay />
</motion.div>
)}
</AnimatePresence>
{/* 2. Song info + action buttons (independent zoom module) */}
{!lyricExpanded && (
<div className="w-full shrink-0 mx-auto" style={coverMaxStyle}>
<SongInfoBar {...songInfoProps} />
</div>
)}
{/* 3. Controls (independent zoom module) */}
<div className="relative z-10 w-full shrink-0 mx-auto" style={coverMaxStyleUnlessExpanded}>
<PlayerControls {...playerControlsProps} />
</div>
{/* Vote banner: absolute overlay at the bottom */}
{activeVote && (
<div className="absolute bottom-0 left-1/2 z-20 w-full -translate-x-1/2 px-2 pb-2">
<VoteBanner vote={activeVote} onCastVote={castVote} />
</div>
)}
</div>
</LayoutGroup>
) : (
// ---------------------------------------------------------------
// Desktop layout: left panel (cover + info + controls) + right lyrics
// ---------------------------------------------------------------
<>
<div className="relative flex w-[40%] flex-col items-center gap-[clamp(12px,3vh,32px)] transition-all duration-300">
{/* 1. Cover — flex-1 fills remaining space, centered */}
<div
ref={coverAreaRef}
className="min-h-0 w-full flex-1 flex items-center justify-center"
style={{ containerType: 'size' }}
>
<NowPlaying />
</div>
{/* 2. Song info + action buttons */}
<div className="w-full shrink-0 mx-auto" style={coverMaxStyle}>
<SongInfoBar {...songInfoProps} />
</div>
{/* 3. Controls */}
<div className="w-full shrink-0 mx-auto" style={coverMaxStyle}>
<PlayerControls {...playerControlsProps} />
</div>
{activeVote && (
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center px-2 pb-2">
<div className="w-full">
<VoteBanner vote={activeVote} onCastVote={castVote} />
</div>
</div>
)}
</div>
<div className="min-h-0 w-[60%] overflow-hidden" style={LYRIC_MASK_STYLE}>
<LyricDisplay />
</div>
</>
)}
</div>
</div>
</div>
)
}