react-hooks/purity 가 useRef 초기화의 Date.now() 호출을 에러로 잡는데, 초기값은 최초 렌더 1회만 평가되어 안전하므로 해당 라인만 eslint-disable 처리. 동작 변경 없음. npm run lint 0 errors.
386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
"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<Track | null>(null);
|
|
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
|
const [isPaused, setIsPaused] = useState<boolean>(false);
|
|
const [volume, setVolume] = useState<number>(50);
|
|
|
|
// 시간 및 진행도 관리
|
|
const [position, setPosition] = useState<number>(0);
|
|
const [duration, setDuration] = useState<number>(0);
|
|
const isDragging = useRef<boolean>(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<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
|
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
|
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(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<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
if (!selectedServer) return;
|
|
setVolume(Number(e.target.value)); // UI 즉시 반영
|
|
};
|
|
|
|
// 🌟 [신규 추가] 드래그가 끝났을 때 (마우스를 뗐을 때) 딱 한 번 API 전송
|
|
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
|
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 (
|
|
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
|
|
|
{/* 1. 노래 정보 영역 */}
|
|
<div className="flex items-center gap-4 w-1/4">
|
|
{track ? (
|
|
<>
|
|
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md overflow-hidden">
|
|
{track.info?.artworkUrl && <img src={track.info.artworkUrl} alt="cover" className="w-full h-full object-cover" />}
|
|
</div>
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="text-sm font-bold text-white truncate cursor-pointer hover:underline">{track.info?.title}</span>
|
|
<span className="text-xs text-neutral-400 truncate cursor-pointer hover:underline">{track.info?.author?.replace(" - Topic", "")}</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
// 재생 중인 곡이 없을 때 빈 공간
|
|
<div className="flex items-center gap-4 text-neutral-600">
|
|
<div className="w-14 h-14 bg-neutral-900 rounded-md"></div>
|
|
<span className="text-sm">재생 중인 곡 없음</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 2. 중앙 컨트롤러 영역 */}
|
|
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
|
|
<div className="flex items-center gap-6">
|
|
{/* 이전 곡 (비활성화 상태) */}
|
|
<button className="text-neutral-600 cursor-not-allowed">
|
|
<SkipBack size={20} />
|
|
</button>
|
|
|
|
{/* 재생/일시정지 버튼 */}
|
|
<button
|
|
onClick={handleTogglePause}
|
|
disabled={!botPlayer || !isPlaying || !track}
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform text-black
|
|
${track ? 'bg-white hover:scale-105' : 'bg-neutral-700 cursor-not-allowed'}`}
|
|
>
|
|
{!isPaused ? <Pause size={18} fill="black" /> : <Play size={18} fill="black" className="ml-0.5" />}
|
|
</button>
|
|
|
|
{/* 다음 곡 스킵 버튼 */}
|
|
<button
|
|
onClick={handleSkip}
|
|
disabled={!botPlayer || !isPlaying || !track}
|
|
className={`transition-colors ${isPlaying && track ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 cursor-not-allowed'}`}
|
|
>
|
|
<SkipForward size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
|
|
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
|
<span className="w-10 text-right">{formatTime(position)}</span>
|
|
|
|
<div className="flex-1 flex items-center group relative h-4">
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={duration || 100}
|
|
value={position}
|
|
disabled={!botPlayer || !isPlaying || !track}
|
|
onChange={(e) => {
|
|
isDragging.current = true;
|
|
setPosition(Number(e.target.value)); // 드래그 중 화면 즉시 업데이트
|
|
}}
|
|
onMouseUp={handleSeekEnd}
|
|
onTouchEnd={handleSeekEnd}
|
|
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
|
// ↑ 투명한 진짜 input을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다.
|
|
/>
|
|
{/* 시각적으로 보여주는 커스텀 바 */}
|
|
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
|
<div
|
|
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
|
style={{ width: `${progressPercent}%` }}
|
|
></div>
|
|
</div>
|
|
{/* 동그란 핸들바 */}
|
|
<div
|
|
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5"
|
|
style={{ left: `${progressPercent}%` }}
|
|
></div>
|
|
</div>
|
|
|
|
<span className="w-10">{formatTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3. 우측 볼륨 영역 */}
|
|
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
|
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
|
|
|
<div className="w-24 h-4 flex items-center group relative">
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
value={volume}
|
|
disabled={!botPlayer || !isPlaying || !track}
|
|
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
|
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
|
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
|
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
|
|
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
|
|
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
|
/>
|
|
{/* 커스텀 볼륨 바 디자인 */}
|
|
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
|
<div
|
|
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
|
style={{ width: `${volume}%` }}
|
|
></div>
|
|
</div>
|
|
{/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */}
|
|
<div
|
|
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5 flex justify-center"
|
|
style={{ left: `${volume}%` }}
|
|
>
|
|
{/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */}
|
|
{isVolumeDragging && (
|
|
<div className="absolute bottom-4 bg-neutral-800 text-white text-[10px] font-bold py-1 px-2 rounded shadow-lg border border-neutral-700 whitespace-nowrap animate-in fade-in zoom-in duration-150">
|
|
{volume}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</footer>
|
|
);
|
|
} |