diff --git a/frontend/src/assets/icon/pin.svg b/frontend/src/assets/icon/pin.svg
new file mode 100644
index 00000000..60dfa418
--- /dev/null
+++ b/frontend/src/assets/icon/pin.svg
@@ -0,0 +1,2 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icon/play-stream.svg b/frontend/src/assets/icon/play-stream.svg
new file mode 100644
index 00000000..090a75f9
--- /dev/null
+++ b/frontend/src/assets/icon/play-stream.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icon/remove.svg b/frontend/src/assets/icon/remove.svg
new file mode 100644
index 00000000..802ca778
--- /dev/null
+++ b/frontend/src/assets/icon/remove.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/src/features/killingParts/components/RegisterPart.tsx b/frontend/src/features/killingParts/components/RegisterPart.tsx
index dbe46970..0d8359b1 100644
--- a/frontend/src/features/killingParts/components/RegisterPart.tsx
+++ b/frontend/src/features/killingParts/components/RegisterPart.tsx
@@ -1,3 +1,4 @@
+import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { useAuthContext } from '@/features/auth/components/AuthProvider';
import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext';
@@ -5,55 +6,50 @@ import { usePostKillingPart } from '@/features/killingParts/remotes/usePostKilli
import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContext';
import useModal from '@/shared/components/Modal/hooks/useModal';
import Modal from '@/shared/components/Modal/Modal';
-import useToastContext from '@/shared/components/Toast/hooks/useToastContext';
+import Spacing from '@/shared/components/Spacing';
import { toPlayingTimeText } from '@/shared/utils/convertTime';
-import copyClipboard from '@/shared/utils/copyClipBoard';
const RegisterPart = () => {
const { isOpen, openModal, closeModal } = useModal();
- const { showToast } = useToastContext();
const { user } = useAuthContext();
- const { interval, partStartTime, songId, songVideoId } = useCollectingPartContext();
+ const { interval, partStartTime, songId } = useCollectingPartContext();
const video = useVideoPlayerContext();
const { createKillingPart } = usePostKillingPart();
+ const navigate = useNavigate();
// 현재 useMutation 훅이 response 객체를 리턴하지 않고 내부적으로 처리합니다.
// 때문에 컴포넌트 단에서 createKillingPart 성공 여부에 따라 등록 완료 만료를 처리를 할 수 없어요!
- // 현재 비로그인 시에 등록을 누르면 두 개의 모달이 뜹니다.
+ // 현재 비로그인 시에 등록을 누르면 두 개의 모달이 뜹니다.정
const submitKillingPart = async () => {
video.pause();
await createKillingPart(songId, { startSecond: partStartTime, length: interval });
- openModal();
+ navigate(-1);
};
const voteTimeText = toPlayingTimeText(partStartTime, partStartTime + interval);
- const copyPartVideoUrl = async () => {
- await copyClipboard(`https://www.youtube.com/watch?v=${songVideoId}&t=${partStartTime}s`);
- closeModal();
- showToast('클립보드에 영상링크가 복사되었습니다.');
- };
-
return (
<>
-
+
등록
- {user?.nickname}님의
- 킬링파트 등록을 완료했습니다.
+ {user?.nickname}님의 파트 저장
- {voteTimeText}
- 파트를 공유해 보세요😀
+
+ {voteTimeText}
+
+
+ 나만의 파트로 등록하시겠습니까?
- 확인
+ 취소
-
- 공유하기
+
+ 등록
@@ -125,3 +121,11 @@ const ButtonContainer = styled.div`
gap: 16px;
width: 100%;
`;
+
+const Part = styled.span`
+ background-color: ${({ theme: { color } }) => color.disabled};
+ border-radius: 10px;
+ padding: 6px 11px;
+ letter-spacing: 1px;
+ color: white;
+`;
diff --git a/frontend/src/features/killingParts/components/VideoBadges.tsx b/frontend/src/features/killingParts/components/VideoBadges.tsx
index 107212d4..3baba8cc 100644
--- a/frontend/src/features/killingParts/components/VideoBadges.tsx
+++ b/frontend/src/features/killingParts/components/VideoBadges.tsx
@@ -1,18 +1,31 @@
-import styled from 'styled-components';
+import styled, { css, keyframes } from 'styled-components';
import playIcon from '@/assets/icon/fill-play.svg';
import pauseIcon from '@/assets/icon/pause.svg';
+import pinIcon from '@/assets/icon/pin.svg';
+import playStreamIcon from '@/assets/icon/play-stream.svg';
+import removeIcon from '@/assets/icon/remove.svg';
import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext';
+import usePin from '@/features/killingParts/hooks/usePin';
import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContext';
import Flex from '@/shared/components/Flex/Flex';
import { toMinSecText } from '@/shared/utils/convertTime';
const VideoBadges = () => {
const { partStartTime, isPlayingEntire, toggleEntirePlaying } = useCollectingPartContext();
+ const {
+ pinList,
+ isPinListEmpty,
+ activePinIndex,
+ ref,
+ pinAnimationRef,
+ addPin,
+ deletePin,
+ playPin,
+ } = usePin();
const video = useVideoPlayerContext();
const partStartTimeText = toMinSecText(partStartTime);
const isPaused = video.playerState === YT.PlayerState.PAUSED;
-
const videoPlay = () => {
if (isPlayingEntire) {
video.play();
@@ -25,19 +38,58 @@ const VideoBadges = () => {
};
return (
-
- {partStartTimeText}
-
-
-
-
- 전체 듣기
-
-
+ <>
+
+
+
+ {partStartTimeText}
+
+
+
+
+
+
+
+
+ 전체 듣기
+
+
+
+ {!isPinListEmpty && (
+
+
+
+ )}
+
+ {pinList.map((pin, index) => (
+
+ {pin.text}
+
+ ))}
+
+
+ >
);
};
export default VideoBadges;
+const PinFlex = styled(Flex)`
+ //overflow-x: scroll;
+ //position: relative;
+ width: 100%;
+`;
+
+const PinInner = styled(Flex)`
+ overflow-x: scroll;
+ width: calc(100% - 44px);
+`;
+
const Badge = styled.span<{ $isActive?: boolean }>`
display: flex;
align-items: center;
@@ -48,16 +100,83 @@ const Badge = styled.span<{ $isActive?: boolean }>`
padding: 0 10px;
font-size: 14px;
- color: ${({ theme: { color }, $isActive }) => ($isActive ? color.black : color.white)};
+ color: ${({ theme: { color } }) => color.white};
text-align: center;
background-color: ${({ theme: { color }, $isActive }) =>
- $isActive ? color.magenta200 : color.disabled};
+ $isActive ? color.magenta700 : color.disabled};
border-radius: 40px;
- transition: background-color 0.2s ease-in;
+ transition:
+ background-color 0.2s ease-in,
+ box-shadow 0.2s ease;
+
+ &:active {
+ box-shadow: 0 0 0 1px inset white;
+ }
@media (min-width: ${({ theme }) => theme.breakPoints.md}) {
font-size: 16px;
}
`;
+
+const StartBadge = styled(Badge)`
+ margin-right: auto;
+ letter-spacing: 1px;
+`;
+
+const slideFirstItem = keyframes`
+ from {
+ opacity: 0;
+ transform: translateX(-30px);
+
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+`;
+
+const slideRestItems = keyframes`
+ from {
+ transform: translateX(-15px);
+ }
+ to {
+ transform: translateX(0);
+ }
+`;
+
+const PinBadge = styled(Badge)<{ $isActive?: boolean; $isNew?: boolean }>`
+ background-color: ${({ theme: { color }, $isActive }) =>
+ $isActive ? color.magenta700 : color.disabledBackground};
+
+ z-index: ${({ $isActive }) => ($isActive ? 1 : 0)};
+ opacity: ${({ $isActive }) => ($isActive ? 1 : 0.5)}
+
+ border: none;
+ width: 50px;
+ white-space: nowrap;
+
+ color: black;
+ font-size: 12px;
+ margin-right: 4px;
+ border-radius: 4px;
+
+ transition: background-color 0.5s ease-in-out;
+ animation: ${({ $isNew }) =>
+ $isNew
+ ? css`
+ ${slideFirstItem} 1s forwards
+ `
+ : css`
+ ${slideRestItems} 0.5s forwards
+ `};
+`;
+
+const DeleteBadge = styled(Badge)`
+ border-radius: 50%;
+ height: 30px;
+ min-width: 30px;
+ padding: 0;
+ margin-right: 10px;
+`;
diff --git a/frontend/src/features/killingParts/components/VideoController.tsx b/frontend/src/features/killingParts/components/VideoController.tsx
index 24726625..eba8bd41 100644
--- a/frontend/src/features/killingParts/components/VideoController.tsx
+++ b/frontend/src/features/killingParts/components/VideoController.tsx
@@ -6,7 +6,7 @@ import Flex from '@/shared/components/Flex/Flex';
const VideoController = () => {
return (
-
+
길이 선택
diff --git a/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx b/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx
index 923a3dcb..0e7c721c 100644
--- a/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx
+++ b/frontend/src/features/killingParts/components/VideoIntervalStepper.tsx
@@ -21,11 +21,11 @@ const StepperElementStyle = css`
min-width: 50px;
margin: 0;
- padding: 4px 11px;
font-weight: 700;
text-align: center;
+ height: 30px;
border: none;
border-radius: 10px;
`;
@@ -36,6 +36,9 @@ const ControlButton = styled.button`
background-color: ${({ theme: { color } }) => color.secondary};
font-size: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
&:active {
background-color: ${({ theme: { color } }) => color.disabled};
diff --git a/frontend/src/features/killingParts/hooks/usePin.ts b/frontend/src/features/killingParts/hooks/usePin.ts
new file mode 100644
index 00000000..f2033269
--- /dev/null
+++ b/frontend/src/features/killingParts/hooks/usePin.ts
@@ -0,0 +1,76 @@
+import { useMemo, useRef, useState } from 'react';
+import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext';
+import { toMinSecText } from '@/shared/utils/convertTime';
+
+interface Pin {
+ partStartTime: number;
+ interval: number;
+ text: string;
+}
+
+const usePin = () => {
+ const { partStartTime, interval, setPartStartTime, setInterval } = useCollectingPartContext();
+ const [pinList, setPinList] = useState([]);
+ const ref = useRef(null);
+
+ const activePinIndex = useMemo(
+ () =>
+ pinList.findIndex((pin) => pin.partStartTime === partStartTime && pin.interval === interval),
+ [pinList, partStartTime, interval]
+ );
+
+ const isPinListEmpty = pinList.length === 0;
+
+ const pinAnimationRef = useRef(1);
+ const refreshPinAnimation = () => {
+ pinAnimationRef.current += 1;
+ };
+
+ const addPin = () => {
+ const text = `${toMinSecText(partStartTime)}`;
+
+ setPinList((prevTimeList) => [
+ {
+ partStartTime,
+ interval,
+ text,
+ },
+ ...prevTimeList.filter((pin) => pin.text !== text),
+ ]);
+
+ if (ref.current) {
+ ref.current.scrollTo({
+ left: 0,
+ behavior: 'smooth',
+ });
+ }
+
+ refreshPinAnimation();
+ };
+
+ const deletePin = () => {
+ if (activePinIndex >= 0) {
+ setPinList(pinList.filter((_, index) => index !== activePinIndex));
+ } else {
+ setPinList(pinList.slice(1));
+ }
+ };
+
+ const playPin = (start: number, interval: number) => () => {
+ setPartStartTime(start);
+ setInterval(interval);
+ };
+
+ return {
+ pinList,
+ isPinListEmpty,
+ activePinIndex,
+ pinAnimationRef,
+ ref,
+ addPin,
+ deletePin,
+ playPin,
+ };
+};
+
+export default usePin;
diff --git a/frontend/src/features/killingParts/hooks/useWave.ts b/frontend/src/features/killingParts/hooks/useWave.ts
index d92fdd09..89a8fe0e 100644
--- a/frontend/src/features/killingParts/hooks/useWave.ts
+++ b/frontend/src/features/killingParts/hooks/useWave.ts
@@ -68,7 +68,7 @@ const useWave = () => {
setXPos(null);
};
- useDebounceEffect(() => video.seekTo(partStartTime), [partStartTime], 150);
+ useDebounceEffect(() => video.seekTo(partStartTime), [partStartTime], 300);
useDebounceEffect(
() => {
if (boxRef.current) {
diff --git a/frontend/src/features/songs/components/CollectingPartProvider.tsx b/frontend/src/features/songs/components/CollectingPartProvider.tsx
index e98eeca3..0448bd43 100644
--- a/frontend/src/features/songs/components/CollectingPartProvider.tsx
+++ b/frontend/src/features/songs/components/CollectingPartProvider.tsx
@@ -14,6 +14,7 @@ interface CollectingPartContextProps extends CollectingPartProviderProps {
interval: number;
isPlayingEntire: boolean;
setPartStartTime: React.Dispatch>;
+ setInterval: React.Dispatch>;
increasePartInterval: () => void;
decreasePartInterval: () => void;
toggleEntirePlaying: () => void;
@@ -72,6 +73,7 @@ export const CollectingPartProvider = ({
songVideoId,
isPlayingEntire,
setPartStartTime,
+ setInterval,
increasePartInterval,
decreasePartInterval,
toggleEntirePlaying,