diff --git a/src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx b/src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx new file mode 100644 index 00000000..9df65729 --- /dev/null +++ b/src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx @@ -0,0 +1,24 @@ +// Storybook 코드 +import { Meta, StoryObj } from '@storybook/react'; +import CustomRangeSlider from './CustomRangeSlider'; + +const meta: Meta = { + title: 'Components/CustomRangeSlider', + component: CustomRangeSlider, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 5, + max: 10, + min: 0, + onValueChange: (value: number) => { + console.log(value); + }, + }, +}; diff --git a/src/components/CustomRangeSlider/CustomRangeSlider.tsx b/src/components/CustomRangeSlider/CustomRangeSlider.tsx new file mode 100644 index 00000000..2fe91682 --- /dev/null +++ b/src/components/CustomRangeSlider/CustomRangeSlider.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; + +interface CustomRangeSliderProps { + // Controlled props + value: number; + onValueChange: (value: number) => void; + + // Configuration + min?: number; + max?: number; + step?: number; +} + +export default function CustomRangeSlider({ + value, + onValueChange, + min = 0, + max = 100, + step = 1, +}: CustomRangeSliderProps) { + const [showTooltip, setShowTooltip] = useState(false); + const range = max - min; + const percentage = + range <= 0 ? 0 : Math.min(100, Math.max(0, ((value - min) / range) * 100)); + + return ( +
+ {/* 스타일링 레이어 */} + {/* 1. 전체 트랙 (배경) */} +
+ + {/* 2. 현재 진행도 트랙 */} +
+ + {/* 3. Thumb (원형 드래그 핸들) */} +
+ {/* 볼륨 수치로 나타내는 말풍선 */} + {showTooltip && ( +
+
+ {value} + {/* 말풍선 꼬리 */} +
+
+
+ )} +
+ + {/* 실제 상호작용 레이어 */} + onValueChange(Number(e.target.value))} + onMouseEnter={() => setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" + /> +
+ ); +} diff --git a/src/components/VolumeBar/VolumeBar.stories.tsx b/src/components/VolumeBar/VolumeBar.stories.tsx new file mode 100644 index 00000000..7ebd5ce3 --- /dev/null +++ b/src/components/VolumeBar/VolumeBar.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from '@storybook/react'; +import VolumeBar from './VolumeBar'; + +const meta: Meta = { + title: 'Components/VolumeBar', + component: VolumeBar, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + volume: 0, + onVolumeChange: (volume: number) => console.log(volume), + }, +}; diff --git a/src/components/VolumeBar/VolumeBar.tsx b/src/components/VolumeBar/VolumeBar.tsx new file mode 100644 index 00000000..6c9a076c --- /dev/null +++ b/src/components/VolumeBar/VolumeBar.tsx @@ -0,0 +1,143 @@ +// Integer 0-10, step = 1 +// Mute button available +import { useEffect, useState } from 'react'; +import CustomRangeSlider from '../CustomRangeSlider/CustomRangeSlider'; +import DTVolume from '../icons/Volume'; +import clsx from 'clsx'; + +interface VolumeBarProps { + volume: number; + onVolumeChange: (volume: number) => void; + className?: string; +} + +const MIN_VOLUME = 0; +const MAX_VOLUME = 10; +const STEP_VOLUME = 1; + +export default function VolumeBar({ + volume, + onVolumeChange, + className = '', +}: VolumeBarProps) { + // 음소거 해제 시 가장 마지막의 볼륨 값을 복원하기 위함 + const [lastVolume, setLastVolume] = useState(volume > 0 ? volume : 5); + + // 음소거 로직 + const handleMute = () => { + if (volume === 0) { + onVolumeChange(lastVolume === 0 ? 1 : lastVolume); + } else { + setLastVolume(volume); + onVolumeChange(0); + } + }; + + // 음소거 버튼은 오직 볼륨이 0일 때에만 흐리게 강조됨 + const isNotMute = volume > 0; + + // 외부에서 볼륨이 변경될 경우, 값을 관측하여 동기화 + useEffect(() => { + if (volume > 0) { + setLastVolume(volume); + } + }, [volume]); + + return ( +
+ {/* SVG Layer */} + + + + + + + + + + + + + + + + + + + + + + + {/* Content Layer */} +
+
+ + { + onVolumeChange(value); + + // 마지막 볼륨이 0으로 저장되면, 음소거를 해제해도 음소거가 유지되는 버그를 피하기 위함 + if (value > 0) { + setLastVolume(value); + } + }} + min={MIN_VOLUME} + max={MAX_VOLUME} + step={STEP_VOLUME} + /> +
+
+
+ ); +} diff --git a/src/components/icons/Icon.stories.tsx b/src/components/icons/Icon.stories.tsx index ddfa56bf..aa1a5668 100644 --- a/src/components/icons/Icon.stories.tsx +++ b/src/components/icons/Icon.stories.tsx @@ -17,6 +17,7 @@ import DTReset from './Reset'; import DTShare from './Share'; import DTExchange from './Exchange'; import DTBell from './Bell'; +import DTVolume from './Volume'; const meta: Meta = { title: 'Design System/Icons', @@ -256,3 +257,15 @@ export const OnBell: Story = {
), }; + +export const OnVolume: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

