feat(space): 사운드 시트와 몰입 모드 크롬 정리 적용
맥락:
- /space 하단 사운드 바를 제거하고 도구를 우측 도크에 수납해 배경 몰입을 강화하며, 몰입 모드 ON 체감을 높이기 위해
변경사항:
- 하단 사운드 프리셋 바를 제거하고 도크에 🎧 Sound 패널을 추가
- features/sound-preset + widgets/sound-sheet를 추가해 프리셋 선택/믹서 UI(더미) 구성
- features/immersion-mode + shared/ui/Toggle을 추가하고 Quick 시트 토글과 연결
- 몰입 모드 ON 시 상단 Current Room 숨김, 허브 버튼 소형화, 레일 미니화, HUD 저대비, 비네팅/그레인 강화
- widgets/space-chrome를 신설해 /space 상단 크롬 렌더링을 분리
- docs/90_current_state.md, docs/session_brief.md 최신 상태로 갱신
검증:
- npx tsc --noEmit
세션-상태: /space는 사운드 시트 기반 도크 구조와 몰입 모드 UI를 제공함
세션-다음: RoomSheetWidget 인원수 기반 정보를 큐레이션 표현으로 전환
세션-리스크: 터치 환경에서 미니 레일 발견성이 낮을 수 있어 보조 힌트가 필요할 수 있음
This commit is contained in:
2
src/features/immersion-mode/index.ts
Normal file
2
src/features/immersion-mode/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './model/useImmersionMode';
|
||||
export * from './ui/ImmersionModeToggle';
|
||||
16
src/features/immersion-mode/model/useImmersionMode.ts
Normal file
16
src/features/immersion-mode/model/useImmersionMode.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useImmersionMode = () => {
|
||||
const [isImmersionMode, setImmersionMode] = useState(false);
|
||||
|
||||
const toggleImmersionMode = () => {
|
||||
setImmersionMode((current) => !current);
|
||||
};
|
||||
|
||||
return {
|
||||
isImmersionMode,
|
||||
toggleImmersionMode,
|
||||
};
|
||||
};
|
||||
25
src/features/immersion-mode/ui/ImmersionModeToggle.tsx
Normal file
25
src/features/immersion-mode/ui/ImmersionModeToggle.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Toggle } from '@/shared/ui';
|
||||
|
||||
interface ImmersionModeToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const ImmersionModeToggle = ({
|
||||
enabled,
|
||||
onToggle,
|
||||
}: ImmersionModeToggleProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm text-white/90">몰입 모드</p>
|
||||
<p className="text-[11px] text-white/56">배경 우선으로 화면 요소를 최소화합니다.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={onToggle}
|
||||
ariaLabel="몰입 모드 토글"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/features/sound-preset/index.ts
Normal file
2
src/features/sound-preset/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './model/useSoundPresetSelection';
|
||||
export * from './ui/SoundPresetControls';
|
||||
50
src/features/sound-preset/model/useSoundPresetSelection.ts
Normal file
50
src/features/sound-preset/model/useSoundPresetSelection.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
|
||||
const TRACK_KEYS = ['white', 'rain', 'cafe', 'wave', 'fan'] as const;
|
||||
|
||||
export type SoundTrackKey = (typeof TRACK_KEYS)[number];
|
||||
|
||||
const DEFAULT_TRACK_LEVELS: Record<SoundTrackKey, number> = {
|
||||
white: 62,
|
||||
rain: 55,
|
||||
cafe: 48,
|
||||
wave: 44,
|
||||
fan: 40,
|
||||
};
|
||||
|
||||
const DEFAULT_MASTER_VOLUME = 68;
|
||||
|
||||
export const useSoundPresetSelection = (initialPresetId?: string) => {
|
||||
const safeInitialPresetId = useMemo(() => {
|
||||
const hasPreset = SOUND_PRESETS.some((preset) => preset.id === initialPresetId);
|
||||
return hasPreset && initialPresetId ? initialPresetId : SOUND_PRESETS[0].id;
|
||||
}, [initialPresetId]);
|
||||
|
||||
const [selectedPresetId, setSelectedPresetId] = useState(safeInitialPresetId);
|
||||
const [isMixerOpen, setMixerOpen] = useState(false);
|
||||
const [isMuted, setMuted] = useState(false);
|
||||
const [masterVolume, setMasterVolume] = useState(DEFAULT_MASTER_VOLUME);
|
||||
const [trackLevels, setTrackLevels] =
|
||||
useState<Record<SoundTrackKey, number>>(DEFAULT_TRACK_LEVELS);
|
||||
|
||||
const setTrackLevel = (track: SoundTrackKey, level: number) => {
|
||||
setTrackLevels((current) => ({ ...current, [track]: level }));
|
||||
};
|
||||
|
||||
return {
|
||||
selectedPresetId,
|
||||
setSelectedPresetId,
|
||||
isMixerOpen,
|
||||
setMixerOpen,
|
||||
isMuted,
|
||||
setMuted,
|
||||
masterVolume,
|
||||
setMasterVolume,
|
||||
trackLevels,
|
||||
setTrackLevel,
|
||||
trackKeys: TRACK_KEYS,
|
||||
};
|
||||
};
|
||||
128
src/features/sound-preset/ui/SoundPresetControls.tsx
Normal file
128
src/features/sound-preset/ui/SoundPresetControls.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { Chip, Toggle } from '@/shared/ui';
|
||||
import type { SoundTrackKey } from '../model/useSoundPresetSelection';
|
||||
|
||||
interface SoundPresetControlsProps {
|
||||
selectedPresetId: string;
|
||||
onSelectPreset: (presetId: string) => void;
|
||||
isMixerOpen: boolean;
|
||||
onToggleMixer: () => void;
|
||||
isMuted: boolean;
|
||||
onMuteChange: (next: boolean) => void;
|
||||
masterVolume: number;
|
||||
onMasterVolumeChange: (next: number) => void;
|
||||
trackKeys: readonly SoundTrackKey[];
|
||||
trackLevels: Record<SoundTrackKey, number>;
|
||||
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
|
||||
}
|
||||
|
||||
const TRACK_LABELS: Record<SoundTrackKey, string> = {
|
||||
white: 'White',
|
||||
rain: 'Rain',
|
||||
cafe: 'Cafe',
|
||||
wave: 'Wave',
|
||||
fan: 'Fan',
|
||||
};
|
||||
|
||||
const clampSliderValue = (value: number) => Math.max(0, Math.min(100, value));
|
||||
|
||||
export const SoundPresetControls = ({
|
||||
selectedPresetId,
|
||||
onSelectPreset,
|
||||
isMixerOpen,
|
||||
onToggleMixer,
|
||||
isMuted,
|
||||
onMuteChange,
|
||||
masterVolume,
|
||||
onMasterVolumeChange,
|
||||
trackKeys,
|
||||
trackLevels,
|
||||
onTrackLevelChange,
|
||||
}: SoundPresetControlsProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.11em] text-white/58">Preset</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SOUND_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
className={`rounded-xl border px-3 py-2 text-left text-xs transition-colors ${
|
||||
selectedPresetId === preset.id
|
||||
? 'border-sky-200/76 bg-sky-300/24 text-sky-50'
|
||||
: 'border-white/18 bg-white/7 text-white/82 hover:bg-white/13'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMixer}
|
||||
className="inline-flex items-center gap-2 text-xs text-white/70 transition hover:text-white"
|
||||
>
|
||||
<span>{isMixerOpen ? 'Mixer 접기' : 'Mixer 펼치기'}</span>
|
||||
<Chip tone="neutral" className="!cursor-pointer !px-2 !py-0.5 text-[10px]">
|
||||
더미
|
||||
</Chip>
|
||||
</button>
|
||||
|
||||
{isMixerOpen ? (
|
||||
<div className="space-y-3 rounded-xl border border-white/14 bg-white/6 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/78">마스터 볼륨</span>
|
||||
<span className="text-[11px] text-white/58">{masterVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={masterVolume}
|
||||
onChange={(event) =>
|
||||
onMasterVolumeChange(clampSliderValue(Number(event.target.value)))
|
||||
}
|
||||
className="w-full accent-sky-300/80"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/12 pt-2">
|
||||
<p className="text-xs text-white/78">뮤트</p>
|
||||
<Toggle
|
||||
checked={isMuted}
|
||||
onChange={onMuteChange}
|
||||
ariaLabel="마스터 뮤트 토글"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-white/12 pt-2">
|
||||
{trackKeys.map((track) => (
|
||||
<div key={track} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-white/72">{TRACK_LABELS[track]}</span>
|
||||
<span className="text-[11px] text-white/50">{trackLevels[track]}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={trackLevels[track]}
|
||||
onChange={(event) =>
|
||||
onTrackLevelChange(
|
||||
track,
|
||||
clampSliderValue(Number(event.target.value)),
|
||||
)
|
||||
}
|
||||
className="w-full accent-sky-200/70"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user