대부분 모든기능 제작

하단 player연결, 일시정지, 스킵, 볼륨, 특정시간재생, queue 디자인 변경, queue 변경기능 제작
This commit is contained in:
tkrmagid-desktop
2026-04-10 00:40:40 +09:00
parent 88a5a88c51
commit 374fbdb1ce
18 changed files with 1265 additions and 83 deletions

View File

@@ -1,47 +1,364 @@
"use client";
import { SkipForward, SkipBack, Volume2, Pause } from "lucide-react";
import { useState, useEffect, useCallback, useRef } from "react";
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
import { useSession } from "next-auth/react";
interface PlayerBarProps {
selectedServer: any;
}
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const { data: session } = useSession();
// 재생 상태 관리
const [track, setTrack] = useState<any>(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); // 재생바를 드래그 중인지 여부
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
// 1. 현재 재생 상태 불러오기 (player_now)
const fetchNowPlaying = useCallback(async () => {
if (!selectedServer) return;
const userId = (session?.user as any)?.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,
userId: userId,
})
});
const data = await res.json();
if (res.ok && data.success && data.track) {
setBotPlayer(data.botPlayer);
setIsPlaying(data.isPlaying);
setIsPaused(data.isPaused);
setTrack(data.track);
setDuration(data.track.info.length || 0);
setVolume(data.volume ?? 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
setPosition(data.position || 0);
}
} else {
setTrack(null);
setIsPlaying(false);
setPosition(0);
setDuration(0);
}
} catch (error) {
console.error("재생 정보 불러오기 실패:", error);
}
}, [selectedServer, session]);
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "player_update") {
fetchNowPlaying();
}
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
useEffect(() => {
let localInterval: NodeJS.Timeout;
let syncInterval: NodeJS.Timeout;
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
if (isPlaying && !isDragging.current) {
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
localInterval = setInterval(() => {
if (!isPaused) setPosition((prev) => {
if (prev >= duration) return duration;
return prev + 1000;
});
}, 1000);
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
syncInterval = setInterval(() => {
if (!isPaused) fetchNowPlaying();
}, 10000);
}
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
return () => {
clearInterval(localInterval);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
// ================= [ 컨트롤러 액션 ] =================
// 재생/일시정지 토글 (player_pause)
const handleTogglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = (session?.user as any)?.id;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(!isPaused);
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
isPaused: String(isPaused),
})
});
const data = await res.json();
if (res.ok && data.success) {
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
setIsPaused(true);
} else {
setIsPaused(false);
}
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!isPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const handleSkip = async () => {
if (!selectedServer || !track) return;
const userId = (session?.user as any)?.id;
try {
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
// 성공하면 곧 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 as any)?.id;
isDragging.current = false;
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
const newPosition = Number(e.currentTarget.value);
try {
await fetch('/api/player/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
seek: newPosition,
userId: userId,
})
});
} 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 as any)?.id;
const finalVolume = Number(e.currentTarget.value);
setVolume(finalVolume); // UI 즉시 반영
try {
await fetch('/api/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
volume: finalVolume,
})
});
} catch (error) {
console.error("볼륨 조절 에러:", error);
}
};
// 시간 포맷 변환 함수 (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;
export default function PlayerBar() {
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">
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md"></div>
<div className="flex flex-col">
<span className="text-sm font-bold text-white hover:underline cursor-pointer"> </span>
<span className="text-xs text-neutral-400 hover:underline cursor-pointer">(IU)</span>
</div>
{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-400 hover:text-white transition-colors">
{/* 이전 곡 (비활성화 상태) */}
<button className="text-neutral-600 cursor-not-allowed">
<SkipBack size={20} />
</button>
<button className="w-8 h-8 rounded-full bg-white flex items-center justify-center hover:scale-105 transition-transform text-black">
<Pause size={18} fill="black" />
{/* 재생/일시정지 버튼 */}
<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 className="text-neutral-400 hover:text-white transition-colors">
{/* 다음 곡 스킵 버튼 */}
<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>01:12</span>
<div className="h-1 bg-neutral-600 rounded-full flex-1 cursor-pointer group">
<div className="h-full bg-white rounded-full w-1/3 group-hover:bg-green-500 transition-colors relative">
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md"></div>
<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>03:16</span>
<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">
<Volume2 size={20} />
<div className="w-24 h-1 bg-neutral-600 rounded-full cursor-pointer group">
<div className="h-full bg-white rounded-full w-2/3 group-hover:bg-green-500 transition-colors"></div>
{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={() => setIsVolumeDragging(true)}
onTouchStart={() => 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>
);
}