diff --git a/src/frontend/src/api/endpoints/room/room.api.ts b/src/frontend/src/api/endpoints/room/room.api.ts index f4de8766..5b77db6f 100644 --- a/src/frontend/src/api/endpoints/room/room.api.ts +++ b/src/frontend/src/api/endpoints/room/room.api.ts @@ -66,7 +66,7 @@ export const roomApi = { sendPlaylist: async (roomId: number, playlist: PlaylistDto[]) => { const { data } = await instance.post('/rooms/playlist', { roomId, - playlist: JSON.stringify(playlist), + playlist: playlist, }); return data; }, @@ -78,7 +78,7 @@ export const roomApi = { targetUserId: userId, newRole: role, }; - const { data } = await instance.post(`/rooms/change-role`, body); + const { data } = await instance.patch(`/rooms/change-role`, body); return data; }, diff --git a/src/frontend/src/components/RoleChangeButton/index.tsx b/src/frontend/src/components/RoleChangeButton/index.tsx index ed1f2376..d4700586 100644 --- a/src/frontend/src/components/RoleChangeButton/index.tsx +++ b/src/frontend/src/components/RoleChangeButton/index.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; - import { CommonButton } from '@/components/common/Button'; - import { ButtonColor } from '@/types/enums/ButtonColor'; import { UserRole } from '@/types/enums/UserRole'; +import { roomApi } from '@/api/endpoints/room/room.api'; interface IRoleChangeButton { + userId: number; + roomId?: number; myRole: UserRole; userRole: UserRole; text: string; @@ -13,19 +14,29 @@ interface IRoleChangeButton { export const RoleChangeButton = (props: IRoleChangeButton) => { const [role, setRole] = useState(props.userRole); - const getRole: { [key: number]: string } = { 0: '방장', 1: '매니저', 2: '일반', }; + const handleRoleChange = async () => { + if (!props.roomId) return; + const newRole = role === UserRole.MEMBER ? UserRole.MANAGER : UserRole.MEMBER; + try { + await roomApi.changeRole(props.roomId, props.userId, newRole.toString()); + setRole(newRole); + } catch (error) { + console.error('역할 변경 중 오류 발생:', error); + } + }; + return ( setRole(role === 2 ? 1 : 2)} + onClick={handleRoleChange} justifycontent="space-between" padding="10px" borderradius="10px" diff --git a/src/frontend/src/components/Sidebar/Playlist/index.tsx b/src/frontend/src/components/Sidebar/Playlist/index.tsx index 0f96f839..734b3d33 100644 --- a/src/frontend/src/components/Sidebar/Playlist/index.tsx +++ b/src/frontend/src/components/Sidebar/Playlist/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import axios from 'axios'; import { CommonButton } from '@/components/common/Button'; import { useVideoStore } from '@/stores/useVideoStore'; @@ -18,8 +18,12 @@ import { import { ButtonColor } from '@/types/enums/ButtonColor'; import { useDebounce } from '@/hooks/utils/useDebounce'; import { PlaylistItem } from './PlaylistItem'; +import { useWebSocketStore } from '@/stores/useWebSocketStore'; +import { useUserStore } from '@/stores/useUserStore'; +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; const API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY as string; +import { roomApi } from '@/api/endpoints/room/room.api'; // 사용자가 입력한 URL로부터 영상의 ID와 시간을 받아온다. const extractVideoIdAndStartTime = (url: string) => { @@ -50,16 +54,76 @@ export const Playlist = () => { setCurrentIndex, } = useVideoStore(); - const ChangeToWebSocketType = () => { - const { videoQueue } = useVideoStore.getState(); - const formattedQueue = videoQueue.map((video, index) => ({ - order: index, - url: `https://www.youtube.com/watch?v=${video.id}${video.start ? `&t=${video.start}` : ''}`, - })); - // TODO: 추후 WebSocket 타입으로 변경할 때 사용 - console.log('playlist: ' + JSON.stringify(formattedQueue)); - }; + const { currentRoom } = useCurrentRoomStore(); + const roomId = currentRoom?.roomDetails?.roomInfo?.[0]?.roomId; + const { subscribeRoomPlaylistUpdate } = useWebSocketStore.getState(); + + // 드래그 상태를 useRef로 관리하고, 강제 업데이트를 위해 forceUpdate 함수를 사용 + const draggedIndexRef = useRef(null); + const dragOverIndexRef = useRef(null); + const [, forceUpdate] = useState(0); + const triggerUpdate = useCallback(() => forceUpdate(n => n + 1), []); + + // 플레이리스트 업데이트 구독 + useEffect(() => { + if (!roomId) { + console.warn('roomId가 없습니다. 구독 취소됨'); + return; + } + subscribeRoomPlaylistUpdate(roomId, async data => { + if (data?.playlist && Array.isArray(data.playlist)) { + const sortedPlaylist = data.playlist.sort((a, b) => a.order - b.order); + const updatedQueue = await Promise.all( + sortedPlaylist.map(async item => { + const { videoId, startTime } = extractVideoIdAndStartTime(item.url); + let title = item.title || ''; + let youtuber = item.youtuber || ''; + + if (!title || !youtuber) { + try { + const { data: apiData } = await axios.get( + 'https://www.googleapis.com/youtube/v3/videos', + { + params: { + part: 'snippet', + id: videoId, + key: API_KEY, + hl: 'ko', + }, + }, + ); + const items = apiData.items; + if (items && items.length > 0) { + if (!title) title = items[0].snippet.title; + if (!youtuber) youtuber = items[0].snippet.channelTitle; + } else { + if (!title) title = '제목 없음'; + if (!youtuber) youtuber = '유튜버 정보 없음'; + } + } catch (error) { + console.error('Error fetching video details for URL:', item.url, error); + if (!title) title = '제목 없음'; + if (!youtuber) youtuber = '유튜버 정보 없음'; + } + } + return { + id: videoId, + start: startTime, + thumbnail: `https://img.youtube.com/vi/${videoId}/0.jpg`, + title, + youtuber, + }; + }), + ); + useVideoStore.setState({ videoQueue: updatedQueue }); + } else { + console.warn('잘못된 웹소켓 데이터 수신:', data); + } + }); + }, [roomId, subscribeRoomPlaylistUpdate]); + + // debouncedInputUrl이 변경되면 YouTube API를 통해 영상 정보를 가져온다 useEffect(() => { const fetchVideoDetails = async (videoId: string) => { try { @@ -100,7 +164,31 @@ export const Playlist = () => { } }, [debouncedInputUrl]); - const handleAddVideo = () => { + const updatePlaylistOnServer = useCallback(async () => { + const { videoQueue } = useVideoStore.getState(); + const userId = useUserStore.getState().user?.userId; + if (!userId) { + console.error('사용자 ID가 없습니다.'); + return; + } + + const requestData = videoQueue.map((video, index) => ({ + order: index, + url: `https://www.youtube.com/watch?v=${video.id}${video.start ? `&t=${video.start}` : ''}`, + title: video.title, + youtuber: video.youtuber, + })); + + try { + const response = await roomApi.sendPlaylist(roomId!, requestData); + console.log('플레이리스트 업데이트 성공:', response); + } catch (error) { + console.error('플레이리스트 업데이트 실패:', error); + } + }, [roomId]); + + // 영상 추가 + const handleAddVideo = useCallback(() => { const { videoId, startTime } = extractVideoIdAndStartTime(inputUrl); if (!videoId) { alert('유효한 유튜브 URL을 입력하세요!'); @@ -115,45 +203,55 @@ export const Playlist = () => { youtuber: videoYoutuber, }); - ChangeToWebSocketType(); - + updatePlaylistOnServer(); setInputUrl(''); setThumbnailPreview(''); setVideoTitle(''); setVideoYoutuber(''); - }; + }, [inputUrl, videoTitle, videoYoutuber, addVideo, updatePlaylistOnServer]); - const handleRemoveVideo = (index: number) => { - removeVideo(index); - ChangeToWebSocketType(); - }; - - const [draggedIndex, setDraggedIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); + // 영상 제거 + const handleRemoveVideo = useCallback( + (index: number) => { + removeVideo(index); + updatePlaylistOnServer(); + }, + [removeVideo, updatePlaylistOnServer], + ); - const handleDragStart = useCallback((index: number) => { - setDraggedIndex(index); - }, []); + // 드래그 앤 드롭 + const handleDragStart = useCallback( + (index: number) => { + draggedIndexRef.current = index; + triggerUpdate(); + }, + [triggerUpdate], + ); - const handleDragOver = useCallback((e: React.DragEvent, index: number) => { - e.preventDefault(); - setDragOverIndex(index); - }, []); + const handleDragOver = useCallback( + (e: React.DragEvent, index: number) => { + e.preventDefault(); + dragOverIndexRef.current = index; + triggerUpdate(); + }, + [triggerUpdate], + ); const handleDragEnd = useCallback(() => { - setDraggedIndex(null); - setDragOverIndex(null); - }, []); + draggedIndexRef.current = null; + dragOverIndexRef.current = null; + triggerUpdate(); + }, [triggerUpdate]); + // 드롭 시 순서 변경 후 서버에 전송 const handleDrop = useCallback( (dropIndex: number) => { + const draggedIndex = draggedIndexRef.current; if (draggedIndex === null || draggedIndex === dropIndex) return; - useVideoStore.setState(state => { const updatedQueue = [...state.videoQueue]; const [draggedItem] = updatedQueue.splice(draggedIndex, 1); updatedQueue.splice(dropIndex, 0, draggedItem); - let newCurrentIndex = state.currentIndex; if (draggedIndex === state.currentIndex) { newCurrentIndex = dropIndex; @@ -162,31 +260,36 @@ export const Playlist = () => { } else if (dropIndex <= state.currentIndex && state.currentIndex < draggedIndex) { newCurrentIndex = state.currentIndex + 1; } - - ChangeToWebSocketType(); - + setTimeout(() => { + updatePlaylistOnServer(); + }, 0); return { videoQueue: updatedQueue, currentIndex: newCurrentIndex }; }); - setDraggedIndex(null); - setDragOverIndex(null); + draggedIndexRef.current = null; + dragOverIndexRef.current = null; + triggerUpdate(); }, - [draggedIndex], + [updatePlaylistOnServer, triggerUpdate], ); - const handleSetCurrentVideo = (index: number) => { - setCurrentVideo(index); - setCurrentIndex(index); - ChangeToWebSocketType(); - }; + // 현재 재생 영상 변경 + const handleSetCurrentVideo = useCallback( + (index: number) => { + setCurrentVideo(index); + setCurrentIndex(index); + updatePlaylistOnServer(); + }, + [setCurrentVideo, setCurrentIndex, updatePlaylistOnServer], + ); + // 드래그한거 미리보기 const getReorderedVideos = useCallback(() => { - if (draggedIndex === null || dragOverIndex === null) return videoQueue; - + if (draggedIndexRef.current === null || dragOverIndexRef.current === null) return videoQueue; const reorderedVideos = [...videoQueue]; - const [draggedVideo] = reorderedVideos.splice(draggedIndex, 1); - reorderedVideos.splice(dragOverIndex, 0, draggedVideo); + const [draggedVideo] = reorderedVideos.splice(draggedIndexRef.current, 1); + reorderedVideos.splice(dragOverIndexRef.current, 0, draggedVideo); return reorderedVideos; - }, [videoQueue, draggedIndex, dragOverIndex]); + }, [videoQueue]); return ( @@ -198,11 +301,17 @@ export const Playlist = () => { video={video} index={index} active={index === currentIndex} - isDragging={index === draggedIndex} - isPreview={draggedIndex !== null && index === dragOverIndex} + isDragging={index === draggedIndexRef.current} + isPreview={draggedIndexRef.current !== null && index === dragOverIndexRef.current} onClick={() => handleSetCurrentVideo(index)} - onMoveUp={() => moveVideoUp(index)} - onMoveDown={() => moveVideoDown(index)} + onMoveUp={() => { + moveVideoUp(index); + updatePlaylistOnServer(); + }} + onMoveDown={() => { + moveVideoDown(index); + updatePlaylistOnServer(); + }} onRemove={() => handleRemoveVideo(index)} onDragStart={() => handleDragStart(index)} onDragOver={e => handleDragOver(e, index)} diff --git a/src/frontend/src/components/Sidebar/UserList/index.tsx b/src/frontend/src/components/Sidebar/UserList/index.tsx index 7011120a..85308683 100644 --- a/src/frontend/src/components/Sidebar/UserList/index.tsx +++ b/src/frontend/src/components/Sidebar/UserList/index.tsx @@ -8,9 +8,11 @@ import { SidebarType } from '@/types/enums/SidebarType'; import { ProfileType } from '@/types/enums/ProfileType'; import { UserRole } from '@/types/enums/UserRole'; -import { memberListTest } from '@/assets/data/memberListTest'; +import { roomApi } from '@/api/endpoints/room/room.api'; +import { useWebSocketStore } from '@/stores/useWebSocketStore'; +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; import { Container, UserListContainer, ProfileWrapper } from './index.css'; - interface IUser { id: number; role: number; @@ -28,39 +30,86 @@ const compareUsers = (a: IUser, b: IUser): number => { export const UserList = () => { const treeRef = useRef | null>(null); const [, setVersion] = useState(0); + const { currentRoom } = useCurrentRoomStore(); + const roomId = currentRoom?.roomDetails.roomInfo[0]?.roomId; + const { subscribeRoomUserInfo, subscribeRoomRoleChange } = useWebSocketStore.getState(); useEffect(() => { - treeRef.current = new RedBlackTree(compareUsers); - memberListTest.forEach(user => treeRef.current?.insert(user)); - setVersion(v => v + 1); - }, []); + if (roomId) { + // 새 레드블랙 트리 생성 + treeRef.current = new RedBlackTree(compareUsers); + roomApi + .getParticipants(roomId.toString()) + .then(participants => { + participants.forEach( + (participant: { + userId: number; + role: number; + nickname: string; + profileImageUrl: string; + }) => { + const user: IUser = { + id: participant.userId, + role: participant.role, + nickname: participant.nickname, + profileImg: participant.profileImageUrl || DefaultProfile, + }; + treeRef.current?.insert(user); + }, + ); + setVersion(v => v + 1); + }) + .catch(error => { + console.error('Error fetching participants', error); + }); + } + }, [roomId]); - const addUser = (user: IUser) => { - if (!treeRef.current) return; - treeRef.current.insert(user); - setVersion(v => v + 1); - }; + // 신규 유저 정보 받기 + useEffect(() => { + if (!roomId) return; + subscribeRoomUserInfo(roomId, data => { + console.log('📥 웹소켓 수신 (user-info):', data); + if (data?.userInfo) { + const userInfo = data.userInfo; + const newUser: IUser = { + id: userInfo.userId, + role: userInfo.role, + nickname: userInfo.nickname, + profileImg: userInfo.profileImageUrl || DefaultProfile, + }; + treeRef.current?.insert(newUser); + setVersion(v => v + 1); + } + }); + }, [roomId, subscribeRoomUserInfo]); - const getSortedUsers = (): IUser[] => { - return treeRef.current ? treeRef.current.inOrderTraversal() : []; - }; - const [activeProfile, setActiveProfile] = useState(null); + // 역할 변경 받기 + useEffect(() => { + if (!roomId) return; + subscribeRoomRoleChange(roomId, data => { + console.log('📥 웹소켓 수신 (role-change):', data); + if (data && data.targetUserId !== undefined && data.newRole !== undefined) { + const users = treeRef.current?.inOrderTraversal() || []; + const updatedUsers = users.map(user => { + if (user.id === data.targetUserId) { + return { ...user, role: data.newRole }; + } + return user; + }); + treeRef.current = new RedBlackTree(compareUsers); + updatedUsers.forEach(user => treeRef.current?.insert(user)); + setVersion(v => v + 1); + } + }); + }, [roomId, subscribeRoomRoleChange]); + const [activeProfile, setActiveProfile] = useState(null); const handleProfileClick = (id: number) => { setActiveProfile(prevId => (prevId === id ? null : id)); }; - const handleAddUser = () => { - const newUser: IUser = { - id: Date.now(), - role: Math.floor(Math.random() * 3), - nickname: `User${Math.floor(Math.random() * 1000)}`, - profileImg: '', - }; - addUser(newUser); - }; - - const users = getSortedUsers(); + const users = treeRef.current ? treeRef.current.inOrderTraversal() : []; return ( @@ -78,6 +127,9 @@ export const UserList = () => { {activeProfile === member.id && ( { ))} - ); diff --git a/src/frontend/src/components/Sidebar/VoiceChat/index.tsx b/src/frontend/src/components/Sidebar/VoiceChat/index.tsx index 6cfaaaf8..119bb8b0 100644 --- a/src/frontend/src/components/Sidebar/VoiceChat/index.tsx +++ b/src/frontend/src/components/Sidebar/VoiceChat/index.tsx @@ -39,6 +39,8 @@ export const VoiceChat = () => { {activeProfile === member.id ? (
{ const { user, fetchMyProfile, clearProfile } = useUserStore(); const { fetchMyRooms } = useMyRoomsStore(); - const { connect } = useWebSocketStore(); + const { connect, subscribeRoomPlaylistUpdate } = useWebSocketStore(); + const { currentRoom } = useCurrentRoomStore(); + const { setVideoQueue } = useVideoStore(); + const roomId = currentRoom?.roomDetails?.roomInfo?.[0]?.roomId; + const accessToken = useAuthStore(state => state.accessToken); + const [isRoomCreateModalOpen, setIsRoomCreateModalOpen] = useState(false); const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); - const accessToken = useAuthStore(state => state.accessToken); useEffect(() => { connect(); @@ -42,7 +52,71 @@ export const TopNavBar = () => { await fetchMyRooms(); }; initializeUser(); - }, [accessToken]); + }, [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 clickNotification = (e: React.MouseEvent) => { e.stopPropagation(); @@ -54,7 +128,7 @@ export const TopNavBar = () => { setIsNotificationModalOpen(false); }; - const clickProfile = async () => { + const clickProfile = () => { setIsProfileModalOpen(true); }; diff --git a/src/frontend/src/components/YoutubePlayer/index.tsx b/src/frontend/src/components/YoutubePlayer/index.tsx index 257b5f0d..da1c7678 100644 --- a/src/frontend/src/components/YoutubePlayer/index.tsx +++ b/src/frontend/src/components/YoutubePlayer/index.tsx @@ -1,23 +1,25 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { useVideoStore } from '@/stores/useVideoStore'; - -declare global { - interface Window { - onYouTubeIframeAPIReady: () => void; - } -} +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import { useWebSocketStore } from '@/stores/useWebSocketStore'; export const YouTubePlayer = () => { - // const [videoQueue, setVideoQueue] = useState([]); // 재생 목록 - // const [currentIndex, setCurrentIndex] = useState(0); // 현재 재생 중인 비디오의 인덱스 - // const [inputUrl, setInputUrl] = useState(''); // 사용자가 입력한 유튜브 URL - // const [seekTime, setSeekTime] = useState(''); // 사용자가 입력한 이동 시간 - - const { videoQueue, currentIndex } = useVideoStore(); - - const playerRef = useRef(null); // 유튜브 플레이어 객체 - const lastKnownTimeRef = useRef(0); // 마지막으로 기록된 재생 시간 + const { videoQueue } = useVideoStore(); + const { currentRoom } = useCurrentRoomStore(); + const { client, subTopic } = useWebSocketStore(); + const pubTopic = useWebSocketStore.getState().pubTopic; + const roomId = currentRoom?.roomDetails?.roomInfo?.[0]?.roomId; + + const playerRef = useRef(null); + const lastSentStateRef = useRef<'playing' | 'paused' | null>(null); + const isRemoteUpdateRef = useRef(false); + + // 현재 재생 중인 영상 + const [currentPlayingVideo, setCurrentPlayingVideo] = useState<{ + id: string; + start: number; + } | null>(null); // 유튜브 API 스크립트 동적 로드 useEffect(() => { @@ -27,51 +29,18 @@ export const YouTubePlayer = () => { script.src = 'https://www.youtube.com/iframe_api'; document.body.appendChild(script); } - - window.onYouTubeIframeAPIReady = () => { - console.log('YouTube API Ready'); - }; }, []); - // // 유튜브 URL에서 videoId 및 startTime(시작 시간) 추출 - // const extractVideoIdAndStartTime = (url: string): { videoId: string; startTime: number } => { - // const regex = - // /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/)|youtu\.be\/)([^&?/]+)(?:.*[?&]t=(\d+))?/; - // const match = url.match(regex); - - // return { - // videoId: match ? match[1] : '', - // startTime: match && match[2] ? parseInt(match[2], 10) : 0, - // }; - // }; - - // // 재생 목록에 추가 - // const handleAddVideo = () => { - // const { videoId, startTime } = extractVideoIdAndStartTime(inputUrl); - // if (!videoId) { - // alert('유효한 유튜브 URL을 입력하세요!'); - // return; - // } - - // setVideoQueue(prevQueue => [ - // ...prevQueue, - // { - // id: videoId, - // start: startTime, - // thumbnail: `https://img.youtube.com/vi/${videoId}/0.jpg`, - // }, - // ]); - // setInputUrl(''); - // }; - // 유튜브 플레이어 로드 const loadPlayer = (id: string, startTime: number = 0) => { if (window.YT && id) { if (playerRef.current) { - playerRef.current.loadVideoById({ - videoId: id, - startSeconds: startTime, - }); + if (typeof playerRef.current.loadVideoById === 'function') { + playerRef.current.loadVideoById({ + videoId: id, + startSeconds: startTime, + }); + } } else { playerRef.current = new window.YT.Player('youtube-player', { height: '100%', @@ -90,109 +59,121 @@ export const YouTubePlayer = () => { // 플레이어 준비 완료 시 실행 const handlePlayerReady = (event: YT.PlayerEvent) => { playerRef.current = event.target; - - lastKnownTimeRef.current = playerRef.current.getCurrentTime(); // 현재 재생 시간 저장 - - // 1000ms마다 현재 시간이 맞는지 확인하다가 시간이 다르다면 바뀐 시간을 console.log로 찍어줌 - // TODO - 매초마다 확인하는 방법이 맞는지 확인하기 - setInterval(() => { - if (playerRef.current) { - const currentTime = playerRef.current.getCurrentTime(); - if (Math.abs(currentTime - lastKnownTimeRef.current) > 1.5) { - console.log(`사용자가 시간을 변경했습니다: ${Math.floor(currentTime)}초`); - } - lastKnownTimeRef.current = currentTime; // 변경된 시간을 최신값으로 업데이트 - } - }, 1000); }; // 유튜브 영상의 재생, 멈춤, 끝남 상태에 따라 동작 - // TODO - 재생시, 끝남시, 다음 영상 재생시 소켓에 보내줌 + const broadcastPlayerState = (state: 'playing' | 'paused', time: number) => { + 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 (event.data === YT.PlayerState.PLAYING) { - console.log('재생'); - } else if (event.data === YT.PlayerState.PAUSED) { - console.log('멈춤'); - } else if (event.data === YT.PlayerState.ENDED) { - // handleNextVideo(); + if (isRemoteUpdateRef.current) { + isRemoteUpdateRef.current = false; + return; + } + if (!playerRef.current) return; + + const playTime = playerRef.current.getCurrentTime(); + if (event.data === window.YT.PlayerState.PLAYING) { + if (lastSentStateRef.current !== 'playing') { + broadcastPlayerState('playing', playTime); + } + } else if (event.data === window.YT.PlayerState.PAUSED) { + if (lastSentStateRef.current !== 'paused') { + broadcastPlayerState('paused', playTime); + } } }; - // 재생할 영상의 index가 바뀌거나 - // video목록에 영상이 추가되었는데 이게 유일한 영상이라 바로 재생해야한다면, - // 유튜브 영상을 재생한다 + // 서버에서 play-time 메시지 수신 → 동기화 useEffect(() => { - if (videoQueue.length > 0) { - loadPlayer(videoQueue[currentIndex].id, videoQueue[currentIndex].start); + if (!roomId) return; + + subTopic( + `/topic/room/${roomId}/play-time`, + (data: { playTime: number; playerState: string }) => { + isRemoteUpdateRef.current = true; + applySyncState(data); + }, + ); + }, [roomId, subTopic]); + + // 서버에서 받은 동기화 적용 + 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') { + playerRef.current.pauseVideo(); } - }, [currentIndex]); + lastSentStateRef.current = playerState as 'playing' | 'paused'; + }; + + // // TODO: 10초마다 현재 재생 상태를 웹소켓으로 전송 (userList의 0번째 닉네임과 내 닉네임이 같을 경우) + // const [playerReady, setPlayerReady] = useState(false); + // const creatorName = currentRoom?.roomDetails?.userList[0]?.nickname; + // useEffect(() => { + // if (!playerReady) return; + // if (!creatorName || creatorName !== currentRoom?.roomDetails?.userList[0]?.nickname) return; // 내 닉네임이 userList[0]의 닉네임과 같지 않으면 실행X + + // const interval = setInterval(() => { // 10초마다 전송 + // if (!playerRef.current) return; + // const playerState = playerRef.current.getPlayerState(); + // const playTime = playerRef.current.getCurrentTime(); + + // if (playerState === window.YT.PlayerState.PLAYING) { + // broadcastPlayerState('playing', playTime); + // } else if (playerState === window.YT.PlayerState.PAUSED) { + // broadcastPlayerState('paused', playTime); + // } + // }, 10000); + // return () => clearInterval(interval); + // }, [creatorName, currentRoom, playerReady]); + + // 영상 변경 시 플레이어 로드 useEffect(() => { - if (videoQueue.length === 1) { - loadPlayer(videoQueue[currentIndex].id, videoQueue[currentIndex].start); + if (!videoQueue.length) return; + + const newCurrentVideo = videoQueue[0]; + + // 현재 상태와 비교 + if ( + !currentPlayingVideo || + currentPlayingVideo.id !== newCurrentVideo.id || + currentPlayingVideo.start !== newCurrentVideo.start + ) { + loadPlayer(newCurrentVideo.id, newCurrentVideo.start); + setCurrentPlayingVideo({ + id: newCurrentVideo.id, + start: newCurrentVideo.start, + }); + } else { + // 동일 영상 id라면 재생 다시 시작 안 함 + console.log('같은 영상입니다'); } - }, [videoQueue]); - - // // 이전 영상 재생 - // const handlePrevVideo = () => { - // if (currentIndex > 0) setCurrentIndex(prev => prev - 1); - // }; - - // 다음 영상 재생 - // const handleNextVideo = () => { - // if (currentIndex < videoQueue.length - 1) setCurrentIndex(prev => prev + 1); - // }; - - // // input창에 숫자를 입력하면 해당 초로 이동 - // // TODO - 나중에 싱크 맞출때 소켓으로 시간 받아오면 해당 시간으로 유튜브 영상 변경 - // const handleSeek = () => { - // if (playerRef.current) { - // const time = parseInt(seekTime, 10); - // const duration = playerRef.current.getDuration(); - - // if (!isNaN(time) && time >= 0 && time <= duration) { - // playerRef.current.seekTo(time, true); - // console.log(`입력에 의해 ${time}초로 이동`); - // } else { - // alert(`0 ~ ${Math.floor(duration)}초 사이의 값을 입력하세요.`); - // } - // } - // }; + }, [videoQueue, currentPlayingVideo]); return ( - +
- {/* - setSeekTime(e.target.value)} - /> - - - {videoQueue.length > 0 && ( - - - - - )} */}
); }; const Container = styled.div` width: 100%; - // height: 100%; display: flex; flex-direction: column; align-items: center; @@ -208,18 +189,3 @@ const VideoWrapper = styled.div` align-items: center; justify-content: center; `; - -// interface VideoItemCSS { -// $active?: boolean; -// } - -// const VideoItem = styled.div` -// display: flex; -// align-items: center; -// gap: 10px; -// cursor: pointer; -// padding: 10px; -// border-radius: 5px; -// background-color: ${({ $active }) => ($active ? '#f8d7da' : '#fff')}; -// border: ${({ $active }) => ($active ? '2px solid #ff0000' : '1px solid #ddd')}; -// `; diff --git a/src/frontend/src/components/common/ProfileDetail/ProfileActions/index.tsx b/src/frontend/src/components/common/ProfileDetail/ProfileActions/index.tsx index 9fec0e18..3d9aa9df 100644 --- a/src/frontend/src/components/common/ProfileDetail/ProfileActions/index.tsx +++ b/src/frontend/src/components/common/ProfileDetail/ProfileActions/index.tsx @@ -13,7 +13,13 @@ export const ProfileActions = (props: ProfileDetailDto) => { ) { return ( <> - + alert('방장에 의해 연결이 끊겼습니다.')} @@ -43,7 +49,13 @@ export const ProfileActions = (props: ProfileDetailDto) => { if (props.myRole === UserRole.CREATOR && props.userRole !== UserRole.CREATOR) { return ( <> - + alert('추방되었습니다.')} diff --git a/src/frontend/src/components/common/ProfileDetail/index.tsx b/src/frontend/src/components/common/ProfileDetail/index.tsx index 142366d0..37c14ba2 100644 --- a/src/frontend/src/components/common/ProfileDetail/index.tsx +++ b/src/frontend/src/components/common/ProfileDetail/index.tsx @@ -3,6 +3,7 @@ import { ProfileInfo } from './ProfileInfo'; import { ProfileActions } from './ProfileActions'; import { Container, Profile, ButtonContainer } from './index.css'; import { ProfileDetailDto } from '@/types/dto/ProfileDetailDto.dto'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; const detailProfile = { nickname: '이노', @@ -15,8 +16,8 @@ export const ProfileDetail = (props: ProfileDetailDto) => { return ( - - + + diff --git a/src/frontend/src/pages/RoomPage/index.tsx b/src/frontend/src/pages/RoomPage/index.tsx index 34c44b97..37d5f0eb 100644 --- a/src/frontend/src/pages/RoomPage/index.tsx +++ b/src/frontend/src/pages/RoomPage/index.tsx @@ -7,6 +7,8 @@ import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useCurrentRoom } from '@/hooks/queries/useCurrentRoom'; +import { useVideoStore } from '@/stores/useVideoStore'; +import { getVideoQueueFromPlaylist } from '@/utils/playlistUtils'; export const RoomPage = () => { const [searchParams] = useSearchParams(); @@ -17,6 +19,15 @@ export const RoomPage = () => { useEffect(() => { if (room) { useCurrentRoomStore.getState().setCurrentRoom(room); + if (room.roomDetails?.playlist && Array.isArray(room.roomDetails.playlist)) { + getVideoQueueFromPlaylist(room.roomDetails.playlist) + .then(videoQueue => { + useVideoStore.getState().setVideoQueue(videoQueue); + }) + .catch(err => { + console.error('playlist 에러', err); + }); + } } return () => { useCurrentRoomStore.getState().clearCurrentRoom(); diff --git a/src/frontend/src/stores/useCurrentRoomStore.ts b/src/frontend/src/stores/useCurrentRoomStore.ts index 8f26edea..041aaa0c 100644 --- a/src/frontend/src/stores/useCurrentRoomStore.ts +++ b/src/frontend/src/stores/useCurrentRoomStore.ts @@ -24,6 +24,7 @@ interface CurrentRoomStore { setCurrentRoom: (room: CurrentRoomDto) => void; clearCurrentRoom: () => void; processBatchMessages: () => void; + pubTopic: (destination: string, message: string) => void; } const BATCH_SIZE = 20; const BATCH_INTERVAL = 100; @@ -110,4 +111,13 @@ export const useCurrentRoomStore = create((set, get) => ({ addMessage: (message: ReceiveMessageDto) => set(state => ({ messages: [...state.messages, message] })), + + pubTopic: (destination: string, message: string) => { + const { client } = useWebSocketStore.getState(); + if (client) { + client.send(destination, {}, message); + } else { + console.warn('WebSocket이 아직 연결되지 않았습니다.'); + } + }, })); diff --git a/src/frontend/src/stores/useUserStore.ts b/src/frontend/src/stores/useUserStore.ts index 1597fb47..eb1e039c 100644 --- a/src/frontend/src/stores/useUserStore.ts +++ b/src/frontend/src/stores/useUserStore.ts @@ -17,6 +17,7 @@ export const useUserStore = create( persist( set => ({ user: null, + roomId: null, setUser: (user: UserResponseDto) => set({ user }), fetchMyProfile: async () => { try { diff --git a/src/frontend/src/stores/useVideoStore.ts b/src/frontend/src/stores/useVideoStore.ts index 4fb904b6..9aa4dfe8 100644 --- a/src/frontend/src/stores/useVideoStore.ts +++ b/src/frontend/src/stores/useVideoStore.ts @@ -11,6 +11,7 @@ interface IVideoItem { interface IVideoStore { videoQueue: IVideoItem[]; currentIndex: number; + setVideoQueue: (videos: IVideoItem[]) => void; addVideo: (video: IVideoItem) => void; removeVideo: (index: number) => void; moveVideoUp: (index: number) => void; @@ -24,6 +25,8 @@ export const useVideoStore = create(set => ({ videoQueue: [], currentIndex: 0, + setVideoQueue: (videos: IVideoItem[]) => set(() => ({ videoQueue: videos })), + addVideo: (video: IVideoItem) => set((state: IVideoStore) => ({ videoQueue: [...state.videoQueue, video], diff --git a/src/frontend/src/stores/useWebSocketStore.ts b/src/frontend/src/stores/useWebSocketStore.ts index 2bd015fd..85226410 100644 --- a/src/frontend/src/stores/useWebSocketStore.ts +++ b/src/frontend/src/stores/useWebSocketStore.ts @@ -6,8 +6,7 @@ // v2 [ ] dm 보내기 import SockJS from 'sockjs-client'; -import Stomp from 'stompjs'; -import { Client } from 'stompjs'; +import Stomp, { Client } from 'stompjs'; import { create } from 'zustand'; import { useUserStore } from './useUserStore'; @@ -21,11 +20,28 @@ interface WebSocketStore { disconnect: () => void; subTopic: (destination: string, callback: (message: T) => void) => void; - subscribeRoom: (roomId: number) => void; - subscribeRooms: (roomIds: number[]) => void; subscribeInvitations: (userId: number) => void; subscribeFriendConnection: (userId: number) => void; + subscribeRoomChat: (roomId: number) => void; + subscribeRoomUserInfo: ( + roomId: number, + callback: (data: { + userInfo: { userId: number; role: number; nickname: string; profileImageUrl: string }; + }) => void, + ) => void; + subscribeRoomRoleChange: ( + roomId: number, + callback: (data: { targetUserId: number; newRole: number }) => void, + ) => void; + subscribeRoomPlaylistUpdate: ( + roomId: number, + callback: (data: { + playlist: { order: number; url: string; title: string; youtuber: string }[]; + }) => void, + ) => void; + subscribeRooms: (roomIds: number[]) => void; unsubscribeAll: () => void; + pubTopic: (destination: string, message: string) => void; } export const useWebSocketStore = create((set, get) => ({ @@ -38,7 +54,7 @@ export const useWebSocketStore = create((set, get) => ({ if (get().client?.connected) { get().disconnect(); } - + // 기존 소켓 정리 if (get().socket) { get().socket?.close(); @@ -116,19 +132,49 @@ export const useWebSocketStore = create((set, get) => ({ }, // 방 채팅 구독 - subscribeRoom: (roomId: number) => { + subscribeRoomChat: (roomId: number) => { const destination = `/topic/room/${roomId}/chat`; get().subTopic(destination, message => { - console.log('subscribeRoom', message); + console.log('subscribeRoomChat', message); }); }, + // 채팅방 내 신규 유저 정보 구독 + subscribeRoomUserInfo: ( + roomId: number, + callback: (data: { + userInfo: { userId: number; role: number; nickname: string; profileImageUrl: string }; + }) => void, + ) => { + const destination = `/topic/room/${roomId}/user-info`; + get().subTopic(destination, callback); + }, + + // 채팅방 내 역할 변경 구독 + subscribeRoomRoleChange: ( + roomId: number, + callback: (data: { targetUserId: number; newRole: number }) => void, + ) => { + const destination = `/topic/room/${roomId}/role-change`; + get().subTopic(destination, callback); + }, + + // 채팅방 내 플레이리스트 업데이트 구독 + subscribeRoomPlaylistUpdate: ( + roomId: number, + callback: (data: { + playlist: { order: number; url: string; title: string; youtuber: string }[]; + }) => void, + ) => { + const destination = `/topic/room/${roomId}/playlist-update`; + get().subTopic(destination, callback); + }, + // 내가 속한 방 채팅 구독 subscribeRooms: (roomIds: number[]) => { if (!roomIds) return; - roomIds.forEach(roomId => { - get().subscribeRoom(roomId); + get().subscribeRoomChat(roomId); }); }, @@ -137,4 +183,13 @@ export const useWebSocketStore = create((set, get) => ({ get().subscriptions.forEach(sub => sub.unsubscribe()); set({ subscriptions: new Map() }); }, + + pubTopic: (destination: string, message: string) => { + const { client } = get(); + if (!client) { + console.warn('⚠ WebSocket이 아직 연결되지 않았습니다.'); + return; + } + client.send(destination, {}, message); + }, })); diff --git a/src/frontend/src/types/dto/ProfileDetailDto.dto.ts b/src/frontend/src/types/dto/ProfileDetailDto.dto.ts index 42e03bd4..1479a5ff 100644 --- a/src/frontend/src/types/dto/ProfileDetailDto.dto.ts +++ b/src/frontend/src/types/dto/ProfileDetailDto.dto.ts @@ -3,7 +3,11 @@ import { SidebarType } from '../enums/SidebarType'; export interface ProfileDetailDto { userId: number; + roomId?: number; userRole: UserRole; myRole: UserRole; sidebarType: SidebarType; + nickname: string; + introduce?: string; + imgUrl: string; } diff --git a/src/frontend/src/utils/playlistUtils.ts b/src/frontend/src/utils/playlistUtils.ts new file mode 100644 index 00000000..16c3ae73 --- /dev/null +++ b/src/frontend/src/utils/playlistUtils.ts @@ -0,0 +1,105 @@ +import axios from 'axios'; + +const API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY as string; + +export interface PlaylistItem { + order: number; + url: string; + title?: string; + youtuber?: string; +} + +export interface VideoItem { + id: string; + start: number; + thumbnail: string; + title: string; + youtuber: string; +} + +// 사용자가 입력한 URL로부터 영상의 ID와 시간을 받아온다. + +export const extractVideoIdAndStartTime = (url: string): { videoId: string; startTime: number } => { + const regex = + /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/)|youtu\.be\/)([^&?/]+)(?:.*[?&]t=(\d+))?/; + const match = url.match(regex); + return { + videoId: match ? match[1] : '', + startTime: match && match[2] ? parseInt(match[2], 10) : 0, + }; +}; + +// YouTube API를 사용해 videoId에 해당하는 영상의 제목과 채널 정보를 가져옴 +export const fetchVideoDetails = async ( + videoId: string, +): Promise<{ title: string; channelTitle: string }> => { + try { + const { data } = await axios.get('https://www.googleapis.com/youtube/v3/videos', { + params: { + part: 'snippet', + id: videoId, + key: API_KEY, + hl: 'ko', + }, + }); + const items = data.items; + if (items && items.length > 0) { + const { title, channelTitle } = items[0].snippet; + return { title, channelTitle }; + } else { + return { title: '', channelTitle: '' }; + } + } catch (error) { + console.error('Failed to fetch video details:', error); + return { title: '', channelTitle: '' }; + } +}; + +// Playlist 배열을 videoQueue 형식으로 변환 + +export const getVideoQueueFromPlaylist = async (playlist: PlaylistItem[]): Promise => { + const sortedPlaylist = [...playlist].sort((a, b) => a.order - b.order); + + const videoQueue = await Promise.all( + sortedPlaylist.map(async item => { + const { videoId, startTime } = extractVideoIdAndStartTime(item.url); + let title = item.title || ''; + let youtuber = item.youtuber || ''; + + if (!title || !youtuber) { + try { + const { data } = await axios.get('https://www.googleapis.com/youtube/v3/videos', { + params: { + part: 'snippet', + id: videoId, + key: API_KEY, + hl: 'ko', + }, + }); + const items = data.items; + if (items && items.length > 0) { + if (!title) title = items[0].snippet.title; + if (!youtuber) youtuber = items[0].snippet.channelTitle; + } else { + if (!title) title = '제목 없음'; + if (!youtuber) youtuber = '유튜버 정보 없음'; + } + } catch (error) { + console.error('Error fetching video details for URL:', item.url, error); + if (!title) title = '제목 없음'; + if (!youtuber) youtuber = '유튜버 정보 없음'; + } + } + + return { + id: videoId, + start: startTime, + thumbnail: `https://img.youtube.com/vi/${videoId}/0.jpg`, + title, + youtuber, + }; + }), + ); + + return videoQueue; +};