fix(page+bot): 리뷰 지적사항 반영 - 서버 멤버십·같은음성채널 인가(1,2), SSE 공유구독 fan-out(3), botRpc 즉시폴링(4), 큐삭제 encoded 검증(6), 진행바 anchor 타이머(7), 캐시 파싱 가드(8), key 안정화(9), 볼륨 롤백(10), SSE 자동재연결(11), body userId 정리(12)
This commit is contained in:
@@ -22,6 +22,9 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const [position, setPosition] = useState<number>(0);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
|
||||
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
|
||||
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
|
||||
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
|
||||
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
||||
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
|
||||
|
||||
@@ -38,7 +41,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -52,7 +54,9 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
setVolume(typeof data.volume === "number" ? data.volume : 50);
|
||||
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
||||
if (!isDragging.current) {
|
||||
setPosition(data.position || 0);
|
||||
const p = data.position || 0;
|
||||
setPosition(p);
|
||||
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
|
||||
}
|
||||
} else {
|
||||
setTrack(null);
|
||||
@@ -84,15 +88,16 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("Player SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
|
||||
};
|
||||
return () => eventSource.close();
|
||||
}, [selectedServer, fetchNowPlaying]);
|
||||
|
||||
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
|
||||
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
|
||||
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
|
||||
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
|
||||
const isPausedRef = useRef(isPaused);
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused;
|
||||
@@ -100,25 +105,20 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||
const localInterval = setInterval(() => {
|
||||
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
|
||||
const tick = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
setPosition((prev) => {
|
||||
if (prev >= duration) return duration;
|
||||
return prev + 1000;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||
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();
|
||||
}, 10000);
|
||||
|
||||
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||
}, 15000);
|
||||
return () => {
|
||||
clearInterval(localInterval);
|
||||
clearInterval(tick);
|
||||
clearInterval(syncInterval);
|
||||
};
|
||||
}, [isPlaying, duration, fetchNowPlaying]);
|
||||
@@ -135,13 +135,13 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
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,
|
||||
userId: userId,
|
||||
isPaused: nextPaused, // boolean 그대로 전송
|
||||
})
|
||||
});
|
||||
@@ -170,7 +170,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
|
||||
@@ -189,6 +188,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
|
||||
// 🌟 [수정됨] 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', {
|
||||
@@ -197,7 +198,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
seek: newPosition,
|
||||
userId: userId,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -221,17 +221,19 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
setVolume(finalVolume); // UI 즉시 반영
|
||||
|
||||
try {
|
||||
await fetch('/api/player/volume', {
|
||||
const res = await fetch('/api/player/volume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
volume: finalVolume,
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
|
||||
} catch (error) {
|
||||
console.error("볼륨 조절 에러:", error);
|
||||
setVolume(prevVolumeRef.current); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,8 +351,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
value={volume}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
||||
onMouseDown={() => setIsVolumeDragging(true)}
|
||||
onTouchStart={() => setIsVolumeDragging(true)}
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user