Skip to content
73 changes: 38 additions & 35 deletions src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,26 @@ interface IPlaylistItem {
active: boolean;
isDragging?: boolean;
isPreview?: boolean;
onClick: (index: number) => void;
draggable: boolean;
onClick?: (index: number) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onRemove: (index: number) => void;
onDragStart: (index: number) => void;
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
onDragEnd?: () => void;
onDragEnd: () => void;
onDrop: (index: number) => void;
}

export const PlaylistItem = (props: IPlaylistItem) => {
return (
<Container
draggable
onDragStart={() => props.onDragStart(props.index)}
draggable={props.draggable}
onDragStart={() => props.onDragStart?.(props.index)}
onDragOver={props.onDragOver}
onDragEnd={props.onDragEnd}
onDrop={() => props.onDrop(props.index)}
onClick={() => props.onClick(props.index)}
onDrop={() => props.onDrop?.(props.index)}
onClick={() => props.onClick?.(props.index)}
$active={props.active}
$isDragging={props.isDragging}
$isPreview={props.isPreview}
Expand All @@ -59,44 +60,46 @@ export const PlaylistItem = (props: IPlaylistItem) => {
<Playlist__Title>{props.video.title || '제목 없음'}</Playlist__Title>
<Playlist__Youtuber>{props.video.youtuber || '유튜버 정보 없음'}</Playlist__Youtuber>
</PreviewInfo>
<ButtonContainer className="button-container">
<div>
{props.draggable && (
<ButtonContainer className="button-container">
<div>
<CommonButton
onClick={e => {
e.stopPropagation();
props.onMoveUp?.(props.index);
}}
color={ButtonColor.DARKGRAY}
borderradius="100px"
padding="5px"
>
<img src={ArrowUp} alt="Move Up" />
</CommonButton>
<CommonButton
onClick={e => {
e.stopPropagation();
props.onMoveDown?.(props.index);
}}
color={ButtonColor.DARKGRAY}
borderradius="100px"
padding="5px"
>
<img src={ArrowDown} alt="Move Down" />
</CommonButton>
</div>
<CommonButton
onClick={e => {
e.stopPropagation();
props.onMoveUp(props.index);
props.onRemove?.(props.index);
}}
color={ButtonColor.DARKGRAY}
borderradius="100px"
padding="5px"
height="24px"
>
<img src={ArrowUp} alt="Move Up" />
<img src={Trashcan} alt="Remove" />
</CommonButton>
<CommonButton
onClick={e => {
e.stopPropagation();
props.onMoveDown(props.index);
}}
color={ButtonColor.DARKGRAY}
borderradius="100px"
padding="5px"
>
<img src={ArrowDown} alt="Move Down" />
</CommonButton>
</div>
<CommonButton
onClick={e => {
e.stopPropagation();
props.onRemove(props.index);
}}
color={ButtonColor.DARKGRAY}
borderradius="100px"
padding="5px"
height="24px"
>
<img src={Trashcan} alt="Remove" />
</CommonButton>
</ButtonContainer>
</ButtonContainer>
)}
</Container>
);
};
30 changes: 18 additions & 12 deletions src/frontend/src/components/Sidebar/Playlist/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import DefaultThumbnail from '@/assets/img/DefaultThumbnail.svg';
import { useWebSocketStore } from '@/stores/useWebSocketStore';
import { useUserStore } from '@/stores/useUserStore';
import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore';
import { UserRole } from '@/types/enums/UserRole';

const API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY as string;
import { roomApi } from '@/api/endpoints/room/room.api';
Expand Down Expand Up @@ -57,6 +58,9 @@ export const Playlist = () => {

const { currentRoom } = useCurrentRoomStore();
const roomId = currentRoom?.roomDetails?.roomInfo?.[0]?.roomId;
const myRole = currentRoom?.myRole;
const isWatchOnly = myRole === UserRole.MEMBER;

const { subscribeRoomPlaylistUpdate } = useWebSocketStore.getState();

// 드래그 상태를 useRef로 관리하고, 강제 업데이트를 위해 forceUpdate 함수를 사용
Expand Down Expand Up @@ -302,17 +306,16 @@ export const Playlist = () => {
video={video}
index={index}
active={index === currentIndex}
isDragging={index === draggedIndexRef.current}
isPreview={draggedIndexRef.current !== null && index === dragOverIndexRef.current}
onClick={() => handleSetCurrentVideo(index)}
onMoveUp={() => {
moveVideoUp(index);
updatePlaylistOnServer();
}}
onMoveDown={() => {
moveVideoDown(index);
updatePlaylistOnServer();
}}
draggable={!isWatchOnly}
isDragging={!isWatchOnly && index === draggedIndexRef.current}
isPreview={
!isWatchOnly &&
draggedIndexRef.current !== null &&
index === dragOverIndexRef.current
}
onClick={!isWatchOnly ? () => handleSetCurrentVideo(index) : undefined}
onMoveUp={() => moveVideoUp(index)}
onMoveDown={() => moveVideoDown(index)}
onRemove={() => handleRemoveVideo(index)}
onDragStart={() => handleDragStart(index)}
onDragOver={e => handleDragOver(e, index)}
Expand Down Expand Up @@ -349,9 +352,12 @@ export const Playlist = () => {
<InputContainer>
<SearchInput
type="text"
placeholder="URL을 입력하세요"
placeholder={
isWatchOnly ? '방장/매니저만 영상을 추가할 수 있습니다' : 'URL을 입력하세요'
}
value={inputUrl}
onChange={e => setInputUrl(e.target.value)}
disabled={isWatchOnly}
/>
</InputContainer>
</div>
Expand Down
76 changes: 2 additions & 74 deletions src/frontend/src/components/TopNavBar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
// import axios from 'axios';
import AddCircleIcon from '@/assets/img/AddCircle.svg';
import BellIcon from '@/assets/img/Bell.svg';
import {
Expand All @@ -27,11 +27,6 @@
import { useFriendStore } from '@/stores/useFriendStore';
import { useNotificationStore } from '@/stores/useNotificationStore';

import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore';
import { useVideoStore } from '@/stores/useVideoStore';

const API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY as string;

export const TopNavBar = () => {
const navigate = useNavigate();
const { user, fetchMyProfile, clearProfile } = useUserStore();
Expand All @@ -43,10 +38,7 @@
resetNotificationCount,
fetchNotifications,
} = useNotificationStore();
const { connect, subscribeRoomPlaylistUpdate } = useWebSocketStore();
const { currentRoom } = useCurrentRoomStore();
const { setVideoQueue } = useVideoStore();
const roomId = currentRoom?.roomDetails?.roomInfo?.[0]?.roomId;
const { connect } = useWebSocketStore();
const accessToken = useAuthStore(state => state.accessToken);

const [isRoomCreateModalOpen, setIsRoomCreateModalOpen] = useState(false);
Expand Down Expand Up @@ -77,72 +69,8 @@
}
};
initializeUser();
}, [accessToken, clearProfile, fetchMyProfile, fetchMyRooms, connect]);

Check warning on line 72 in src/frontend/src/components/TopNavBar/index.tsx

View workflow job for this annotation

GitHub Actions / frontend-ci

React Hook useEffect has missing dependencies: 'fetchFriends', 'fetchNotifications', and 'increaseNotificationCount'. Either include them or remove the dependency array

// YouTube API를 이용하여 영상 정보(제목 & 유튜버) 가져오기
const fetchVideoDetails = async (videoIds: string[]) => {
if (!videoIds.length) return {};

try {
const { data } = await axios.get('https://www.googleapis.com/youtube/v3/videos', {
params: {
part: 'snippet',
id: videoIds.join(','),
key: API_KEY,
hl: 'ko',
},
});

const videoDetailsMap: Record<string, { title: string; youtuber: string }> = {};
data.items.forEach(
(item: { id: string; snippet: { title: string; channelTitle: string } }) => {
videoDetailsMap[item.id] = {
title: item.snippet.title,
youtuber: item.snippet.channelTitle,
};
},
);

return videoDetailsMap;
} catch (error) {
console.error('YouTube API 호출 실패:', error);
return {};
}
};

// 방의 플레이리스트 업데이트 구독
useEffect(() => {
if (!roomId) return;

subscribeRoomPlaylistUpdate(roomId, async data => {
console.log('플레이리스트 업데이트 수신:', data);

if (data?.playlist && Array.isArray(data.playlist)) {
const sortedPlaylist = data.playlist.sort((a, b) => a.order - b.order);

const videoIds = sortedPlaylist
.map(item => {
return item.url.split('v=')[1]?.split('&')[0] || '';
})
.filter(id => id);

const videoDetailsMap = await fetchVideoDetails(videoIds);

const updatedQueue = sortedPlaylist.map(item => {
const videoId = item.url.split('v=')[1]?.split('&')[0] || '';
return {
id: videoId,
start: parseInt(item.url.split('t=')[1] || '0', 10),
thumbnail: `https://img.youtube.com/vi/${videoId}/0.jpg`,
title: item.title || videoDetailsMap[videoId]?.title || '제목 없음',
youtuber: item.youtuber || videoDetailsMap[videoId]?.youtuber || '유튜버 정보 없음',
};
});
setVideoQueue(updatedQueue);
}
});
}, [roomId, subscribeRoomPlaylistUpdate, setVideoQueue]);

const clickCreateRoom = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!user) {
Expand Down
28 changes: 24 additions & 4 deletions src/frontend/src/components/YoutubePlayer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import { useVideoStore } from '@/stores/useVideoStore';
import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore';
import { useWebSocketStore } from '@/stores/useWebSocketStore';
import { UserRole } from '@/types/enums/UserRole';

export const YouTubePlayer = () => {
const { videoQueue } = useVideoStore();
const { currentRoom } = useCurrentRoomStore();
const { roomId } = useCurrentRoomStore.getState();
const { client, subTopic } = useWebSocketStore();
const pubTopic = useWebSocketStore.getState().pubTopic;
const roomId = currentRoom?.roomDetails?.roomInfo?.[0]?.roomId;
const myRole = useCurrentRoomStore.getState().currentRoom?.myRole;
const isWatchOnly = myRole === UserRole.MEMBER;

const playerRef = useRef<YT.Player | null>(null);
const lastSentStateRef = useRef<'playing' | 'paused' | null>(null);
Expand All @@ -29,6 +31,9 @@
script.src = 'https://www.youtube.com/iframe_api';
document.body.appendChild(script);
}
return () => {
useVideoStore.getState().clearVideoQueue();
};
}, []);