볼륨 조절

+
+ ), +}; diff --git a/src/components/icons/Volume.tsx b/src/components/icons/Volume.tsx new file mode 100644 index 00000000..b9a86ec7 --- /dev/null +++ b/src/components/icons/Volume.tsx @@ -0,0 +1,22 @@ +import { IconProps } from './IconProps'; + +export default function DTVolume({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index dcdb1beb..b2d0e73c 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -62,7 +62,7 @@ export function useModal(options: UseModalOptions = {}) { return (
diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 8ef669a3..caf3ece9 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -20,7 +20,7 @@ function StickyTriSectionHeader(props: PropsWithChildren) { const { children } = props; return ( -
+
{children}
diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 86fe87f2..c5efdfa5 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -15,6 +15,8 @@ import clsx from 'clsx'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; import { RiFullscreenFill, RiFullscreenExitFill } from 'react-icons/ri'; +import DTVolume from '../../components/icons/Volume'; +import VolumeBar from '../../components/VolumeBar/VolumeBar'; export default function TimerPage() { const pathParams = useParams(); @@ -39,9 +41,14 @@ export default function TimerPage() { isLoading, isError, refetch, + isVolumeBarOpen, + toggleVolumeBar, + volume, + setVolume, isFullscreen, setFullscreen, toggleFullscreen, + volumeRef, } = state; // If error, print error message and let user be able to retry @@ -103,6 +110,26 @@ export default function TimerPage() { )} + +
+ + + {isVolumeBarOpen && ( +
+ setVolume(value)} + /> +
+ )} +
diff --git a/src/page/TimerPage/hooks/useBellSound.ts b/src/page/TimerPage/hooks/useBellSound.ts index a9e96588..b9c35011 100644 --- a/src/page/TimerPage/hooks/useBellSound.ts +++ b/src/page/TimerPage/hooks/useBellSound.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { NormalTimerLogics } from './useNormalTimer'; import { BellConfig } from '../../../type/type'; @@ -7,14 +7,84 @@ interface UseBellSoundProps { bells?: BellConfig[] | null; } +const STORAGE_KEY = 'timer-volume'; +const DEFAULT_VOLUME = 0.5; + export function useBellSound({ normalTimer, bells }: UseBellSoundProps) { + /** 볼륨 값 검증 함수 + * @param value 검증하고자 하는 볼륨 값 + * @returns 검증 성공 여부 (`boolean`) + */ + const isValidVolume = (value: number): boolean => { + if ( + value === null || + Number.isNaN(value) || + !Number.isFinite(value) || + value < 0.0 || + value > 1.0 + ) { + return false; + } + return true; + }; + + // 볼륨 초기화 함수 + const getAndInitVolume = () => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem(STORAGE_KEY); + // 로컬 저장소 값 자체가 null일 경우 + if (saved === null) { + return DEFAULT_VOLUME; + } + + // 볼륨 값 검증 + if (isValidVolume(Number(saved))) { + return Number(saved); + } else { + return DEFAULT_VOLUME; + } + } + return DEFAULT_VOLUME; + }; + + const [volume, setVolume] = useState(() => getAndInitVolume()); + const volumeRef = useRef(volume); + + /** 안전하게 볼륨 값을 갱신하는 함수. + * 값이 정상적이지 않을 경우, 일괄 0.5로 초기화합니다. + * @param value 갱신할 볼륨 값 (실수 0.0부터 1.0 사이) + */ + const updateVolume = (value: number) => { + if (isValidVolume(value)) { + setVolume(value); + } else { + setVolume(DEFAULT_VOLUME); + } + }; + + // playBell과 같은 콜백 함수에서 최신 volume 값을 참조하기 위해 ref를 동기화 + useEffect(() => { + volumeRef.current = volume; + }, [volume]); + // 종소리 여러 번 - 새로운 Audio로 재생 - function playBell(count: number) { + const playBell = useCallback((count: number) => { const audio = new Audio(`/sounds/bell-${count}.mp3`); + audio.volume = volumeRef.current; audio.play().catch((err) => { console.warn('audio.play() 실패:', err); }); - } + }, []); + + // 볼륨 변경 시 최신 값을 로컬 저장소에 저장 + // 500 ms 디바운싱 적용하여 성능 문제 예방 + useEffect(() => { + const timerId = setTimeout(() => { + localStorage.setItem(STORAGE_KEY, volume.toString()); + }, 500); + + return () => clearTimeout(timerId); + }, [volume]); useEffect(() => { const timerVal = normalTimer.timer; @@ -39,5 +109,7 @@ export function useBellSound({ normalTimer, bells }: UseBellSoundProps) { playBell(bell.count); } }); - }, [normalTimer.timer, bells, normalTimer.defaultTimer]); + }, [normalTimer.timer, bells, normalTimer.defaultTimer, playBell]); + + return { volume, updateVolume }; } diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts index 813150d7..c94d55ab 100644 --- a/src/page/TimerPage/hooks/useTimerPageState.ts +++ b/src/page/TimerPage/hooks/useTimerPageState.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { useGetDebateTableData } from '../../../hooks/query/useGetDebateTableData'; @@ -18,6 +19,8 @@ import { import { useTimerBackground } from './useTimerBackground'; import useFullscreen from '../../../hooks/useFullscreen'; +const VOLUME_SCALE = 10; + /** * 타이머 페이지의 상태(타이머, 라운드, 벨 등) 전반을 관리하는 커스텀 훅 */ @@ -55,11 +58,30 @@ export function useTimerPageState(tableId: number): TimerPageLogics { useState('PROS'); // 벨 사운드 관련 훅 - useBellSound({ + const { volume: rawVolume, updateVolume: updateRawVolume } = useBellSound({ normalTimer, - bells: data?.table[index].bell, + bells: data?.table[index]?.bell, }); + // 볼륨 값과 조절 함수 + // - React 내부적으로는 0.0 ~ 1.0 사이 값 사용 + // - 아래 값과 함수를 통해 사용자에게는 0 ~ 10 사이 값으로 인식되게 값을 변형 + const volume = Math.round(rawVolume * VOLUME_SCALE); + const updateVolume = (value: number) => { + if (value < 0 || value > VOLUME_SCALE) { + return; + } + + // UI 상의 0 ~ 10 볼륨을 React 내부 로직의 0.0 ~ 1.0으로 바꾸어서 갱신 + updateRawVolume(value / VOLUME_SCALE); + }; + + // 벨 볼륨 관련 + const [isVolumeBarOpen, setIsVolumeBarOpen] = useState(false); + const toggleVolumeBar = () => { + setIsVolumeBarOpen((prev) => !prev); + }; + const { bg, setBg } = useTimerBackground({ timer1, timer2, @@ -149,6 +171,25 @@ export function useTimerPageState(tableId: number): TimerPageLogics { [prosConsSelected, switchCamp, timer1, timer2], ); + // 볼륨 바 바깥 영역에서의 클릭을 처리하기 위한 레퍼런스와 함수 + const volumeRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + volumeRef.current && + !volumeRef.current.contains(event.target as Node) && + isVolumeBarOpen + ) { + toggleVolumeBar(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isVolumeBarOpen, toggleVolumeBar]); + /** * 라운드 이동/초기 진입 시 타이머 상태 초기화 및 셋업 */ @@ -262,6 +303,11 @@ export function useTimerPageState(tableId: number): TimerPageLogics { isFullscreen, toggleFullscreen, setFullscreen, + volume, + setVolume: updateVolume, + isVolumeBarOpen, + toggleVolumeBar, + volumeRef, }; } @@ -287,4 +333,9 @@ export interface TimerPageLogics { isFullscreen: boolean; toggleFullscreen: () => void; setFullscreen: (value: boolean) => void; + volume: number; + setVolume: (value: number) => void; + toggleVolumeBar: () => void; + isVolumeBarOpen: boolean; + volumeRef: React.RefObject; }