현재 소프트웨어 마에스트로에서 모도코라는 홈페이지를 제작중입니다.
유저 피드백 중 다른 사람들과 같이 유튜브를 볼 수 있는(음악 들을 수 있는) 기능을 추가해 달라는 의견이 있었습니다. 이를 반영해 youtube 플레이어를 만들어보았습니다.
어떠한 방식으로 구현할지 고민을 했습니다. 제가 찾아보았을 때 두 가지 케이스가 있었습니다.
네이버의 웨일온 스터디에서는 유튜브 링크를 공유하면 다른 사용자들도 해당 영상을 동시 시청하는 방식이였습니다. 한번에 하나의 영상만 공유가 가능했습니다.
디스코드의 Watch Together는 유튜브 검색기능을 통해 자신이 원하는 영상을 플레이리스트에 추가하는 방식이였습니다. 단, 호스팅하고 있는 사람만 해당 동영상을 제어(재생, 멈춤, 재생바 이동 등)할 수 있습니다.
직접 사용하고 팀원들과 이야기해본 후 디스코드처럼 플레이리스트 형식으로 제작하는 방식을 선택했습니다. 단, 디스코드 처럼 한 사람만 제어하기 보다 사용자들은 자유롭게 플레이리스트에 영상을 추가 및 제어할 수 있게 하였으며 각자의 플레이리스트에서 삭제하는 것을 허용하였습니다. 삭제는 다른 사용자들에게는 반영되지 않습니다.
ex) A가 1번 곡 추가 -> youtube player를 같이 보는 사용자들에게 1번 곡 추가,
A가 1번 곡 삭제 -> A의 플레이리스트에선 삭제되지만 youtube player를 같이 보는 다른 사용자들에게는 반영x
그리고 저는 커스텀하기 간단한 react-player 라이브러리를 사용하였습니다. 이를 통해 플레이리스트에 있는 곡들이 자동으로 연속되어 재생되도록 하였습니다.
사용자가 상단바에 있는 youtube 로고를 클릭하면 youtube player 소켓에 join합니다. 검색창에 자신이 원하는 키워드로 검색하면 music 카테고리의 영상들을 보여줍니다. 자신이 원하는 영상을 클릭하면 youtube player를 구독하고 있는 모든 유저에게 해당 플레이리스트가 sync(추가) 됩니다.
(styled-components와 일부 코드들은 삭제하였습니다.)
...
export default function YoutubeModal({ roomId }: { roomId: string }) {
const [playlist, setPlaylist] = useState([]); // 플레이리스트 목록
const [searchList, setSearchList] = useState([]); // 검색 목록
const [nowPlaying, setNowPlaying] = useState<number>(0); // 현재 play 중인 index
const isInPlaylist = (video: youtubeSearch) => { // playlist에 있는 영상 확인
return playlist.some((item) => item.id.videoId === video.id.videoId);
};
const removeItem = (index: number) => { // 플레이리스트의 index번째 영상 삭제
setPlaylist(playlist.filter((_, i) => i !== index));
if (index <= nowPlaying && nowPlaying !== 0) {
setNowPlaying(nowPlaying - 1);
}
};
useEffect(() => {
initSocketConnection(); // 소켓 연결
joinYoutube(roomId); // youtube socket emit join
const addVideoFunc = (data) => {
data.playlist.map((item) =>
setPlaylist((playlist) => [...playlist, item.video]),
);
};
addVideo(addVideoFunc); // add video 구독
return () => {
leaveYoutube(roomId);
disconnectSocket();
};
}, [roomId]);
return (
<Component>
<YoutubeModalHeader />
<YoutubeModalInput setSearchList={setSearchList} />
<Playing>
<YoutubeModalPlayer
playlist={playlist}
nowPlaying={nowPlaying}
setNowPlaying={setNowPlaying}
/>
<Playlist>
{playlist.map((item, index) => (
<PlaylistItem
key={Symbol(item.id.videoId).toString()}
item={item}
index={index}
removeItem={removeItem}
/>
))}
</Playlist>
</Playing>
<SearchList>
{searchList.map((item: youtubeSearch) => (
<SearchListItem
key={item.id.videoId}
item={item}
roomId={roomId}
isInPlaylist={isInPlaylist}
/>
))}
</SearchList>
</Component>
);
}
...
export default React.memo(function SearchListItem({
roomId,
item,
isInPlaylist,
}: {
roomId: string;
item: youtubeSearch;
isInPlaylist: (_video: youtubeSearch) => boolean;
}) {
const isAdded = isInPlaylist(item);
const title = calculateTitle(item.snippet.title);
const onClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
if (!isAdded) { // youtube player 구독하고 있는 사용자들에게 해당 영상 emit
selectVideo(roomId, item);
}
};
return (
<Component>
<VideoComponent isAdded={isAdded}>
<InnerComponent
isAdded={isAdded}
id={!isAdded ? 'addVideoButton' : ''}
onClick={onClick}
>
<SvgComponent isAdded={isAdded}>
{isAdded ? <Check /> : <Plus />}
</SvgComponent>
</InnerComponent>
<Image src={item.snippet.thumbnails.medium.url} />
</VideoComponent>
<TitleComponent>
<Title>{title}</Title>
</TitleComponent>
</Component>
);
});
...
export default function YoutubeModalPlayer({
playlist,
nowPlaying,
setNowPlaying,
}: {
playlist: youtubeSearch[];
nowPlaying: number;
setNowPlaying: React.Dispatch<React.SetStateAction<number>>;
}) {
const list = playlist.map((item) => item.id.videoId);
const onEnded = () => { // 한 영상 끝났을 때 다음 영상 재생
setNowPlaying((nowPlaying + 1) % playlist.length);
};
if (playlist.length === 0) {
return <Empty>플레이리스트가 비어있어요</Empty>;
}
return (
<ReactPlayer
url={`https://www.youtube.com/watch?v=${list[nowPlaying]}`}
playing
controls
loop={playlist.length === 1}
width="62%"
height="100%"
volume={0.5}
onEnded={onEnded}
/>
);
}
...
export default React.memo(function PlaylistItem({
item,
index,
removeItem,
}: {
item: youtubeSearch;
index: number;
removeItem: (_index: number) => void;
}) {
return (
<Item>
<Title>{item.snippet.title}</Title>
<DeleteItem type="button" onClick={() => removeItem(index)}> // 클릭시 플레이리스트에서 해당 영상 삭제
<X />
</DeleteItem>
</Item>
);
});
전체 소스코드는 깃허브를 참고해주시면 감사하겠습니다.
이렇게 하여 youtube player 기능을 완성하였습니다. 평소에 저도 추가했으면 좋겠다고 생각한 기능이었는데 실제로 다른 유저분들과 사용해보니 더 좋은 것 같습니다ㅎㅎ
+) 유저 피드백에서 방삭제 기능도 추가했습니다ㅎㅎ 음성조절은 원래 있는 기능인데... 저희 서비스 ux가 별로 안좋나봅니당..........................ㅠㅠ 주륵
ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