import { Button } from '@/components/ui/button' import { Slider } from '@/components/ui/slider' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { formatTime } from '@/lib/format' import { AbilityContext } from '@/providers/ability-context' import { useSocketContext } from '@/providers/socket-context' import { usePlayerStore } from '@/stores/playerStore' import { useRoomStore } from '@/stores/roomStore' import type { PlayMode, VoteAction } from '@music-together/shared' import { EVENTS, TIMING } from '@music-together/shared' import { ArrowRightToLine, ListMusic, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { memo, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' /** Design-time width (px) at which the controls are laid out — CSS zoom scales from this baseline */ const DESIGN_WIDTH = 300 const PLAY_MODE_CYCLE: PlayMode[] = ['sequential', 'loop-all', 'loop-one', 'shuffle'] const PLAY_MODE_CONFIG: Record = { sequential: { icon: ArrowRightToLine, label: '顺序播放' }, 'loop-all': { icon: Repeat, label: '列表循环' }, 'loop-one': { icon: Repeat1, label: '单曲循环' }, shuffle: { icon: Shuffle, label: '随机播放' }, } interface PlayerControlsProps { onPlay: () => void onPause: () => void onSeek: (time: number) => void onNext: () => void onPrev: () => void onOpenQueue: () => void onStartVote: (action: VoteAction, payload?: Record) => void } export const PlayerControls = memo(function PlayerControls({ onPlay, onPause, onSeek, onNext, onPrev, onOpenQueue, onStartVote, }: PlayerControlsProps) { const { socket } = useSocketContext() const isPlaying = usePlayerStore((s) => s.isPlaying) const currentTime = usePlayerStore((s) => s.currentTime) const duration = usePlayerStore((s) => s.duration) const currentTrack = usePlayerStore((s) => s.currentTrack) const queueLength = useRoomStore((s) => s.room?.queue?.length ?? 0) const playMode = useRoomStore((s) => s.room?.playMode ?? 'sequential') const ability = useContext(AbilityContext) const canSeek = ability.can('seek', 'Player') const canPlay = ability.can('play', 'Player') const canSetMode = ability.can('set-mode', 'Player') const canVote = ability.can('vote', 'Player') const [skipCooldown, setSkipCooldown] = useState(false) const [playCooldown, setPlayCooldown] = useState(false) const [isSeeking, setIsSeeking] = useState(false) const [seekTime, setSeekTime] = useState(0) const cooldownTimer = useRef>(null) const playCooldownTimer = useRef>(null) const wrapperRef = useRef(null) const innerRef = useRef(null) const disabled = !currentTrack // Clean up cooldown timers on unmount useEffect(() => { return () => { if (cooldownTimer.current) clearTimeout(cooldownTimer.current) if (playCooldownTimer.current) clearTimeout(playCooldownTimer.current) } }, []) // Scale entire controls area proportionally — like the cover image useLayoutEffect(() => { const wrapper = wrapperRef.current const inner = innerRef.current if (!wrapper || !inner) return const update = () => { inner.style.setProperty('zoom', String(wrapper.clientWidth / DESIGN_WIDTH)) } update() const ro = new ResizeObserver(() => update()) ro.observe(wrapper) return () => ro.disconnect() }, []) const handleSkip = (action: () => void, voteAction: 'next' | 'prev') => { if (skipCooldown) return if (ability.can(voteAction, 'Player')) { action() } else if (canVote) { onStartVote(voteAction) } setSkipCooldown(true) if (cooldownTimer.current) clearTimeout(cooldownTimer.current) cooldownTimer.current = setTimeout(() => setSkipCooldown(false), TIMING.PLAYER_NEXT_DEBOUNCE_MS) } const handlePlayPause = () => { if (playCooldown) return if (canPlay) { if (isPlaying) onPause() else onPlay() } else if (canVote) { onStartVote(isPlaying ? 'pause' : 'resume') } setPlayCooldown(true) if (playCooldownTimer.current) clearTimeout(playCooldownTimer.current) playCooldownTimer.current = setTimeout(() => setPlayCooldown(false), TIMING.PLAYER_NEXT_DEBOUNCE_MS) } const handlePlayModeToggle = () => { const currentIdx = PLAY_MODE_CYCLE.indexOf(playMode) const nextMode = PLAY_MODE_CYCLE[(currentIdx + 1) % PLAY_MODE_CYCLE.length] if (canSetMode) { socket.emit(EVENTS.PLAYER_SET_MODE, { mode: nextMode }) } else if (canVote) { onStartVote('set-mode', { mode: nextMode }) } } const modeConfig = PLAY_MODE_CONFIG[playMode] const ModeIcon = modeConfig.icon return (
{/* 1. Progress bar */}
0 ? ((isSeeking ? seekTime : currentTime) / duration) * 100 : 0]} max={100} step={0.1} disabled={disabled || !canSeek} onValueChange={(val) => { if (duration > 0) { setIsSeeking(true) setSeekTime((val[0] / 100) * duration) } }} onValueCommit={(val) => { if (duration > 0) { onSeek((val[0] / 100) * duration) } setIsSeeking(false) }} className="w-full" />
{formatTime(isSeeking ? seekTime : currentTime)} {formatTime(duration)}
{/* 2. Controls row — left/right flex-1 keeps center truly centered */}
{/* Left: play mode */}
{modeConfig.label}
{/* Center: prev + play/pause + next */}
上一首 {isPlaying ? '暂停' : '播放'} 下一首
{/* Right: queue */}
播放列表
) })