Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
972507c
[FE] REFACTOR: 플레이리스트 전송용 변수명 변경 #206
juwon5272 Feb 20, 2025
c61e23b
[FE] REFACTOR: 역할 변경 API call patch로 변경 #206
juwon5272 Feb 20, 2025
66b84d8
[FE] FEAT: 역할 변경에 필요한 매개변수 추가 #203
juwon5272 Feb 20, 2025
57db509
[FE] FEAT: 프로필에 필요한 매개변수 추가 #203
juwon5272 Feb 20, 2025
3b47b11
[FE] FEAT: 유저리스트에 필요한 매개변수 추가 #203
juwon5272 Feb 20, 2025
f3eeacd
[FE] FEAT: 유튜브 플레이리스트 동기화 #206
juwon5272 Feb 20, 2025
b490cb7
[FE] FEAT: 유튜브 영상 재생여부와 시간 동기화 #206
juwon5272 Feb 20, 2025
bcb366b
[FE] FEAT: 역할 변경 구현 #206
juwon5272 Feb 20, 2025
ba00edd
[FE] REFACTOR: 프로필 조회에서 역할 변경을 위한 매개변수 추가 #206
juwon5272 Feb 20, 2025
cd140f8
[FE] FEAT: 웹소켓 서버에 메세지 전송 #206
juwon5272 Feb 20, 2025
1ec8433
[FE] FEAT: 플레이리스트 동기화 #206
juwon5272 Feb 20, 2025
744fa90
[FE] FEAT: 유저리스트 받아오기 및 동기화 #206
juwon5272 Feb 20, 2025
3f1a9ee
[FE] REFACTOR: 기능 테스트용 버튼 삭제 #206
juwon5272 Feb 20, 2025
bf2fa57
[FE] FIX: playlist 순서변경시 영상 다시 시작 방지 #206
juwon5272 Feb 20, 2025
057d035
[FE] FEAT: 초기 playlist 설정 추가 #206
juwon5272 Feb 20, 2025
64f845f
[FE] FEAT: 유튜브 URL에서 videoId,시간 추출해서 형식에 맞게 바뀌는 util
juwon5272 Feb 20, 2025
82360fa
[FE] FEAT: videoQueue 초기 세팅을 위한 함수 추가 #206
juwon5272 Feb 20, 2025
fe3696f
[FE] FEAT: 영상 동기화 및 플레이리스트 동기화 #206
juwon5272 Feb 20, 2025
7862e30
[FE] FEAT: 플레이리스트 변경시 재생중인 영상에 영향주지 않게 영상 기억 #206
juwon5272 Feb 20, 2025
dadf61e
[FE] FIX: 타입 추가
juwon5272 Feb 21, 2025
54965f3
[FE] FIX: 타입 추가
juwon5272 Feb 21, 2025
5b3e8a0
[FE] REFACTOR: 바뀐 ProfileDetail에 맞게 매개변수 주기
juwon5272 Feb 21, 2025
68a47e6
[FE] FEAT: 재생중인 영상이 바뀌는 경우가 아니면 다시 재생하지 않음 #206
juwon5272 Feb 21, 2025
0c1f5fc
[FE] REFACTOR: 함수 메모이제이션 + 불필요한 렌더링 최소화(useRef) #206
juwon5272 Feb 23, 2025
7a1530d
[FE] FEAT: playlist, userList 페이지에 진입시 웹소켓 구독 #206
juwon5272 Feb 23, 2025
1ba1c99
[FE] ETC: 10초에 한 번 CREATOR가 현재 시간 보내는 로직 주석으로 추가
juwon5272 Feb 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/frontend/src/api/endpoints/room/room.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand All @@ -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;
},

Expand Down
19 changes: 15 additions & 4 deletions src/frontend/src/components/RoleChangeButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,42 @@
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;
}

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 (
<CommonButton
color={ButtonColor.DARKGRAY}
width="100%"
height="40px"
onClick={() => setRole(role === 2 ? 1 : 2)}
onClick={handleRoleChange}
justifycontent="space-between"
padding="10px"
borderradius="10px"
Expand Down
215 changes: 162 additions & 53 deletions src/frontend/src/components/Sidebar/Playlist/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand Down Expand Up @@ -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<number | null>(null);
const dragOverIndexRef = useRef<number | null>(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 {
Expand Down Expand Up @@ -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을 입력하세요!');
Expand All @@ -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<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(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<HTMLDivElement>, index: number) => {
e.preventDefault();
setDragOverIndex(index);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>, 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;
Expand All @@ -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 (
<Container>
Expand All @@ -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)}
Expand Down
Loading
Loading