feat(sound): 우하단 빠른 볼륨/3프리셋 및 더미 저장 복원 적용

This commit is contained in:
2026-03-04 15:20:56 +09:00
parent 8f64637b6f
commit 27a64d9d81
7 changed files with 368 additions and 112 deletions

View File

@@ -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<SoundTrackKey, number> = {
};
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<Record<SoundTrackKey, number>>(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,

View File

@@ -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);

View File

@@ -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 (
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-34' : 'opacity-78',
)}
>
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
<div className="flex flex-col gap-1">
<button
type="button"
aria-label="인박스 열기"
title="인박스"
onClick={onOpenInbox}
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
>
{RAIL_ICON.inbox}
{thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
{formatThoughtCount(thoughtCount)}
</span>
) : null}
</button>
<button
type="button"
aria-label="Quick Controls 열기"
title="Quick Controls"
onClick={onOpenControlCenter}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
>
{RAIL_ICON.controlCenter}
</button>
</div>
</div>
</div>
);
};

View File

@@ -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<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [noteDraft, setNoteDraft] = useState('');
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
const [plan, setPlan] = useState<PlanTier>('normal');
const [isIdle, setIdle] = useState(false);
const volumeFeedbackTimerRef = useRef<number | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 ? (
<>
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-34' : 'opacity-78',
)}
>
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
<div className="flex flex-col gap-1">
<button
type="button"
aria-label="인박스 열기"
title="인박스"
onClick={() => openUtilityPanel('inbox')}
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
>
{RAIL_ICON.inbox}
{thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
{formatThoughtCount(thoughtCount)}
</span>
) : null}
</button>
<button
type="button"
aria-label="Quick Controls 열기"
title="Quick Controls"
onClick={() => openUtilityPanel('control-center')}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
>
{RAIL_ICON.controlCenter}
</button>
</div>
</div>
</div>
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
onOpenInbox={() => openUtilityPanel('inbox')}
onOpenControlCenter={() => openUtilityPanel('control-center')}
/>
<div
className={cn(
@@ -344,29 +365,12 @@ export const SpaceToolsDockWidget = ({
</button>
{openPopover === 'notes' ? (
<div
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
>
<p className="text-[11px] text-white/56"> </p>
<div className="mt-2 flex gap-1.5">
<input
value={noteDraft}
onChange={(event) => 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"
/>
<button
type="button"
onClick={handleNoteSubmit}
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
>
</button>
</div>
<p className="mt-2 text-[11px] text-white/52"> .</p>
</div>
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={setNoteDraft}
onDraftEnter={handleNoteSubmit}
onSubmit={handleNoteSubmit}
/>
) : null}
</div>
</div>
@@ -393,37 +397,26 @@ export const SpaceToolsDockWidget = ({
</button>
{openPopover === 'sound' ? (
<div
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
>
<p className="text-[11px] text-white/56"> </p>
<div className="mt-2 flex flex-wrap gap-1.5">
{quickSoundPresets.map((preset) => {
const selected = preset.id === selectedPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => {
onSelectPreset(preset.id);
pushToast({ title: `${preset.label}로 전환했어요.` });
setOpenPopover(null);
}}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={() => {
const nextMuted = !isSoundMuted;
onSetSoundMuted(nextMuted);
showVolumeFeedback(nextMuted ? 0 : soundVolume);
}}
onVolumeChange={handleVolumeChange}
onVolumeKeyDown={handleVolumeKeyDown}
onSelectPreset={(presetId) => {
onSelectPreset(presetId);
pushToast({ title: '사운드 변경(더미)' });
setOpenPopover(null);
}}
/>
) : null}
</div>
</div>

View File

@@ -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 (
<div
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
>
<p className="text-[11px] text-white/56"> </p>
<div className="mt-2 flex gap-1.5">
<input
value={noteDraft}
onChange={(event) => 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"
/>
<button
type="button"
onClick={onSubmit}
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
>
</button>
</div>
<p className="mt-2 text-[11px] text-white/52"> .</p>
</div>
);
};

View File

@@ -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<HTMLInputElement>) => void;
onSelectPreset: (presetId: string) => void;
}
export const QuickSoundPopover = ({
selectedSoundLabel,
isSoundMuted,
soundVolume,
volumeFeedback,
quickSoundPresets,
selectedPresetId,
onToggleMute,
onVolumeChange,
onVolumeKeyDown,
onSelectPreset,
}: QuickSoundPopoverProps) => {
return (
<div
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
>
<p className="text-[11px] text-white/56"> </p>
<p className="mt-1 truncate text-sm font-medium text-white/88">{selectedSoundLabel}</p>
<div className="mt-3 rounded-xl border border-white/14 bg-white/[0.04] px-2.5 py-2">
<div className="flex items-center gap-2">
<button
type="button"
aria-label={isSoundMuted ? '음소거 해제' : '음소거'}
onClick={onToggleMute}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-xs text-white/80 transition-colors hover:bg-white/[0.12]"
>
🔇
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={soundVolume}
onChange={(event) => 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"
/>
<span className="w-9 text-right text-[11px] text-white/66">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
</div>
</div>
<p className="mt-3 text-[11px] text-white/56"> </p>
<div className="mt-2 flex flex-wrap gap-1.5">
{quickSoundPresets.map((preset) => {
const selected = preset.id === selectedPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => onSelectPreset(preset.id)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
);
};

View File

@@ -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}