// 유튜브 플레이어 로드
Expand All @@ -46,7 +51,12 @@
height: '100%',
width: '100%',
videoId: id,
playerVars: { autoplay: 1, controls: 1, start: startTime },
playerVars: {
autoplay: 1,
controls: isWatchOnly ? 0 : 1,
disablekb: 1,
start: startTime,
},
events: {
onStateChange: handleVideoStateChange,
onReady: handlePlayerReady,
Expand All @@ -63,17 +73,23 @@

// 유튜브 영상의 재생, 멈춤, 끝남 상태에 따라 동작
const broadcastPlayerState = (state: 'playing' | 'paused', time: number) => {
if (myRole === 2) return;
const roomId = useCurrentRoomStore.getState().roomId;

if (!client || !roomId || !pubTopic) {
console.warn('⚠ WebSocket 준비 안됨');
return;
}

lastSentStateRef.current = state;
const message = JSON.stringify({ roomId, playTime: time, playerState: state });
pubTopic(`/app/play-time`, message);
};

// 내부 이벤트로 인한 상태 변화 감지
const handleVideoStateChange = (event: YT.OnStateChangeEvent) => {
if (isWatchOnly) return;

if (isRemoteUpdateRef.current) {
isRemoteUpdateRef.current = false;
return;
Expand Down Expand Up @@ -109,7 +125,6 @@
const applySyncState = ({ playTime, playerState }: { playTime: number; playerState: string }) => {
if (!playerRef.current) return;
playerRef.current.seekTo(playTime, true);

if (playerState === 'playing') {
playerRef.current.playVideo();
} else if (playerState === 'paused') {
Expand Down Expand Up @@ -161,11 +176,12 @@
// 동일 영상 id라면 재생 다시 시작 안 함
console.log('같은 영상입니다');
}
}, [videoQueue, currentPlayingVideo]);

Check warning on line 179 in src/frontend/src/components/YoutubePlayer/index.tsx

View workflow job for this annotation

GitHub Actions / frontend-ci

React Hook useEffect has a missing dependency: 'loadPlayer'. Either include it or remove the dependency array

return (
<Container>
<VideoWrapper>
{/* <VideoDiv id="youtube-player" $isWatchOnly={isWatchOnly}></VideoDiv> */}
<div id="youtube-player"></div>
</VideoWrapper>
</Container>
Expand All @@ -190,3 +206,7 @@
justify-content: center;
overflow: hidden;
`;

// const VideoDiv = styled.div<{ $isWatchOnly: boolean }>`
// ${({ $isWatchOnly }) => $isWatchOnly && 'pointer-events: none;'}
// `;
Loading
Loading