"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react"; import { useSession } from "next-auth/react"; import type { DiscordServer, Track } from "@/types/music"; interface PlayerBarProps { selectedServer: DiscordServer | null; } export default function PlayerBar({ selectedServer }: PlayerBarProps) { const { data: session } = useSession(); // 재생 상태 관리 const [track, setTrack] = useState(null); const [botPlayer, setBotPlayer] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const [volume, setVolume] = useState(50); // 시간 및 진행도 관리 const [position, setPosition] = useState(0); const [duration, setDuration] = useState(0); const isDragging = useRef(false); // 재생바를 드래그 중인지 여부 // 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at) // useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨). // eslint-disable-next-line react-hooks/purity const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() }); const prevVolumeRef = useRef(50); // 드래그 시작 시점 볼륨(실패 시 롤백용) // 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적 const [isVolumeDragging, setIsVolumeDragging] = useState(false); // 1. 현재 재생 상태 불러오기 (player_now) const fetchNowPlaying = useCallback(async () => { if (!selectedServer) return; const userId = session?.user?.id; if (!userId) return; try { const res = await fetch('/api/player/now', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverId: selectedServer.id, }) }); const data = await res.json(); if (res.ok && data.success && data.track) { setBotPlayer(Boolean(data.botPlayer)); setIsPlaying(Boolean(data.isPlaying)); setIsPaused(Boolean(data.isPaused)); setTrack(data.track as Track); setDuration(Number(data.track?.info?.length ?? 0) || 0); setVolume(typeof data.volume === "number" ? data.volume : 50); // 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐) if (!isDragging.current) { const p = data.position || 0; setPosition(p); anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정 } } else { setTrack(null); setIsPlaying(false); setPosition(0); setDuration(0); } } catch (error) { console.error("재생 정보 불러오기 실패:", error); } }, [selectedServer, session]); // 2. 초기 로드 및 SSE 실시간 업데이트 수신 useEffect(() => { if (!selectedServer) return; // 서버 선택 시 1회 즉시 동기화 — 의도적 패턴. // eslint-disable-next-line react-hooks/set-state-in-effect fetchNowPlaying(); // 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호 const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data?.type === "player_update") { fetchNowPlaying(); } } catch (err) { console.warn("SSE JSON 파싱 실패:", err); } }; // 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구). eventSource.onerror = () => { console.warn("Player SSE 일시 오류 — 자동 재연결 대기"); }; return () => eventSource.close(); }, [selectedServer, fetchNowPlaying]); // 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로, // "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다. // anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor). const isPausedRef = useRef(isPaused); useEffect(() => { isPausedRef.current = isPaused; }, [isPaused]); useEffect(() => { if (!isPlaying) return; // 250ms마다 anchor 기준으로 재계산(부드러운 진행) const tick = setInterval(() => { if (isPausedRef.current || isDragging.current) return; const a = anchorRef.current; const next = a.pos + (Date.now() - a.at); setPosition(duration > 0 ? Math.min(duration, next) : next); }, 250); // 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서) const syncInterval = setInterval(() => { if (isPausedRef.current || isDragging.current) return; fetchNowPlaying(); }, 15000); return () => { clearInterval(tick); clearInterval(syncInterval); }; }, [isPlaying, duration, fetchNowPlaying]); // ================= [ 컨트롤러 액션 ] ================= // 재생/일시정지 토글 (player_pause) const handleTogglePause = async () => { if (!selectedServer || !track) return; if (!isPlaying) return; const userId = session?.user?.id; if (!userId) return; const nextPaused = !isPaused; // UI 즉각 반영 (Optimistic UI) setIsPaused(nextPaused); anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신 try { const res = await fetch('/api/player/pause', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverId: selectedServer.id, isPaused: nextPaused, // boolean 그대로 전송 }) }); const data = await res.json(); if (res.ok && data.success) { // 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지. if (typeof data.paused === "boolean") setIsPaused(data.paused); } else { // 실패 시 롤백 setIsPaused(!nextPaused); } } catch (error) { console.error("일시정지 에러:", error); setIsPaused(!nextPaused); // 실패 시 롤백 } }; // 다음 곡 스킵 (player_skip) const handleSkip = async () => { if (!selectedServer || !track) return; const userId = session?.user?.id; if (!userId) return; try { await fetch('/api/player/skip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverId: selectedServer.id, }) }); // 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌 setTimeout(fetchNowPlaying, 500); } catch (error) { console.error("스킵 에러:", error); } }; // 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경 const handleSeekEnd = async (e: React.MouseEvent | React.TouchEvent) => { if (!selectedServer || !track) return; const userId = session?.user?.id; if (!userId) return; isDragging.current = false; // 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다. const newPosition = Number(e.currentTarget.value); setPosition(newPosition); anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신 try { await fetch('/api/player/seek', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverId: selectedServer.id, seek: newPosition, }) }); } catch (error) { console.error("시간 이동 에러:", error); } }; // 볼륨 조절 (player_volume) const handleVolumeChange = async (e: React.ChangeEvent) => { if (!selectedServer) return; setVolume(Number(e.target.value)); // UI 즉시 반영 }; // 🌟 [신규 추가] 드래그가 끝났을 때 (마우스를 뗐을 때) 딱 한 번 API 전송 const handleVolumeEnd = async (e: React.MouseEvent | React.TouchEvent) => { setIsVolumeDragging(false); if (!selectedServer) return; const userId = session?.user?.id; if (!userId) return; const finalVolume = Number(e.currentTarget.value); setVolume(finalVolume); // UI 즉시 반영 try { const res = await fetch('/api/player/volume', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverId: selectedServer.id, volume: finalVolume, }) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백 } catch (error) { console.error("볼륨 조절 에러:", error); setVolume(prevVolumeRef.current); // 실패 시 롤백 } }; // 시간 포맷 변환 함수 (ms -> mm:ss) const formatTime = (ms: number) => { if (!ms || isNaN(ms)) return "00:00"; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }; // 진행도 퍼센트 계산 (0 ~ 100) const progressPercent = duration > 0 ? (position / duration) * 100 : 0; return ( ); }