From 27a64d9d810d0047f1265371c4cb5d748cc14eac Mon Sep 17 00:00:00 2001 From: corpi Date: Wed, 4 Mar 2026 15:20:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(sound):=20=EC=9A=B0=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=EB=B9=A0=EB=A5=B8=20=EB=B3=BC=EB=A5=A8/3=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EC=85=8B=20=EB=B0=8F=20=EB=8D=94=EB=AF=B8=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=B3=B5=EC=9B=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/useSoundPresetSelection.ts | 83 +++++++- .../model/getQuickSoundPresets.ts | 6 +- .../space-tools-dock/ui/FocusRightRail.tsx | 53 +++++ .../ui/SpaceToolsDockWidget.tsx | 195 +++++++++--------- .../ui/popovers/QuickNotesPopover.tsx | 46 +++++ .../ui/popovers/QuickSoundPopover.tsx | 89 ++++++++ .../ui/SpaceWorkspaceWidget.tsx | 8 + 7 files changed, 368 insertions(+), 112 deletions(-) create mode 100644 src/widgets/space-tools-dock/ui/FocusRightRail.tsx create mode 100644 src/widgets/space-tools-dock/ui/popovers/QuickNotesPopover.tsx create mode 100644 src/widgets/space-tools-dock/ui/popovers/QuickSoundPopover.tsx diff --git a/src/features/sound-preset/model/useSoundPresetSelection.ts b/src/features/sound-preset/model/useSoundPresetSelection.ts index 73c1dc6..c668e01 100644 --- a/src/features/sound-preset/model/useSoundPresetSelection.ts +++ b/src/features/sound-preset/model/useSoundPresetSelection.ts @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { SOUND_PRESETS } from '@/entities/session'; const TRACK_KEYS = ['white', 'rain', 'cafe', 'wave', 'fan'] as const; @@ -16,24 +16,93 @@ const DEFAULT_TRACK_LEVELS: Record = { }; const DEFAULT_MASTER_VOLUME = 68; +const SOUND_PREF_STORAGE_KEY = 'viberoom:sound-pref:v1'; + +const clampVolume = (value: number) => { + return Math.min(100, Math.max(0, value)); +}; + +interface StoredSoundPref { + selectedPresetId?: string; + masterVolume?: number; + isMuted?: boolean; +} + +const readStoredSoundPref = (): StoredSoundPref => { + if (typeof window === 'undefined') { + return {}; + } + + const raw = window.localStorage.getItem(SOUND_PREF_STORAGE_KEY); + + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw); + + if (!parsed || typeof parsed !== 'object') { + return {}; + } + + return parsed as StoredSoundPref; + } catch { + return {}; + } +}; export const useSoundPresetSelection = (initialPresetId?: string) => { + const storedPref = useMemo(() => readStoredSoundPref(), []); + const safeInitialPresetId = useMemo(() => { - const hasPreset = SOUND_PRESETS.some((preset) => preset.id === initialPresetId); - return hasPreset && initialPresetId ? initialPresetId : SOUND_PRESETS[0].id; - }, [initialPresetId]); + const candidates = [initialPresetId, storedPref.selectedPresetId]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + if (SOUND_PRESETS.some((preset) => preset.id === candidate)) { + return candidate; + } + } + + return SOUND_PRESETS[0].id; + }, [initialPresetId, storedPref.selectedPresetId]); const [selectedPresetId, setSelectedPresetId] = useState(safeInitialPresetId); const [isMixerOpen, setMixerOpen] = useState(false); - const [isMuted, setMuted] = useState(false); - const [masterVolume, setMasterVolume] = useState(DEFAULT_MASTER_VOLUME); + const [isMuted, setMuted] = useState(Boolean(storedPref.isMuted)); + const [masterVolume, setMasterVolume] = useState( + clampVolume(storedPref.masterVolume ?? DEFAULT_MASTER_VOLUME), + ); const [trackLevels, setTrackLevels] = useState>(DEFAULT_TRACK_LEVELS); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem( + SOUND_PREF_STORAGE_KEY, + JSON.stringify({ + selectedPresetId, + masterVolume: clampVolume(masterVolume), + isMuted, + }), + ); + }, [selectedPresetId, masterVolume, isMuted]); + const setTrackLevel = (track: SoundTrackKey, level: number) => { setTrackLevels((current) => ({ ...current, [track]: level })); }; + const setMasterVolumeSafe = (nextVolume: number) => { + setMasterVolume(clampVolume(nextVolume)); + }; + return { selectedPresetId, setSelectedPresetId, @@ -42,7 +111,7 @@ export const useSoundPresetSelection = (initialPresetId?: string) => { isMuted, setMuted, masterVolume, - setMasterVolume, + setMasterVolume: setMasterVolumeSafe, trackLevels, setTrackLevel, trackKeys: TRACK_KEYS, diff --git a/src/widgets/space-tools-dock/model/getQuickSoundPresets.ts b/src/widgets/space-tools-dock/model/getQuickSoundPresets.ts index 9951e67..1a59219 100644 --- a/src/widgets/space-tools-dock/model/getQuickSoundPresets.ts +++ b/src/widgets/space-tools-dock/model/getQuickSoundPresets.ts @@ -11,10 +11,8 @@ const isQuickAvailablePreset = (preset: SoundPreset | undefined): preset is Soun return !PRO_LOCKED_SOUND_IDS.includes(preset.id); }; -export const getQuickSoundPresets = (presets: SoundPreset[], selectedPresetId: string) => { - const uniquePresetIds = Array.from(new Set([selectedPresetId, ...QUICK_SOUND_FALLBACK_IDS])); - - return uniquePresetIds +export const getQuickSoundPresets = (presets: SoundPreset[]) => { + return QUICK_SOUND_FALLBACK_IDS .map((presetId) => presets.find((preset) => preset.id === presetId)) .filter(isQuickAvailablePreset) .slice(0, 3); diff --git a/src/widgets/space-tools-dock/ui/FocusRightRail.tsx b/src/widgets/space-tools-dock/ui/FocusRightRail.tsx new file mode 100644 index 0000000..1efd4e8 --- /dev/null +++ b/src/widgets/space-tools-dock/ui/FocusRightRail.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/shared/lib/cn'; +import { formatThoughtCount, RAIL_ICON } from './constants'; + +interface FocusRightRailProps { + isIdle: boolean; + thoughtCount: number; + onOpenInbox: () => void; + onOpenControlCenter: () => void; +} + +export const FocusRightRail = ({ + isIdle, + thoughtCount, + onOpenInbox, + onOpenControlCenter, +}: FocusRightRailProps) => { + return ( +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx index e20c203..c6112b3 100644 --- a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx +++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import type { PlanTier } from '@/entities/plan'; import type { RoomTheme } from '@/entities/room'; import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session'; @@ -13,7 +13,10 @@ import { SpaceSideSheet } from '@/widgets/space-sheet-shell'; import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types'; import { applyQuickPack } from '../model/applyQuickPack'; import { getQuickSoundPresets } from '../model/getQuickSoundPresets'; -import { ANCHOR_ICON, formatThoughtCount, RAIL_ICON, UTILITY_PANEL_TITLE } from './constants'; +import { ANCHOR_ICON, formatThoughtCount, UTILITY_PANEL_TITLE } from './constants'; +import { FocusRightRail } from './FocusRightRail'; +import { QuickNotesPopover } from './popovers/QuickNotesPopover'; +import { QuickSoundPopover } from './popovers/QuickSoundPopover'; import { InboxToolPanel } from './panels/InboxToolPanel'; interface SpaceToolsDockWidgetProps { isFocusMode: boolean; @@ -22,6 +25,10 @@ interface SpaceToolsDockWidgetProps { selectedTimerLabel: string; timerPresets: TimerPreset[]; selectedPresetId: string; + soundVolume: number; + onSetSoundVolume: (volume: number) => void; + isSoundMuted: boolean; + onSetSoundMuted: (nextMuted: boolean) => void; thoughts: RecentThought[]; thoughtCount: number; onRoomSelect: (roomId: string) => void; @@ -43,6 +50,10 @@ export const SpaceToolsDockWidget = ({ selectedTimerLabel, timerPresets, selectedPresetId, + soundVolume, + onSetSoundVolume, + isSoundMuted, + onSetSoundMuted, thoughts, thoughtCount, onRoomSelect, @@ -60,8 +71,10 @@ export const SpaceToolsDockWidget = ({ const [openPopover, setOpenPopover] = useState(null); const [utilityPanel, setUtilityPanel] = useState(null); const [noteDraft, setNoteDraft] = useState(''); + const [volumeFeedback, setVolumeFeedback] = useState(null); const [plan, setPlan] = useState('normal'); const [isIdle, setIdle] = useState(false); + const volumeFeedbackTimerRef = useRef(null); const selectedSoundLabel = useMemo(() => { return ( @@ -70,8 +83,17 @@ export const SpaceToolsDockWidget = ({ }, [selectedPresetId]); const quickSoundPresets = useMemo(() => { - return getQuickSoundPresets(SOUND_PRESETS, selectedPresetId); - }, [selectedPresetId]); + return getQuickSoundPresets(SOUND_PRESETS); + }, []); + + useEffect(() => { + return () => { + if (volumeFeedbackTimerRef.current) { + window.clearTimeout(volumeFeedbackTimerRef.current); + volumeFeedbackTimerRef.current = null; + } + }; + }, []); useEffect(() => { if (!openPopover) { @@ -167,15 +189,6 @@ export const SpaceToolsDockWidget = ({ }); }; - const handleNoteKeyDown = (event: ReactKeyboardEvent) => { - if (event.key !== 'Enter') { - return; - } - - event.preventDefault(); - handleNoteSubmit(); - }; - const handleInboxComplete = (thought: RecentThought) => { const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted); @@ -262,6 +275,42 @@ export const SpaceToolsDockWidget = ({ const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') => applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast }); + const showVolumeFeedback = (nextVolume: number) => { + setVolumeFeedback(`${nextVolume}%`); + + if (volumeFeedbackTimerRef.current) { + window.clearTimeout(volumeFeedbackTimerRef.current); + } + + volumeFeedbackTimerRef.current = window.setTimeout(() => { + setVolumeFeedback(null); + volumeFeedbackTimerRef.current = null; + }, 900); + }; + + const handleVolumeChange = (nextVolume: number) => { + const clamped = Math.min(100, Math.max(0, nextVolume)); + onSetSoundVolume(clamped); + + if (isSoundMuted && clamped > 0) { + onSetSoundMuted(false); + } + + showVolumeFeedback(clamped); + }; + + const handleVolumeKeyDown = (event: ReactKeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + + const step = event.shiftKey ? 10 : 5; + const delta = event.key === 'ArrowRight' ? step : -step; + handleVolumeChange(soundVolume + delta); + }; + return ( <> {openPopover ? ( @@ -287,40 +336,12 @@ export const SpaceToolsDockWidget = ({ {isFocusMode ? ( <> -
-
-
- - -
-
-
+ openUtilityPanel('inbox')} + onOpenControlCenter={() => openUtilityPanel('control-center')} + />
{openPopover === 'notes' ? ( -
-

떠오른 생각을 잠깐 주차해요

-
- setNoteDraft(event.target.value)} - onKeyDown={handleNoteKeyDown} - placeholder="떠오른 생각을 잠깐 주차…" - className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none" - /> - -
-

나중에 인박스에서 정리해요.

-
+ ) : null}
@@ -393,37 +397,26 @@ export const SpaceToolsDockWidget = ({ {openPopover === 'sound' ? ( -
-

빠른 사운드 전환

-
- {quickSoundPresets.map((preset) => { - const selected = preset.id === selectedPresetId; - - return ( - - ); - })} -
-
+ { + const nextMuted = !isSoundMuted; + onSetSoundMuted(nextMuted); + showVolumeFeedback(nextMuted ? 0 : soundVolume); + }} + onVolumeChange={handleVolumeChange} + onVolumeKeyDown={handleVolumeKeyDown} + onSelectPreset={(presetId) => { + onSelectPreset(presetId); + pushToast({ title: '사운드 변경(더미)' }); + setOpenPopover(null); + }} + /> ) : null} diff --git a/src/widgets/space-tools-dock/ui/popovers/QuickNotesPopover.tsx b/src/widgets/space-tools-dock/ui/popovers/QuickNotesPopover.tsx new file mode 100644 index 0000000..964ca9d --- /dev/null +++ b/src/widgets/space-tools-dock/ui/popovers/QuickNotesPopover.tsx @@ -0,0 +1,46 @@ +interface QuickNotesPopoverProps { + noteDraft: string; + onDraftChange: (value: string) => void; + onDraftEnter: () => void; + onSubmit: () => void; +} + +export const QuickNotesPopover = ({ + noteDraft, + onDraftChange, + onDraftEnter, + onSubmit, +}: QuickNotesPopoverProps) => { + return ( +
+

떠오른 생각을 잠깐 주차해요

+
+ onDraftChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + onDraftEnter(); + }} + placeholder="떠오른 생각을 잠깐 주차…" + className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none" + /> + +
+

나중에 인박스에서 정리해요.

+
+ ); +}; diff --git a/src/widgets/space-tools-dock/ui/popovers/QuickSoundPopover.tsx b/src/widgets/space-tools-dock/ui/popovers/QuickSoundPopover.tsx new file mode 100644 index 0000000..70048fd --- /dev/null +++ b/src/widgets/space-tools-dock/ui/popovers/QuickSoundPopover.tsx @@ -0,0 +1,89 @@ +import { cn } from '@/shared/lib/cn'; +import type { SoundPreset } from '@/entities/session'; +import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; + +interface QuickSoundPopoverProps { + selectedSoundLabel: string; + isSoundMuted: boolean; + soundVolume: number; + volumeFeedback: string | null; + quickSoundPresets: SoundPreset[]; + selectedPresetId: string; + onToggleMute: () => void; + onVolumeChange: (nextVolume: number) => void; + onVolumeKeyDown: (event: ReactKeyboardEvent) => void; + onSelectPreset: (presetId: string) => void; +} + +export const QuickSoundPopover = ({ + selectedSoundLabel, + isSoundMuted, + soundVolume, + volumeFeedback, + quickSoundPresets, + selectedPresetId, + onToggleMute, + onVolumeChange, + onVolumeKeyDown, + onSelectPreset, +}: QuickSoundPopoverProps) => { + return ( +
+

현재 사운드

+

{selectedSoundLabel}

+ +
+
+ + onVolumeChange(Number(event.target.value))} + onKeyDown={onVolumeKeyDown} + aria-label="사운드 볼륨" + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/18 accent-sky-200" + /> + + {volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)} + +
+
+ +

빠른 전환

+
+ {quickSoundPresets.map((preset) => { + const selected = preset.id === selectedPresetId; + + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index a41cc24..48ee49f 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -80,6 +80,10 @@ export const SpaceWorkspaceWidget = () => { const { selectedPresetId, setSelectedPresetId, + masterVolume, + setMasterVolume, + isMuted, + setMuted, } = useSoundPresetSelection(initialSoundPresetId); const selectedRoom = useMemo(() => { @@ -212,6 +216,10 @@ export const SpaceWorkspaceWidget = () => { onRoomSelect={setSelectedRoomId} onTimerSelect={setSelectedTimerLabel} onSelectPreset={setSelectedPresetId} + soundVolume={masterVolume} + onSetSoundVolume={setMasterVolume} + isSoundMuted={isMuted} + onSetSoundMuted={setMuted} onCaptureThought={(note) => addThought(note, selectedRoom.name)} onDeleteThought={removeThought} onSetThoughtCompleted={setThoughtCompleted}