Phase 9 완료: 전체 남은 기능 구현
1. RecordingTip: thinking 상태 전환 시 opacity 보장 2. 히스토리 오디오 경로: WAV 파일 경로를 DB에 기록 3. 마이크 테스트 UI: Settings Audio 탭에 TEST 버튼 + 레벨 바 4. 온보딩 플로우: 첫 실행 시 3단계 안내 (환영→마이크→핫키→완료) 5. MetalDial 컴포넌트: 동심원 그루브, 회전 포인터, LED 인디케이터
This commit is contained in:
parent
d7074e359d
commit
c338a19c90
7 changed files with 431 additions and 4 deletions
|
|
@ -137,16 +137,21 @@ async function initVoiceMode(): Promise<void> {
|
||||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||||
soundEffect.play('recording-stop')
|
soundEffect.play('recording-stop')
|
||||||
|
|
||||||
// 이력 저장
|
// 이력 저장 (오디오 파일 경로 포함)
|
||||||
try {
|
try {
|
||||||
|
const { join } = await import('path')
|
||||||
|
const { app } = await import('electron')
|
||||||
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
|
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
|
||||||
|
const audioPath = join(app.getPath('userData'), 'recordings', `${session.id}.wav`)
|
||||||
|
|
||||||
getHistoryService().create({
|
getHistoryService().create({
|
||||||
originalText: session.transcription || finalText,
|
originalText: session.transcription || finalText,
|
||||||
polishedText: session.processedText,
|
polishedText: session.processedText,
|
||||||
mode: session.mode === 'hands-free' ? 'dictation' : session.mode,
|
mode: session.mode === 'hands-free' ? 'dictation' : session.mode,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
duration: (Date.now() - session.startedAt) / 1000,
|
duration: (Date.now() - session.startedAt) / 1000,
|
||||||
wordCount
|
wordCount,
|
||||||
|
audioLocalPath: audioPath,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to save history: ${error instanceof Error ? error.message : String(error)}`)
|
logger.warn(`Failed to save history: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// src/renderer/components/AppLayout.tsx
|
// src/renderer/components/AppLayout.tsx
|
||||||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Box, Typography, Tooltip } from '@mui/material'
|
import { Box, Typography, Tooltip } from '@mui/material'
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
import HistoryIcon from '@mui/icons-material/History'
|
import HistoryIcon from '@mui/icons-material/History'
|
||||||
|
|
@ -14,6 +14,7 @@ import { HistoryPage } from '../pages/HistoryPage'
|
||||||
import { DictionaryPage } from '../pages/DictionaryPage'
|
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||||
import { CommandsPage } from '../pages/CommandsPage'
|
import { CommandsPage } from '../pages/CommandsPage'
|
||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal } from './SettingsModal'
|
||||||
|
import { OnboardingModal } from './OnboardingModal'
|
||||||
import { StatusBar } from './StatusBar'
|
import { StatusBar } from './StatusBar'
|
||||||
import { d3roPalette, d3roFontMono } from '../theme'
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
|
|
||||||
|
|
@ -29,6 +30,19 @@ const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }
|
||||||
export function AppLayout(): React.ReactElement {
|
export function AppLayout(): React.ReactElement {
|
||||||
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
||||||
|
|
||||||
|
// 첫 실행 감지
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI.config.getAll().then((r) => {
|
||||||
|
if (r.success) {
|
||||||
|
const cfg = r.data as Record<string, unknown>
|
||||||
|
if (!cfg['onboardingCompleted']) {
|
||||||
|
setOnboardingOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
||||||
|
|
@ -161,6 +175,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
</Box>
|
</Box>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
231
src/renderer/components/OnboardingModal.tsx
Normal file
231
src/renderer/components/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
// src/renderer/components/OnboardingModal.tsx
|
||||||
|
// 첫 실행 시 마이크 + 핫키 설정 안내
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material'
|
||||||
|
import MicIcon from '@mui/icons-material/Mic'
|
||||||
|
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
|
import { Led } from './ds'
|
||||||
|
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||||
|
import type { HotkeyBinding, AudioDevice } from '@shared/types'
|
||||||
|
|
||||||
|
interface OnboardingModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
||||||
|
const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: 완료
|
||||||
|
const [devices, setDevices] = useState<AudioDevice[]>([])
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState('default')
|
||||||
|
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
|
||||||
|
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setStep(0)
|
||||||
|
window.electronAPI.audio.getDevices().then((r) => {
|
||||||
|
if (r.success) setDevices(r.data)
|
||||||
|
})
|
||||||
|
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
|
||||||
|
if (r.success && r.data) setHotkeyBinding(r.data)
|
||||||
|
})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
// 온보딩 완료 플래그 저장
|
||||||
|
window.electronAPI.config.set({ key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig, value: true as never })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHotkeySave = (binding: HotkeyBinding) => {
|
||||||
|
setHotkeyBinding(binding)
|
||||||
|
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
||||||
|
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
bgcolor: d3roPalette.bg.chassis,
|
||||||
|
backgroundImage: 'none',
|
||||||
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
|
boxShadow: '0 40px 80px rgba(0,0,0,0.8)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent sx={{ p: 4 }}>
|
||||||
|
{/* Step 0: 환영 */}
|
||||||
|
{step === 0 && (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||||
|
<Led color="amber" pulse size={16} />
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 300,
|
||||||
|
color: d3roPalette.accent.amber,
|
||||||
|
mt: 3,
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
D3RO-VOICE
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
||||||
|
타이핑 없이, 음성으로. 로컬 AI 음성 어시스턴트입니다.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
|
||||||
|
시작하기
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: 마이크 */}
|
||||||
|
{step === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||||
|
<MicIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||||
|
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||||
|
마이크 설정
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||||
|
사용할 마이크를 선택하세요. 나중에 설정에서 변경할 수 있습니다.
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1} mb={3}>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Box
|
||||||
|
key={d.deviceId}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDevice(d.deviceId)
|
||||||
|
window.electronAPI.audio.setSelectedDevice({ deviceId: d.deviceId })
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: selectedDevice === d.deviceId ? d3roPalette.accent.amberDim : d3roPalette.bg.inset,
|
||||||
|
border: selectedDevice === d.deviceId
|
||||||
|
? `1px solid ${d3roPalette.accent.amber}`
|
||||||
|
: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
|
'&:hover': { bgcolor: d3roPalette.bg.cardHover },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontSize: '13px' }}>
|
||||||
|
{d.label}{d.isDefault ? ' (기본)' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Button onClick={() => setStep(0)} sx={{ color: d3roPalette.text.inactive }}>뒤로</Button>
|
||||||
|
<Button variant="contained" onClick={() => setStep(2)}>다음</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: 핫키 */}
|
||||||
|
{step === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||||
|
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||||
|
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||||
|
단축키 설정
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||||
|
받아쓰기 단축키를 설정하세요. 키를 누르고 있는 동안 녹음됩니다.
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: '10px',
|
||||||
|
bgcolor: d3roPalette.bg.inset,
|
||||||
|
boxShadow: 'inset 0 2px 6px rgba(0,0,0,0.4)',
|
||||||
|
textAlign: 'center',
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hotkeyBinding ? (
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
|
||||||
|
<Led color="green" size={8} />
|
||||||
|
{hotkeyBinding.displayLabel.split(' + ').map((key) => (
|
||||||
|
<Chip
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontWeight: 700,
|
||||||
|
bgcolor: d3roPalette.bg.chassis,
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
border: `1px solid ${d3roPalette.border.default}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
|
||||||
|
단축키가 설정되지 않았습니다
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => setHotkeyModalOpen(true)}
|
||||||
|
sx={{ mb: 3, fontFamily: d3roFontMono }}
|
||||||
|
>
|
||||||
|
{hotkeyBinding ? '단축키 변경' : '단축키 설정'}
|
||||||
|
</Button>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Button onClick={() => setStep(1)} sx={{ color: d3roPalette.text.inactive }}>뒤로</Button>
|
||||||
|
<Button variant="contained" onClick={() => setStep(3)}>다음</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: 완료 */}
|
||||||
|
{step === 3 && (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
|
||||||
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
|
||||||
|
설정 완료!
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
||||||
|
{hotkeyBinding
|
||||||
|
? `${hotkeyBinding.displayLabel} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.`
|
||||||
|
: '설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.'}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={handleFinish} fullWidth>
|
||||||
|
시작하기
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<HotkeyRecordModal
|
||||||
|
open={hotkeyModalOpen}
|
||||||
|
onClose={() => setHotkeyModalOpen(false)}
|
||||||
|
onSave={handleHotkeySave}
|
||||||
|
currentBinding={hotkeyBinding}
|
||||||
|
title="받아쓰기 단축키 설정"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -191,6 +191,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
// 오디오 디바이스
|
// 오디오 디바이스
|
||||||
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
|
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
|
||||||
|
const [micTesting, setMicTesting] = useState(false)
|
||||||
|
const [micLevel, setMicLevel] = useState(0)
|
||||||
|
|
||||||
// 설정 로드
|
// 설정 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -500,6 +502,58 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 마이크 테스트 */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant={micTesting ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
if (micTesting) {
|
||||||
|
setMicTesting(false)
|
||||||
|
setMicLevel(0)
|
||||||
|
} else {
|
||||||
|
setMicTesting(true)
|
||||||
|
// 3초 후 자동 종료
|
||||||
|
const unsub = window.electronAPI.voice.onAudioLevel((e) => {
|
||||||
|
setMicLevel(e.level)
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
setMicTesting(false)
|
||||||
|
setMicLevel(0)
|
||||||
|
unsub()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '11px',
|
||||||
|
minWidth: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{micTesting ? 'STOP' : 'TEST'}
|
||||||
|
</Button>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
height: 8,
|
||||||
|
bgcolor: d3roPalette.bg.inset,
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${Math.min(100, micLevel * 100)}%`,
|
||||||
|
height: '100%',
|
||||||
|
bgcolor: micLevel > 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'width 100ms ease-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
|
|
||||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||||
|
|
|
||||||
119
src/renderer/components/ds/MetalDial.tsx
Normal file
119
src/renderer/components/ds/MetalDial.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// src/renderer/components/ds/MetalDial.tsx
|
||||||
|
// 시안 A: 메탈 다이얼 — 정밀기기 회전 노브, 동심원 그루브, LED 인디케이터
|
||||||
|
|
||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||||
|
|
||||||
|
interface MetalDialProps {
|
||||||
|
/** 0.0 ~ 1.0 값 (다이얼 위치) */
|
||||||
|
value?: number
|
||||||
|
/** 라벨 텍스트 */
|
||||||
|
label?: string
|
||||||
|
/** 크기 (px) */
|
||||||
|
size?: number
|
||||||
|
/** LED 인디케이터 색상 */
|
||||||
|
ledColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetalDial({
|
||||||
|
value = 0,
|
||||||
|
label,
|
||||||
|
size = 120,
|
||||||
|
ledColor = d3roPalette.accent.amber,
|
||||||
|
}: MetalDialProps): React.ReactElement {
|
||||||
|
const rotation = value * 270 - 135 // -135° ~ +135° 범위
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
|
{/* 다이얼 외곽 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: d3roPalette.bg.chassis,
|
||||||
|
boxShadow: `
|
||||||
|
0 4px 12px rgba(0,0,0,0.5),
|
||||||
|
inset 0 2px 4px rgba(255,255,255,0.08),
|
||||||
|
inset 0 -2px 4px rgba(0,0,0,0.3)
|
||||||
|
`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 동심원 그루브 (CSS로 시뮬레이션) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: size - 16,
|
||||||
|
height: size - 16,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `
|
||||||
|
repeating-radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
${d3roPalette.bg.chassis} 0px,
|
||||||
|
${d3roPalette.bg.card} 1px,
|
||||||
|
${d3roPalette.bg.chassis} 2px
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
boxShadow: `
|
||||||
|
inset 0 1px 3px rgba(0,0,0,0.6),
|
||||||
|
0 1px 1px rgba(255,255,255,0.05)
|
||||||
|
`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transition: 'transform 0.2s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 포인터 인디케이터 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: ledColor,
|
||||||
|
boxShadow: `0 0 6px ${ledColor}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* LED 인디케이터 (우측 상단) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: size * 0.25,
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: ledColor,
|
||||||
|
boxShadow: `0 0 4px ${ledColor}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 라벨 */}
|
||||||
|
{label && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '9px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '1.5px',
|
||||||
|
color: d3roPalette.text.inactive,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,4 @@ export { Led } from './Led'
|
||||||
export { PhysicalButton } from './PhysicalButton'
|
export { PhysicalButton } from './PhysicalButton'
|
||||||
export { MetalCard } from './MetalCard'
|
export { MetalCard } from './MetalCard'
|
||||||
export { PhosphorText } from './PhosphorText'
|
export { PhosphorText } from './PhosphorText'
|
||||||
|
export { MetalDial } from './MetalDial'
|
||||||
|
|
|
||||||
|
|
@ -163,12 +163,14 @@
|
||||||
audioLevel = data.level || 0
|
audioLevel = data.level || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 상태 변경
|
// 상태 변경 (showRecordingTip + updateRecordingTipState 양쪽에서 사용)
|
||||||
window.popupAPI.on('window:tipStateChanged', function (data) {
|
window.popupAPI.on('window:tipStateChanged', function (data) {
|
||||||
var state = data.state
|
var state = data.state
|
||||||
if (state === 'recording') showRecording()
|
if (state === 'recording') showRecording()
|
||||||
else if (state === 'thinking') showThinking()
|
else if (state === 'thinking') showThinking()
|
||||||
else if (state === 'error') showError(data.errorMessage)
|
else if (state === 'error') showError(data.errorMessage)
|
||||||
|
// 항상 가시성 보장
|
||||||
|
container.style.opacity = '1'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 클릭 시 녹음 취소
|
// 클릭 시 녹음 취소
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue