diff --git a/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx b/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx index f2e79b11..c5606a95 100644 --- a/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx +++ b/src/frontend/src/components/Sidebar/Playlist/PlaylistItem/index.tsx @@ -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) => void; - onDragEnd?: () => void; + onDragEnd: () => void; onDrop: (index: number) => void; } export const PlaylistItem = (props: IPlaylistItem) => { return ( 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} @@ -59,44 +60,46 @@ export const PlaylistItem = (props: IPlaylistItem) => { {props.video.title || '제목 없음'} {props.video.youtuber || '유튜버 정보 없음'} - -
+ {props.draggable && ( + +
+ { + e.stopPropagation(); + props.onMoveUp?.(props.index); + }} + color={ButtonColor.DARKGRAY} + borderradius="100px" + padding="5px" + > + Move Up + + { + e.stopPropagation(); + props.onMoveDown?.(props.index); + }} + color={ButtonColor.DARKGRAY} + borderradius="100px" + padding="5px" + > + Move Down + +
{ e.stopPropagation(); - props.onMoveUp(props.index); + props.onRemove?.(props.index); }} color={ButtonColor.DARKGRAY} borderradius="100px" padding="5px" + height="24px" > - Move Up + Remove - { - e.stopPropagation(); - props.onMoveDown(props.index); - }} - color={ButtonColor.DARKGRAY} - borderradius="100px" - padding="5px" - > - Move Down - -
- { - e.stopPropagation(); - props.onRemove(props.index); - }} - color={ButtonColor.DARKGRAY} - borderradius="100px" - padding="5px" - height="24px" - > - Remove - -
+ + )}
); }; diff --git a/src/frontend/src/components/Sidebar/Playlist/index.tsx b/src/frontend/src/components/Sidebar/Playlist/index.tsx index 9196e4a3..76ffab82 100644 --- a/src/frontend/src/components/Sidebar/Playlist/index.tsx +++ b/src/frontend/src/components/Sidebar/Playlist/index.tsx @@ -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'; @@ -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 함수를 사용 @@ -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)} @@ -349,9 +352,12 @@ export const Playlist = () => { setInputUrl(e.target.value)} + disabled={isWatchOnly} /> diff --git a/src/frontend/src/components/TopNavBar/index.tsx b/src/frontend/src/components/TopNavBar/index.tsx index 608597de..b9890ee3 100644 --- a/src/frontend/src/components/TopNavBar/index.tsx +++ b/src/frontend/src/components/TopNavBar/index.tsx @@ -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 { @@ -27,11 +27,6 @@ import { friendApi } from '@/api/endpoints/friend/friend.api'; 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(); @@ -43,10 +38,7 @@ export const TopNavBar = () => { 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); @@ -79,70 +71,6 @@ export const TopNavBar = () => { initializeUser(); }, [accessToken, clearProfile, fetchMyProfile, fetchMyRooms, connect]); - // 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 = {}; - 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) => { e.stopPropagation(); if (!user) { diff --git a/src/frontend/src/components/YoutubePlayer/index.tsx b/src/frontend/src/components/YoutubePlayer/index.tsx index 14e01ead..6f6dcf8e 100644 --- a/src/frontend/src/components/YoutubePlayer/index.tsx +++ b/src/frontend/src/components/YoutubePlayer/index.tsx @@ -3,13 +3,15 @@ import styled from 'styled-components'; 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(null); const lastSentStateRef = useRef<'playing' | 'paused' | null>(null); @@ -29,6 +31,9 @@ export const YouTubePlayer = () => { script.src = 'https://www.youtube.com/iframe_api'; document.body.appendChild(script); } + return () => { + useVideoStore.getState().clearVideoQueue(); + }; }, []); // 유튜브 플레이어 로드 @@ -46,7 +51,12 @@ export const YouTubePlayer = () => { 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, @@ -63,10 +73,14 @@ export const YouTubePlayer = () => { // 유튜브 영상의 재생, 멈춤, 끝남 상태에 따라 동작 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); @@ -74,6 +88,8 @@ export const YouTubePlayer = () => { // 내부 이벤트로 인한 상태 변화 감지 const handleVideoStateChange = (event: YT.OnStateChangeEvent) => { + if (isWatchOnly) return; + if (isRemoteUpdateRef.current) { isRemoteUpdateRef.current = false; return; @@ -109,7 +125,6 @@ export const YouTubePlayer = () => { 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') { @@ -166,6 +181,7 @@ export const YouTubePlayer = () => { return ( + {/* */}
@@ -190,3 +206,7 @@ const VideoWrapper = styled.div` justify-content: center; overflow: hidden; `; + +// const VideoDiv = styled.div<{ $isWatchOnly: boolean }>` +// ${({ $isWatchOnly }) => $isWatchOnly && 'pointer-events: none;'} +// `; diff --git a/src/frontend/src/pages/RoomPage/index.tsx b/src/frontend/src/pages/RoomPage/index.tsx index 37d5f0eb..c61d9a92 100644 --- a/src/frontend/src/pages/RoomPage/index.tsx +++ b/src/frontend/src/pages/RoomPage/index.tsx @@ -1,3 +1,4 @@ +import axios from 'axios'; import { Sidebar } from '@/components/Sidebar'; import { YouTubePlayer } from '@/components/YoutubePlayer'; import { RoomDetail } from '@/components/RoomDetail'; @@ -9,12 +10,15 @@ import { useSearchParams } from 'react-router-dom'; import { useCurrentRoom } from '@/hooks/queries/useCurrentRoom'; import { useVideoStore } from '@/stores/useVideoStore'; import { getVideoQueueFromPlaylist } from '@/utils/playlistUtils'; - +import { useWebSocketStore } from '@/stores/useWebSocketStore'; export const RoomPage = () => { const [searchParams] = useSearchParams(); const roomCode = searchParams.get('code'); const { data: room } = useCurrentRoom(roomCode); console.log('RoomPage:', room); + const { setVideoQueue } = useVideoStore(); + const { subscribeRoomPlaylistUpdate } = useWebSocketStore(); + const { roomId } = useCurrentRoomStore(); useEffect(() => { if (room) { @@ -34,6 +38,70 @@ export const RoomPage = () => { }; }, [room]); + // 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: import.meta.env.VITE_YOUTUBE_API_KEY, + hl: 'ko', + }, + }); + + const videoDetailsMap: Record = {}; + 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]); + return ( <> diff --git a/src/frontend/src/stores/useVideoStore.ts b/src/frontend/src/stores/useVideoStore.ts index 9aa4dfe8..a9245d2d 100644 --- a/src/frontend/src/stores/useVideoStore.ts +++ b/src/frontend/src/stores/useVideoStore.ts @@ -19,6 +19,7 @@ interface IVideoStore { setCurrentIndex: (index: number) => void; setCurrentVideo: (index: number) => void; moveToNextVideo: () => void; + clearVideoQueue: () => void; } export const useVideoStore = create(set => ({ @@ -88,4 +89,6 @@ export const useVideoStore = create(set => ({ currentIndex: 0, }; }), + + clearVideoQueue: () => set(() => ({ videoQueue: [] })), }));